diff --git a/STATUS.md b/STATUS.md index 529dd78..73de51e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,28 @@ # Pinakes Android — Build Status +## 1.11.0 (versionCode 25): Pinakes 0.9.0, and its whole admin from the staff area + +Pairs with Pinakes 0.9.0-rc.2 (Mobile API 1.10.0 for the staff areas, the copy editor's consultation switch and `patron_type` on the profile; Mobile API 1.9.0 for `closed_dates`, `loanable`, `reservations_enabled`, `/me/movements` and the new 409 codes; Mobile API 1.9.1 for the `/health` toggles `fines`, `closures_calendar` and `circulation_rules`). Works with older servers: every field is optional, the three flags default to off, and their absence keeps the 1.10.0 behaviour. + +- **Feature toggles**: `/health.features.fines`, `closures_calendar` and `circulation_rules` are read and stored with the other flags; catalogue mode turns all three off whatever the server sends. +- **Closures calendar**: `closed_dates` paints closed days grey (legend "Library closed", only when the library has closures), they cannot be picked as a start, a tap says the library is closed, the pre-selected day skips them, and a proposed return date always lands on an open day (the default return moves forward as the server does; a shortened one sent as `end_date` moves back, never past the booked day). Same calendar for the reader and for the staff loan desk. +- **Consultation only and closed waitlists**: with `circulation_rules` on, the book page reads `loanable` and `reservations_enabled` from the availability payload; the loan button stays visible but disabled, with the reason under it ("Consultation only", or no reservations with this card). A 409 `not_loanable` / `reservations_disabled` turns the button the same way at once. +- **Refusal codes**: `not_loanable`, `reservations_disabled`, `reservation_limit_reached`, `media_limit_reached`, `max_loans_reached` and `not_eligible` with `meta.reason` `debt_block` / `card_expired` / `user_suspended` have their own wording for readers and staff; unknown codes keep the server message. Staff borrower pages and the new-loan form name the eligibility reason in the app's words. +- **Balance and movements** (Profile, only with `features.fines`): balance, the "loans blocked" banner with the library's threshold, fines still accruing on overdue loans, and the ledger. A 404 `feature_disabled` closes the page without an error. +- Additive, shown when the server sends them: `patron_type` on the profile, `consultation_only` on a staff copy. +- **Consultation-only copies on their own switch**: Pinakes 0.9.0 moved them off the rules matrix (`/health.features.consultation_only`). The book page now asks for the reader's rules when either `circulation_rules` or `consultation_only` is on, so a consultation-only title shows the disabled button with its reason even with patron-type rules off. A server without the key keeps tying it to `circulation_rules`. `automatic_fines` and `debt_block` need no client change: the server already leaves the accruing fines out and sends `debt_block_threshold_cents: null` and `debt_blocked: false` when they are off. +- **A loan approved on a closed day**: with the library closed today, an auto-approved request comes back `prenotato` with `start_date` (the next open day). The confirmation now says from which day the book can be picked up, instead of "ready for pickup". +- **Quoted searches offline** (Pinakes #501): online, quotes ask the server for the exact spelling. The offline catalogue folds accents and cannot tell spellings apart, so it now drops the quotes and searches the words, as the server reads them (straight and typographic quotes; an inch mark after a digit stays), in the text query and in the author and publisher filters. Before, it looked for the quote characters and found nothing. +- **Staff reservations beyond the patron type's limits**: with `circulation_rules` on, the staff new-reservation form has the website's switch "Ignore the reservation limits of the patron's type", sent as `override_limits` on `POST /staff/reservations`. Without the switch, or with the rules off, the key is left out. +- **Consultation-only copies from the copy editor**: with `features.consultation_only` on, the staff copy editor has the switch the book page has; `consultation_only` is sent on `PUT /staff/copies/{id}` only when it changed. The server refuses it for a copy on loan, awaiting pickup or requested, as the website does. Needs Mobile API 1.10.0 (Pinakes 0.9.0-rc.2), which also sends `patron_type` on `GET /me` for the profile row. +- **Library card barcode**: the profile draws the card code (`codice_tessera`) as Code128, the printed card's default (Pinakes 0.9.0 library cards), so the desk and the visitor-log kiosk can scan the phone. Only printable ASCII is encoded, as on the printed card. +- **Staff patron tools** (Mobile API 1.10.0, each shown only when its flag and the server's `can_*` allow it): the staff patron screen gains a patron section with the photo (take, pick, replace, remove; fetched with the token and kept in memory only), the patron type with the type picker and the "set the expiry from the type" switch, card renewal (Idempotency-Key) and the printable card PDF, and the balance with the debt block leading to the new ledger screen (balance, fines accruing on open loans, paged movements with the operator and the payment receipt PDF, payment, charge and waiver sheets with exact decimal-to-cents parsing and a required Idempotency-Key). The staff return shows the late fine a return today charges, with a waiver switch and its mandatory reason (`waive_fine`/`waive_reason`). The front desk gains the visitor log (card scan in CODE 128 or QR, manual entry, branch choice, a result card with a short tone, guests, and a statistics tab with periods, day and hour bars, latest visits, CSV export and, for administrators, retention and purge) and the patron CSV import (upload with separator choice, preview, column mapping with required fields, invitations, the chunk loop of the catalogue import, counters, error report and example file; the jobs left open here or on another device are listed with Resume and Cancel). A 403 `not_permitted` shows the server message and keeps the staff area. +- **Fixes from a live run against a 0.9.0-rc.2 server**: switching a module in Library settings re-reads `/health` at once, so the hub adds or drops the entry without an app restart; the patron type editor's "Empty: …" hint shows what clearing the field gives, not the type's own value; the staff ledger words the balance about the patron; the finished patron import offers the error report only when it has rows; MARC jobs whose list answers late stay in view; a stocktake status poll answered after a close can no longer reopen the session on screen. `tools/e2e/live_smoke_090.py` drives every staff area on an emulator against a real server. +- No change to the local cache database (Room version 3). +- **Staff: library settings** (Pinakes 0.9.0, Mobile API 1.10.0, areas `library_settings`, `circulation_rules`, `closures_calendar`): three screens in the hub's Library section. *Library settings* has the web's Modules, Loans and Cards tabs: module switches with their help and dependencies (read-only state and no save action when `can_configure` is false), every loan setting with its own input (amounts in the server currency, parsed to cents exactly; fields outside `editable_fields` shown as values, so Staff never sends the money policy), card layout and texts; a save sends only what changed. *Patron types and loan rules* lists the types (default, inactive, patrons) with a sheet for every field (empty inherits, the inherited value as hint), default, order and delete, plus the rules matrix (patron type, "all types" included, by material) whose changed cells are saved together. *Closures calendar* shows the month (by branch) with each closed day's reasons, and edits weekly closed days, closures, yearly switches, the national holiday preset (country, region, year preview), the library's own holidays of every kind, and imports an ICS file. A screen this account may not use (403 `not_permitted` on its first load, or 404 `feature_disabled` for Staff with the module off) closes with the server's message; a refused action inside a screen shows the message and stays. Only the role gate's 403 `forbidden` drops the staff role. +- **Cataloguing (Pinakes 0.9.0, Mobile API 1.10.0)**: the staff hub's Cataloguing section opens Suppliers (list with search and book counts, create with an Idempotency-Key and the server's field errors under each field, edit as a patch, delete saying how many books lose the link), MARC (import: file from the document picker, flavour, encoding, duplicate mode, supplier and covers from `/staff/marc/options`, preview of the first records, then the CSV import's chunk driver with out-of-order recovery, pause, resume and cancel; the unfinished MARC jobs on the server (`GET /staff/marc/imports`, so also one started on another device or whose id the app lost) are listed above the form with Resume and Cancel; export of the whole catalogue or of chosen books in MARC 21, MARCXML or UNIMARC, and the chosen books' catalogue cards) and Copy cataloguing (catalogues, index, term with an ISBN/ISSN scan, results to select, duplicate mode and covers, idempotent import with links to the new records; the plugin's absence is explained instead of the form). The book form gains a supplier picker and the RDA content, media and carrier selects ("derived" names the server's value) only while the lookups carry them, so the keys stay out with the modules off; the record's screen prints its catalogue card and exports it in MARC. The staff book list has no selection mode, so the multi-book actions live in the MARC screen. A 403 `not_permitted` shows the server message and keeps the staff role. +- **Staff: stocktake and statistics** (Pinakes 0.9.0, Mobile API 1.10.0, areas `stocktake` and `statistics`): *Stocktake* lists the sessions by status with their counters and opens new ones from the server's options (name, note, branch, shelf and its level, genre, media type, shelfmark range) under an Idempotency-Key. A session screen is the scanner: the camera reads copy labels continuously (a label held in view is sent once, reading it again after moving away is sent and recorded as a repeat, as on the web), a typed or keyboard-wedge code works the same, every scan carries the shelf being checked and its own Idempotency-Key, and the answer shows at once in the outcome's web colour with the web's tone (silent unless the ringer is on) and a haptic tick. Counters, the latest scans (by outcome, only mine) and the session status are polled every four seconds while the screen is visible, so several operators see each other's progress; a code list (.txt/.csv) can be uploaded; close asks what to do with the missing copies and says how many there are (mark lost only when the server allows it, also to finish a cut-short marking); a refused mark-lost (403 `not_permitted`) shows the server's message and keeps the role; cancel asks first. The report lists the rows by category and downloads the CSV or the PDF (a too-long PDF says to take the CSV). Offline the screens leave and no code is kept for later. *Statistics* shows the overview, loans by month, state and material as bars, the most borrowed books (a tap opens the staff book) and the most active readers, then only the sections the server sends (patron types, fines, stocktake), visitors, and the fines report with its period and CSV, amounts in the server's currency. Bodies captured from a 0.9.0 server back 39 unit and Robolectric tests and `StocktakeFlowTest`. + ## 1.10.0 (versionCode 24): loan requests bounded by a scheduled loan Pairs with Pinakes 0.8.7 (Mobile API 1.8.2). Works with older servers: every new field is optional and its absence keeps the 1.9.0 behaviour. diff --git a/_contract/health-sample.json b/_contract/health-sample.json index a1cde2f..8911230 100644 --- a/_contract/health-sample.json +++ b/_contract/health-sample.json @@ -1 +1 @@ -{"data":{"name":"Pinakes","logo":null,"version":"0.7.20.2","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true},"catalogue_mode":false,"app_access_enabled":true,"registration_enabled":true,"private_mode":false,"vapid_public_key":"BAI9Ljyok0x691trnj9VsLYnPPGG651bXV9UDXM-TwKViUOWm9l3HPfRfXodc8-YaR7rMCc6fjrZKJv9ZBZBunE"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} +{"data":{"name":"Pinakes","logo":null,"version":"0.9.0","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true,"reviews":true,"archives":true,"desiderata":true,"staff_catalog":true,"catalog_sync":true,"calendar_feed":true,"automatic_fines":false,"debt_block":false,"consultation_only":true,"fines":false,"closures_calendar":true,"circulation_rules":true,"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true,"staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true,"staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true},"catalogue_mode":false,"loan_approval_required":true,"app_access_enabled":true,"registration_enabled":true,"registration":{"require_cognome":true,"require_telefono":true,"require_indirizzo":true,"show_sesso":true,"show_cod_fiscale":true,"custom_fields":[]},"private_mode":false,"vapid_public_key":"BE1SJEHx27wB0gDE4cx6BRrW6HRbSpDuJoGXpMaRMRsZ-7sKEQpOOk1XEy0TBIdiP-ZPF54Hmw5wNsoJI22xWdA"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ccab9b0..2d8d5c2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -166,8 +166,8 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" minSdk = 26 targetSdk = 35 - versionCode = 24 - versionName = "1.10.0" + versionCode = 25 + versionName = "1.11.0" vectorDrawables { useSupportLibrary = true diff --git a/app/src/androidTest/assets/fixtures/s090_library_calendar_full.json b/app/src/androidTest/assets/fixtures/s090_library_calendar_full.json new file mode 100644 index 0000000..47d148d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_calendar_full.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"today":"2026-11-01","month":"2026-12","prev_month":"2026-11","next_month":"2027-01","branch_id":null,"days":[{"date":"2026-12-01","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-02","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-03","weekday":4,"closed":false,"reasons":[]},{"date":"2026-12-04","weekday":5,"closed":false,"reasons":[]},{"date":"2026-12-05","weekday":6,"closed":false,"reasons":[]},{"date":"2026-12-06","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-12-07","weekday":1,"closed":false,"reasons":[]},{"date":"2026-12-08","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-09","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-10","weekday":4,"closed":false,"reasons":[]},{"date":"2026-12-11","weekday":5,"closed":false,"reasons":[]},{"date":"2026-12-12","weekday":6,"closed":false,"reasons":[]},{"date":"2026-12-13","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-12-14","weekday":1,"closed":false,"reasons":[]},{"date":"2026-12-15","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-16","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-17","weekday":4,"closed":false,"reasons":[]},{"date":"2026-12-18","weekday":5,"closed":false,"reasons":[]},{"date":"2026-12-19","weekday":6,"closed":false,"reasons":[]},{"date":"2026-12-20","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-12-21","weekday":1,"closed":false,"reasons":[]},{"date":"2026-12-22","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-23","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-24","weekday":4,"closed":true,"reasons":[{"type":"closure","id":1134,"name":"Chiusura natalizia (prova app)"}]},{"date":"2026-12-25","weekday":5,"closed":true,"reasons":[{"type":"closure","id":1134,"name":"Chiusura natalizia (prova app)"}]},{"date":"2026-12-26","weekday":6,"closed":true,"reasons":[{"type":"closure","id":1134,"name":"Chiusura natalizia (prova app)"}]},{"date":"2026-12-27","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-12-28","weekday":1,"closed":true,"reasons":[{"type":"custom_holiday","id":52,"name":"Date (prova app)"}]},{"date":"2026-12-29","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-30","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-31","weekday":4,"closed":false,"reasons":[]}],"closed_weekdays":[0],"closures":[{"id":1135,"start_date":"2026-08-15","end_date":"2026-08-15","description":"Ferragosto (prova app)","yearly":true,"branch_id":null,"branch_name":null,"preset_rule":null,"active":false,"editable":true},{"id":1134,"start_date":"2026-12-24","end_date":"2026-12-26","description":"Chiusura natalizia (prova app)","yearly":false,"branch_id":null,"branch_name":null,"preset_rule":null,"active":true,"editable":true}],"custom_holidays_available":true,"custom_holidays":[{"id":52,"name":"Date (prova app)","kind":"dates","month":null,"day":null,"weekday":null,"occurrence":null,"offset_days":null,"calendar":null,"dates":["2026-12-28","2027-01-04"],"duration_days":1,"weekend_rule":null,"year_from":null,"year_until":null,"branch_id":null,"branch_name":null,"active":true,"description":"Date specifiche (2)","next_dates":["2026-12-28","2027-01-04"]},{"id":53,"name":"Rosh Hashanah (prova app)","kind":"calendar","month":1,"day":1,"weekday":null,"occurrence":null,"offset_days":null,"calendar":"hebrew","dates":[],"duration_days":2,"weekend_rule":null,"year_from":null,"year_until":null,"branch_id":null,"branch_name":null,"active":true,"description":"Calendario Ebraico: 1 Tishrei · 2 giorni","next_dates":["2027-10-02","2027-10-03","2028-09-21"]},{"id":51,"name":"Santo patrono (prova app)","kind":"nth_weekday","month":11,"day":null,"weekday":1,"occurrence":-1,"offset_days":null,"calendar":null,"dates":[],"duration_days":1,"weekend_rule":null,"year_from":null,"year_until":null,"branch_id":null,"branch_name":null,"active":false,"description":"L’ultimo lunedì di novembre","next_dates":["2026-11-30","2027-11-29","2028-11-27"]}],"custom_holiday_options":{"kinds":["fixed","nth_weekday","easter","orthodox_easter","calendar","dates"],"weekend_rules":["next_monday","us"],"occurrences":[1,2,3,4,5,-1],"calendars":[{"code":"islamic-umalqura","label":"Islamico (Umm al-Qura)","available":true,"months":["Muharram","Safar","Rabi' al-awwal","Rabi' al-thani","Jumada al-awwal","Jumada al-thani","Rajab","Sha'ban","Ramadan","Shawwal","Dhu al-Qa'dah","Dhu al-Hijjah"]},{"code":"hebrew","label":"Ebraico","available":true,"months":["Tishrei","Cheshvan","Kislev","Tevet","Shevat","Adar I","Adar (Adar II)","Nisan","Iyar","Sivan","Tammuz","Av","Elul"]},{"code":"chinese","label":"Cinese (lunisolare)","available":true,"months":["正月 (1)","二月 (2)","三月 (3)","四月 (4)","五月 (5)","六月 (6)","七月 (7)","八月 (8)","九月 (9)","十月 (10)","冬月 (11)","腊月 (12)"]},{"code":"persian","label":"Persiano (Hijri solare)","available":true,"months":["Farvardin","Ordibehesht","Khordad","Tir","Mordad","Shahrivar","Mehr","Aban","Azar","Dey","Bahman","Esfand"]},{"code":"indian","label":"Indiano (Saka)","available":true,"months":["Chaitra","Vaishakha","Jyeshtha","Ashadha","Shravana","Bhadra","Ashvin","Kartika","Agrahayana","Pausha","Magha","Phalguna"]},{"code":"ethiopic","label":"Etiope","available":true,"months":["Meskerem","Tikimt","Hidar","Tahsas","Tir","Yekatit","Megabit","Miyazya","Ginbot","Sene","Hamle","Nehase","Pagume"]},{"code":"coptic","label":"Copto","available":true,"months":["Thout","Paopi","Hathor","Koiak","Tobi","Meshir","Paremhat","Parmouti","Pashons","Paoni","Epip","Mesori","Pi Kogi Enavot"]}],"max_duration_days":14,"max_dates":400},"holiday_preset":{"country":"IT","country_name":"Italia","region":"","regions":[],"year":2026,"years":{"min":1900,"max":2199},"data_years":null,"saved":false,"schema_ready":true,"missing":[],"unknown_country":"N","holidays":[{"id":"IT:new_year","name":"Capodanno","name_en":"Capodanno","date":"2026-01-01","dates":["2026-01-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:epiphany","name":"Epifania","name_en":"Epifania","date":"2026-01-06","dates":["2026-01-06"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter","name":"Pasqua","name_en":"Pasqua","date":"2026-04-05","dates":["2026-04-05"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter_monday","name":"Lunedì dell'Angelo (Pasquetta)","name_en":"Lunedì dell'Angelo (Pasquetta)","date":"2026-04-06","dates":["2026-04-06"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:liberation","name":"Festa della Liberazione","name_en":"Festa della Liberazione","date":"2026-04-25","dates":["2026-04-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:labour","name":"Festa del Lavoro","name_en":"Festa del Lavoro","date":"2026-05-01","dates":["2026-05-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:republic","name":"Festa della Repubblica","name_en":"Festa della Repubblica","date":"2026-06-02","dates":["2026-06-02"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:ferragosto","name":"Ferragosto","name_en":"Ferragosto","date":"2026-08-15","dates":["2026-08-15"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:san_francesco","name":"San Francesco d'Assisi, patrono d'Italia","name_en":"San Francesco d'Assisi, patrono d'Italia","date":"2026-10-04","dates":["2026-10-04"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:all_saints","name":"Ognissanti","name_en":"Ognissanti","date":"2026-11-01","dates":["2026-11-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:immaculate","name":"Immacolata Concezione","name_en":"Immacolata Concezione","date":"2026-12-08","dates":["2026-12-08"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:christmas","name":"Natale","name_en":"Natale","date":"2026-12-25","dates":["2026-12-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:st_stephen","name":"Santo Stefano","name_en":"Santo Stefano","date":"2026-12-26","dates":["2026-12-26"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true}]},"branches":[],"limits":{"yearly_span_days":363,"once_span_days":3660,"ics_max_bytes":1048576}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_cards_admin.json b/app/src/androidTest/assets/fixtures/s090_library_cards_admin.json new file mode 100644 index 0000000..8ef1462 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_cards_admin.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"settings":{"layout":"classic","code_type":"code128","format":"single","title":"","back_text":"","show_photo":true,"show_logo":true,"show_expiry":true},"options":{"layout":["classic","minimal","portrait"],"code_type":["code128","qr"],"format":["single","sheet"]},"limits":{"title_max":60,"back_text_max":600}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_cards_staff_disabled.json b/app/src/androidTest/assets/fixtures/s090_library_cards_staff_disabled.json new file mode 100644 index 0000000..549c54d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_cards_staff_disabled.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"feature_disabled","message":"Funzione non disponibile su questa istanza."}} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_health.json b/app/src/androidTest/assets/fixtures/s090_library_health.json new file mode 100644 index 0000000..e42b536 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_health.json @@ -0,0 +1 @@ +{"data":{"name":"Pinakes","logo":null,"version":"0.9.0-rc.1","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true,"reviews":true,"archives":true,"desiderata":true,"staff_catalog":true,"catalog_sync":true,"calendar_feed":true,"automatic_fines":false,"debt_block":false,"consultation_only":true,"fines":false,"closures_calendar":true,"circulation_rules":true,"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true,"staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true,"staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true,"staff_circulation_rules":true,"staff_closures_calendar":true,"staff_ledger":false,"staff_library_settings":true,"staff_patrons":true,"staff_stocktake":false,"stocktake":false,"marc":true,"visitor_log":false,"catalogue_cards":true,"suppliers":false,"rda_types":false,"patron_cards":true,"user_import":true,"patron_photos":true,"automatic_card_expiry":true,"loss_penalties":false,"attendance_retention":false},"catalogue_mode":false,"loan_approval_required":true,"app_access_enabled":true,"registration_enabled":true,"registration":{"require_cognome":true,"require_telefono":true,"require_indirizzo":true,"show_sesso":true,"show_cod_fiscale":true,"custom_fields":[]},"private_mode":false,"vapid_public_key":"BAeIEfzXgH-K-K8vvexn2fopg0tHoj0O5q84qROQn8k5QTEVfK0mwsZoLPR6PLQ_l3ujMU4wQzf8hizluOTfgIk"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_loans_admin.json b/app/src/androidTest/assets/fixtures/s090_library_loans_admin.json new file mode 100644 index 0000000..9f4a774 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_loans_admin.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"currency":"EUR","settings":{"loan_duration_days":30,"pickup_expiry_days":3,"pickup_reminder_days":4,"card_expiry_warning_days":0,"max_renewals":3,"max_active_loans_per_user":0,"max_loan_duration_days":90,"recall_interval_days":7,"recall_max_count":3,"auto_approve_requests":false,"allow_multiple_loans_same_book":false,"recall_auto_enabled":false,"app_timezone":"Europe/Rome","daily_fine_cents":null,"fine_max_per_loan_cents":null,"debt_block_threshold_cents":null,"fine_grace_days":0,"fine_grace_mode":"threshold","card_renewal_base":"today","fine_charged_email":false},"editable_fields":["loan_duration_days","pickup_expiry_days","pickup_reminder_days","card_expiry_warning_days","max_renewals","max_active_loans_per_user","max_loan_duration_days","recall_interval_days","recall_max_count","auto_approve_requests","allow_multiple_loans_same_book","recall_auto_enabled","app_timezone","daily_fine_cents","fine_max_per_loan_cents","debt_block_threshold_cents","fine_grace_days","fine_grace_mode","card_renewal_base","fine_charged_email"],"admin_only_fields":["daily_fine_cents","fine_max_per_loan_cents","debt_block_threshold_cents","fine_grace_days","fine_grace_mode","card_renewal_base","fine_charged_email"],"limits":{"loan_duration_days":{"min":1,"max":3650},"pickup_expiry_days":{"min":1,"max":30},"pickup_reminder_days":{"min":0,"max":30},"card_expiry_warning_days":{"min":0,"max":365},"max_renewals":{"min":0,"max":100},"max_active_loans_per_user":{"min":0,"max":1000},"max_loan_duration_days":{"min":1,"max":3650},"recall_interval_days":{"min":1,"max":365},"recall_max_count":{"min":1,"max":50},"fine_grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"options":{"fine_grace_mode":["threshold","deduct"],"card_renewal_base":["today","expiry"]}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_loans_staff.json b/app/src/androidTest/assets/fixtures/s090_library_loans_staff.json new file mode 100644 index 0000000..c498736 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_loans_staff.json @@ -0,0 +1 @@ +{"data":{"can_configure":false,"currency":"EUR","settings":{"loan_duration_days":30,"pickup_expiry_days":3,"pickup_reminder_days":4,"card_expiry_warning_days":0,"max_renewals":3,"max_active_loans_per_user":0,"max_loan_duration_days":90,"recall_interval_days":7,"recall_max_count":3,"auto_approve_requests":false,"allow_multiple_loans_same_book":false,"recall_auto_enabled":false,"app_timezone":"Europe/Rome","daily_fine_cents":null,"fine_max_per_loan_cents":null,"debt_block_threshold_cents":null,"fine_grace_days":0,"fine_grace_mode":"threshold","card_renewal_base":"today","fine_charged_email":false},"editable_fields":["loan_duration_days","pickup_expiry_days","pickup_reminder_days","card_expiry_warning_days","max_renewals","max_active_loans_per_user","max_loan_duration_days","recall_interval_days","recall_max_count","auto_approve_requests","allow_multiple_loans_same_book","recall_auto_enabled","app_timezone"],"admin_only_fields":["daily_fine_cents","fine_max_per_loan_cents","debt_block_threshold_cents","fine_grace_days","fine_grace_mode","card_renewal_base","fine_charged_email"],"limits":{"loan_duration_days":{"min":1,"max":3650},"pickup_expiry_days":{"min":1,"max":30},"pickup_reminder_days":{"min":0,"max":30},"card_expiry_warning_days":{"min":0,"max":365},"max_renewals":{"min":0,"max":100},"max_active_loans_per_user":{"min":0,"max":1000},"max_loan_duration_days":{"min":1,"max":3650},"recall_interval_days":{"min":1,"max":365},"recall_max_count":{"min":1,"max":50},"fine_grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"options":{"fine_grace_mode":["threshold","deduct"],"card_renewal_base":["today","expiry"]}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_loans_staff_refused.json b/app/src/androidTest/assets/fixtures/s090_library_loans_staff_refused.json new file mode 100644 index 0000000..30d1ad0 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_loans_staff_refused.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"daily_fine_cents":"Operazione riservata agli amministratori"}},"error":{"code":"not_permitted","message":"Operazione riservata agli amministratori"}} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_modules_admin.json b/app/src/androidTest/assets/fixtures/s090_library_modules_admin.json new file mode 100644 index 0000000..c85c199 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_modules_admin.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"catalogue_mode":false,"attendance_retention_days":365,"attendance_retention_max_days":3650,"modules":[{"key":"stocktake","label":"Revisione inventariale","help":"Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo \"Revisore inventario\" resta ma non apre nulla.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"marc","label":"Import ed export MARC","help":"Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"visitor_log","label":"Registro presenze","help":"Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":true},{"key":"catalogue_cards","label":"Schede catalografiche","help":"Stampa della scheda catalografica di un libro o di una selezione.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"suppliers","label":"Fornitori","help":"Anagrafica fornitori e campo \"Fornitore\" nella scheda libro e nell'import CSV.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"rda_types","label":"Tipi RDA","help":"Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"patron_cards","label":"Stampa delle tessere","help":"Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"user_import","label":"Importazione utenti da CSV","help":"Caricamento massivo dei lettori con invito via email.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"circulation_rules","label":"Regole per tipologia di lettore","help":"Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":true},{"key":"closures_calendar","label":"Calendario chiusure","help":"Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":true},{"key":"fines","label":"Registro movimenti","help":"Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.","group":"loans","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":true},{"key":"patron_photos","label":"Foto dei lettori","help":"Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":"patron_cards","circulation":false},{"key":"consultation_only","label":"Copie in sola consultazione","help":"Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":"circulation_rules","circulation":true},{"key":"automatic_card_expiry","label":"Validità automatica delle tessere","help":"Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.","group":"loans","stored":true,"effective":true,"parent":"circulation_rules","inherits_from":"circulation_rules","circulation":true},{"key":"automatic_fines","label":"Multe automatiche per ritardo","help":"Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"debt_block","label":"Blocco dei prestiti per debito","help":"Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"loss_penalties","label":"Riporta penalità per perdita e danno nel registro","help":"Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"attendance_retention","label":"Conservazione a registro presenze sospeso","help":"Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_modules_saved.json b/app/src/androidTest/assets/fixtures/s090_library_modules_saved.json new file mode 100644 index 0000000..35779aa --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_modules_saved.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"catalogue_mode":false,"attendance_retention_days":400,"attendance_retention_max_days":3650,"modules":[{"key":"stocktake","label":"Revisione inventariale","help":"Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo \"Revisore inventario\" resta ma non apre nulla.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"marc","label":"Import ed export MARC","help":"Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"visitor_log","label":"Registro presenze","help":"Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":true},{"key":"catalogue_cards","label":"Schede catalografiche","help":"Stampa della scheda catalografica di un libro o di una selezione.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"suppliers","label":"Fornitori","help":"Anagrafica fornitori e campo \"Fornitore\" nella scheda libro e nell'import CSV.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"rda_types","label":"Tipi RDA","help":"Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"patron_cards","label":"Stampa delle tessere","help":"Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"user_import","label":"Importazione utenti da CSV","help":"Caricamento massivo dei lettori con invito via email.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"circulation_rules","label":"Regole per tipologia di lettore","help":"Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":true},{"key":"closures_calendar","label":"Calendario chiusure","help":"Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":true},{"key":"fines","label":"Registro movimenti","help":"Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.","group":"loans","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":true},{"key":"patron_photos","label":"Foto dei lettori","help":"Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":"patron_cards","circulation":false},{"key":"consultation_only","label":"Copie in sola consultazione","help":"Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":"circulation_rules","circulation":true},{"key":"automatic_card_expiry","label":"Validità automatica delle tessere","help":"Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.","group":"loans","stored":true,"effective":true,"parent":"circulation_rules","inherits_from":"circulation_rules","circulation":true},{"key":"automatic_fines","label":"Multe automatiche per ritardo","help":"Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"debt_block","label":"Blocco dei prestiti per debito","help":"Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"loss_penalties","label":"Riporta penalità per perdita e danno nel registro","help":"Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"attendance_retention","label":"Conservazione a registro presenze sospeso","help":"Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_modules_staff.json b/app/src/androidTest/assets/fixtures/s090_library_modules_staff.json new file mode 100644 index 0000000..d758655 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_modules_staff.json @@ -0,0 +1 @@ +{"data":{"can_configure":false,"catalogue_mode":false,"attendance_retention_days":365,"attendance_retention_max_days":3650,"modules":[{"key":"stocktake","label":"Revisione inventariale","help":"Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo \"Revisore inventario\" resta ma non apre nulla.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"marc","label":"Import ed export MARC","help":"Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"visitor_log","label":"Registro presenze","help":"Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":true},{"key":"catalogue_cards","label":"Schede catalografiche","help":"Stampa della scheda catalografica di un libro o di una selezione.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"suppliers","label":"Fornitori","help":"Anagrafica fornitori e campo \"Fornitore\" nella scheda libro e nell'import CSV.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"rda_types","label":"Tipi RDA","help":"Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"patron_cards","label":"Stampa delle tessere","help":"Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"user_import","label":"Importazione utenti da CSV","help":"Caricamento massivo dei lettori con invito via email.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"circulation_rules","label":"Regole per tipologia di lettore","help":"Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":true},{"key":"closures_calendar","label":"Calendario chiusure","help":"Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":true},{"key":"fines","label":"Registro movimenti","help":"Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.","group":"loans","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":true},{"key":"patron_photos","label":"Foto dei lettori","help":"Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":"patron_cards","circulation":false},{"key":"consultation_only","label":"Copie in sola consultazione","help":"Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":"circulation_rules","circulation":true},{"key":"automatic_card_expiry","label":"Validità automatica delle tessere","help":"Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.","group":"loans","stored":true,"effective":true,"parent":"circulation_rules","inherits_from":"circulation_rules","circulation":true},{"key":"automatic_fines","label":"Multe automatiche per ritardo","help":"Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"debt_block","label":"Blocco dei prestiti per debito","help":"Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"loss_penalties","label":"Riporta penalità per perdita e danno nel registro","help":"Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"attendance_retention","label":"Conservazione a registro presenze sospeso","help":"Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_rules.json b/app/src/androidTest/assets/fixtures/s090_library_rules.json new file mode 100644 index 0000000..3b41f6a --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_rules.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"currency":"EUR","media":[{"key":"libro","label":"Libro"},{"key":"disco","label":"Disco"},{"key":"audiolibro","label":"Audiolibro"},{"key":"dvd","label":"DVD"},{"key":"altro","label":"Altro"}],"rows":[{"type_id":0,"name":"Tutte le tipologie"},{"type_id":1,"name":"Standard"}],"limits":{"max_loans":{"min":0,"max":1000},"loan_duration_days":{"min":1,"max":3650},"max_renewals":{"min":0,"max":100},"renewal_days":{"min":1,"max":3650},"grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"cells":[{"type_id":0,"media":"libro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"disco","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"audiolibro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"dvd","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"altro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"libro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"disco","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"audiolibro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"dvd","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"altro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_rules_saved.json b/app/src/androidTest/assets/fixtures/s090_library_rules_saved.json new file mode 100644 index 0000000..45ccfe7 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_rules_saved.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"currency":"EUR","media":[{"key":"libro","label":"Libro"},{"key":"disco","label":"Disco"},{"key":"audiolibro","label":"Audiolibro"},{"key":"dvd","label":"DVD"},{"key":"altro","label":"Altro"}],"rows":[{"type_id":0,"name":"Tutte le tipologie"},{"type_id":1,"name":"Standard"}],"limits":{"max_loans":{"min":0,"max":1000},"loan_duration_days":{"min":1,"max":3650},"max_renewals":{"min":0,"max":100},"renewal_days":{"min":1,"max":3650},"grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"cells":[{"type_id":0,"media":"libro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"disco","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"audiolibro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"dvd","has_rule":true,"loanable":false,"max_loans":null,"loan_duration_days":7,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"altro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"libro","has_rule":true,"loanable":null,"max_loans":4,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":25,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"disco","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"audiolibro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"dvd","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":false,"max_loans":0,"loan_duration_days":7,"max_renewals":3,"renewal_days":7,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"altro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_type_created.json b/app/src/androidTest/assets/fixtures/s090_library_type_created.json new file mode 100644 index 0000000..a42bb24 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_type_created.json @@ -0,0 +1 @@ +{"data":{"id":116,"code":"ricercatori_app","name":"Ricercatori app","description":null,"reservations_enabled":true,"active":true,"is_default":false,"order":20,"user_count":0,"max_loans":5,"loan_duration_days":null,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":5,"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":30,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_types_staff_refused.json b/app/src/androidTest/assets/fixtures/s090_library_types_staff_refused.json new file mode 100644 index 0000000..c7037c2 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_types_staff_refused.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"not_permitted","message":"Operazione riservata agli amministratori"}} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_types_two.json b/app/src/androidTest/assets/fixtures/s090_library_types_two.json new file mode 100644 index 0000000..b3d1a54 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_types_two.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"currency":"EUR","default_type_id":1,"globals":{"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":null,"max_loans":0,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null},"limits":{"max_loans":{"min":0,"max":1000},"loan_duration_days":{"min":1,"max":3650},"max_request_duration_days":{"min":1,"max":3650},"max_renewals":{"min":0,"max":100},"renewal_days":{"min":1,"max":3650},"max_reservations":{"min":0,"max":1000},"card_duration_days":{"min":1,"max":36500},"grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"types":[{"id":1,"code":"standard","name":"Standard","description":null,"reservations_enabled":true,"active":true,"is_default":true,"order":10,"user_count":0,"max_loans":null,"loan_duration_days":null,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":0,"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":30,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}},{"id":116,"code":"ricercatori_app","name":"Ricercatori app","description":null,"reservations_enabled":true,"active":true,"is_default":false,"order":20,"user_count":0,"max_loans":null,"loan_duration_days":60,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":0,"loan_duration_days":60,"max_request_duration_days":90,"max_renewals":3,"renewal_days":60,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_library_weekdays_saved.json b/app/src/androidTest/assets/fixtures/s090_library_weekdays_saved.json new file mode 100644 index 0000000..26b70c0 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_library_weekdays_saved.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"today":"2026-11-01","month":"2026-11","prev_month":"2026-10","next_month":"2026-12","branch_id":null,"days":[{"date":"2026-11-01","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-11-02","weekday":1,"closed":false,"reasons":[]},{"date":"2026-11-03","weekday":2,"closed":false,"reasons":[]},{"date":"2026-11-04","weekday":3,"closed":false,"reasons":[]},{"date":"2026-11-05","weekday":4,"closed":false,"reasons":[]},{"date":"2026-11-06","weekday":5,"closed":false,"reasons":[]},{"date":"2026-11-07","weekday":6,"closed":false,"reasons":[]},{"date":"2026-11-08","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-11-09","weekday":1,"closed":false,"reasons":[]},{"date":"2026-11-10","weekday":2,"closed":false,"reasons":[]},{"date":"2026-11-11","weekday":3,"closed":false,"reasons":[]},{"date":"2026-11-12","weekday":4,"closed":false,"reasons":[]},{"date":"2026-11-13","weekday":5,"closed":false,"reasons":[]},{"date":"2026-11-14","weekday":6,"closed":false,"reasons":[]},{"date":"2026-11-15","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-11-16","weekday":1,"closed":false,"reasons":[]},{"date":"2026-11-17","weekday":2,"closed":false,"reasons":[]},{"date":"2026-11-18","weekday":3,"closed":false,"reasons":[]},{"date":"2026-11-19","weekday":4,"closed":false,"reasons":[]},{"date":"2026-11-20","weekday":5,"closed":false,"reasons":[]},{"date":"2026-11-21","weekday":6,"closed":false,"reasons":[]},{"date":"2026-11-22","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-11-23","weekday":1,"closed":false,"reasons":[]},{"date":"2026-11-24","weekday":2,"closed":false,"reasons":[]},{"date":"2026-11-25","weekday":3,"closed":false,"reasons":[]},{"date":"2026-11-26","weekday":4,"closed":false,"reasons":[]},{"date":"2026-11-27","weekday":5,"closed":false,"reasons":[]},{"date":"2026-11-28","weekday":6,"closed":false,"reasons":[]},{"date":"2026-11-29","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-11-30","weekday":1,"closed":false,"reasons":[]}],"closed_weekdays":[0],"closures":[],"custom_holidays_available":true,"custom_holidays":[],"custom_holiday_options":{"kinds":["fixed","nth_weekday","easter","orthodox_easter","calendar","dates"],"weekend_rules":["next_monday","us"],"occurrences":[1,2,3,4,5,-1],"calendars":[{"code":"islamic-umalqura","label":"Islamico (Umm al-Qura)","available":true,"months":["Muharram","Safar","Rabi' al-awwal","Rabi' al-thani","Jumada al-awwal","Jumada al-thani","Rajab","Sha'ban","Ramadan","Shawwal","Dhu al-Qa'dah","Dhu al-Hijjah"]},{"code":"hebrew","label":"Ebraico","available":true,"months":["Tishrei","Cheshvan","Kislev","Tevet","Shevat","Adar I","Adar (Adar II)","Nisan","Iyar","Sivan","Tammuz","Av","Elul"]},{"code":"chinese","label":"Cinese (lunisolare)","available":true,"months":["正月 (1)","二月 (2)","三月 (3)","四月 (4)","五月 (5)","六月 (6)","七月 (7)","八月 (8)","九月 (9)","十月 (10)","冬月 (11)","腊月 (12)"]},{"code":"persian","label":"Persiano (Hijri solare)","available":true,"months":["Farvardin","Ordibehesht","Khordad","Tir","Mordad","Shahrivar","Mehr","Aban","Azar","Dey","Bahman","Esfand"]},{"code":"indian","label":"Indiano (Saka)","available":true,"months":["Chaitra","Vaishakha","Jyeshtha","Ashadha","Shravana","Bhadra","Ashvin","Kartika","Agrahayana","Pausha","Magha","Phalguna"]},{"code":"ethiopic","label":"Etiope","available":true,"months":["Meskerem","Tikimt","Hidar","Tahsas","Tir","Yekatit","Megabit","Miyazya","Ginbot","Sene","Hamle","Nehase","Pagume"]},{"code":"coptic","label":"Copto","available":true,"months":["Thout","Paopi","Hathor","Koiak","Tobi","Meshir","Paremhat","Parmouti","Pashons","Paoni","Epip","Mesori","Pi Kogi Enavot"]}],"max_duration_days":14,"max_dates":400},"holiday_preset":{"country":"IT","country_name":"Italia","region":"","regions":[],"year":2026,"years":{"min":1900,"max":2199},"data_years":null,"saved":false,"schema_ready":true,"missing":[],"unknown_country":"N","holidays":[{"id":"IT:new_year","name":"Capodanno","name_en":"Capodanno","date":"2026-01-01","dates":["2026-01-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:epiphany","name":"Epifania","name_en":"Epifania","date":"2026-01-06","dates":["2026-01-06"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter","name":"Pasqua","name_en":"Pasqua","date":"2026-04-05","dates":["2026-04-05"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter_monday","name":"Lunedì dell'Angelo (Pasquetta)","name_en":"Lunedì dell'Angelo (Pasquetta)","date":"2026-04-06","dates":["2026-04-06"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:liberation","name":"Festa della Liberazione","name_en":"Festa della Liberazione","date":"2026-04-25","dates":["2026-04-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:labour","name":"Festa del Lavoro","name_en":"Festa del Lavoro","date":"2026-05-01","dates":["2026-05-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:republic","name":"Festa della Repubblica","name_en":"Festa della Repubblica","date":"2026-06-02","dates":["2026-06-02"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:ferragosto","name":"Ferragosto","name_en":"Ferragosto","date":"2026-08-15","dates":["2026-08-15"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:san_francesco","name":"San Francesco d'Assisi, patrono d'Italia","name_en":"San Francesco d'Assisi, patrono d'Italia","date":"2026-10-04","dates":["2026-10-04"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:all_saints","name":"Ognissanti","name_en":"Ognissanti","date":"2026-11-01","dates":["2026-11-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:immaculate","name":"Immacolata Concezione","name_en":"Immacolata Concezione","date":"2026-12-08","dates":["2026-12-08"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:christmas","name":"Natale","name_en":"Natale","date":"2026-12-25","dates":["2026-12-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:st_stephen","name":"Santo Stefano","name_en":"Santo Stefano","date":"2026-12-26","dates":["2026-12-26"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true}]},"branches":[],"limits":{"yearly_span_days":363,"once_span_days":3660,"ics_max_bytes":1048576}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_att_checkin.json b/app/src/androidTest/assets/fixtures/s090_patrons_att_checkin.json new file mode 100644 index 0000000..103fa5e --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_att_checkin.json @@ -0,0 +1 @@ +{"data":{"id":35,"type":"member","created_at":"2026-11-01 19:59:31","branch":null,"member":{"id":113146,"short_name":"Marta P.","card_code":"PCAP090B","account_status":"attivo","card_active":true,"card_expires_at":"2027-03-31","card_expired":false,"has_photo":false,"photo_url":null},"guest":null,"warning":null,"message":"Welcome, Marta P.: visit recorded."},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_att_guest.json b/app/src/androidTest/assets/fixtures/s090_patrons_att_guest.json new file mode 100644 index 0000000..06e18f8 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_att_guest.json @@ -0,0 +1 @@ +{"data":{"id":36,"type":"guest","created_at":"2026-11-01 19:59:31","branch":null,"member":null,"guest":{"name":"Visiting pcap","reason":"Research"},"warning":null,"message":"Guest visit recorded."},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_att_overview.json b/app/src/androidTest/assets/fixtures/s090_patrons_att_overview.json new file mode 100644 index 0000000..f288679 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_att_overview.json @@ -0,0 +1 @@ +{"data":{"days":30,"periods":[7,30,90,365],"today":{"members":0,"guests":0,"total":0},"totals":{"members":0,"guests":0,"total":0},"daily":[{"date":"2026-10-03","members":0,"guests":0,"total":0},{"date":"2026-10-04","members":0,"guests":0,"total":0},{"date":"2026-10-05","members":0,"guests":0,"total":0},{"date":"2026-10-06","members":0,"guests":0,"total":0},{"date":"2026-10-07","members":0,"guests":0,"total":0},{"date":"2026-10-08","members":0,"guests":0,"total":0},{"date":"2026-10-09","members":0,"guests":0,"total":0},{"date":"2026-10-10","members":0,"guests":0,"total":0},{"date":"2026-10-11","members":0,"guests":0,"total":0},{"date":"2026-10-12","members":0,"guests":0,"total":0},{"date":"2026-10-13","members":0,"guests":0,"total":0},{"date":"2026-10-14","members":0,"guests":0,"total":0},{"date":"2026-10-15","members":0,"guests":0,"total":0},{"date":"2026-10-16","members":0,"guests":0,"total":0},{"date":"2026-10-17","members":0,"guests":0,"total":0},{"date":"2026-10-18","members":0,"guests":0,"total":0},{"date":"2026-10-19","members":0,"guests":0,"total":0},{"date":"2026-10-20","members":0,"guests":0,"total":0},{"date":"2026-10-21","members":0,"guests":0,"total":0},{"date":"2026-10-22","members":0,"guests":0,"total":0},{"date":"2026-10-23","members":0,"guests":0,"total":0},{"date":"2026-10-24","members":0,"guests":0,"total":0},{"date":"2026-10-25","members":0,"guests":0,"total":0},{"date":"2026-10-26","members":0,"guests":0,"total":0},{"date":"2026-10-27","members":0,"guests":0,"total":0},{"date":"2026-10-28","members":0,"guests":0,"total":0},{"date":"2026-10-29","members":0,"guests":0,"total":0},{"date":"2026-10-30","members":0,"guests":0,"total":0},{"date":"2026-10-31","members":0,"guests":0,"total":0},{"date":"2026-11-01","members":0,"guests":0,"total":0}],"hourly":[{"hour":0,"total":0},{"hour":1,"total":0},{"hour":2,"total":0},{"hour":3,"total":0},{"hour":4,"total":0},{"hour":5,"total":0},{"hour":6,"total":0},{"hour":7,"total":0},{"hour":8,"total":0},{"hour":9,"total":0},{"hour":10,"total":0},{"hour":11,"total":0},{"hour":12,"total":0},{"hour":13,"total":0},{"hour":14,"total":0},{"hour":15,"total":0},{"hour":16,"total":0},{"hour":17,"total":0},{"hour":18,"total":0},{"hour":19,"total":0},{"hour":20,"total":0},{"hour":21,"total":0},{"hour":22,"total":0},{"hour":23,"total":0}],"recent":[],"branches":[],"retention":{"days":365,"max_days":3650,"default_days":365,"continues_when_off":false},"can_configure":true},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_att_unknown.json b/app/src/androidTest/assets/fixtures/s090_patrons_att_unknown.json new file mode 100644 index 0000000..01b820c --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_att_unknown.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"card_not_found","message":"Card not recognised. Check the code or record the visit as a guest."}} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_health.json b/app/src/androidTest/assets/fixtures/s090_patrons_health.json new file mode 100644 index 0000000..25d8b8d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_health.json @@ -0,0 +1 @@ +{"data":{"name":"Pinakes","logo":null,"version":"0.9.0-rc.1","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true,"reviews":true,"archives":true,"desiderata":true,"staff_catalog":true,"catalog_sync":true,"calendar_feed":true,"automatic_fines":true,"debt_block":true,"consultation_only":true,"fines":true,"closures_calendar":true,"circulation_rules":true,"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true,"staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true,"staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true,"staff_attendance":true,"staff_circulation_rules":true,"staff_closures_calendar":true,"staff_ledger":true,"staff_library_settings":true,"staff_patrons":true,"staff_statistics":true,"staff_stocktake":false,"staff_user_import":true,"stocktake":false,"marc":true,"visitor_log":true,"catalogue_cards":true,"suppliers":false,"rda_types":false,"patron_cards":true,"user_import":true,"patron_photos":true,"automatic_card_expiry":true,"loss_penalties":false,"attendance_retention":false},"catalogue_mode":false,"loan_approval_required":true,"app_access_enabled":true,"registration_enabled":true,"registration":{"require_cognome":true,"require_telefono":true,"require_indirizzo":true,"show_sesso":true,"show_cod_fiscale":true,"custom_fields":[]},"private_mode":false,"vapid_public_key":"BPJ1AUzmSjOizW5uX6WW-7r61-rRpiP2hlXHlvGfpDFlq6KY1zZHKRdzny3uzpyPq2C51pppPmmMrkn8k3oUD9Q"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_ledger.json b/app/src/androidTest/assets/fixtures/s090_patrons_ledger.json new file mode 100644 index 0000000..e56ab73 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_ledger.json @@ -0,0 +1 @@ +{"data":{"user_id":113146,"currency":"EUR","balance_cents":1250,"balance":"12.50","accrued_cents":200,"accrued":[{"loan_id":98229,"title":"Patroncap fine book","due_at":"2026-10-28","days_late":4,"amount_cents":200}],"debt_block_threshold_cents":1000,"debt_blocked":true,"automatic_fines":true,"payment_methods":[{"code":"contanti","label":"Cash"},{"code":"pos","label":"Card (POS)"},{"code":"bonifico","label":"Bank transfer"},{"code":"altro","label":"Other"}],"max_amount_cents":99999999,"can_record_payment":true,"can_charge":true,"can_waive":true,"items":[{"id":18,"type":"addebito_manuale","type_label":"Manual charge","amount_cents":1250,"amount":"12.50","description":"Lost library card","payment_method":null,"loan_id":null,"book_title":null,"created_at":"2026-11-01 19:59:29","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false}]},"meta":{"count":1,"limit":30,"next_cursor":null,"has_more":false,"total_count":1},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_ledger_after.json b/app/src/androidTest/assets/fixtures/s090_patrons_ledger_after.json new file mode 100644 index 0000000..c20854d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_ledger_after.json @@ -0,0 +1 @@ +{"data":{"user_id":113146,"currency":"EUR","balance_cents":750,"balance":"7.50","accrued_cents":200,"accrued":[{"loan_id":98229,"title":"Patroncap fine book","due_at":"2026-10-28","days_late":4,"amount_cents":200}],"debt_block_threshold_cents":1000,"debt_blocked":false,"automatic_fines":true,"payment_methods":[{"code":"contanti","label":"Cash"},{"code":"pos","label":"Card (POS)"},{"code":"bonifico","label":"Bank transfer"},{"code":"altro","label":"Other"}],"max_amount_cents":99999999,"can_record_payment":true,"can_charge":true,"can_waive":true,"items":[{"id":19,"type":"pagamento","type_label":"Payment","amount_cents":-500,"amount":"-5.00","description":"","payment_method":"pos","loan_id":null,"book_title":null,"created_at":"2026-11-01 19:59:30","payment_method_label":"Card (POS)","operator":{"id":31650,"name":"E2E Audit"},"receipt_available":true},{"id":18,"type":"addebito_manuale","type_label":"Manual charge","amount_cents":1250,"amount":"12.50","description":"Lost library card","payment_method":null,"loan_id":null,"book_title":null,"created_at":"2026-11-01 19:59:29","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false}]},"meta":{"count":2,"limit":30,"next_cursor":null,"has_more":false,"total_count":2},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_loan_overdue.json b/app/src/androidTest/assets/fixtures/s090_patrons_loan_overdue.json new file mode 100644 index 0000000..454c78b --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_loan_overdue.json @@ -0,0 +1 @@ +{"data":{"id":98229,"status":"in_ritardo","status_label":"Overdue","origin":"diretto","book":{"id":343656,"title":"Patroncap fine book","cover_url":"http://localhost:8195/uploads/copertine/placeholder.jpg","isbn13":null},"copy":{"id":110233,"inventory_number":"LIB-343656-C1","status":"prestato"},"user":{"id":113146,"name":"Marta Patroncap","email":"patroncap2_090@example.test","card_number":"PCAP090B","status":"attivo"},"processed_by":{"id":31650,"name":"E2E Audit"},"requested_at":"2026-11-01","loaned_at":"2026-10-12","due_at":"2026-10-28","pickup_deadline":null,"returned_at":null,"returned_late":false,"days_overdue":4,"renewals":0,"max_renewals":3,"penalty":null,"notes":null,"recall_count":0,"last_recall_at":null,"created_at":"2026-11-01 19:59:29","updated_at":"2026-11-01 19:59:29","actions":["return","edit","recall","receipt","email_receipt"],"late_fine":{"currency":"EUR","automatic_fines":true,"accruing_days":4,"accruing_cents":200,"charged_cents":0}},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_loans_overdue.json b/app/src/androidTest/assets/fixtures/s090_patrons_loans_overdue.json new file mode 100644 index 0000000..4abae1d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_loans_overdue.json @@ -0,0 +1 @@ +{"data":[{"id":98229,"status":"in_ritardo","status_label":"Overdue","origin":"diretto","book":{"id":343656,"title":"Patroncap fine book","cover_url":"http://localhost:8195/uploads/copertine/placeholder.jpg","isbn13":null},"copy":{"id":110233,"inventory_number":"LIB-343656-C1","status":"prestato"},"user":{"id":113146,"name":"Marta Patroncap","email":"patroncap2_090@example.test","card_number":"PCAP090B","status":"attivo"},"processed_by":{"id":31650,"name":"E2E Audit"},"requested_at":"2026-11-01","loaned_at":"2026-10-12","due_at":"2026-10-28","pickup_deadline":null,"returned_at":null,"returned_late":false,"days_overdue":4,"renewals":0,"max_renewals":3,"penalty":null,"notes":null,"recall_count":0,"last_recall_at":null,"created_at":"2026-11-01 19:59:29","updated_at":"2026-11-01 19:59:29","actions":["return","edit","recall","receipt","email_receipt"]}],"meta":{"count":1,"limit":30,"next_cursor":null,"has_more":false,"total_count":1},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_np_purge.json b/app/src/androidTest/assets/fixtures/s090_patrons_np_purge.json new file mode 100644 index 0000000..2ffa88f --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_np_purge.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"not_permitted","message":"Operation reserved for administrators."}} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_payment.json b/app/src/androidTest/assets/fixtures/s090_patrons_payment.json new file mode 100644 index 0000000..1130edd --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_payment.json @@ -0,0 +1 @@ +{"data":{"movement":{"id":19,"type":"pagamento","type_label":"Payment","amount_cents":-500,"amount":"-5.00","description":"","payment_method":"pos","loan_id":null,"book_title":null,"created_at":"2026-11-01 19:59:30","payment_method_label":"Card (POS)","operator":{"id":31650,"name":"E2E Audit"},"receipt_available":true},"user_id":113146,"currency":"EUR","balance_cents":750,"balance":"7.50","debt_blocked":false},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_return_waived.json b/app/src/androidTest/assets/fixtures/s090_patrons_return_waived.json new file mode 100644 index 0000000..d730736 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_return_waived.json @@ -0,0 +1 @@ +{"data":{"id":98229,"status":"restituito","status_label":"Returned","origin":"diretto","book":{"id":343656,"title":"Patroncap fine book","cover_url":"http://localhost:8195/uploads/copertine/placeholder.jpg","isbn13":null},"copy":{"id":110233,"inventory_number":"LIB-343656-C1","status":"disponibile"},"user":{"id":113146,"name":"Marta Patroncap","email":"patroncap2_090@example.test","card_number":"PCAP090B","status":"attivo"},"processed_by":{"id":31650,"name":"E2E Audit"},"requested_at":"2026-11-01","loaned_at":"2026-10-12","due_at":"2026-10-28","pickup_deadline":null,"returned_at":"2026-11-01","returned_late":true,"days_overdue":0,"renewals":0,"max_renewals":3,"penalty":null,"notes":null,"recall_count":0,"last_recall_at":null,"created_at":"2026-11-01 19:59:29","updated_at":"2026-11-01 19:59:31","actions":["receipt"],"late_fine":{"currency":"EUR","automatic_fines":true,"accruing_days":0,"accruing_cents":0,"charged_cents":0}},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_summary.json b/app/src/androidTest/assets/fixtures/s090_patrons_summary.json new file mode 100644 index 0000000..452929a --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_summary.json @@ -0,0 +1 @@ +{"data":{"pending":0,"pickup_ready":0,"scheduled":0,"active":1,"overdue":1,"reservations":0,"due_today":0,"returned_today":0,"today":"2026-11-01","settings":{"loan_duration_days":30,"pickup_expiry_days":3,"max_renewals":3,"max_active_loans_per_user":0,"allow_multiple_loans_same_book":false}},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_ui_cancelled.json b/app/src/androidTest/assets/fixtures/s090_patrons_ui_cancelled.json new file mode 100644 index 0000000..3569323 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_ui_cancelled.json @@ -0,0 +1 @@ +{"data":{"id":"7eb8631a-3ab1-46ec-b464-ee47f50aa144","cancelled":true,"status":"cancelled","import_id":"11680492bb5bd59b5f2564ebcd604a7ccd51","history_warning":null},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_ui_chunk.json b/app/src/androidTest/assets/fixtures/s090_patrons_ui_chunk.json new file mode 100644 index 0000000..e2042d6 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_ui_chunk.json @@ -0,0 +1 @@ +{"data":{"id":"74151523-1959-4ac8-b635-0bd862ae9a27","kind":"users","file_name":"import2.csv","status":"completed","started":true,"complete":true,"total_rows":2,"processed":2,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1b_090@example.test","PCAPIMP1B"],["NoMail","Pcapimport","","PCAPIMP3B"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":false,"created":1,"duplicates":0,"failed":1,"invited":0,"invite_failed":0,"errors":[{"line":3,"title":"NoMail Pcapimport","message":"Email missing.","type":"validation"}],"import_id":"178225c0c2b3d046d7954ee9e9f57237db42","report_available":true,"notice":null,"log_error":null,"created_at":"2026-11-01 19:59:31","updated_at":"2026-11-01 19:59:32","expires_at":"2026-11-02 19:59:32"},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_ui_job_ready.json b/app/src/androidTest/assets/fixtures/s090_patrons_ui_job_ready.json new file mode 100644 index 0000000..0651ce4 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_ui_job_ready.json @@ -0,0 +1 @@ +{"data":{"id":"f15dde31-c5f2-4184-8f16-1a6df05246b8","kind":"users","file_name":"readers-a.csv","status":"ready","started":false,"complete":false,"total_rows":2,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1c_090@example.test","PCAPIMP1C"],["Sara","Pcapimport","pcapimp2c_090@example.test","PCAPIMP2C"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":null,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":null,"report_available":false,"notice":null,"log_error":null,"created_at":"2026-11-01 20:28:21","updated_at":"2026-11-01 20:28:21","expires_at":"2026-11-02 20:28:21"},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_ui_list.json b/app/src/androidTest/assets/fixtures/s090_patrons_ui_list.json new file mode 100644 index 0000000..79716c1 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_ui_list.json @@ -0,0 +1 @@ +{"data":[{"id":"7eb8631a-3ab1-46ec-b464-ee47f50aa144","kind":"users","file_name":"readers-b.csv","status":"ready","started":true,"complete":false,"total_rows":2,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1c_090@example.test","PCAPIMP1C"],["Sara","Pcapimport","pcapimp2c_090@example.test","PCAPIMP2C"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":false,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":null,"report_available":false,"notice":null,"log_error":null,"created_at":"2026-11-01 20:28:21","updated_at":"2026-11-01 20:28:21","expires_at":"2026-11-02 20:28:21"},{"id":"f15dde31-c5f2-4184-8f16-1a6df05246b8","kind":"users","file_name":"readers-a.csv","status":"ready","started":false,"complete":false,"total_rows":2,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1c_090@example.test","PCAPIMP1C"],["Sara","Pcapimport","pcapimp2c_090@example.test","PCAPIMP2C"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":null,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":null,"report_available":false,"notice":null,"log_error":null,"created_at":"2026-11-01 20:28:21","updated_at":"2026-11-01 20:28:21","expires_at":"2026-11-02 20:28:21"}],"meta":{"count":2,"limit":30,"next_cursor":null,"has_more":false},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_ui_list_after.json b/app/src/androidTest/assets/fixtures/s090_patrons_ui_list_after.json new file mode 100644 index 0000000..0ff6b99 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_ui_list_after.json @@ -0,0 +1 @@ +{"data":[{"id":"7eb8631a-3ab1-46ec-b464-ee47f50aa144","kind":"users","file_name":"readers-b.csv","status":"cancelled","started":true,"complete":false,"total_rows":2,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1c_090@example.test","PCAPIMP1C"],["Sara","Pcapimport","pcapimp2c_090@example.test","PCAPIMP2C"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":false,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":"11680492bb5bd59b5f2564ebcd604a7ccd51","report_available":true,"notice":null,"log_error":null,"created_at":"2026-11-01 20:28:21","updated_at":"2026-11-01 20:28:30","expires_at":"2026-11-02 20:28:30"},{"id":"f15dde31-c5f2-4184-8f16-1a6df05246b8","kind":"users","file_name":"readers-a.csv","status":"cancelled","started":false,"complete":false,"total_rows":2,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1c_090@example.test","PCAPIMP1C"],["Sara","Pcapimport","pcapimp2c_090@example.test","PCAPIMP2C"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":null,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":null,"report_available":false,"notice":null,"log_error":null,"created_at":"2026-11-01 20:28:21","updated_at":"2026-11-01 20:28:30","expires_at":"2026-11-02 20:28:30"}],"meta":{"count":2,"limit":30,"next_cursor":null,"has_more":false},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_ui_start.json b/app/src/androidTest/assets/fixtures/s090_patrons_ui_start.json new file mode 100644 index 0000000..df543a2 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_ui_start.json @@ -0,0 +1 @@ +{"data":{"id":"74151523-1959-4ac8-b635-0bd862ae9a27","kind":"users","file_name":"import2.csv","status":"ready","started":true,"complete":false,"total_rows":2,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1b_090@example.test","PCAPIMP1B"],["NoMail","Pcapimport","","PCAPIMP3B"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":false,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":null,"report_available":false,"notice":null,"log_error":null,"created_at":"2026-11-01 19:59:31","updated_at":"2026-11-01 19:59:31","expires_at":"2026-11-02 19:59:31"},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_ui_upload.json b/app/src/androidTest/assets/fixtures/s090_patrons_ui_upload.json new file mode 100644 index 0000000..84375b6 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_ui_upload.json @@ -0,0 +1 @@ +{"data":{"id":"74151523-1959-4ac8-b635-0bd862ae9a27","kind":"users","file_name":"import2.csv","status":"ready","started":false,"complete":false,"total_rows":2,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1b_090@example.test","PCAPIMP1B"],["NoMail","Pcapimport","","PCAPIMP3B"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":null,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":null,"report_available":false,"notice":null,"log_error":null,"created_at":"2026-11-01 19:59:31","updated_at":"2026-11-01 19:59:31","expires_at":"2026-11-02 19:59:31"},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_patrons_user.json b/app/src/androidTest/assets/fixtures/s090_patrons_user.json new file mode 100644 index 0000000..4a8f917 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_patrons_user.json @@ -0,0 +1 @@ +{"data":{"id":113146,"name":"Marta Patroncap","email":"patroncap2_090@example.test","card_number":"PCAP090B","status":"attivo","card_expires_at":"2027-03-31","role":"standard","active_loans":1,"active_reservations":0,"eligible":false,"eligibility_reason":"debt_block","patron_type":{"id":4,"code":"pcap_fines2","name":"E2E Fines","assigned":true},"has_photo":false,"photo_url":null,"ledger":{"balance_cents":1250,"currency":"EUR","debt_blocked":true},"can_edit":true,"loans":[{"id":98229,"status":"in_ritardo","status_label":"Overdue","origin":"diretto","book":{"id":343656,"title":"Patroncap fine book","cover_url":"http://localhost:8195/uploads/copertine/placeholder.jpg","isbn13":null},"copy":{"id":110233,"inventory_number":"LIB-343656-C1","status":"prestato"},"user":{"id":113146,"name":"Marta Patroncap","email":"patroncap2_090@example.test","card_number":"PCAP090B","status":"attivo"},"processed_by":{"id":31650,"name":"E2E Audit"},"requested_at":"2026-11-01","loaned_at":"2026-10-12","due_at":"2026-10-28","pickup_deadline":null,"returned_at":null,"returned_late":false,"days_overdue":4,"renewals":0,"max_renewals":3,"penalty":null,"notes":null,"recall_count":0,"last_recall_at":null,"created_at":"2026-11-01 19:59:29","updated_at":"2026-11-01 19:59:29","actions":["return","edit","recall","receipt","email_receipt"]}],"reservations":[]},"meta":{},"error":null} diff --git a/app/src/androidTest/assets/fixtures/s090_statistics_full.json b/app/src/androidTest/assets/fixtures/s090_statistics_full.json new file mode 100644 index 0000000..f3e1e45 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_statistics_full.json @@ -0,0 +1 @@ +{"data":{"overview":{"books_available":62,"books_on_loan":0,"active_loans":2,"overdue_loans":0,"completed_loans":15,"borrowers":8},"loans_by_month":[{"month":"2026-09","total":2},{"month":"2026-10","total":15},{"month":"2026-11","total":1}],"loans_by_status":[{"status":"in_corso","label":"In Corso","total":2}],"loans_by_media_type":[{"media_type":"libro","label":"Libro","total":8},{"media_type":"disco","label":"Disco","total":0},{"media_type":"audiolibro","label":"Audiolibro","total":0},{"media_type":"dvd","label":"DVD","total":0},{"media_type":"altro","label":"Altro","total":0}],"top_books":[{"id":62988,"title":"Il nome della rosa","cover_url":"http://localhost:8192/uploads/copertine/copertina_6a19b9feb8eb97.41923692.jpg","total_loans":1,"completed_loans":0,"active_loans":0,"overdue_loans":0},{"id":138376,"title":"E2E Edited Book mumjcgbp","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":138377,"title":"Fantastic Mr. Fox","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":221653,"title":"E2E Edited Book mux6dfk7","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":243897,"title":"Pride and Prejudice","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":253918,"title":"Pride and prejudice","cover_url":"http://localhost:8192/uploads/copertine/copertina_6ace511eebab47.54483099.jpg","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0},{"id":339352,"title":"E2E Edited Book mvrdo6d1","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":339386,"title":"E2E Manual Book mvreq1qg","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":341188,"title":"ASTD4A665 B2","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0}],"top_readers":[{"id":31650,"name":"E2E Audit","email":"e2e-audit@test.local","user_type":"admin","total_loans":9,"completed_loans":9,"active_loans":0,"overdue_loans":0},{"id":86263,"name":"Lettore Dimostrazione","email":"lettore.demo@test.local","user_type":"standard","total_loans":3,"completed_loans":2,"active_loans":0,"overdue_loans":0},{"id":6805,"name":"Giulia Bianchi","email":"giulia.bianchi@esempio.it","user_type":"standard","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0},{"id":47370,"name":"E2E Usermumjcgbp","email":"e2e-user-mumjcgbp@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":74168,"name":"E2E Usermux6dfk7","email":"e2e-user-mux6dfk7@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":112753,"name":"E2E Usermvrdo6d1","email":"e2e-user-mvrdo6d1@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":112758,"name":"E2E Usermvreq1qg","email":"e2e-user-mvreq1qg@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":113889,"name":"E2E ASTR","email":"ast-reader-astd4a665@stocktake.test.local","user_type":"standard","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0}],"visitors":{"days":30,"members":0,"guests":0,"total":0,"daily":[{"date":"2026-10-03","members":0,"guests":0,"total":0},{"date":"2026-10-04","members":0,"guests":0,"total":0},{"date":"2026-10-05","members":0,"guests":0,"total":0},{"date":"2026-10-06","members":0,"guests":0,"total":0},{"date":"2026-10-07","members":0,"guests":0,"total":0},{"date":"2026-10-08","members":0,"guests":0,"total":0},{"date":"2026-10-09","members":0,"guests":0,"total":0},{"date":"2026-10-10","members":0,"guests":0,"total":0},{"date":"2026-10-11","members":0,"guests":0,"total":0},{"date":"2026-10-12","members":0,"guests":0,"total":0},{"date":"2026-10-13","members":0,"guests":0,"total":0},{"date":"2026-10-14","members":0,"guests":0,"total":0},{"date":"2026-10-15","members":0,"guests":0,"total":0},{"date":"2026-10-16","members":0,"guests":0,"total":0},{"date":"2026-10-17","members":0,"guests":0,"total":0},{"date":"2026-10-18","members":0,"guests":0,"total":0},{"date":"2026-10-19","members":0,"guests":0,"total":0},{"date":"2026-10-20","members":0,"guests":0,"total":0},{"date":"2026-10-21","members":0,"guests":0,"total":0},{"date":"2026-10-22","members":0,"guests":0,"total":0},{"date":"2026-10-23","members":0,"guests":0,"total":0},{"date":"2026-10-24","members":0,"guests":0,"total":0},{"date":"2026-10-25","members":0,"guests":0,"total":0},{"date":"2026-10-26","members":0,"guests":0,"total":0},{"date":"2026-10-27","members":0,"guests":0,"total":0},{"date":"2026-10-28","members":0,"guests":0,"total":0},{"date":"2026-10-29","members":0,"guests":0,"total":0},{"date":"2026-10-30","members":0,"guests":0,"total":0},{"date":"2026-10-31","members":0,"guests":0,"total":0},{"date":"2026-11-01","members":0,"guests":0,"total":0}]},"sections":[{"id":"patron-types","title":"Prestiti per tipologia di lettore","rows":[{"label":"Standard","value":8}],"note":"Ultimi 12 mesi. I lettori senza tipologia sono contati in «Standard», la tipologia predefinita."},{"id":"fines","title":"Multe","rows":[{"label":"Addebitato","value":"12,50 €"},{"label":"Incassato","value":"5,00 €"},{"label":"Condonato","value":"0,00 €"},{"label":"Stornato","value":"0,00 €"},{"label":"Saldo da incassare","value":"7,50 €"},{"label":"Lettori con saldo a debito","value":1}],"note":"Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso."}],"fines_report":{"from":"2026-01-01","to":"2026-11-30","currency":"EUR","months":[{"month":"2026-11","charged_cents":1250,"collected_cents":500,"waived_cents":0,"reversed_cents":0}],"totals":{"charged_cents":1250,"collected_cents":500,"waived_cents":0,"reversed_cents":0}}},"meta":{"modules":{"visitor_log":true,"circulation_rules":true,"fines":true,"stocktake":true}},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_stocktake_close_lost_refused.json b/app/src/androidTest/assets/fixtures/s090_stocktake_close_lost_refused.json new file mode 100644 index 0000000..05f7637 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_stocktake_close_lost_refused.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"not_permitted","message":"Solo un amministratore può chiudere la revisione segnando come perse le copie mancanti."}} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_stocktake_closed_none.json b/app/src/androidTest/assets/fixtures/s090_stocktake_closed_none.json new file mode 100644 index 0000000..e0b62ca --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_stocktake_closed_none.json @@ -0,0 +1 @@ +{"data":{"id":41,"name":"ASTD4A665 shelf X","status":"closed","status_label":"Chiusa","status_color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":"2026-11-01T20:17:34+00:00","closed_by":{"id":113888,"name":"E2E ASTS"},"missing_action":"none","summary":{"expected":4,"found":2,"misplaced":1,"on_loan":1,"missing":0,"lost":0,"unknown":2,"out_of_scope":1,"duplicates":1,"scans":8,"mine":7},"pending_lost":0,"can_scan":false,"can_upload":false,"can_close":false,"can_cancel":false,"can_mark_lost":false,"recent_scans":[{"id":99,"code":"ASTD4A665-GHOST","inventory_number":"ASTD4A665-GHOST","outcome":"unknown","outcome_label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:34+00:00"},{"id":98,"code":"ASTD4A665-A3","inventory_number":"ASTD4A665-A3","outcome":"found","outcome_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"copy_id":110853,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:34+00:00"},{"id":97,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"duplicate","outcome_label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":31650,"name":"E2E Audit"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":96,"code":"ASTD4A665-Y1","inventory_number":"ASTD4A665-Y1","outcome":"out_of_scope","outcome_label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"copy_id":110855,"book_id":341189,"title":"ASTD4A665 B3","collocation":"ASTD4A665.B3","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":95,"code":"ASTD4A665-NOPE","inventory_number":"ASTD4A665-NOPE","outcome":"unknown","outcome_label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":94,"code":"ASTD4A665-L1","inventory_number":"ASTD4A665-L1","outcome":"on_loan","outcome_label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"copy_id":110854,"book_id":341188,"title":"ASTD4A665 B2","collocation":"ASTD4A665.B2","media_type":"libro","copy_status":"prestato","copy_status_label":"Prestato","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":93,"code":"ASTD4A665-A2","inventory_number":"ASTD4A665-A2","outcome":"misplaced","outcome_label":"Fuori posto","color":{"name":"amber","foreground":"#92400e","background":"#fef3c7"},"copy_id":110852,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":92,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"found","outcome_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"copy_id":110851,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"}],"history":[{"event":"inventory.closed","label":"Revisione inventariale chiusa","operator_name":"E2E ASTS","at":"2026-11-01T20:17:34+00:00"},{"event":"inventory.opened","label":"Revisione inventariale aperta","operator_name":"E2E ASTS","at":"2026-11-01T20:17:32+00:00"}],"close_result":{"resumed":false,"marked_lost":0,"pending_lost":0,"partial":false,"message":"Revisione chiusa: le copie mancanti sono rimaste invariate."}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_stocktake_health.json b/app/src/androidTest/assets/fixtures/s090_stocktake_health.json new file mode 100644 index 0000000..946a152 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_stocktake_health.json @@ -0,0 +1 @@ +{"data":{"name":"Pinakes","logo":null,"version":"0.9.0-rc.1","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true,"reviews":true,"archives":true,"desiderata":true,"staff_catalog":true,"catalog_sync":true,"calendar_feed":true,"automatic_fines":false,"debt_block":false,"consultation_only":true,"fines":true,"closures_calendar":true,"circulation_rules":true,"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true,"staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true,"staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true,"staff_attendance":true,"staff_catalogue_cards":true,"staff_circulation_rules":true,"staff_closures_calendar":true,"staff_ledger":true,"staff_library_settings":true,"staff_marc":true,"staff_patrons":true,"staff_statistics":true,"staff_stocktake":true,"staff_suppliers":false,"staff_user_import":true,"stocktake":true,"marc":true,"visitor_log":true,"catalogue_cards":true,"suppliers":false,"rda_types":false,"patron_cards":true,"user_import":true,"patron_photos":true,"automatic_card_expiry":true,"loss_penalties":false,"attendance_retention":false},"catalogue_mode":false,"loan_approval_required":true,"app_access_enabled":true,"registration_enabled":true,"registration":{"require_cognome":true,"require_telefono":true,"require_indirizzo":true,"show_sesso":true,"show_cod_fiscale":true,"custom_fields":[]},"private_mode":false,"vapid_public_key":"BC3bzpzhkfeec0L8zhwaSI3iEIgP6ZLNoAiUf7FWKTSY-Jph0N0H39LWQNdVoanG6aTr_nFAhhYoXUDIlaSdqdI"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_stocktake_options_staff.json b/app/src/androidTest/assets/fixtures/s090_stocktake_options_staff.json new file mode 100644 index 0000000..3be12d8 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_stocktake_options_staff.json @@ -0,0 +1 @@ +{"data":{"branches":[],"shelves":[{"id":1504,"code":"ASTD4A665X","name":"Stocktake app X","label":"[ASTD4A665X] Stocktake app X","levels":[{"id":2154,"level":1}]},{"id":1505,"code":"ASTD4A665Y","name":"Stocktake app Y","label":"[ASTD4A665Y] Stocktake app Y","levels":[]},{"id":1099,"code":"ZT","name":"Armadio test","label":"[ZT] Armadio test","levels":[{"id":1607,"level":1}]}],"genres":[{"id":2742,"label":"CascRoot_muwg10hj"},{"id":2743,"label":"— CascGenre_muwg10hj"},{"id":2744,"label":"— — CascSub_muwg10hj"},{"id":2776,"label":"CascRoot_muwhtg4d"},{"id":2777,"label":"— CascGenre_muwhtg4d"},{"id":2778,"label":"— — CascSub_muwhtg4d"},{"id":2784,"label":"CascRoot_muwhv6ft"},{"id":2785,"label":"— CascGenre_muwhv6ft"},{"id":2786,"label":"— — CascSub_muwhv6ft"},{"id":2792,"label":"CascRoot_muwhwnha"},{"id":2793,"label":"— CascGenre_muwhwnha"},{"id":2794,"label":"— — CascSub_muwhwnha"},{"id":2853,"label":"CascRoot_mux6kb11"},{"id":2854,"label":"— CascGenre_mux6kb11"},{"id":2855,"label":"— — CascSub_mux6kb11"},{"id":3446,"label":"CascRoot_mv6ab6kt"},{"id":3447,"label":"— CascGenre_mv6ab6kt"},{"id":3448,"label":"— — CascSub_mv6ab6kt"},{"id":3286,"label":"Classici"},{"id":5088,"label":"E2E SEO Padre mvjjkous"},{"id":5089,"label":"— E2E SEO Figlio mvjjkous"},{"id":3288,"label":"Fantasy"},{"id":3287,"label":"Filosofia"},{"id":3285,"label":"Memorialistica"},{"id":2861,"label":"Narrativa"},{"id":2,"label":"Poesia"},{"id":8,"label":"— Didascalica"},{"id":125,"label":"— — epistola filosofica in versi"},{"id":124,"label":"— — favola in versi"},{"id":121,"label":"— — Poema didascalico - poemetto didascalico"},{"id":120,"label":"— — Poema didattico-allegorico - poemetto didattico-allegorico"},{"id":122,"label":"— — Poema sapienziale - poemetto sapienziale"},{"id":123,"label":"— — Profezia"},{"id":7,"label":"— Epica"},{"id":118,"label":"— — Epillio"},{"id":115,"label":"— — Poema epico - collettivo"},{"id":116,"label":"— — Poema epico - epico-cavalleresco"},{"id":114,"label":"— — Poema epico - mitologico"},{"id":117,"label":"— — Poema epico - poemetto epico"},{"id":113,"label":"— — Poema epico - storico"},{"id":119,"label":"— — Poema eroicomico - poemetto eroicomico"},{"id":9,"label":"— Lirica"},{"id":154,"label":"— — Ballata"},{"id":158,"label":"— — Calligramma"},{"id":149,"label":"— — Canzone - libera"},{"id":148,"label":"— — Canzone - petrarchesca"},{"id":150,"label":"— — Capitolo"},{"id":151,"label":"— — Carme"},{"id":156,"label":"— — Centone"},{"id":159,"label":"— — Epistola lirica"},{"id":144,"label":"— — Lauda"},{"id":129,"label":"— — lirica corale - Ditirambo"},{"id":130,"label":"— — lirica corale - Encomio"},{"id":131,"label":"— — lirica corale - Epicedio"},{"id":132,"label":"— — lirica corale - Epinicio"},{"id":133,"label":"— — lirica corale - Epitalamio"},{"id":134,"label":"— — lirica corale - Imeneo"},{"id":135,"label":"— — lirica corale - Inno"},{"id":136,"label":"— — lirica corale - Iporchema"},{"id":137,"label":"— — lirica corale - Nomo"},{"id":138,"label":"— — lirica corale - Partenio"},{"id":139,"label":"— — lirica corale - Peana"},{"id":140,"label":"— — lirica corale - Prosodio"},{"id":141,"label":"— — lirica corale - Scolio"},{"id":142,"label":"— — lirica corale - Treno"},{"id":126,"label":"— — lirica monodica - Elegia"},{"id":127,"label":"— — lirica monodica - Giambo - Cinedio"},{"id":128,"label":"— — lirica monodica - Giambo - Meliambo"},{"id":153,"label":"— — Madrigale"},{"id":152,"label":"— — Ode"},{"id":155,"label":"— — Poemetto lirico"},{"id":145,"label":"— — Poesia bucolica"},{"id":143,"label":"— — Preghiera"},{"id":146,"label":"— — Satira"},{"id":147,"label":"— — Sonetto"},{"id":157,"label":"— — Verso libero"},{"id":1,"label":"Prosa"},{"id":5,"label":"— Divulgativa"},{"id":98,"label":"— — Aforisma"},{"id":106,"label":"— — agiografia"},{"id":99,"label":"— — Apologia"},{"id":105,"label":"— — aretalogia"},{"id":107,"label":"— — biografia kerygmatica"},{"id":94,"label":"— — Dialogo"},{"id":102,"label":"— — Emblematica"},{"id":103,"label":"— — Epistola in prosa - privata"},{"id":104,"label":"— — Epistola in prosa - pubblica"},{"id":101,"label":"— — Manifesto"},{"id":97,"label":"— — Opuscolo - Libello"},{"id":100,"label":"— — Parabola"},{"id":109,"label":"— — romanzo d'inchiesta"},{"id":108,"label":"— — romanzo filosofico"},{"id":96,"label":"— — Saggio"},{"id":95,"label":"— — Trattato"},{"id":6,"label":"— Eloquenza"},{"id":112,"label":"— — Oratoria - Oratoria deliberativa"},{"id":110,"label":"— — Oratoria - Oratoria epidittica"},{"id":111,"label":"— — Oratoria - Oratoria giudiziaria"},{"id":4,"label":"— Narrativa"},{"id":91,"label":"— — Biografia - autobiografia"},{"id":92,"label":"— — Biografia - memorialistica"},{"id":93,"label":"— — Diario"},{"id":86,"label":"— — Favola in prosa"},{"id":85,"label":"— — Fiaba"},{"id":84,"label":"— — Racconto - stessi sottogeneri del romanzo"},{"id":83,"label":"— — Romanzo - epistolare"},{"id":77,"label":"— — Romanzo - erotico"},{"id":47,"label":"— — Romanzo - fantascienza - fantapolitica - distopia"},{"id":46,"label":"— — Romanzo - fantascienza - fantapolitica - utopia"},{"id":41,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - apocalittico e postapocalittico"},{"id":33,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - atompunk"},{"id":19,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - biopunk"},{"id":32,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - clockpunk"},{"id":22,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - decopunk"},{"id":21,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - dieselpunk"},{"id":29,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Dreampunk"},{"id":31,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Ecopunk"},{"id":30,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Elfpunk"},{"id":34,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - mythpunk"},{"id":20,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - nanopunk"},{"id":26,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - nowpunk"},{"id":18,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - postcyberpunk"},{"id":28,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - raypunk"},{"id":25,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Rococopunk"},{"id":24,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - sandalpunk"},{"id":37,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - solarpunk"},{"id":36,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - steampunk"},{"id":27,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - steelpunk"},{"id":35,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - stonepunk"},{"id":23,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Swordpunk"},{"id":45,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - edisonata"},{"id":44,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - new wave"},{"id":42,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - planetary romance e sword and planet"},{"id":17,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - Singularity Fiction"},{"id":38,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - space opera - space opera militare"},{"id":39,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - space opera - space western"},{"id":40,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - space opera - xenofiction"},{"id":43,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - viaggi nel tempo"},{"id":53,"label":"— — Romanzo - fantasy - fantasy contemporaneo - bangsian fantasy"},{"id":52,"label":"— — Romanzo - fantasy - fantasy contemporaneo - urban fantasy"},{"id":49,"label":"— — Romanzo - fantasy - high fantasy o fantasy epico - dark fantasy"},{"id":48,"label":"— — Romanzo - fantasy - high fantasy o fantasy epico - sword and sorcery"},{"id":50,"label":"— — Romanzo - fantasy - low fantasy"},{"id":51,"label":"— — Romanzo - fantasy - Realismo magico"},{"id":12,"label":"— — Romanzo - frammento"},{"id":71,"label":"— — Romanzo - giallo - Enigma della camera chiusa"},{"id":69,"label":"— — Romanzo - giallo - giallo giudiziario"},{"id":70,"label":"— — Romanzo - giallo - giallo medico"},{"id":73,"label":"— — Romanzo - hard boiled"},{"id":72,"label":"— — Romanzo - noir - noir metropolitano"},{"id":75,"label":"— — Romanzo - poliziesco - Police procedural"},{"id":78,"label":"— — Romanzo - psicologico"},{"id":56,"label":"— — Romanzo - romanzo d'appendice"},{"id":54,"label":"— — Romanzo - romanzo d'avventura"},{"id":59,"label":"— — Romanzo - romanzo d'azione"},{"id":58,"label":"— — Romanzo - romanzo dell'orrore - gotico"},{"id":55,"label":"— — Romanzo - romanzo di formazione"},{"id":57,"label":"— — Romanzo - romanzo per ragazzi"},{"id":76,"label":"— — Romanzo - rosa"},{"id":82,"label":"— — Romanzo - sociale"},{"id":74,"label":"— — Romanzo - soft boiled"},{"id":60,"label":"— — Romanzo - Spy story"},{"id":15,"label":"— — Romanzo - storico - cappa e spada o wuxia"},{"id":13,"label":"— — Romanzo - storico - fantastoria"},{"id":16,"label":"— — Romanzo - storico - Peplum o Sword and sandal"},{"id":14,"label":"— — Romanzo - storico - ucronia"},{"id":65,"label":"— — Romanzo - thriller - Action Thriller - Pulp - Crime Thriller"},{"id":67,"label":"— — Romanzo - thriller - legal thriller"},{"id":61,"label":"— — Romanzo - thriller - Mistery Thriller"},{"id":64,"label":"— — Romanzo - thriller - Noir Thriller"},{"id":66,"label":"— — Romanzo - thriller - Tecnothriller"},{"id":63,"label":"— — Romanzo - thriller - Thriller di spionaggio"},{"id":68,"label":"— — Romanzo - thriller - Thriller politico - Thriller cospirativo"},{"id":62,"label":"— — Romanzo - thriller - Thriller psicologico"},{"id":81,"label":"— — Romanzo - umoristico - parodia"},{"id":80,"label":"— — Romanzo - umoristico - satira"},{"id":79,"label":"— — Romanzo - umoristico - umorismo nero"},{"id":88,"label":"— — Storiografia - annali"},{"id":89,"label":"— — Storiografia - cronaca"},{"id":87,"label":"— — Storiografia - monografia"},{"id":90,"label":"— — Storiografia - storia universale"},{"id":3,"label":"Teatro"},{"id":11,"label":"— Teatro di musica"},{"id":174,"label":"— — Balletto"},{"id":180,"label":"— — Dramma musicale"},{"id":177,"label":"— — Mimo lirico - ilarodia"},{"id":178,"label":"— — Mimo lirico - magodia"},{"id":176,"label":"— — Mimo lirico - mimiambo"},{"id":179,"label":"— — Mimo lirico - Musicale"},{"id":171,"label":"— — Opera - farsa"},{"id":173,"label":"— — Opera - Intermezzi"},{"id":169,"label":"— — Opera - opera buffa"},{"id":168,"label":"— — Opera - opera seria"},{"id":170,"label":"— — Opera - operetta"},{"id":172,"label":"— — Opera - Zarzuela"},{"id":181,"label":"— — Oratorio"},{"id":175,"label":"— — Pantomima"},{"id":10,"label":"— Teatro di prosa"},{"id":161,"label":"— — Commedia"},{"id":164,"label":"— — Commedia dell'arte"},{"id":162,"label":"— — Dramma"},{"id":165,"label":"— — Farsa"},{"id":163,"label":"— — Melodramma"},{"id":167,"label":"— — Mimo prosastico"},{"id":166,"label":"— — Sketch"},{"id":160,"label":"— — Tragedia"},{"id":630,"label":"zz Interop140 Genere zz-int140-0a9da0db"},{"id":4515,"label":"ZZ_LANG282_3fd637ba4a_G"}],"media_types":[{"code":"libro","label":"Libro"},{"code":"disco","label":"Disco"},{"code":"audiolibro","label":"Audiolibro"},{"code":"dvd","label":"DVD"},{"code":"altro","label":"Altro"}],"outcomes":[{"code":"found","label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"tone":{"frequency_hz":880,"duration_ms":120},"scan_result":true},{"code":"misplaced","label":"Fuori posto","color":{"name":"amber","foreground":"#92400e","background":"#fef3c7"},"tone":{"frequency_hz":520,"duration_ms":250},"scan_result":true},{"code":"on_loan","label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"tone":{"frequency_hz":660,"duration_ms":250},"scan_result":true},{"code":"missing","label":"Mancante","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"tone":null,"scan_result":false},{"code":"unknown","label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"tone":{"frequency_hz":220,"duration_ms":450},"scan_result":true},{"code":"out_of_scope","label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"tone":{"frequency_hz":440,"duration_ms":300},"scan_result":true},{"code":"duplicate","label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"tone":{"frequency_hz":330,"duration_ms":200},"scan_result":true}],"statuses":[{"code":"open","label":"Aperta","color":{"name":"green","foreground":"#166534","background":"#dcfce7"}},{"code":"closed","label":"Chiusa","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"}},{"code":"cancelled","label":"Annullata","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"}}],"missing_actions":[{"code":"none","label":"Lascia le copie mancanti come sono","admin_only":false},{"code":"lost","label":"Segna come perse le copie mancanti","admin_only":true}],"can_mark_lost":false,"limits":{"max_code_length":100,"max_upload_codes":5000,"max_upload_bytes":2097152,"upload_extensions":["txt","csv"],"max_name_length":255,"max_collocation_length":50,"scans_per_minute":600}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_stocktake_report.json b/app/src/androidTest/assets/fixtures/s090_stocktake_report.json new file mode 100644 index 0000000..ad15544 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_stocktake_report.json @@ -0,0 +1 @@ +{"data":[{"category":"missing","category_label":"Mancante","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"code":"ASTD4A665-A3","title":"ASTD4A665 B1","media_type":"libro","collocation":"ASTD4A665.B1","expected_shelf":"[ASTD4A665X] Stocktake app X","copy_status":"disponibile","copy_status_label":"Disponibile","operator_name":null,"scanned_at":null},{"category":"duplicate","category_label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"code":"ASTD4A665-A1","title":"","media_type":null,"collocation":null,"expected_shelf":null,"copy_status":null,"copy_status_label":null,"operator_name":"E2E Audit","scanned_at":"2026-11-01T20:17:33+00:00"},{"category":"out_of_scope","category_label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"code":"ASTD4A665-Y1","title":"ASTD4A665 B3","media_type":"libro","collocation":"ASTD4A665.B3","expected_shelf":null,"copy_status":"disponibile","copy_status_label":"Disponibile","operator_name":"E2E ASTS","scanned_at":"2026-11-01T20:17:33+00:00"},{"category":"unknown","category_label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"code":"ASTD4A665-NOPE","title":"","media_type":null,"collocation":null,"expected_shelf":null,"copy_status":null,"copy_status_label":null,"operator_name":"E2E ASTS","scanned_at":"2026-11-01T20:17:33+00:00"},{"category":"on_loan","category_label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"code":"ASTD4A665-L1","title":"ASTD4A665 B2","media_type":"libro","collocation":"ASTD4A665.B2","expected_shelf":"[ASTD4A665X] Stocktake app X","copy_status":"prestato","copy_status_label":"Prestato","operator_name":"E2E ASTS","scanned_at":"2026-11-01T20:17:33+00:00"},{"category":"misplaced","category_label":"Fuori posto","color":{"name":"amber","foreground":"#92400e","background":"#fef3c7"},"code":"ASTD4A665-A2","title":"ASTD4A665 B1","media_type":"libro","collocation":"ASTD4A665.B1","expected_shelf":"[ASTD4A665X] Stocktake app X","copy_status":"disponibile","copy_status_label":"Disponibile","operator_name":"E2E ASTS","scanned_at":"2026-11-01T20:17:33+00:00"},{"category":"found","category_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"code":"ASTD4A665-A1","title":"ASTD4A665 B1","media_type":"libro","collocation":"ASTD4A665.B1","expected_shelf":"[ASTD4A665X] Stocktake app X","copy_status":"disponibile","copy_status_label":"Disponibile","operator_name":"E2E ASTS","scanned_at":"2026-11-01T20:17:33+00:00"}],"meta":{"count":7,"limit":50,"next_cursor":null,"has_more":false,"total_count":7},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_stocktake_scan_found.json b/app/src/androidTest/assets/fixtures/s090_stocktake_scan_found.json new file mode 100644 index 0000000..18365ca --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_stocktake_scan_found.json @@ -0,0 +1 @@ +{"data":{"scan":{"id":92,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"found","outcome_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"copy_id":110851,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},"tone":{"frequency_hz":880,"duration_ms":120},"summary":{"expected":4,"found":1,"misplaced":0,"on_loan":1,"missing":2,"lost":0,"unknown":0,"out_of_scope":0,"duplicates":0,"scans":1,"mine":1},"session_status":"open"},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_stocktake_session_created.json b/app/src/androidTest/assets/fixtures/s090_stocktake_session_created.json new file mode 100644 index 0000000..4452d36 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_stocktake_session_created.json @@ -0,0 +1 @@ +{"data":{"id":41,"name":"ASTD4A665 shelf X","status":"open","status_label":"Aperta","status_color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":null,"closed_by":null,"missing_action":null,"summary":{"expected":4,"found":0,"misplaced":0,"on_loan":1,"missing":3,"lost":0,"unknown":0,"out_of_scope":0,"duplicates":0,"scans":0,"mine":0},"pending_lost":0,"can_scan":true,"can_upload":true,"can_close":true,"can_cancel":true,"can_mark_lost":false,"recent_scans":[],"history":[{"event":"inventory.opened","label":"Revisione inventariale aperta","operator_name":"E2E ASTS","at":"2026-11-01T20:17:32+00:00"}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_stocktake_session_detail.json b/app/src/androidTest/assets/fixtures/s090_stocktake_session_detail.json new file mode 100644 index 0000000..f735a1b --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_stocktake_session_detail.json @@ -0,0 +1 @@ +{"data":{"id":41,"name":"ASTD4A665 shelf X","status":"open","status_label":"Aperta","status_color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":null,"closed_by":null,"missing_action":null,"summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":1,"out_of_scope":1,"duplicates":1,"scans":6,"mine":5},"pending_lost":0,"can_scan":true,"can_upload":true,"can_close":true,"can_cancel":true,"can_mark_lost":false,"recent_scans":[{"id":97,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"duplicate","outcome_label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":31650,"name":"E2E Audit"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":96,"code":"ASTD4A665-Y1","inventory_number":"ASTD4A665-Y1","outcome":"out_of_scope","outcome_label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"copy_id":110855,"book_id":341189,"title":"ASTD4A665 B3","collocation":"ASTD4A665.B3","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":95,"code":"ASTD4A665-NOPE","inventory_number":"ASTD4A665-NOPE","outcome":"unknown","outcome_label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":94,"code":"ASTD4A665-L1","inventory_number":"ASTD4A665-L1","outcome":"on_loan","outcome_label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"copy_id":110854,"book_id":341188,"title":"ASTD4A665 B2","collocation":"ASTD4A665.B2","media_type":"libro","copy_status":"prestato","copy_status_label":"Prestato","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":93,"code":"ASTD4A665-A2","inventory_number":"ASTD4A665-A2","outcome":"misplaced","outcome_label":"Fuori posto","color":{"name":"amber","foreground":"#92400e","background":"#fef3c7"},"copy_id":110852,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":92,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"found","outcome_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"copy_id":110851,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"}],"history":[{"event":"inventory.opened","label":"Revisione inventariale aperta","operator_name":"E2E ASTS","at":"2026-11-01T20:17:32+00:00"}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_stocktake_session_detail_mine.json b/app/src/androidTest/assets/fixtures/s090_stocktake_session_detail_mine.json new file mode 100644 index 0000000..0a3f34c --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_stocktake_session_detail_mine.json @@ -0,0 +1 @@ +{"data":{"id":41,"name":"ASTD4A665 shelf X","status":"open","status_label":"Aperta","status_color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":null,"closed_by":null,"missing_action":null,"summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":1,"out_of_scope":1,"duplicates":1,"scans":6,"mine":1},"pending_lost":0,"can_scan":true,"can_upload":true,"can_close":true,"can_cancel":true,"can_mark_lost":true,"recent_scans":[{"id":97,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"duplicate","outcome_label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":31650,"name":"E2E Audit"},"scanned_at":"2026-11-01T20:17:33+00:00"}],"history":[{"event":"inventory.opened","label":"Revisione inventariale aperta","operator_name":"E2E ASTS","at":"2026-11-01T20:17:32+00:00"}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_stocktake_sessions_open.json b/app/src/androidTest/assets/fixtures/s090_stocktake_sessions_open.json new file mode 100644 index 0000000..1f275ac --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_stocktake_sessions_open.json @@ -0,0 +1 @@ +{"data":[{"id":41,"name":"ASTD4A665 shelf X","status":"open","status_label":"Aperta","status_color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":null,"closed_by":null,"missing_action":null,"summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":1,"out_of_scope":1,"duplicates":1,"scans":6,"mine":5},"pending_lost":0,"can_scan":true,"can_upload":true,"can_close":true,"can_cancel":true,"can_mark_lost":false}],"meta":{"count":1,"limit":30,"next_cursor":null,"has_more":false,"total_count":1},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/s090_stocktake_state.json b/app/src/androidTest/assets/fixtures/s090_stocktake_state.json new file mode 100644 index 0000000..ce28211 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/s090_stocktake_state.json @@ -0,0 +1 @@ +{"data":{"id":41,"status":"open","summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":1,"out_of_scope":1,"duplicates":1,"scans":6,"mine":5},"recent_scans":[{"id":97,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"duplicate","outcome_label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":31650,"name":"E2E Audit"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":96,"code":"ASTD4A665-Y1","inventory_number":"ASTD4A665-Y1","outcome":"out_of_scope","outcome_label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"copy_id":110855,"book_id":341189,"title":"ASTD4A665 B3","collocation":"ASTD4A665.B3","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":95,"code":"ASTD4A665-NOPE","inventory_number":"ASTD4A665-NOPE","outcome":"unknown","outcome_label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":94,"code":"ASTD4A665-L1","inventory_number":"ASTD4A665-L1","outcome":"on_loan","outcome_label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"copy_id":110854,"book_id":341188,"title":"ASTD4A665 B2","collocation":"ASTD4A665.B2","media_type":"libro","copy_status":"prestato","copy_status_label":"Prestato","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":93,"code":"ASTD4A665-A2","inventory_number":"ASTD4A665-A2","outcome":"misplaced","outcome_label":"Fuori posto","color":{"name":"amber","foreground":"#92400e","background":"#fef3c7"},"copy_id":110852,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":92,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"found","outcome_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"copy_id":110851,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_cc_import.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_cc_import.json new file mode 100644 index 0000000..a04235f --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_cc_import.json @@ -0,0 +1 @@ +{"data":{"imported":1,"updated":0,"skipped":0,"failed":0,"covers":0,"import_id":"e4a081b37f4129e58d75ac79688b67ef967a","items":[{"id":"6eb1848e432e386c","action":"created","book_id":338683,"title":"Libro Cat090 Prova","warnings":[],"message":null}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_cc_search.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_cc_search.json new file mode 100644 index 0000000..b7b4354 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_cc_search.json @@ -0,0 +1 @@ +{"data":{"search_id":"53248631-5faf-463d-8da2-09b7f326a9f3","expires_at":"2026-11-02 19:39:52","results":[{"id":"6eb1848e432e386c","server":"Questa installazione (Pinakes)","flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"cover_url":null,"existing_id":null,"detail":[{"tag":"LDR","ind":"","value":"00000nam a2200000 4500"},{"tag":"001","ind":"","value":"338681"},{"tag":"008","ind":"","value":"261101s xx "},{"tag":"245","ind":"00","value":"$a Libro Cat090 Prova"},{"tag":"852","ind":" ","value":"$j LIB-338681-C1 $z Status: Available $z Note: Copia 1 di 1"},{"tag":"866","ind":" ","value":"$a Total copies: 1, Available: 1"}]},{"id":"9de1cf3b2e40e13c","server":"Questa installazione (Pinakes)","flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"cover_url":null,"existing_id":null,"detail":[{"tag":"LDR","ind":"","value":"00000nam a2200000 4500"},{"tag":"001","ind":"","value":"338682"},{"tag":"008","ind":"","value":"261101s xx ita "},{"tag":"041","ind":"0 ","value":"$a ita"},{"tag":"245","ind":"00","value":"$a Libro Cat090 Prova"},{"tag":"852","ind":" ","value":"$j LIB-338682-C1 $z Status: Available $z Note: Copia 1 di 1"},{"tag":"866","ind":" ","value":"$a Total copies: 1, Available: 1"}]}],"servers":[{"key":"local","name":"Questa installazione (Pinakes)","total":2,"count":2,"error":null}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_cc_servers.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_cc_servers.json new file mode 100644 index 0000000..bcdc717 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_cc_servers.json @@ -0,0 +1 @@ +{"data":{"servers":[{"key":"cfg:0","name":"BIBSYS - Norwegian Union Catalogue","kind":"configured","url":"https://bibsys.alma.exlibrisgroup.com/view/sru/47BIBSYS_NETWORK","syntax":"marcxml"},{"key":"cfg:1","name":"LIBRIS - Swedish Union Catalogue","kind":"configured","url":"https://libris.kb.se/sru/libris","syntax":"marcxml"},{"key":"sbn","name":"SBN - OPAC del Servizio Bibliotecario Nazionale","kind":"preset","url":"https://opac.sbn.it","syntax":"sbn"},{"key":"preset:loc","name":"Library of Congress","kind":"preset","url":"http://lx2.loc.gov:210/LCDB","syntax":"marcxml"},{"key":"preset:bnf","name":"BnF - Bibliothèque nationale de France","kind":"preset","url":"https://catalogue.bnf.fr/api/SRU","syntax":"unimarcxchange"},{"key":"preset:dnb","name":"DNB - Deutsche Nationalbibliothek","kind":"preset","url":"https://services.dnb.de/sru/dnb","syntax":"MARC21-xml"},{"key":"preset:k10plus","name":"K10plus (GBV)","kind":"preset","url":"http://sru.k10plus.de/opac-de-627","syntax":"marcxml"},{"key":"local","name":"Questa installazione (Pinakes)","kind":"local","url":"http://localhost:8194/api/sru","syntax":"marcxml"}],"indexes":[{"value":"title","label":"Titolo"},{"value":"author","label":"Autore"},{"value":"isbn","label":"ISBN"},{"value":"issn","label":"ISSN"},{"value":"any","label":"Tutti i campi"}],"duplicate_modes":[{"value":"skip","label":"Salta","description":"Il libro già in catalogo resta com'è"},{"value":"fill","label":"Completa i campi vuoti","description":"Solo i campi vuoti del libro vengono riempiti dal record"},{"value":"overwrite","label":"Sovrascrivi","description":"I campi presenti nel record sostituiscono quelli del libro; gli altri restano"}],"max_results_per_server":25,"max_import_ids":100},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_health.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_health.json new file mode 100644 index 0000000..100a8af --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_health.json @@ -0,0 +1 @@ +{"data":{"name":"Pinakes","logo":null,"version":"0.9.0-rc.1","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true,"reviews":true,"archives":true,"desiderata":true,"staff_catalog":true,"catalog_sync":true,"calendar_feed":true,"automatic_fines":false,"debt_block":false,"consultation_only":true,"fines":false,"closures_calendar":true,"circulation_rules":true,"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true,"staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true,"staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true,"staff_attendance":false,"staff_catalogue_cards":true,"staff_circulation_rules":true,"staff_closures_calendar":true,"staff_ledger":false,"staff_library_settings":true,"staff_marc":true,"staff_patrons":true,"staff_statistics":true,"staff_stocktake":false,"staff_suppliers":true,"staff_user_import":true,"stocktake":false,"marc":true,"visitor_log":false,"catalogue_cards":true,"suppliers":true,"rda_types":true,"patron_cards":true,"user_import":true,"patron_photos":true,"automatic_card_expiry":true,"loss_penalties":false,"attendance_retention":false},"catalogue_mode":false,"loan_approval_required":true,"app_access_enabled":true,"registration_enabled":true,"registration":{"require_cognome":true,"require_telefono":true,"require_indirizzo":true,"show_sesso":true,"show_cod_fiscale":true,"custom_fields":[]},"private_mode":false,"vapid_public_key":"BK1dWXzNtPiZOaBfelUsLszTE-E_EeL8a-wYLVs_wUR2XMjR3uYIY89X53Wl0TngenWEx2VzCqtRcfOX31zuEog"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_lookups_full.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_lookups_full.json new file mode 100644 index 0000000..b914c30 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_lookups_full.json @@ -0,0 +1 @@ +{"data":{"languages":[{"code":"italiano","label":"italiano"},{"code":"English","label":"English"},{"code":"ZzLang3fd637ba4a","label":"ZzLang3fd637ba4a"},{"code":"ZzSort3fd637ba4a","label":"ZzSort3fd637ba4a"},{"code":"Deutsch","label":"Deutsch"},{"code":"Français","label":"Français"},{"code":"inglese","label":"inglese"},{"code":"ZzOther3fd637ba4a","label":"ZzOther3fd637ba4a"}],"media_types":[{"value":"libro","label":"Libro"},{"value":"disco","label":"Disco"},{"value":"audiolibro","label":"Audiolibro"},{"value":"dvd","label":"DVD"},{"value":"altro","label":"Altro"}],"formats":[{"value":"cartaceo","label":"Cartaceo"}],"acquisition_types":[{"value":"acquisto","label":"Acquisto"},{"value":"donazione","label":"Donazione"},{"value":"prestito","label":"Prestito"}],"contributor_roles":[{"value":"principale","label":"Autore"},{"value":"co-autore","label":"Co-autore"},{"value":"traduttore","label":"Traduttore"},{"value":"illustratore","label":"Illustratore"},{"value":"curatore","label":"Curatore"},{"value":"colorista","label":"Colorista"}],"copy_states":[{"value":"disponibile","label":"Disponibile","creatable":true,"editable":true},{"value":"prestato","label":"Prestato","creatable":false,"editable":false},{"value":"prenotato","label":"Prenotato","creatable":false,"editable":false},{"value":"manutenzione","label":"Manutenzione","creatable":true,"editable":true},{"value":"in_restauro","label":"In restauro","creatable":true,"editable":true},{"value":"perso","label":"Perso","creatable":true,"editable":true},{"value":"danneggiato","label":"Danneggiato","creatable":true,"editable":true},{"value":"in_trasferimento","label":"In trasferimento","creatable":true,"editable":true}],"series_types":[{"value":"serie","label":"Serie"},{"value":"universo","label":"Universo / macroserie"},{"value":"ciclo","label":"Ciclo"},{"value":"stagione","label":"Stagione"},{"value":"spin_off","label":"Spin-off"},{"value":"arco","label":"Arco narrativo"},{"value":"collezione_editoriale","label":"Collana editoriale"},{"value":"altro","label":"Altro"}],"suppliers":[{"id":21,"name":"Libreria Cat090 Prova"}],"rda_content_types":[{"value":"txt","label":"Testo"},{"value":"spw","label":"Parola parlata"},{"value":"prm","label":"Musica eseguita"},{"value":"ntm","label":"Musica notata"},{"value":"snd","label":"Suoni"},{"value":"sti","label":"Immagine fissa"},{"value":"tdi","label":"Immagine in movimento bidimensionale"},{"value":"cri","label":"Immagine cartografica"},{"value":"cop","label":"Programma per computer"},{"value":"xxx","label":"Altro"}],"rda_media_types":[{"value":"n","label":"Senza mediazione"},{"value":"s","label":"Audio"},{"value":"v","label":"Video"},{"value":"c","label":"Computer"},{"value":"h","label":"Microforma"},{"value":"g","label":"Proiettato"},{"value":"x","label":"Altro"}],"rda_carrier_types":[{"value":"nc","label":"Volume"},{"value":"no","label":"Scheda"},{"value":"nb","label":"Foglio"},{"value":"sd","label":"Disco sonoro"},{"value":"ss","label":"Audiocassetta"},{"value":"vd","label":"Videodisco"},{"value":"cd","label":"Disco per computer"},{"value":"cr","label":"Risorsa online"},{"value":"zu","label":"Non specificato"}],"return_outcomes":[{"value":"restituito","label":"Restituito"},{"value":"manutenzione","label":"Manutenzione"},{"value":"in_restauro","label":"In restauro"},{"value":"perso","label":"Perso"},{"value":"danneggiato","label":"Danneggiato"}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_chunk_409.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_chunk_409.json new file mode 100644 index 0000000..edc235d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_chunk_409.json @@ -0,0 +1 @@ +{"data":null,"meta":{"processed":0},"error":{"code":"out_of_order","message":"Blocco fuori sequenza: riprendi dalla riga indicata."}} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_job_completed.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_job_completed.json new file mode 100644 index 0000000..8cb5160 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_job_completed.json @@ -0,0 +1 @@ +{"data":{"id":"6dd79110-5736-4456-aabb-07e682b77466","kind":"marc","file_name":"prova.mrc","status":"completed","total_rows":1,"processed":1,"chunk_size":10,"enable_scraping":false,"imported":1,"updated":0,"scraped":0,"authors_created":0,"publishers_created":0,"errors":0,"error_details":[],"complete":true,"created_at":"2026-11-01 19:39:44","updated_at":"2026-11-01 19:39:44","expires_at":"2026-11-02 19:39:44","skipped":0,"covers":0,"failed":0,"import_id":"fcba5bd46c2dbafb057abe13d8615218e30c","duplicates":"skip","download_covers":false,"supplier_id":21,"flavour":"auto","encoding":"auto","container":"iso2709","unreadable":0,"truncated":null,"preview":[{"position":1,"flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"has_cover":false,"existing_id":null,"error":null}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_job_ready.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_job_ready.json new file mode 100644 index 0000000..ce67135 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_job_ready.json @@ -0,0 +1 @@ +{"data":{"id":"6dd79110-5736-4456-aabb-07e682b77466","kind":"marc","file_name":"prova.mrc","status":"ready","total_rows":1,"processed":0,"chunk_size":10,"enable_scraping":false,"imported":0,"updated":0,"scraped":0,"authors_created":0,"publishers_created":0,"errors":0,"error_details":[],"complete":false,"created_at":"2026-11-01 19:39:44","updated_at":"2026-11-01 19:39:44","expires_at":"2026-11-02 19:39:44","skipped":0,"covers":0,"failed":0,"import_id":null,"duplicates":"skip","download_covers":false,"supplier_id":21,"flavour":"auto","encoding":"auto","container":"iso2709","unreadable":0,"truncated":null,"preview":[{"position":1,"flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"has_cover":false,"existing_id":null,"error":null}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_jobs.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_jobs.json new file mode 100644 index 0000000..cd356e5 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_jobs.json @@ -0,0 +1 @@ +{"data":[{"id":"3232bb79-91dd-427d-afae-ce574c49af04","kind":"marc","file_name":"altro-dispositivo.mrc","status":"ready","total_rows":1,"processed":0,"chunk_size":10,"enable_scraping":false,"imported":0,"updated":0,"scraped":0,"authors_created":0,"publishers_created":0,"errors":0,"error_details":[],"complete":false,"created_at":"2026-11-01 20:28:13","updated_at":"2026-11-01 20:28:13","expires_at":"2026-11-02 20:28:13","skipped":0,"covers":0,"failed":0,"import_id":null,"duplicates":"fill","download_covers":false,"supplier_id":null,"flavour":"auto","encoding":"auto","container":"iso2709","unreadable":0,"truncated":null,"preview":[{"position":1,"flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"has_cover":false,"existing_id":null,"error":null}]},{"id":"a0769c0f-5d48-4968-aed6-e19722059184","kind":"marc","file_name":"vecchio.mrc","status":"cancelled","total_rows":1,"processed":0,"chunk_size":10,"enable_scraping":false,"imported":0,"updated":0,"scraped":0,"authors_created":0,"publishers_created":0,"errors":0,"error_details":[],"complete":false,"created_at":"2026-11-01 20:28:13","updated_at":"2026-11-01 20:28:13","expires_at":"2026-11-02 20:28:13","skipped":0,"covers":0,"failed":0,"import_id":null,"duplicates":"skip","download_covers":false,"supplier_id":null,"flavour":"auto","encoding":"auto","container":"iso2709","unreadable":0,"truncated":null,"preview":[{"position":1,"flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"has_cover":false,"existing_id":null,"error":null}]}],"meta":{"count":2,"limit":30,"next_cursor":null,"has_more":false},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_options.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_options.json new file mode 100644 index 0000000..70718f8 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_options.json @@ -0,0 +1 @@ +{"data":{"flavours":[{"value":"auto","label":"Riconoscimento automatico"},{"value":"marc21","label":"MARC 21"},{"value":"unimarc","label":"UNIMARC"},{"value":"danmarc2","label":"danMARC2"}],"encodings":[{"value":"auto","label":"Dal leader del record (posizione 9)"},{"value":"utf8","label":"UTF-8"},{"value":"marc8","label":"MARC-8"}],"duplicate_modes":[{"value":"skip","label":"Salta","description":"Il libro già in catalogo resta com'è"},{"value":"fill","label":"Completa i campi vuoti","description":"Solo i campi vuoti del libro vengono riempiti dal record"},{"value":"overwrite","label":"Sovrascrivi","description":"I campi presenti nel record sostituiscono quelli del libro; gli altri restano"}],"export_formats":[{"value":"marc21","label":"MARC 21 (.mrc)"},{"value":"marcxml","label":"MARCXML"},{"value":"unimarc","label":"UNIMARC (.mrc)"}],"extensions":["mrc","marc","iso","dat","xml","marcxml"],"max_file_bytes":20971520,"max_records":20000,"max_export_ids":1000,"suppliers":[{"id":21,"name":"Libreria Cat090 Prova"}],"copy_cataloging":{"available":true,"reason":null}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_options_copy_unavailable.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_options_copy_unavailable.json new file mode 100644 index 0000000..75f46f4 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_marc_options_copy_unavailable.json @@ -0,0 +1 @@ +{"data": {"flavours": [{"value": "auto", "label": "Riconoscimento automatico"}, {"value": "marc21", "label": "MARC 21"}, {"value": "unimarc", "label": "UNIMARC"}, {"value": "danmarc2", "label": "danMARC2"}], "encodings": [{"value": "auto", "label": "Dal leader del record (posizione 9)"}, {"value": "utf8", "label": "UTF-8"}, {"value": "marc8", "label": "MARC-8"}], "duplicate_modes": [{"value": "skip", "label": "Salta", "description": "Il libro già in catalogo resta com'è"}, {"value": "fill", "label": "Completa i campi vuoti", "description": "Solo i campi vuoti del libro vengono riempiti dal record"}, {"value": "overwrite", "label": "Sovrascrivi", "description": "I campi presenti nel record sostituiscono quelli del libro; gli altri restano"}], "export_formats": [{"value": "marc21", "label": "MARC 21 (.mrc)"}, {"value": "marcxml", "label": "MARCXML"}, {"value": "unimarc", "label": "UNIMARC (.mrc)"}], "extensions": ["mrc", "marc", "iso", "dat", "xml", "marcxml"], "max_file_bytes": 20971520, "max_records": 20000, "max_export_ids": 1000, "suppliers": [{"id": 21, "name": "Libreria Cat090 Prova"}], "copy_cataloging": {"available": false, "reason": "plugin_inactive"}}, "meta": {}, "error": null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_prova.mrc b/app/src/androidTest/assets/fixtures/staff090_catalogue_prova.mrc new file mode 100644 index 0000000..981ca27 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_prova.mrc @@ -0,0 +1 @@ +00380nam a2200145 4500001000700000003001100007005001700018007000300035008004100038040003700079245002300116336003300139337002800172338003400200338681IT-Pinakes20261101193924.0ta261101s xx d aIT-PinakesbitaerdacIT-Pinakes00aLibro Cat090 Prova aspoken wordbspw2rdacontent aunmediatedbn2rdamedia acomputer discbcd2rdacarrier \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_supplier_422.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_supplier_422.json new file mode 100644 index 0000000..2d90914 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_supplier_422.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"vat_number":"Valore troppo lungo (massimo 20 caratteri).","name":"Il nome del fornitore è obbligatorio.","email":"L'email del fornitore non è un indirizzo valido.","website":"Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it."},"field":"vat_number"},"error":{"code":"validation","message":"Valore troppo lungo (massimo 20 caratteri)."}} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_supplier_created.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_supplier_created.json new file mode 100644 index 0000000..db0791b --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_supplier_created.json @@ -0,0 +1 @@ +{"data":{"id":21,"name":"Libreria Cat090 Prova","contact_person":"Mario Rossi","email":"ordini@example.org","phone":"+39 02 1234","website":"https://www.example.org","address":"Via Roma 1, Milano","vat_number":"IT01234567890","notes":"Consegna il martedì","book_count":0,"created_at":"2026-11-01T19:39:15+00:00","updated_at":"2026-11-01T19:39:15+00:00","can_edit":true,"can_delete":true},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_supplier_deleted.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_supplier_deleted.json new file mode 100644 index 0000000..b149e12 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_supplier_deleted.json @@ -0,0 +1 @@ +{"data":{"id":21,"deleted":true,"books_unlinked":2},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/assets/fixtures/staff090_catalogue_suppliers.json b/app/src/androidTest/assets/fixtures/staff090_catalogue_suppliers.json new file mode 100644 index 0000000..0cd82b3 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff090_catalogue_suppliers.json @@ -0,0 +1 @@ +{"data":[{"id":21,"name":"Libreria Cat090 Prova","contact_person":"Mario Rossi","email":"ordini@example.org","phone":"+39 02 1234","website":"https://www.example.org","address":"Via Roma 1, Milano","vat_number":"IT01234567890","notes":"Consegna il martedì","book_count":0,"created_at":"2026-11-01T19:39:15+00:00","updated_at":"2026-11-01T19:39:15+00:00","can_edit":true,"can_delete":true}],"meta":{"count":1,"can_create":true},"error":null} \ No newline at end of file diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt index 3fd59dd..43d3ef3 100644 --- a/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt @@ -186,6 +186,21 @@ abstract class AppFlowTest { compose.waitForIdle() } + /** + * Tap the button tagged [tag] until one more [method] [path] request reaches the fake server + * (at most three taps, never a second request). A form in a sheet moves while the sheet + * slides in and the keyboard animates above it (seconds on a loaded emulator): a tap computed + * from the previous layout can land beside the button and send nothing. + */ + protected fun tapUntilSent(tag: String, method: String, path: String) { + val before = api.recorded(method, path).size + waitFor(androidx.compose.ui.test.hasTestTag(tag)) + repeat(3) { + if (api.recorded(method, path).size > before) return@repeat + runCatching { clickTag(tag); compose.waitUntil(3_000) { api.recorded(method, path).size > before } } + } + } + /** The UTF-8 body of the last [method] [path] request. */ protected fun lastBody(method: String, path: String): String { waitRequest(method, path) diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalLinkRefreshFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalLinkRefreshFlowTest.kt index 4c59b84..668fe29 100644 --- a/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalLinkRefreshFlowTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalLinkRefreshFlowTest.kt @@ -31,7 +31,7 @@ class DigitalLinkRefreshFlowTest : AppFlowTest() { lateinit var vm: BookDetailViewModel instrumentation.runOnMainSync { vm = BookDetailViewModel(SavedStateHandle(mapOf(Routes.ARG_BOOK_ID to 1)), - deps.catalogRepository(), deps.libraryRepository(), deps.wishlistRepository(), deps.connectivity()) + deps.catalogRepository(), deps.libraryRepository(), deps.wishlistRepository(), deps.connectivity(), deps.featureStore()) store.put("digital", vm) } compose.waitUntil(10_000) { vm.state.value.content is UiState.Success } diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt index 5366f8d..c43a1a2 100644 --- a/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt @@ -58,8 +58,12 @@ class FakePinakes { val apiBaseUrl: String get() = server.url("/api/v1/").toString() val origin: String get() = apiBaseUrl.removeSuffix("/api/v1/") - fun json(route: String, fixture: String, status: Int = 200) { - routes[route] = { MockResponse().setResponseCode(status).setHeader("Content-Type", "application/json").setBody(fixture(fixture)) } + /** [delayMs] holds the body back, to let the screen draw before the answer arrives. */ + fun json(route: String, fixture: String, status: Int = 200, delayMs: Long = 0) { + routes[route] = { + MockResponse().setResponseCode(status).setHeader("Content-Type", "application/json").setBody(fixture(fixture)) + .apply { if (delayMs > 0) setBodyDelay(delayMs, java.util.concurrent.TimeUnit.MILLISECONDS) } + } } /** Answer [route] with each fixture in turn (status, fixture), repeating the last one. */ diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/LibrarySettingsFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/LibrarySettingsFlowTest.kt new file mode 100644 index 0000000..6d45b41 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/LibrarySettingsFlowTest.kt @@ -0,0 +1,162 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performTextReplacement +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Hub → Library: settings (modules, loans), patron types, rules matrix and closures calendar, + * against bodies captured from a Pinakes 0.9.0 server (Mobile API 1.10.0). + */ +class LibrarySettingsFlowTest : AppFlowTest() { + + private val health = "s090_library_health" + + private fun replace(tag: String, value: String) { + val matcher = hasSetTextAction() and hasTestTag(tag) + waitFor(matcher) + compose.onAllNodes(matcher).onFirst().performScrollToSafely().performTextReplacement(value) + compose.waitForIdle() + } + + @Test fun anAdministratorSwitchesAModuleOn() { + launch(health = health) { + json("GET staff/settings/modules", "s090_library_modules_admin") + json("GET staff/settings/loans", "s090_library_loans_admin") + json("GET staff/settings/cards", "s090_library_cards_admin") + json("PUT staff/settings/modules", "s090_library_modules_saved") + } + openStaffHub() + click(text(R.string.staff090_settings)) + waitText(text(R.string.staff090_library_tab_cards)) + click("Fornitori") + clickTag("settings-save") + assertEquals("""{"features":{"suppliers":true}}""", lastBody("PUT", "staff/settings/modules")) + waitText(text(R.string.staff090_library_saved)) + } + + @Test fun staffSavesALoanRuleWithoutMoneyOrModules() { + launch(role = "staff", health = health) { + json("GET me", "me_staff") + json("GET staff/settings/modules", "s090_library_modules_staff") + json("GET staff/settings/loans", "s090_library_loans_staff") + json("GET staff/settings/cards", "s090_library_cards_staff_disabled", status = 404) + json("PUT staff/settings/loans", "s090_library_loans_staff") + } + openStaffHub() + click(text(R.string.staff090_settings)) + waitText(text(R.string.staff090_library_modules_read_only)) + // Modules: no save action at all; no cards tab (404 feature_disabled for Staff). + assertTrue(compose.onAllNodes(hasTestTag("settings-save")).fetchSemanticsNodes().isEmpty()) + assertFalse(exists(text(R.string.staff090_library_tab_cards))) + click(text(R.string.staff090_library_tab_loans)) + replace("loan-max_renewals", "4") + assertTrue(compose.onAllNodes(hasTestTag("loan-daily_fine_cents")).fetchSemanticsNodes().isEmpty()) + clickTag("settings-save") + assertEquals("""{"max_renewals":4}""", lastBody("PUT", "staff/settings/loans")) + } + + @Test fun anAdministratorCreatesAPatronType() { + launch(health = health) { + json("GET staff/circulation/types", "s090_library_types_two") + json("POST staff/circulation/types", "s090_library_type_created", status = 201) + } + openStaffHub() + click(text(R.string.staff090_circulation_rules)) + waitText("Ricercatori app") + clickDescription(text(R.string.staff090_library_type_new)) + typeIntoTag("type-name", "Docenti") + typeIntoTag("type-max_loans", "3") + clickTag("type-save") + assertEquals( + """{"name":"Docenti","reservations_enabled":true,"active":true,"max_loans":3}""", + lastBody("POST", "staff/circulation/types"), + ) + assertEquals(36, api.recorded("POST", "staff/circulation/types").last().getHeader("Idempotency-Key").orEmpty().length) + } + + @Test fun theRulesMatrixSavesOnlyTheChangedCell() { + launch(health = health) { + json("GET staff/circulation/types", "s090_library_types_two") + json("GET staff/circulation/rules", "s090_library_rules") + json("PUT staff/circulation/rules", "s090_library_rules_saved") + } + openStaffHub() + click(text(R.string.staff090_circulation_rules)) + click(text(R.string.staff090_library_matrix)) + waitText(text(R.string.staff090_library_all_types).uppercase()) + clickTag("cell-0-dvd") + typeIntoTag("cell-loan_duration_days", "7") + clickTag("cell-apply") + clickTag("rules-save") + assertEquals("""{"cells":[{"type_id":0,"media":"dvd","loan_duration_days":7}]}""", lastBody("PUT", "staff/circulation/rules")) + waitText(text(R.string.staff090_library_saved)) + } + + @Test fun aClosedDaySaysWhyAndWeeklyDaysAreSaved() { + launch(health = health) { + json("GET staff/calendar", "s090_library_calendar_full") + json("PUT staff/calendar/weekdays", "s090_library_weekdays_saved") + } + openStaffHub() + click(text(R.string.staff090_calendar)) + clickTag("calendar-day-2026-12-25") + waitText(text(R.string.staff090_library_reason_closure, "Chiusura natalizia (prova app)")) + clickInDialog(text(R.string.staff090_library_close)) + clickTag("weekday-6") + clickTag("weekdays-save") + assertEquals("""{"closed_weekdays":[0,6]}""", lastBody("PUT", "staff/calendar/weekdays")) + } + + @Test fun staffSeesLibrarySettingsButNotTheAdministratorTools() { + launch(role = "staff", health = health) { json("GET me", "me_staff") } + openStaffHub() + waitText(text(R.string.staff090_settings)) + // The server admits only administrators to patron types, loan rules and the calendar. + assertFalse(exists(text(R.string.staff090_circulation_rules))) + assertFalse(exists(text(R.string.staff090_calendar))) + } + + /** + * An account the app still takes for an administrator, demoted to Staff on the server: the + * area refuses its first load with 403 not_permitted. The screen closes with the server's + * message and the stored role stays (only the role gate's `forbidden` drops it). + */ + @Test fun aRefusedFirstLoadClosesTheScreenAndKeepsTheRole() { + launch(health = health) { + json("GET staff/circulation/types", "s090_library_types_staff_refused", status = 403) + } + openStaffHub() + click(text(R.string.staff090_circulation_rules)) + waitRequest("GET", "staff/circulation/types") + waitText(text(R.string.staff_scan_subtitle)) + assertFalse(exists(text(R.string.staff090_library_types).uppercase())) + assertEquals("admin", deps.session().role.value) + waitText(text(R.string.staff090_calendar)) + } + + @Test fun aRefusedSaveShowsTheServerMessageAndStaysOnTheScreen() { + launch(role = "staff", health = health) { + json("GET me", "me_staff") + json("GET staff/settings/modules", "s090_library_modules_staff") + json("GET staff/settings/loans", "s090_library_loans_staff") + json("GET staff/settings/cards", "s090_library_cards_staff_disabled", status = 404) + json("PUT staff/settings/loans", "s090_library_loans_staff_refused", status = 403) + } + openStaffHub() + click(text(R.string.staff090_settings)) + click(text(R.string.staff090_library_tab_loans)) + replace("loan-max_renewals", "4") + clickTag("settings-save") + waitRequest("PUT", "staff/settings/loans") + waitText("Operazione riservata agli amministratori") + assertTrue(compose.onAllNodes(hasTestTag("loan-max_renewals")).fetchSemanticsNodes().isNotEmpty()) + assertEquals("staff", deps.session().role.value) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/LoanCreateFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/LoanCreateFlowTest.kt index d328842..2a56895 100644 --- a/app/src/androidTest/java/com/pinakes/app/staffe2e/LoanCreateFlowTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/LoanCreateFlowTest.kt @@ -58,6 +58,6 @@ class LoanCreateFlowTest : AppFlowTest() { typeIntoTag("picker-search", "Orl") click("Orlando") click(text(R.string.staff_loan_create)) - waitText(text(R.string.staff_err_not_eligible)) + waitText(text(R.string.staff_err_user_suspended)) } } diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/Staff090CatalogueFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/Staff090CatalogueFlowTest.kt new file mode 100644 index 0000000..289345e --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/Staff090CatalogueFlowTest.kt @@ -0,0 +1,274 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import com.pinakes.app.R +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pinakes 0.9.0 cataloguing from the staff hub: suppliers, the book form's supplier and RDA + * fields, MARC import and export, copy cataloguing. The fixtures `staff090_catalogue_*` are + * bodies the slot server answered (Mobile API 1.10.0). + */ +class Staff090CatalogueFlowTest : AppFlowTest() { + + private val health = "staff090_catalogue_health" + + private fun openCataloguing(entry: Int, configure: FakePinakes.() -> Unit) { + launch(health = health) { + json("GET staff/lookups", "staff090_catalogue_lookups_full") + json("GET staff/marc/options", "staff090_catalogue_marc_options") + configure() + } + openStaffHub() + click(text(entry)) + } + + /** Scroll the lazy list until the node tagged [tag] is composed. */ + private fun scrollToTag(tag: String) { + val lists = compose.onAllNodes(androidx.compose.ui.test.hasScrollToNodeAction()) + compose.waitUntil(10_000) { lists.fetchSemanticsNodes().isNotEmpty() } + for (i in 0 until lists.fetchSemanticsNodes().size) { + if (runCatching { lists[i].performScrollToNode(androidx.compose.ui.test.hasTestTag(tag)) }.isSuccess) return + } + } + + /** + * Open the dropdown tagged [tag] and pick [option]. The first tap right after typing can + * land while the keyboard closes and the form moves, so the tap is repeated until the menu shows. + */ + private fun pickFromMenu(tag: String, option: String) { + repeat(3) { + if (exists(option)) return@repeat + runCatching { clickTag(tag); compose.waitUntil(3_000) { exists(option) } } + } + click(option) + } + + /** Tap [tag] until the server has seen [count] supplier POSTs (a tap can land while the sheet moves). */ + private fun tapUntilPosted(tag: String, count: Int) { + repeat(3) { + if (api.recorded("POST", "staff/suppliers").size < count) { + runCatching { clickTag(tag); compose.waitUntil(3_000) { api.recorded("POST", "staff/suppliers").size >= count } } + } + } + assertEquals(count, api.recorded("POST", "staff/suppliers").size) + } + + // ---------- suppliers ---------- + + @Test fun aSupplierFieldErrorIsShownThenTheRetryKeepsItsKey() { + openCataloguing(R.string.staff090_suppliers) { + json("GET staff/suppliers", "staff090_catalogue_suppliers") + sequence("POST staff/suppliers", 422 to "staff090_catalogue_supplier_422", 201 to "staff090_catalogue_supplier_created") + } + waitText("Libreria Cat090 Prova") + clickDescription(text(R.string.staff090_catalogue_supplier_new)) + // The sheet slides in: type only once it is up, and check the text landed in the field. + waitFor(androidx.compose.ui.test.hasTestTag("supplier-save")) + compose.waitForIdle() + repeat(3) { + if (!exists("Libreria Nuova")) runCatching { typeIntoTag("supplier-field-name", "Libreria Nuova") } + } + tapUntilPosted("supplier-save", 1) + // The server's own text, under its field. + waitText("Il nome del fornitore è obbligatorio.") + // The field error moved the button: tap again until the retry reaches the server. + tapUntilPosted("supplier-save", 2) + waitGone(androidx.compose.ui.test.hasTestTag("supplier-save")) + val posts = api.recorded("POST", "staff/suppliers") + assertEquals(2, posts.size) + val key = posts.first().getHeader("Idempotency-Key") + assertTrue(!key.isNullOrBlank()) + assertEquals(key, posts.last().getHeader("Idempotency-Key")) + assertEquals("""{"name":"Libreria Nuova"}""", posts.last().body.clone().readUtf8()) + } + + @Test fun deletingASupplierSaysHowManyBooksLoseIt() { + openCataloguing(R.string.staff090_suppliers) { + body( + "GET staff/suppliers", 200, + FakePinakes.fixture("staff090_catalogue_suppliers").replace("\"book_count\":0", "\"book_count\":2"), + ) + json("DELETE staff/suppliers/21", "staff090_catalogue_supplier_deleted") + } + waitText("Libreria Cat090 Prova") + clickDescription(text(R.string.cd_more_options)) + click(text(R.string.staff090_catalogue_supplier_delete)) + waitText(text(R.string.staff090_catalogue_supplier_delete_books, "Libreria Cat090 Prova", 2)) + clickInDialog(text(R.string.staff090_catalogue_supplier_delete)) + waitText(text(R.string.staff090_catalogue_supplier_deleted, 2)) + assertEquals(1, api.recorded("DELETE", "staff/suppliers/21").size) + } + + // ---------- book form ---------- + + @Test fun aNewRecordCarriesSupplierAndRdaOverride() { + launch(health = health) { + json("GET staff/lookups", "staff090_catalogue_lookups_full") + json("POST staff/books", "staff_book_created", status = 201) + } + openStaffHub() + click(text(R.string.staff_add_manual)) + waitText(text(R.string.staff_field_title)) + compose.onAllNodes(hasSetTextAction() and hasText(text(R.string.staff_field_title), substring = true)).onFirst().performTextInput("Libro con fornitore") + pickFromMenu("book-supplier", "Libreria Cat090 Prova") + pickFromMenu("book-rda-content", "Parola parlata") + click(text(R.string.staff_book_save)) + waitRequest("POST", "staff/books") + val body = Json.parseToJsonElement(api.recorded("POST", "staff/books").single().body.readUtf8()).jsonObject + assertEquals("21", body["supplier_id"].toString()) + assertEquals("\"spw\"", body["rda_content_type"].toString()) + assertFalse(body.containsKey("rda_media_type")) + } + + @Test fun withTheModulesOffTheFormSendsNoCataloguingKey() { + launch { json("POST staff/books", "staff_book_created", status = 201) } + openStaffHub() + click(text(R.string.staff_add_manual)) + waitText(text(R.string.staff_field_title)) + compose.onAllNodes(hasSetTextAction() and hasText(text(R.string.staff_field_title), substring = true)).onFirst().performTextInput("Libro semplice") + assertFalse(exists(text(R.string.staff090_catalogue_rda_title))) + click(text(R.string.staff_book_save)) + waitRequest("POST", "staff/books") + val body = Json.parseToJsonElement(api.recorded("POST", "staff/books").single().body.readUtf8()).jsonObject + assertFalse(body.containsKey("supplier_id")) + assertFalse(body.containsKey("rda_content_type")) + } + + // ---------- MARC ---------- + + @Test fun uploadPreviewThenChunksFromTheServersPosition() { + val name = "e2e-marc-${System.currentTimeMillis() % 100000}.mrc" + val bytes = instrumentation.context.assets.open("fixtures/staff090_catalogue_prova.mrc").use { it.readBytes() } + // The record is plain ASCII (control characters included), so the text round trip keeps every byte. + val uri = publishDownload(name, String(bytes, Charsets.US_ASCII), mime = "application/octet-stream") + try { + openCataloguing(R.string.staff090_marc) { + json("POST staff/marc/imports", "staff090_catalogue_marc_job_ready", status = 201) + json("GET staff/marc/imports/6dd79110-5736-4456-aabb-07e682b77466", "staff090_catalogue_marc_job_ready") + // The server says it is still at 0 (another device's chunk failed), then completes. + sequence( + "POST staff/marc/imports/6dd79110-5736-4456-aabb-07e682b77466/chunk", + 409 to "staff090_catalogue_marc_chunk_409", + 200 to "staff090_catalogue_marc_job_completed", + ) + } + click(text(R.string.staff_import_choose_file)) + pickInDocumentsUi(name) + waitText(name, timeoutMs = 15_000) + pickFromMenu("marc-supplier", "Libreria Cat090 Prova") + clickTag("marc-upload") + waitText(text(R.string.staff090_catalogue_marc_preview)) + val upload = api.recorded("POST", "staff/marc/imports").single().body.clone().readUtf8() + assertTrue(upload, upload.contains("filename=\"$name\"")) + assertTrue(upload, upload.contains("name=\"supplier_id\"") && upload.contains("21")) + assertTrue(upload, upload.contains("name=\"duplicates\"")) + waitText("Libro Cat090 Prova") + clickTag("marc-run") + waitText(text(R.string.staff_import_status_done), timeoutMs = 20_000) + val starts = api.recorded("POST", "staff/marc/imports/6dd79110-5736-4456-aabb-07e682b77466/chunk").map { it.body.clone().readUtf8() } + assertEquals(listOf("""{"start":0}""", """{"start":0}"""), starts) + waitText(text(R.string.staff090_catalogue_marc_progress, 1, 1)) + } finally { + context.contentResolver.delete(uri, null, null) + } + } + + private val otherJob = "3232bb79-91dd-427d-afae-ce574c49af04" + + @Test fun aJobStartedElsewhereIsResumedFromTheList() { + openCataloguing(R.string.staff090_marc) { + // The list answers after the form is drawn, as on a slow connection: the jobs must + // still show at the top, not above it. + json("GET staff/marc/imports", "staff090_catalogue_marc_jobs", delayMs = 1_500) + body( + "POST staff/marc/imports/$otherJob/chunk", 200, + FakePinakes.fixture("staff090_catalogue_marc_job_completed").replace("6dd79110-5736-4456-aabb-07e682b77466", otherJob), + ) + } + waitText("altro-dispositivo.mrc") + // The cancelled job of the same list is not offered. + assertFalse(exists("vecchio.mrc")) + clickTag("marc-open-resume-$otherJob") + waitText(text(R.string.staff_import_status_done), timeoutMs = 20_000) + assertEquals(listOf("""{"start":0}"""), api.recorded("POST", "staff/marc/imports/$otherJob/chunk").map { it.body.clone().readUtf8() }) + } + + @Test fun aListedJobCanBeCancelled() { + openCataloguing(R.string.staff090_marc) { + // The list answers after the form is drawn, as on a slow connection: the jobs must + // still show at the top, not above it. + json("GET staff/marc/imports", "staff090_catalogue_marc_jobs", delayMs = 1_500) + body("DELETE staff/marc/imports/$otherJob", 200, """{"data":{"id":"$otherJob","cancelled":true},"meta":{},"error":null}""") + } + waitText("altro-dispositivo.mrc") + clickTag("marc-open-cancel-$otherJob") + waitRequest("DELETE", "staff/marc/imports/$otherJob") + waitGone(hasText("altro-dispositivo.mrc")) + } + + @Test fun exportTheWholeCatalogue() { + openCataloguing(R.string.staff090_marc) { + body( + "GET staff/marc/export", 200, String(instrumentation.context.assets.open("fixtures/staff090_catalogue_prova.mrc").use { it.readBytes() }, Charsets.ISO_8859_1), + headers = mapOf("Content-Type" to "application/marc", "Content-Disposition" to "attachment; filename=\"catalogo_unimarc_2026-11-01.mrc\""), + ) + } + clickTag("marc-tab-export") + click("UNIMARC (.mrc)") + clickTag("marc-export") + waitText("catalogo_unimarc_2026-11-01.mrc") + val request = api.recorded("GET", "staff/marc/export").single() + assertEquals("unimarc", request.requestUrl?.queryParameter("format")) + assertEquals(null, request.requestUrl?.queryParameter("ids")) + } + + // ---------- copy cataloguing ---------- + + @Test fun searchImportAndOpenTheNewBook() { + openCataloguing(R.string.staff090_copy_cataloging) { + json("GET staff/copy-cataloging/servers", "staff090_catalogue_cc_servers") + json("POST staff/copy-cataloging/search", "staff090_catalogue_cc_search", status = 201) + json("POST staff/copy-cataloging/import", "staff090_catalogue_cc_import") + json("GET staff/books/338683", "staff_book_5") + } + clickTag("copy-server-local") + typeIntoTag("copy-term", "Libro Cat090 Prova") + clickTag("copy-search") + waitRequest("POST", "staff/copy-cataloging/search") + val search = Json.parseToJsonElement(api.recorded("POST", "staff/copy-cataloging/search").single().body.readUtf8()).jsonObject + assertEquals("""["local"]""", search["servers"].toString()) + assertEquals("\"Libro Cat090 Prova\"", search["term"].toString()) + // The results sit below the search form: scroll the list to them first. + scrollToTag("copy-result-6eb1848e432e386c") + clickTag("copy-result-6eb1848e432e386c") + scrollToTag("copy-import") + clickTag("copy-import") + waitRequest("POST", "staff/copy-cataloging/import") + val import = api.recorded("POST", "staff/copy-cataloging/import").single() + assertTrue(!import.getHeader("Idempotency-Key").isNullOrBlank()) + val body = Json.parseToJsonElement(import.body.readUtf8()).jsonObject + assertEquals("""["6eb1848e432e386c"]""", body["ids"].toString()) + assertEquals("\"53248631-5faf-463d-8da2-09b7f326a9f3\"", body["search_id"].toString()) + scrollListTo(text(R.string.staff090_catalogue_copy_action_created)) + click(text(R.string.staff090_catalogue_copy_action_created)) + waitRequest("GET", "staff/books/338683") + } + + @Test fun withoutTheZ3950PluginTheReasonIsShown() { + openCataloguing(R.string.staff090_copy_cataloging) { + json("GET staff/marc/options", "staff090_catalogue_marc_options_copy_unavailable") + } + waitText(text(R.string.staff090_catalogue_copy_reason_plugin)) + assertTrue(api.recorded("GET", "staff/copy-cataloging/servers").isEmpty()) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/Staff090PatronsFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/Staff090PatronsFlowTest.kt new file mode 100644 index 0000000..c3f60f1 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/Staff090PatronsFlowTest.kt @@ -0,0 +1,231 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performTextReplacement +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The Pinakes 0.9.0 patron tools (Mobile API 1.10.0) against bodies captured from a real + * 0.9.0-rc.1 server (fixtures `s090_patrons_*`): the patron section and the ledger, the return + * with a waived late fine, the visitor log desk and the patron CSV import. + */ +class Staff090PatronsFlowTest : AppFlowTest() { + + private val health = "s090_patrons_health" + + private fun desk() = launch(health = health) { + json("GET staff/circulation/summary", "s090_patrons_summary") + json("GET staff/loans", "s090_patrons_loans_overdue") + json("GET staff/loans/98229", "s090_patrons_loan_overdue") + json("POST staff/loans/98229/return", "s090_patrons_return_waived") + json("GET staff/users/113146", "s090_patrons_user") + sequence("GET staff/users/113146/ledger", 200 to "s090_patrons_ledger", 200 to "s090_patrons_ledger_after") + json("POST staff/users/113146/ledger/payments", "s090_patrons_payment", status = 201) + } + + private fun openOverdueLoan() { + openStaffHub() + click(text(R.string.staff_circulation_desk)) + clickTag("counter-overdue") + click("Patroncap fine book") + waitText("LIB-343656-C1") + } + + @Test fun theReturnShowsTheLateFineAndSendsTheWaiver() { + desk() + openOverdueLoan() + clickTag("action-return") + waitText(text(R.string.staff_return_title)) + waitFor(androidx.compose.ui.test.hasTestTag("return-fine")) + click(text(R.string.staff090_patrons_return_waive)) + clickTag("return-confirm") + compose.waitForIdle() + // No reason yet: nothing sent. + assertTrue(api.recorded("POST", "staff/loans/98229/return").isEmpty()) + typeIntoTag("return-waive-reason", "First time") + clickTag("return-confirm") + val body = lastBody("POST", "staff/loans/98229/return") + assertTrue(body, body.contains("\"waive_fine\":true")) + assertTrue(body, body.contains("\"waive_reason\":\"First time\"")) + waitText(text(R.string.staff_loan_returned)) + } + + @Test fun theBorrowerShowsTheBalanceAndAPaymentIsRecordedOnce() { + desk() + openOverdueLoan() + click(text(R.string.staff_user_open)) + waitFor(androidx.compose.ui.test.hasTestTag("patron-section")) + waitText("E2E Fines") + click(text(R.string.staff090_ledger)) + waitRequest("GET", "staff/users/113146/ledger") + // The description is part of the row's detail line ("Lost library card · date"). + waitFor(androidx.compose.ui.test.hasText("Lost library card", substring = true)) + waitText("Patroncap fine book") + clickTag("ledger-payment") + clickTag("method-pos") + clickTag("ledger-submit") + waitRequest("POST", "staff/users/113146/ledger/payments") + val sent = api.recorded("POST", "staff/users/113146/ledger/payments").last() + val body = sent.body.clone().readUtf8() + // The amount field starts from the whole balance (12.50): exact cents, no float. + assertTrue(body, body.contains("\"amount_cents\":1250")) + assertTrue(body, body.contains("\"method\":\"pos\"")) + assertNotNull(sent.getHeader("Idempotency-Key")) + assertEquals(1, api.recorded("POST", "staff/users/113146/ledger/payments").size) + waitText(text(R.string.staff090_patrons_payment_recorded)) + waitFor(androidx.compose.ui.test.hasTestTag("receipt-19")) + } + + /** + * Type [code] in the desk field and send it with the keyboard's Done, as a wedge reader does, + * until the server has seen [posts] check-ins. The field sits under imePadding: while the + * keyboard animates in (seconds on a loaded emulator) the whole column moves, so a tap on + * the field's icon computed from the old layout can land beside it and send nothing. + */ + private fun checkInAtDesk(code: String, posts: Int) { + val field = androidx.compose.ui.test.hasSetTextAction() and androidx.compose.ui.test.hasTestTag("card-code-input") + repeat(3) { + if (api.recorded("POST", "staff/attendance/check-ins").size >= posts) return@repeat + runCatching { + waitFor(field) + compose.onAllNodes(field).onFirst().performTextReplacement(code) + compose.onAllNodes(field).onFirst().performImeAction() + compose.waitUntil(5_000) { api.recorded("POST", "staff/attendance/check-ins").size >= posts } + } + } + assertEquals(posts, api.recorded("POST", "staff/attendance/check-ins").size) + } + + @Test fun theDeskRecordsACardAndSaysWhenItIsUnknown() { + launch(health = health) { + json("GET staff/attendance", "s090_patrons_att_overview") + sequence( + "POST staff/attendance/check-ins", + 201 to "s090_patrons_att_checkin", + 404 to "s090_patrons_att_unknown", + ) + json("POST staff/attendance/guests", "s090_patrons_att_guest", status = 201) + } + openStaffHub() + click(text(R.string.staff090_attendance)) + denyCameraPromptIfShown() + checkInAtDesk(" PCAP090B ", posts = 1) + val first = lastBody("POST", "staff/attendance/check-ins") + assertTrue(first, first.contains("\"card_code\":\"PCAP090B\"")) + assertNotNull(api.recorded("POST", "staff/attendance/check-ins").last().getHeader("Idempotency-Key")) + waitFor(androidx.compose.ui.test.hasTestTag("visit-welcome")) + waitText("Marta P.") + + checkInAtDesk("NOPE-PCAP", posts = 2) + waitFor(androidx.compose.ui.test.hasTestTag("visit-unknown_card")) + + clickTag("guest-open") + typeIntoTag("guest-name", "Visiting pcap") + typeIntoTag("guest-reason", "Research") + clickTag("guest-save") + val guest = lastBody("POST", "staff/attendance/guests") + assertTrue(guest, guest.contains("\"name\":\"Visiting pcap\"")) + waitFor(androidx.compose.ui.test.hasTestTag("visit-guest")) + } + + @Test fun anAdministratorOnlyActionRefusedKeepsTheStaffArea() { + launch(role = "staff", health = health) { + // The app refreshes the role from /me: a Staff account's own answer. + json("GET me", "me_staff") + json("GET staff/attendance", "s090_patrons_att_overview") + json("POST staff/attendance/purge", "s090_patrons_np_purge", status = 403) + } + openStaffHub() + click(text(R.string.staff090_attendance)) + denyCameraPromptIfShown() + clickTag("tab-stats") + // The captured overview is an administrator's: the purge is offered. + clickTag("attendance-purge") + clickInDialog(text(R.string.staff090_patrons_purge)) + waitText("Operation reserved for administrators.") + // 403 not_permitted never drops the role: still on the screen, still staff. + assertFalse(api.recorded("POST", "staff/attendance/purge").isEmpty()) + waitFor(androidx.compose.ui.test.hasTestTag("tab-desk")) + assertEquals("staff", deps.session().role.value) + } + + @Test fun aPatronCsvIsUploadedMappedAndImported() { + val name = "e2e-patrons-${System.currentTimeMillis() % 100000}.csv" + val uri = publishDownload(name, "Nome;Cognome;Email;Tessera\nLuca;Pcapimport;pcapimp1b_090@example.test;PCAPIMP1B\nNoMail;Pcapimport;;PCAPIMP3B\n") + try { + launch(health = health) { + json("POST staff/user-imports", "s090_patrons_ui_upload", status = 201) + routeJobs() + } + openStaffHub() + click(text(R.string.staff090_user_import)) + click(text(R.string.staff_import_choose_file)) + pickInDocumentsUi(name) + waitText(name, timeoutMs = 15_000) + clickTag("user-import-upload") + val upload = lastBody("POST", "staff/user-imports") + assertTrue(upload, upload.contains("filename=\"$name\"")) + assertTrue(upload, upload.contains("name=\"delimiter\"")) + waitFor(androidx.compose.ui.test.hasTestTag("user-import-preview")) + clickTag("user-import-start") + val start = lastBody("POST", "staff/user-imports/${JOB}/start") + assertTrue(start, start.contains("\"mapping\":[\"nome\",\"cognome\",\"email\",\"codice_tessera\"]")) + assertTrue(start, start.contains("\"send_invites\":false")) + assertEquals("""{"start":0}""", lastBody("POST", "staff/user-imports/${JOB}/chunk")) + waitText(text(R.string.staff_import_status_done), timeoutMs = 20_000) + waitFor(androidx.compose.ui.test.hasTestTag("user-import-errors")) + } finally { + context.contentResolver.delete(uri, null, null) + } + } + + @Test fun anImportLeftOpenElsewhereIsResumedOrCancelled() { + launch(health = health) { + sequence("GET staff/user-imports", 200 to "s090_patrons_ui_list", 200 to "s090_patrons_ui_list_after") + json("GET staff/user-imports/$READY", "s090_patrons_ui_job_ready") + json("DELETE staff/user-imports/$STARTED", "s090_patrons_ui_cancelled") + } + openStaffHub() + click(text(R.string.staff090_user_import)) + waitText("readers-b.csv") + waitText(text(R.string.staff090_patrons_import_open_not_started, 2)) + // Cancel the started one without opening it: the list is read again. + clickTag("open-cancel-$STARTED") + waitRequest("DELETE", "staff/user-imports/$STARTED") + waitText(text(R.string.staff090_patrons_import_cancelled)) + compose.waitUntil(10_000) { api.recorded("GET", "staff/user-imports").size >= 2 } + } + + @Test fun anImportNotStartedYetReopensOnItsMapping() { + launch(health = health) { + json("GET staff/user-imports", "s090_patrons_ui_list") + json("GET staff/user-imports/$READY", "s090_patrons_ui_job_ready") + } + openStaffHub() + click(text(R.string.staff090_user_import)) + clickTag("open-resume-$READY") + waitRequest("GET", "staff/user-imports/$READY") + waitFor(androidx.compose.ui.test.hasTestTag("user-import-preview")) + waitFor(androidx.compose.ui.test.hasTestTag("user-import-start")) + assertTrue(api.recorded("POST", "staff/user-imports/$READY/chunk").isEmpty()) + } + + private fun FakePinakes.routeJobs() { + json("POST staff/user-imports/$JOB/start", "s090_patrons_ui_start") + json("POST staff/user-imports/$JOB/chunk", "s090_patrons_ui_chunk") + json("GET staff/user-imports/$JOB", "s090_patrons_ui_chunk") + } + + private companion object { + const val JOB = "74151523-1959-4ac8-b635-0bd862ae9a27" + /** The open jobs of s090_patrons_ui_list: readers-a (not started) and readers-b (started). */ + const val READY = "f15dde31-c5f2-4184-8f16-1a6df05246b8" + const val STARTED = "7eb8631a-3ab1-46ec-b464-ee47f50aa144" + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/StocktakeFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/StocktakeFlowTest.kt new file mode 100644 index 0000000..8237a2b --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/StocktakeFlowTest.kt @@ -0,0 +1,197 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performScrollToNode +import com.pinakes.app.R +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * Hub → Stocktake and Statistics, against bodies captured from a Pinakes 0.9.0 server (Mobile + * API 1.10.0): open a session, record a typed code with the current shelf and an + * Idempotency-Key, see the answer and the polled counters, create a session, close it, a + * refused mark-lost close that keeps the staff role, and the statistics' fines CSV. + */ +class StocktakeFlowTest : AppFlowTest() { + + private val health = "s090_stocktake_health" + private val json = Json { ignoreUnknownKeys = true } + + private fun shell(command: String) = instrumentation.uiAutomation.executeShellCommand(command).close() + + /** + * The scanner screens are driven by typed codes: the camera stays refused without a system + * prompt (user-fixed), so no permission dialog can cover the screen halfway through a test. + */ + @Before fun refuseCamera() { + shell("pm revoke ${context.packageName} android.permission.CAMERA") + shell("pm set-permission-flags ${context.packageName} android.permission.CAMERA user-fixed") + } + + @After fun restoreCamera() { + shell("pm clear-permission-flags ${context.packageName} android.permission.CAMERA user-fixed") + } + + private fun fixtureData(name: String) = json.parseToJsonElement(FakePinakes.fixture(name)).jsonObject["data"]!!.jsonObject + + /** The open session of the captures, its shelf and the text of a found answer. */ + private val sessionId by lazy { fixtureData("s090_stocktake_session_detail")["id"]!!.jsonPrimitive.int } + private val shelfId by lazy { fixtureData("s090_stocktake_session_detail")["filter"]!!.jsonObject["shelf_id"]!!.jsonPrimitive.int } + private val sessionName by lazy { fixtureData("s090_stocktake_session_detail")["name"]!!.jsonPrimitive.content } + + private fun FakePinakes.stocktake(detail: String = "s090_stocktake_session_detail", options: String = "s090_stocktake_options_staff") { + json("GET me", "me_staff") + json("GET staff/stocktake/options", options) + json("GET staff/stocktake/sessions", "s090_stocktake_sessions_open") + json("GET staff/stocktake/sessions/$sessionId", detail) + json("GET staff/stocktake/sessions/$sessionId/state", "s090_stocktake_state") + } + + private fun openSession() { + openStaffHub() + click(text(R.string.staff090_stocktake)) + clickTag("session-$sessionId") + pastCameraPrompt() + } + + /** The scanner asks for the camera when it first shows: deny it (manual entry stays) until the session screen is back in front. */ + private fun pastCameraPrompt() { + val input = androidx.compose.ui.test.hasTestTag("stocktake-code-input") + repeat(4) { + denyCameraPromptIfShown() + if (runCatching { waitFor(input, 4_000) }.isSuccess) return + } + waitFor(input, 1) + } + + /** Scroll the session list to [tag] (items below the scanner are not composed until then), then click it. */ + private fun scrollAndClick(tag: String) { + val list = compose.onAllNodes(androidx.compose.ui.test.hasScrollToNodeAction()) + compose.waitUntil(10_000) { list.fetchSemanticsNodes().isNotEmpty() } + list.onFirst().performScrollToNode(androidx.compose.ui.test.hasTestTag(tag)) + clickTag(tag) + } + + @Test fun aTypedCodeIsRecordedWithTheShelfAndAKeyAndTheCountersFollowThePoll() { + launch(role = "staff", health = health) { + stocktake() + json("POST staff/stocktake/sessions/$sessionId/scans", "s090_stocktake_scan_found", status = 201) + } + openSession() + typeIntoTag("stocktake-code-input", "AST-A1") + clickDescription(text(R.string.staff090_stocktake_send_code)) + assertEquals("""{"code":"AST-A1","shelf_id":$shelfId}""", lastBody("POST", "staff/stocktake/sessions/$sessionId/scans")) + assertEquals(36, api.recorded("POST", "staff/stocktake/sessions/$sessionId/scans").last().getHeader("Idempotency-Key").orEmpty().length) + // The answer, in its outcome's words, at once. + val label = fixtureData("s090_stocktake_scan_found")["scan"]!!.jsonObject["outcome_label"]!!.jsonPrimitive.content + waitText(label) + // Polling while visible, so other operators' scans show up. + waitRequest("GET", "staff/stocktake/sessions/$sessionId/state", timeoutMs = 12_000) + assertEquals("staff", deps.session().role.value) + } + + @Test fun aNewSessionIsOpenedOnceWithItsIdempotencyKey() { + launch(role = "staff", health = health) { + stocktake() + json("POST staff/stocktake/sessions", "s090_stocktake_session_created", status = 201) + } + openStaffHub() + click(text(R.string.staff090_stocktake)) + clickDescription(text(R.string.staff090_stocktake_new)) + typeIntoTag("stocktake-name", "Shelf X from the phone") + clickTag("stocktake-create") + pastCameraPrompt() + assertEquals("""{"name":"Shelf X from the phone"}""", lastBody("POST", "staff/stocktake/sessions")) + assertEquals(36, api.recorded("POST", "staff/stocktake/sessions").last().getHeader("Idempotency-Key").orEmpty().length) + // The new session opens on its scanner. + waitRequest("GET", "staff/stocktake/sessions/$sessionId") + waitText(sessionName) + } + + @Test fun closingStatesTheMissingCountAndLeavesThemAlone() { + launch(role = "staff", health = health) { + stocktake() + // A slow poll: the one in flight when the close lands still says "open". + json("GET staff/stocktake/sessions/$sessionId/state", "s090_stocktake_state", delayMs = 8_000) + json("POST staff/stocktake/sessions/$sessionId/close", "s090_stocktake_closed_none") + } + openSession() + // Close while a poll is in flight: its stale "open" must not bring the session back. + waitRequest("GET", "staff/stocktake/sessions/$sessionId/state") + scrollAndClick("stocktake-close") + val missing = fixtureData("s090_stocktake_session_detail")["summary"]!!.jsonObject["missing"]!!.jsonPrimitive.int + waitText(text(R.string.staff090_stocktake_close_confirm_none, missing)) + // Staff is not offered "mark as lost". + assertFalse(exists(text(R.string.staff090_stocktake_action_lost))) + clickTag("close-confirm") + assertEquals("""{"missing_action":"none"}""", lastBody("POST", "staff/stocktake/sessions/$sessionId/close")) + // The server's sentence about the close, and no close action any more. + waitText(fixtureData("s090_stocktake_closed_none")["close_result"]!!.jsonObject["message"]!!.jsonPrimitive.content) + waitGone(androidx.compose.ui.test.hasTestTag("stocktake-close")) + // Let the slow poll answer (8 s after it left), then the session must still be closed. + Thread.sleep(9_000) + compose.waitForIdle() + assertFalse(compose.onAllNodes(androidx.compose.ui.test.hasTestTag("stocktake-close")).fetchSemanticsNodes().isNotEmpty()) + assertTrue(exists(fixtureData("s090_stocktake_closed_none")["close_result"]!!.jsonObject["message"]!!.jsonPrimitive.content)) + } + + @Test fun aRefusedMarkLostShowsTheServerMessageAndKeepsTheRole() { + // The session still offers "lost" (an administrator's view), the server refuses it to this account. + launch(role = "staff", health = health) { + stocktake(detail = "s090_stocktake_session_detail_mine") + json("POST staff/stocktake/sessions/$sessionId/close", "s090_stocktake_close_lost_refused", status = 403) + } + openSession() + scrollAndClick("stocktake-close") + clickTag("close-lost") + clickTag("close-confirm") + assertEquals("""{"missing_action":"lost"}""", lastBody("POST", "staff/stocktake/sessions/$sessionId/close")) + val message = json.parseToJsonElement(FakePinakes.fixture("s090_stocktake_close_lost_refused")).jsonObject["error"]!!.jsonObject["message"]!!.jsonPrimitive.content + waitText(message) + assertEquals("staff", deps.session().role.value) + assertTrue(exists(sessionName)) + } + + @Test fun theReportListsTheRowsAndDownloadsTheCsv() { + launch(role = "staff", health = health) { + stocktake() + json("GET staff/stocktake/sessions/$sessionId/report", "s090_stocktake_report") + body("GET staff/stocktake/sessions/$sessionId/report.csv", 200, "Esito,Codice\nTrovata,AST-A1\n", + mapOf("Content-Type" to "text/csv; charset=utf-8", "Content-Disposition" to "attachment; filename=\"revisione-$sessionId.csv\"")) + } + openSession() + clickDescription(text(R.string.staff090_stocktake_report)) + val code = json.parseToJsonElement(FakePinakes.fixture("s090_stocktake_report")).jsonObject["data"]!!.let { (it as kotlinx.serialization.json.JsonArray).first().jsonObject["code"]!!.jsonPrimitive.content } + waitText(code) + clickTag("report-csv") + waitRequest("GET", "staff/stocktake/sessions/$sessionId/report.csv") + waitText("revisione-$sessionId.csv") + } + + @Test fun statisticsShowTheFinesReportAndDownloadItsCsv() { + launch(role = "staff", health = health) { + json("GET me", "me_staff") + json("GET staff/statistics", "s090_statistics_full") + body("GET staff/statistics/fines.csv", 200, "Mese,Addebitato\n2026-11,12.50\n", + mapOf("Content-Type" to "text/csv; charset=UTF-8", "Content-Disposition" to "attachment; filename=\"multe_2026-01-01_2026-11-30.csv\"")) + } + openStaffHub() + click(text(R.string.staff090_statistics)) + waitText(text(R.string.staff090_statistics_overview).uppercase()) + scrollListTo(text(R.string.staff090_statistics_fines_csv)) + clickTag("fines-csv") + waitRequest("GET", "staff/statistics/fines.csv") + val url = api.recorded("GET", "staff/statistics/fines.csv").last().requestUrl!! + assertEquals("2026-01-01", url.queryParameter("fines_from")) + assertEquals("2026-11-30", url.queryParameter("fines_to")) + waitText("multe_2026-01-01_2026-11-30.csv") + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/Tier2FlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/Tier2FlowTest.kt index c8958ca..6767746 100644 --- a/app/src/androidTest/java/com/pinakes/app/staffe2e/Tier2FlowTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/Tier2FlowTest.kt @@ -61,7 +61,7 @@ class Tier2FlowTest : AppFlowTest() { clickDescription(text(R.string.staff_year_add)) compose.onAllNodes(hasSetTextAction() and hasTestTag("year-input")).onFirst().performTextReplacement("2027") typeInto(text(R.string.staff_year_volume), "34") - clickTag("form-save") + tapUntilSent("form-save", "POST", "periodicals/staff/16/years") val body = lastBody("POST", "periodicals/staff/16/years") assertTrue(body, body.contains("\"year\":2027") && body.contains("\"volume\":\"34\"")) waitText(text(R.string.staff_year_added)) @@ -78,7 +78,7 @@ class Tier2FlowTest : AppFlowTest() { click(text(R.string.staff_clubs)) clickDescription(text(R.string.staff_club_new)) compose.onAllNodes(hasSetTextAction() and hasTestTag("club-name")).onFirst().performTextReplacement("Lettori del giovedì") - clickTag("form-save") + tapUntilSent("form-save", "POST", "bookclub/staff/clubs") val body = lastBody("POST", "bookclub/staff/clubs") assertTrue(body, body.contains("\"name\":\"Lettori del giovedì\"") && body.contains("\"privacy\":\"public\"")) waitText("Ada Rossi") @@ -99,7 +99,7 @@ class Tier2FlowTest : AppFlowTest() { click("Lettori del giovedì") click(text(R.string.staff_club_workflow_edit)) click(text(R.string.staff_club_state_add)) - clickTag("form-save") + tapUntilSent("form-save", "PUT", "bookclub/staff/clubs/1132/workflow") val body = lastBody("PUT", "bookclub/staff/clubs/1132/workflow") assertEquals(4, Regex("\"label\"").findAll(body).count()) assertTrue(body, body.contains("\"voting\":true")) @@ -118,12 +118,12 @@ class Tier2FlowTest : AppFlowTest() { click(text(R.string.staff_works)) clickDescription(text(R.string.staff_work_new)) compose.onAllNodes(hasSetTextAction() and hasTestTag("work-title")).onFirst().performTextReplacement("Odissea") - clickTag("form-save") + tapUntilSent("form-save", "POST", "staff/works") assertTrue(lastBody("POST", "staff/works").contains("\"uniform_title\":\"Odissea\"")) waitText(text(R.string.staff_work_expressions).uppercase()) click(text(R.string.staff_expression_add)) compose.onAllNodes(hasSetTextAction() and hasTestTag("expression-title")).onFirst().performTextReplacement("Odissea (trad. Calzecchi Onesti)") - clickTag("form-save") + tapUntilSent("form-save", "POST", "staff/works/2/expressions") val expr = lastBody("POST", "staff/works/2/expressions") assertTrue(expr, expr.contains("\"type\":\"testo\"") && expr.contains("Calzecchi")) click(text(R.string.staff_work_attach)) diff --git a/app/src/main/java/com/pinakes/app/data/local/SearchText.kt b/app/src/main/java/com/pinakes/app/data/local/SearchText.kt index 1151036..71f2e8b 100644 --- a/app/src/main/java/com/pinakes/app/data/local/SearchText.kt +++ b/app/src/main/java/com/pinakes/app/data/local/SearchText.kt @@ -14,6 +14,8 @@ object SearchText { private val COMBINING = Regex("\\p{M}+") private val ID_SEPARATORS = Regex("[\\s-]") private val IDENTIFIER_LIKE = Regex("^[0-9Xx\\s-]+$") + private const val QUOTES = "\"\u201C\u201D\u201E\u00AB\u00BB" + private val SPACES = Regex("\\s+") /** Lower-case, accent-free form of [text] ("Città" → "citta", "Ærø" keeps æ/ø, ß stays). */ fun fold(text: String?): String = @@ -39,13 +41,42 @@ object SearchText { * so "978-88-04" finds 9788804…. */ fun queryPattern(query: String?): String { - val trimmed = query?.trim().orEmpty() + val trimmed = withoutSearchQuotes(query?.trim().orEmpty()) val compact = if (IDENTIFIER_LIKE.matches(trimmed) && trimmed.count { it.isDigit() } >= 4) { trimmed.replace(ID_SEPARATORS, "") } else trimmed return escapeLike(fold(compact)) } + /** + * [query] without the quotes that mark an exact spelling online (Pinakes #501): straight and + * typographic quotes, as the server reads them. The offline key folds accents, so it cannot + * tell "Häffner" from "Haffner"; without this the quotes themselves were searched for and a + * quoted query found nothing offline. An inch mark (a straight or closing quote right after + * a digit, outside a quoted part, as in `12"`) stays, as it does on the server. + */ + fun withoutSearchQuotes(query: String): String { + if (query.none { it in QUOTES }) return query + val out = StringBuilder(query.length) + var open = false + var prev = ' ' + for (c in query) { + if (c in QUOTES) { + if (!open && (c == '"' || c == '\u201D') && prev.isDigit()) out.append(c) + else { open = !open; out.append(' ') } + } else out.append(c) + prev = c + } + return out.toString().replace(SPACES, " ").trim() + } + + /** + * The LIKE pattern of the author or publisher filter. Those are quoted for an exact spelling + * too (#501); offline the quotes go, as in [queryPattern]. Case and accents are left to the + * DAO, as before. + */ + fun filterPattern(value: String?): String = escapeLike(withoutSearchQuotes(value?.trim().orEmpty())) + /** Escape the LIKE metacharacters for `ESCAPE '\'`. */ fun escapeLike(value: String): String = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index eddfc99..0bbd201 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -117,6 +117,43 @@ data class HealthFeatures( @SerialName("staff_frbr") val staffFrbr: Boolean = false, @SerialName("staff_digital_library") val staffDigitalLibrary: Boolean = false, @SerialName("staff_librarything") val staffLibraryThing: Boolean = false, + // Pinakes 0.9.0 feature toggles (Mobile API 1.9.1), each already false in catalogue mode. + // Older servers omit them: no fines ledger, no closures calendar, no patron-type rules. + val fines: Boolean = false, + @SerialName("closures_calendar") val closuresCalendar: Boolean = false, + @SerialName("circulation_rules") val circulationRules: Boolean = false, + // Pinakes 0.9.0 splits consultation-only copies off the rules matrix: with this switch on + // and `circulation_rules` off, `loanable` can still be false. A server that predates the + // split omits the key and ties consultation to `circulation_rules`, as before. + @SerialName("consultation_only") val consultationOnly: Boolean = false, + // Mobile API 1.10.0: the effective state of every other Pinakes 0.9.0 module. + val stocktake: Boolean = false, + val marc: Boolean = false, + @SerialName("visitor_log") val visitorLog: Boolean = false, + @SerialName("catalogue_cards") val catalogueCards: Boolean = false, + val suppliers: Boolean = false, + @SerialName("rda_types") val rdaTypes: Boolean = false, + @SerialName("patron_cards") val patronCards: Boolean = false, + @SerialName("user_import") val userImport: Boolean = false, + @SerialName("patron_photos") val patronPhotos: Boolean = false, + @SerialName("automatic_card_expiry") val automaticCardExpiry: Boolean = false, + @SerialName("automatic_fines") val automaticFines: Boolean = false, + @SerialName("debt_block") val debtBlock: Boolean = false, + @SerialName("loss_penalties") val lossPenalties: Boolean = false, + @SerialName("attendance_retention") val attendanceRetention: Boolean = false, + // Mobile API 1.10.0 staff areas of the 0.9.0 modules. Older servers omit them: hidden. + @SerialName("staff_patrons") val staffPatrons: Boolean = false, + @SerialName("staff_ledger") val staffLedger: Boolean = false, + @SerialName("staff_stocktake") val staffStocktake: Boolean = false, + @SerialName("staff_library_settings") val staffLibrarySettings: Boolean = false, + @SerialName("staff_circulation_rules") val staffCirculationRules: Boolean = false, + @SerialName("staff_closures_calendar") val staffClosuresCalendar: Boolean = false, + @SerialName("staff_suppliers") val staffSuppliers: Boolean = false, + @SerialName("staff_catalogue_cards") val staffCatalogueCards: Boolean = false, + @SerialName("staff_marc") val staffMarc: Boolean = false, + @SerialName("staff_attendance") val staffAttendance: Boolean = false, + @SerialName("staff_statistics") val staffStatistics: Boolean = false, + @SerialName("staff_user_import") val staffUserImport: Boolean = false, ) // ---------- Auth ---------- @@ -231,6 +268,8 @@ data class UserProfile( @SerialName("custom_fields") val customFields: List = emptyList(), /** Fields the library hides (`sesso`, `cod_fiscale`); their keys are omitted from `/me`. */ @SerialName("hidden_fields") val hiddenFields: List = emptyList(), + /** The reader's patron type (Pinakes 0.9.0 circulation rules); null when the server omits it. */ + @SerialName("patron_type") val patronType: PatronTypeRef? = null, ) { val fullName: String get() = listOf(nome, cognome).filter { it.isNotBlank() }.joinToString(" ") } @@ -510,6 +549,13 @@ data class ReservationItem( @SerialName("expires_at") val expiresAt: String? = null, ) +/** A patron type (`tipologia lettore`) as `/me` names it. */ +@Serializable +data class PatronTypeRef( + val code: String? = null, + val name: String? = null, +) + // ---------- Availability calendar ---------- // Mirrors GET /catalog/books/{id}/availability — ~180 days of per-day availability // used to drive the colored loan-request calendar (matches the website). @@ -524,6 +570,15 @@ data class AvailabilityCalendar( @SerialName("loan_duration_days") val loanDurationDays: Int? = null, // Longest loan POST /reservations accepts through end_date (Mobile API 1.8.2); null on older servers. @SerialName("max_loan_duration_days") val maxLoanDurationDays: Int? = null, + // Closures calendar (Mobile API 1.9.0): days the library is closed. Not a start/pickup + // day; a return date on one moves to the next open day. Empty on older servers. + @SerialName("closed_dates") val closedDates: List = emptyList(), + // Circulation rules of the reader's patron type (Mobile API 1.9.0). False `loanable` = + // consultation only (POST /reservations answers 409 not_loanable); false + // `reservations_enabled` = no waitlist (409 reservations_disabled). Older servers omit + // both, which reads as the behaviour they always had. + val loanable: Boolean = true, + @SerialName("reservations_enabled") val reservationsEnabled: Boolean = true, ) @Serializable @@ -555,11 +610,16 @@ data class CirculationRequestResult( @SerialName("book_id") val bookId: Int = 0, @SerialName("loan_id") val loanId: Int? = null, @SerialName("auto_approved") val autoApproved: Boolean? = null, - val status: String? = null, // pendente | da_ritirare + val status: String? = null, // pendente | da_ritirare | prenotato (Pinakes 0.9.0) + // Start day of an approved loan that begins on a later open day (`prenotato`): the library + // is closed today. Null otherwise, and on servers before Pinakes 0.9.0. + @SerialName("start_date") val startDate: String? = null, ) { val kind: CirculationRequestKind get() = when { type == "reservation" -> CirculationRequestKind.Reservation + // Approved, but it starts on the next open day: not waiting at the desk yet. + type == "loan" && status == "prenotato" -> CirculationRequestKind.LoanScheduled type == "loan" && (autoApproved == true || status == "da_ritirare") -> CirculationRequestKind.LoanReadyForPickup type == "loan" && (autoApproved == false || status in setOf("pendente", "in_attesa")) -> @@ -572,6 +632,8 @@ enum class CirculationRequestKind { Reservation, LoanPending, LoanReadyForPickup, + /** Approved and starting on a later open day (`prenotato`, with `start_date`). */ + LoanScheduled, Unknown, } diff --git a/app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt b/app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt new file mode 100644 index 0000000..c57e59b --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/MovementsModels.kt @@ -0,0 +1,66 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +/** + * `GET /me/movements` (Mobile API 1.9.0, Pinakes 0.9.0 fines ledger): the reader's balance + * (positive = owed to the library), the fines still accruing on overdue loans and the ledger + * entries, newest first. Every field is optional so a partial payload never crashes the page; + * a server without the ledger answers 404 `feature_disabled` and the page is not offered. + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class MovementsPayload( + val currency: String? = null, + /** Null when the server omits it: [balanceTotalCents] then reads [balance]. */ + @SerialName("balance_cents") val balanceCents: Int? = null, + /** Decimal string in [currency] ("4.50"), the fallback when [balanceCents] is missing. */ + val balance: String? = null, + /** Null when the server omits it: [accruedTotalCents] then adds up [accrued]. */ + @SerialName("accrued_cents") val accruedCents: Int? = null, + val accrued: List = emptyList(), + @SerialName("debt_block_threshold_cents") val debtBlockThresholdCents: Int? = null, + @SerialName("debt_blocked") val debtBlocked: Boolean = false, + /** The server names the list `items`; the 0.9.0 plan called it `movements`: both are read. */ + @JsonNames("movements") val items: List = emptyList(), +) { + /** The fines still accruing: the server's total, else the sum of its rows (an explicit 0 stays 0). */ + val accruedTotalCents: Int get() = accruedCents ?: accrued.sumOf { it.amountCents } + + /** The balance in cents: `balance_cents`, else the decimal `balance` ("4.50", "-2,00"), else 0. */ + val balanceTotalCents: Int get() = balanceCents ?: decimalToCents(balance) ?: 0 + + private companion object { + fun decimalToCents(raw: String?): Int? = raw?.trim()?.replace(',', '.')?.takeIf { it.isNotEmpty() } + ?.let { runCatching { java.math.BigDecimal(it).movePointRight(2).setScale(0, java.math.RoundingMode.HALF_UP).intValueExact() }.getOrNull() } + } +} + +/** One ledger entry: a charge (positive amount), a payment, waiver or reversal (negative). */ +@Serializable +data class Movement( + val id: Int = 0, + /** multa_ritardo | addebito_perdita | addebito_danno | addebito_manuale | pagamento | condono | storno */ + val type: String = "", + @SerialName("type_label") val typeLabel: String? = null, + @SerialName("amount_cents") val amountCents: Int = 0, + val amount: String? = null, + val description: String? = null, + @SerialName("payment_method") val paymentMethod: String? = null, + @SerialName("loan_id") val loanId: Int? = null, + @SerialName("book_title") val bookTitle: String? = null, + @SerialName("created_at") val createdAt: String? = null, +) + +/** A late fine still growing on an overdue loan: charged only when the book comes back. */ +@Serializable +data class AccruedFine( + @SerialName("loan_id") val loanId: Int = 0, + val title: String? = null, + @SerialName("due_at") val dueAt: String? = null, + @SerialName("days_late") val daysLate: Int = 0, + @SerialName("amount_cents") val amountCents: Int = 0, +) diff --git a/app/src/main/java/com/pinakes/app/data/model/Staff090CatalogueModels.kt b/app/src/main/java/com/pinakes/app/data/model/Staff090CatalogueModels.kt new file mode 100644 index 0000000..1d69385 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/Staff090CatalogueModels.kt @@ -0,0 +1,274 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.booleanOrNull + +// Pinakes 0.9.0 cataloguing areas (Mobile API 1.10.0): suppliers, catalogue cards, MARC import +// and export, copy cataloguing. Every field has a default, so a server that omits one still +// decodes. + +/** + * The `{data, meta, error}` envelope with the meta kept as raw JSON: the cataloguing routes + * answer keys the shared [Meta] does not model (`meta.fields` of a 422, `meta.can_create` of + * the supplier list). + */ +@Serializable +data class Envelope090( + val data: T? = null, + val meta: JsonElement? = null, + val error: ApiError? = null, +) + +/** `{value, label}` of a server choice list (flavours, encodings, formats, indexes). */ +@Serializable +data class ValueLabel(val value: String = "", val label: String = "") + +/** A duplicate mode (`skip` | `fill` | `overwrite`) with the web form's explanation. */ +@Serializable +data class DuplicateMode(val value: String = "", val label: String = "", val description: String = "") + +// ---------- Suppliers ---------- + +@Serializable +data class Supplier( + val id: Int = 0, + val name: String = "", + @SerialName("contact_person") val contactPerson: String? = null, + val email: String? = null, + val phone: String? = null, + val website: String? = null, + val address: String? = null, + @SerialName("vat_number") val vatNumber: String? = null, + val notes: String? = null, + @SerialName("book_count") val bookCount: Int = 0, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + @SerialName("can_edit") val canEdit: Boolean = false, + @SerialName("can_delete") val canDelete: Boolean = false, +) + +/** `GET /staff/suppliers` with its `meta.can_create`. */ +data class SupplierList(val items: List, val canCreate: Boolean) + +@Serializable +data class SupplierDeleteResult( + val id: Int = 0, + val deleted: Boolean = false, + @SerialName("books_unlinked") val booksUnlinked: Int = 0, +) + +// ---------- Books: supplier and RDA fields (read-only parts) ---------- + +/** `rda_effective` of a staff book: the override, or the triple derived from the media type. */ +@Serializable +data class RdaEffective( + @SerialName("content_type") val contentType: String? = null, + @SerialName("media_type") val mediaType: String? = null, + @SerialName("carrier_type") val carrierType: String? = null, +) + +// ---------- MARC ---------- + +@Serializable +data class CopyCatalogingAvailability(val available: Boolean = false, val reason: String? = null) + +@Serializable +data class MarcOptions( + val flavours: List = emptyList(), + val encodings: List = emptyList(), + @SerialName("duplicate_modes") val duplicateModes: List = emptyList(), + @SerialName("export_formats") val exportFormats: List = emptyList(), + val extensions: List = emptyList(), + @SerialName("max_file_bytes") val maxFileBytes: Long = 20L * 1024 * 1024, + @SerialName("max_records") val maxRecords: Int = 20000, + @SerialName("max_export_ids") val maxExportIds: Int = 1000, + /** Null while the suppliers module is off: no supplier choice. */ + val suppliers: List? = null, + @SerialName("copy_cataloging") val copyCataloging: CopyCatalogingAvailability = CopyCatalogingAvailability(), +) + +@Serializable +data class MarcPreviewRow( + val position: Int = 0, + val flavour: String = "", + val title: String = "", + val subtitle: String? = null, + val authors: String? = null, + val publisher: String? = null, + val year: String? = null, + val isbn: String? = null, + @SerialName("has_cover") val hasCover: Boolean = false, + /** The book already in the catalogue under the duplicate rule. */ + @SerialName("existing_id") val existingId: Int? = null, + /** Set for a record that could not be read. */ + val error: String? = null, +) + +/** + * A MARC import job. The first keys are those of the CSV [ImportJob] with the same meaning, so + * [asImportJob] hands it to [com.pinakes.app.data.staff.ImportProgress] unchanged. + */ +@Serializable +data class MarcImportJob( + val id: String = "", + val kind: String = "marc", + @SerialName("file_name") val fileName: String = "", + val status: String = "ready", + @SerialName("total_rows") val totalRows: Int = 0, + val processed: Int = 0, + @SerialName("chunk_size") val chunkSize: Int = 10, + @SerialName("enable_scraping") val enableScraping: Boolean = false, + val imported: Int = 0, + val updated: Int = 0, + val scraped: Int = 0, + @SerialName("authors_created") val authorsCreated: Int = 0, + @SerialName("publishers_created") val publishersCreated: Int = 0, + val errors: Int = 0, + @SerialName("error_details") val errorDetails: List = emptyList(), + val complete: Boolean = false, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + @SerialName("expires_at") val expiresAt: String? = null, + // MARC only + val skipped: Int = 0, + val covers: Int = 0, + val failed: Int = 0, + @SerialName("import_id") val importId: String? = null, + val duplicates: String = "skip", + @SerialName("download_covers") val downloadCovers: Boolean = false, + @SerialName("supplier_id") val supplierId: Int? = null, + val flavour: String = "auto", + val encoding: String = "auto", + /** `iso2709` | `xml`. */ + val container: String = "", + val unreadable: Int = 0, + /** Message when the file holds more records than the server imports. */ + val truncated: String? = null, + val preview: List = emptyList(), +) { + fun asImportJob(): ImportJob = ImportJob( + id = id, kind = kind, fileName = fileName, status = status, totalRows = totalRows, processed = processed, + chunkSize = chunkSize, enableScraping = enableScraping, imported = imported, updated = updated, scraped = scraped, + authorsCreated = authorsCreated, publishersCreated = publishersCreated, errors = errors, errorDetails = errorDetails, + complete = complete, createdAt = createdAt, updatedAt = updatedAt, expiresAt = expiresAt, + ) + + /** "#position (title): message" lines, as the server writes them. */ + val errorLines: List get() = asImportJob().errorLines +} + +@Serializable +data class MarcCancelResult(val id: String = "", val cancelled: Boolean = false) + +// ---------- Copy cataloguing ---------- + +@Serializable +data class CopyCatalogingServer( + /** `cfg:N`, `sbn`, `preset:loc|bnf|dnb|k10plus`, `local`. */ + val key: String = "", + val name: String = "", + /** `configured` | `preset` | `local`. */ + val kind: String = "", + val url: String = "", + val syntax: String = "", +) + +@Serializable +data class CopyCatalogingServers( + val servers: List = emptyList(), + val indexes: List = emptyList(), + @SerialName("duplicate_modes") val duplicateModes: List = emptyList(), + @SerialName("max_results_per_server") val maxResultsPerServer: Int = 25, + @SerialName("max_import_ids") val maxImportIds: Int = 100, +) + +@Serializable +data class MarcField(val tag: String = "", val ind: String = "", val value: String = "") + +@Serializable +data class CopyCatalogingResult( + /** 16 hex characters, passed back to the import. */ + val id: String = "", + /** The catalogue's display name. */ + val server: String = "", + val flavour: String = "", + val title: String = "", + val subtitle: String? = null, + val authors: String? = null, + val publisher: String? = null, + val year: String? = null, + val isbn: String? = null, + @SerialName("cover_url") val coverUrl: String? = null, + @SerialName("existing_id") val existingId: Int? = null, + val detail: List = emptyList(), +) + +@Serializable +data class CopyCatalogingServerReport( + val key: String = "", + val name: String = "", + val total: Int = 0, + val count: Int = 0, + val error: String? = null, +) + +@Serializable +data class CopyCatalogingSearch( + @SerialName("search_id") val searchId: String = "", + @SerialName("expires_at") val expiresAt: String? = null, + val results: List = emptyList(), + val servers: List = emptyList(), +) + +@Serializable +data class CopyCatalogingItem( + val id: String = "", + /** `created` | `updated` | `skipped` | `failed`. */ + val action: String = "failed", + @SerialName("book_id") val bookId: Int? = null, + val title: String = "", + val warnings: List = emptyList(), + val message: String? = null, +) + +@Serializable +data class CopyCatalogingImport( + val imported: Int = 0, + val updated: Int = 0, + val skipped: Int = 0, + val failed: Int = 0, + val covers: Int = 0, + @SerialName("import_id") val importId: String? = null, + val items: List = emptyList(), +) + +@Serializable +data class CopyCatalogingSearchRequest( + val servers: List, + val index: String, + val term: String, + val max: Int, +) + +@Serializable +data class CopyCatalogingImportRequest( + @SerialName("search_id") val searchId: String, + val ids: List, + val duplicates: String, + val covers: Boolean, +) + +/** `meta.fields` of a 422 `validation`: field → message (empty when absent). */ +fun JsonElement?.fieldErrors(): Map { + val fields = (this as? JsonObject)?.get("fields") as? JsonObject ?: return emptyMap() + return fields.mapNotNull { (key, value) -> (value as? JsonPrimitive)?.contentOrNull?.let { key to it } }.toMap() +} + +/** A boolean of the raw meta, or null. */ +fun JsonElement?.metaBoolean(key: String): Boolean? = + ((this as? JsonObject)?.get(key) as? JsonPrimitive)?.booleanOrNull diff --git a/app/src/main/java/com/pinakes/app/data/model/Staff090LibraryModels.kt b/app/src/main/java/com/pinakes/app/data/model/Staff090LibraryModels.kt new file mode 100644 index 0000000..f63cb64 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/Staff090LibraryModels.kt @@ -0,0 +1,384 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +// Library settings of the Pinakes 0.9.0 modules (Mobile API 1.10.0, areas `library_settings`, +// `circulation_rules`, `closures_calendar`). Money is always integer cents in the server's +// `currency`; a null number or amount means "not set here, inherited from the layer above". + +/** A `{min, max}` range of a numeric field. */ +@Serializable +data class Staff090LibraryRange(val min: Long = 0, val max: Long = Long.MAX_VALUE) + +// ---------- Settings → Modules ---------- + +@Serializable +data class Staff090LibraryModules( + @SerialName("can_configure") val canConfigure: Boolean = false, + @SerialName("catalogue_mode") val catalogueMode: Boolean = false, + @SerialName("attendance_retention_days") val attendanceRetentionDays: Int = 0, + @SerialName("attendance_retention_max_days") val attendanceRetentionMaxDays: Int = 3650, + val modules: List = emptyList(), +) + +@Serializable +data class Staff090LibraryModule( + val key: String = "", + val label: String = "", + val help: String = "", + /** `modules` (Settings → Modules) or `loans` (Settings → Loans). */ + val group: String = "modules", + /** The saved switch (what the form shows). */ + val stored: Boolean = false, + /** Whether the module is actually on: off with its [parent] off, or circulation in catalogue mode. */ + val effective: Boolean = false, + /** The module this one needs switched on to work. */ + val parent: String? = null, + /** The module whose saved state this one copies until it is saved on its own. */ + @SerialName("inherits_from") val inheritsFrom: String? = null, + val circulation: Boolean = false, +) + +// ---------- Settings → Loans ---------- + +@Serializable +data class Staff090LibraryLoans( + @SerialName("can_configure") val canConfigure: Boolean = false, + val currency: String? = null, + /** Values by API key: integers, booleans, strings, cents (null = no amount). */ + val settings: Map = emptyMap(), + @SerialName("editable_fields") val editableFields: List = emptyList(), + @SerialName("admin_only_fields") val adminOnlyFields: List = emptyList(), + val limits: Map = emptyMap(), + val options: Map> = emptyMap(), +) + +// ---------- Settings → Cards ---------- + +@Serializable +data class Staff090LibraryCards( + @SerialName("can_configure") val canConfigure: Boolean = false, + @SerialName("module_enabled") val moduleEnabled: Boolean = false, + val settings: Staff090LibraryCardSettings = Staff090LibraryCardSettings(), + val options: Map> = emptyMap(), + val limits: Staff090LibraryCardLimits = Staff090LibraryCardLimits(), +) + +@Serializable +data class Staff090LibraryCardSettings( + val layout: String = "", + @SerialName("code_type") val codeType: String = "", + val format: String = "", + val title: String = "", + @SerialName("back_text") val backText: String = "", + @SerialName("show_photo") val showPhoto: Boolean = false, + @SerialName("show_logo") val showLogo: Boolean = false, + @SerialName("show_expiry") val showExpiry: Boolean = false, +) + +@Serializable +data class Staff090LibraryCardLimits( + @SerialName("title_max") val titleMax: Int = 60, + @SerialName("back_text_max") val backTextMax: Int = 600, +) + +// ---------- Patron types ---------- + +@Serializable +data class Staff090LibraryTypes( + @SerialName("can_configure") val canConfigure: Boolean = false, + @SerialName("module_enabled") val moduleEnabled: Boolean = false, + val currency: String? = null, + @SerialName("default_type_id") val defaultTypeId: Int? = null, + /** Settings → Loans, the layer every empty type value inherits. */ + val globals: Staff090LibraryTypeValues = Staff090LibraryTypeValues(), + val limits: Map = emptyMap(), + val types: List = emptyList(), +) + +/** The numbers of a patron type (also its `effective` policy and the `globals`); null = inherited. */ +@Serializable +data class Staff090LibraryTypeValues( + @SerialName("max_loans") val maxLoans: Int? = null, + @SerialName("loan_duration_days") val loanDurationDays: Int? = null, + @SerialName("max_request_duration_days") val maxRequestDurationDays: Int? = null, + @SerialName("max_renewals") val maxRenewals: Int? = null, + @SerialName("renewal_days") val renewalDays: Int? = null, + @SerialName("reservations_enabled") val reservationsEnabled: Boolean? = null, + @SerialName("max_reservations") val maxReservations: Int? = null, + @SerialName("card_duration_days") val cardDurationDays: Int? = null, + @SerialName("grace_days") val graceDays: Int? = null, + @SerialName("daily_fine_cents") val dailyFineCents: Int? = null, + @SerialName("max_fine_per_loan_cents") val maxFinePerLoanCents: Int? = null, + @SerialName("debt_block_threshold_cents") val debtBlockThresholdCents: Int? = null, +) + +@Serializable +data class Staff090LibraryPatronType( + val id: Int = 0, + val code: String = "", + val name: String = "", + val description: String? = null, + @SerialName("reservations_enabled") val reservationsEnabled: Boolean = true, + val active: Boolean = true, + @SerialName("is_default") val isDefault: Boolean = false, + val order: Int = 0, + @SerialName("user_count") val userCount: Int = 0, + @SerialName("max_loans") val maxLoans: Int? = null, + @SerialName("loan_duration_days") val loanDurationDays: Int? = null, + @SerialName("max_request_duration_days") val maxRequestDurationDays: Int? = null, + @SerialName("max_renewals") val maxRenewals: Int? = null, + @SerialName("renewal_days") val renewalDays: Int? = null, + @SerialName("max_reservations") val maxReservations: Int? = null, + @SerialName("card_duration_days") val cardDurationDays: Int? = null, + @SerialName("grace_days") val graceDays: Int? = null, + @SerialName("daily_fine_cents") val dailyFineCents: Int? = null, + @SerialName("max_fine_per_loan_cents") val maxFinePerLoanCents: Int? = null, + @SerialName("debt_block_threshold_cents") val debtBlockThresholdCents: Int? = null, + /** The policy this type actually gets (its values, else the global ones). */ + val effective: Staff090LibraryTypeValues? = null, +) + +@Serializable +data class Staff090LibraryTypeDeleted( + val id: Int = 0, + val deleted: Boolean = false, + @SerialName("users_reassigned") val usersReassigned: Int = 0, +) + +// ---------- Rules matrix ---------- + +@Serializable +data class Staff090LibraryRules( + @SerialName("can_configure") val canConfigure: Boolean = false, + @SerialName("module_enabled") val moduleEnabled: Boolean = false, + val currency: String? = null, + val media: List = emptyList(), + /** Patron types; `type_id` 0 is "all types". */ + val rows: List = emptyList(), + val limits: Map = emptyMap(), + val cells: List = emptyList(), +) + +@Serializable +data class Staff090LibraryMedia(val key: String = "", val label: String = "") + +@Serializable +data class Staff090LibraryRuleRow(@SerialName("type_id") val typeId: Int = 0, val name: String = "") + +@Serializable +data class Staff090LibraryRuleCell( + @SerialName("type_id") val typeId: Int = 0, + val media: String = "", + @SerialName("has_rule") val hasRule: Boolean = false, + val loanable: Boolean? = null, + @SerialName("max_loans") val maxLoans: Int? = null, + @SerialName("loan_duration_days") val loanDurationDays: Int? = null, + @SerialName("max_renewals") val maxRenewals: Int? = null, + @SerialName("renewal_days") val renewalDays: Int? = null, + @SerialName("grace_days") val graceDays: Int? = null, + @SerialName("daily_fine_cents") val dailyFineCents: Int? = null, + @SerialName("max_fine_per_loan_cents") val maxFinePerLoanCents: Int? = null, + /** What an empty value of this cell falls back to. */ + val inherited: Staff090LibraryRuleInherited = Staff090LibraryRuleInherited(), +) + +@Serializable +data class Staff090LibraryRuleInherited( + val loanable: Boolean = true, + @SerialName("max_loans") val maxLoans: Int? = null, + @SerialName("loan_duration_days") val loanDurationDays: Int? = null, + @SerialName("max_renewals") val maxRenewals: Int? = null, + @SerialName("renewal_days") val renewalDays: Int? = null, + @SerialName("grace_days") val graceDays: Int? = null, + @SerialName("daily_fine_cents") val dailyFineCents: Int? = null, + @SerialName("max_fine_per_loan_cents") val maxFinePerLoanCents: Int? = null, +) + +// ---------- Closures calendar ---------- + +@Serializable +data class Staff090LibraryCalendar( + @SerialName("can_configure") val canConfigure: Boolean = false, + @SerialName("module_enabled") val moduleEnabled: Boolean = false, + val today: String = "", + /** `YYYY-MM` */ + val month: String = "", + @SerialName("prev_month") val prevMonth: String = "", + @SerialName("next_month") val nextMonth: String = "", + @SerialName("branch_id") val branchId: Int? = null, + val days: List = emptyList(), + /** 0 = Sunday … 6 = Saturday. */ + @SerialName("closed_weekdays") val closedWeekdays: List = emptyList(), + val closures: List = emptyList(), + @SerialName("custom_holidays_available") val customHolidaysAvailable: Boolean = false, + @SerialName("custom_holidays") val customHolidays: List = emptyList(), + @SerialName("custom_holiday_options") val customHolidayOptions: Staff090LibraryCustomOptions = Staff090LibraryCustomOptions(), + @SerialName("holiday_preset") val holidayPreset: Staff090LibraryHolidayPreset = Staff090LibraryHolidayPreset(), + val branches: List = emptyList(), + val limits: Staff090LibraryCalendarLimits = Staff090LibraryCalendarLimits(), +) + +@Serializable +data class Staff090LibraryDay( + val date: String = "", + val weekday: Int = 0, + val closed: Boolean = false, + val reasons: List = emptyList(), +) + +@Serializable +data class Staff090LibraryDayReason( + /** `weekly`, `closure`, `holiday` or `custom_holiday`. */ + val type: String = "", + /** A closure or custom holiday id (number), a preset rule id (text), null for the weekly closure. */ + val id: JsonElement? = null, + val name: String = "", +) { + val numericId: Int? get() = (id as? JsonPrimitive)?.takeUnless { it.isString }?.contentOrNull?.toIntOrNull() +} + +@Serializable +data class Staff090LibraryClosure( + val id: Int = 0, + @SerialName("start_date") val startDate: String = "", + @SerialName("end_date") val endDate: String = "", + val description: String = "", + val yearly: Boolean = false, + @SerialName("branch_id") val branchId: Int? = null, + @SerialName("branch_name") val branchName: String? = null, + /** Set on a closure made by a national holiday preset: edited from the preset, not here. */ + @SerialName("preset_rule") val presetRule: String? = null, + val active: Boolean = true, + val editable: Boolean = true, +) + +@Serializable +data class Staff090LibraryCustomHoliday( + val id: Int = 0, + val name: String = "", + /** `fixed`, `nth_weekday`, `easter`, `orthodox_easter`, `calendar` or `dates`. */ + val kind: String = "", + val month: Int? = null, + val day: Int? = null, + val weekday: Int? = null, + val occurrence: Int? = null, + @SerialName("offset_days") val offsetDays: Int? = null, + val calendar: String? = null, + val dates: List = emptyList(), + @SerialName("duration_days") val durationDays: Int = 1, + @SerialName("weekend_rule") val weekendRule: String? = null, + @SerialName("year_from") val yearFrom: Int? = null, + @SerialName("year_until") val yearUntil: Int? = null, + @SerialName("branch_id") val branchId: Int? = null, + @SerialName("branch_name") val branchName: String? = null, + val active: Boolean = true, + /** The rule in words, in the operator's language. */ + val description: String = "", + @SerialName("next_dates") val nextDates: List = emptyList(), +) + +@Serializable +data class Staff090LibraryCustomOptions( + val kinds: List = emptyList(), + @SerialName("weekend_rules") val weekendRules: List = emptyList(), + val occurrences: List = emptyList(), + val calendars: List = emptyList(), + @SerialName("max_duration_days") val maxDurationDays: Int = 14, + @SerialName("max_dates") val maxDates: Int = 400, +) + +@Serializable +data class Staff090LibraryWorldCalendar( + val code: String = "", + val label: String = "", + val available: Boolean = true, + val months: List = emptyList(), +) + +@Serializable +data class Staff090LibraryHolidayPreset( + val country: String = "", + @SerialName("country_name") val countryName: String = "", + val region: String = "", + val regions: List = emptyList(), + val year: Int = 0, + val years: Staff090LibraryRange? = null, + /** Countries computed from a dataset: the first and last year it covers. */ + @SerialName("data_years") val dataYears: List? = null, + /** True when the library already saved a selection for this country. */ + val saved: Boolean = false, + @SerialName("schema_ready") val schemaReady: Boolean = true, + val missing: List = emptyList(), + val holidays: List = emptyList(), +) + +@Serializable +data class Staff090LibraryCodeName(val code: String = "", val name: String = "") + +@Serializable +data class Staff090LibraryHoliday( + val id: String = "", + val name: String = "", + @SerialName("name_en") val nameEn: String = "", + val date: String? = null, + val dates: List = emptyList(), + val yearly: Boolean = false, + val regional: Boolean = false, + val optional: Boolean = false, + val category: String = "", + val estimated: Boolean = false, + /** Part of the saved selection (or of the default one). */ + val checked: Boolean = false, +) + +@Serializable +data class Staff090LibraryCountries( + val countries: List = emptyList(), + val country: String = "", + @SerialName("country_name") val countryName: String = "", + val region: String = "", + val regions: List = emptyList(), + val year: Int = 0, + val years: Staff090LibraryRange? = null, + @SerialName("data_years") val dataYears: List? = null, + val saved: Boolean = false, + @SerialName("schema_ready") val schemaReady: Boolean = true, + val holidays: List = emptyList(), +) { + val preset: Staff090LibraryHolidayPreset + get() = Staff090LibraryHolidayPreset( + country = country, countryName = countryName, region = region, regions = regions, year = year, years = years, + dataYears = dataYears, saved = saved, schemaReady = schemaReady, holidays = holidays, + ) +} + +@Serializable +data class Staff090LibraryCalendarLimits( + @SerialName("yearly_span_days") val yearlySpanDays: Int = 363, + @SerialName("once_span_days") val onceSpanDays: Int = 3660, + @SerialName("ics_max_bytes") val icsMaxBytes: Int = 1_048_576, +) + +@Serializable +data class Staff090LibraryPresetApplied( + val message: String = "", + @SerialName("holiday_preset") val holidayPreset: Staff090LibraryHolidayPreset = Staff090LibraryHolidayPreset(), +) + +@Serializable +data class Staff090LibraryYearlySaved( + val changed: Int = 0, + @SerialName("yearly_closures") val yearlyClosures: List = emptyList(), +) + +@Serializable +data class Staff090LibraryIcsImported( + val added: Int = 0, + @SerialName("added_ids") val addedIds: List = emptyList(), + val message: String = "", +) diff --git a/app/src/main/java/com/pinakes/app/data/model/Staff090PatronsModels.kt b/app/src/main/java/com/pinakes/app/data/model/Staff090PatronsModels.kt new file mode 100644 index 0000000..9027d40 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/Staff090PatronsModels.kt @@ -0,0 +1,312 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// Pinakes 0.9.0 patron tools (Mobile API 1.10.0, tags staff-patrons, staff-ledger, +// staff-attendance, staff-user-import). Every field has a default: older servers omit the +// newer keys, and the server is free to add more. Money is always integer cents in the +// server's `currency`. + +// ---------- Patron record (StaffUser 1.10.0 fields) ---------- + +/** The effective patron type of an account; [assigned] false = it follows the default type. */ +@Serializable +data class StaffPatronTypeRef( + val id: Int = 0, + val code: String = "", + val name: String = "", + val assigned: Boolean = false, +) + +/** The balance summary on a StaffUser; null while the fines module is off. */ +@Serializable +data class StaffLedgerSummary( + @SerialName("balance_cents") val balanceCents: Int = 0, + val currency: String? = null, + @SerialName("debt_blocked") val debtBlocked: Boolean = false, +) + +/** `late_fine` of a staff loan (detail and return answers); null while fines are off. */ +@Serializable +data class StaffLoanLateFine( + val currency: String? = null, + @SerialName("automatic_fines") val automaticFines: Boolean = false, + @SerialName("accruing_days") val accruingDays: Int = 0, + /** What a return today would charge: shown before confirming, with the waiver option. */ + @SerialName("accruing_cents") val accruingCents: Int = 0, + @SerialName("charged_cents") val chargedCents: Int = 0, +) + +/** One entry of `GET /staff/patron-types`. */ +@Serializable +data class PatronType( + val id: Int = 0, + val code: String = "", + val name: String = "", + val description: String? = null, + @SerialName("is_default") val isDefault: Boolean = false, + /** Inactive types cannot be assigned anew; an account keeps the one it has. */ + val active: Boolean = true, + @SerialName("card_duration_days") val cardDurationDays: Int? = null, +) + +@Serializable +data class PatronTypesMeta( + @SerialName("default_id") val defaultId: Int? = null, + @SerialName("can_assign") val canAssign: Boolean = false, +) + +/** The picker: the types plus whether this operator may assign them. */ +data class PatronTypeList(val types: List, val defaultId: Int?, val canAssign: Boolean) + +/** `PUT /staff/users/{id}/patron-type`. 0 = the default type (the server reads 0 as null). */ +@Serializable +data class PatronTypeRequest( + @SerialName("patron_type_id") val patronTypeId: Int, + @SerialName("recompute_card_expiry") val recomputeCardExpiry: Boolean = false, +) + +@Serializable +data class PatronTypeUpdateMeta(@SerialName("card_expiry_recomputed") val cardExpiryRecomputed: Boolean = false) + +/** The user after a patron type change, and whether its card expiry was set from the type. */ +data class PatronTypeUpdate(val user: StaffUser, val cardExpiryRecomputed: Boolean) + +/** An envelope whose `meta` carries endpoint-specific keys (patron types, patron type change). */ +@Serializable +data class MetaEnvelope( + val data: T? = null, + val meta: M? = null, + val error: ApiError? = null, +) + +/** `POST|DELETE /staff/users/{id}/photo`. */ +@Serializable +data class PatronPhotoState( + @SerialName("user_id") val userId: Int = 0, + @SerialName("has_photo") val hasPhoto: Boolean = false, + @SerialName("photo_url") val photoUrl: String? = null, +) + +// ---------- Ledger ---------- + +@Serializable +data class PaymentMethod(val code: String = "", val label: String = "") + +/** A ledger movement as the desk sees it: the reader's [Movement] plus the operator fields. */ +@Serializable +data class StaffLedgerMovement( + val id: Int = 0, + val type: String = "", + @SerialName("type_label") val typeLabel: String? = null, + /** Signed: positive = charge, negative = payment, waiver or reversal. */ + @SerialName("amount_cents") val amountCents: Int = 0, + val amount: String? = null, + val description: String? = null, + @SerialName("payment_method") val paymentMethod: String? = null, + @SerialName("loan_id") val loanId: Int? = null, + @SerialName("book_title") val bookTitle: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("payment_method_label") val paymentMethodLabel: String? = null, + val operator: IdName? = null, + @SerialName("receipt_available") val receiptAvailable: Boolean = false, +) { + /** The reader's row, so the reader movements composables draw it (method in words). */ + fun toMovement(): Movement = Movement( + id = id, type = type, typeLabel = typeLabel, amountCents = amountCents, amount = amount, description = description, + paymentMethod = paymentMethodLabel ?: paymentMethod, loanId = loanId, bookTitle = bookTitle, createdAt = createdAt, + ) +} + +/** `GET /staff/users/{id}/ledger`. */ +@Serializable +data class StaffLedger( + @SerialName("user_id") val userId: Int = 0, + val currency: String? = null, + @SerialName("balance_cents") val balanceCents: Int = 0, + val balance: String? = null, + @SerialName("accrued_cents") val accruedCents: Int = 0, + val accrued: List = emptyList(), + @SerialName("debt_block_threshold_cents") val debtBlockThresholdCents: Int? = null, + @SerialName("debt_blocked") val debtBlocked: Boolean = false, + @SerialName("automatic_fines") val automaticFines: Boolean = false, + @SerialName("payment_methods") val paymentMethods: List = emptyList(), + @SerialName("max_amount_cents") val maxAmountCents: Int = 99_999_999, + @SerialName("can_record_payment") val canRecordPayment: Boolean = false, + @SerialName("can_charge") val canCharge: Boolean = false, + @SerialName("can_waive") val canWaive: Boolean = false, + val items: List = emptyList(), +) { + /** The balance card of the reader screen, reused as is. */ + fun toPayload(): MovementsPayload = MovementsPayload( + currency = currency, balanceCents = balanceCents, balance = balance, accruedCents = accruedCents, accrued = accrued, + debtBlockThresholdCents = debtBlockThresholdCents, debtBlocked = debtBlocked, + ) +} + +/** The answer of a payment, charge or waiver: the movement and the balance after it. */ +@Serializable +data class LedgerWriteResult( + val movement: StaffLedgerMovement = StaffLedgerMovement(), + @SerialName("user_id") val userId: Int = 0, + val currency: String? = null, + @SerialName("balance_cents") val balanceCents: Int = 0, + val balance: String? = null, + @SerialName("debt_blocked") val debtBlocked: Boolean = false, +) + +@Serializable +data class LedgerPaymentRequest(@SerialName("amount_cents") val amountCents: Int, val method: String, val note: String? = null) + +@Serializable +data class LedgerChargeRequest(@SerialName("amount_cents") val amountCents: Int, val description: String) + +@Serializable +data class LedgerWaiverRequest(@SerialName("amount_cents") val amountCents: Int, val reason: String) + +// ---------- Visitor log ---------- + +@Serializable +data class AttendanceCounts(val members: Int = 0, val guests: Int = 0, val total: Int = 0) + +@Serializable +data class AttendanceDay(val date: String = "", val members: Int = 0, val guests: Int = 0, val total: Int = 0) + +@Serializable +data class AttendanceHour(val hour: Int = 0, val total: Int = 0) + +@Serializable +data class AttendanceRecent( + @SerialName("created_at") val createdAt: String = "", + /** `member` | `guest`. */ + val type: String = "", + val label: String = "", +) + +@Serializable +data class AttendanceRetention( + /** 0 = visits are kept forever. */ + val days: Int = 365, + @SerialName("max_days") val maxDays: Int = 3650, + @SerialName("default_days") val defaultDays: Int = 365, + @SerialName("continues_when_off") val continuesWhenOff: Boolean = false, +) + +/** `GET /staff/attendance`. */ +@Serializable +data class AttendanceOverview( + val days: Int = 30, + val periods: List = listOf(7, 30, 90, 365), + val today: AttendanceCounts = AttendanceCounts(), + val totals: AttendanceCounts = AttendanceCounts(), + val daily: List = emptyList(), + val hourly: List = emptyList(), + val recent: List = emptyList(), + val branches: List = emptyList(), + val retention: AttendanceRetention = AttendanceRetention(), + @SerialName("can_configure") val canConfigure: Boolean = false, +) + +@Serializable +data class AttendanceMember( + val id: Int = 0, + @SerialName("short_name") val shortName: String = "", + @SerialName("card_code") val cardCode: String = "", + @SerialName("account_status") val accountStatus: String? = null, + @SerialName("card_active") val cardActive: Boolean = true, + @SerialName("card_expires_at") val cardExpiresAt: String? = null, + @SerialName("card_expired") val cardExpired: Boolean = false, + @SerialName("has_photo") val hasPhoto: Boolean = false, + @SerialName("photo_url") val photoUrl: String? = null, +) + +@Serializable +data class AttendanceGuest(val name: String? = null, val reason: String? = null) + +/** One recorded visit (check-in or guest). */ +@Serializable +data class AttendanceVisit( + val id: Int = 0, + /** `member` | `guest`. */ + val type: String = "", + @SerialName("created_at") val createdAt: String? = null, + val branch: IdName? = null, + val member: AttendanceMember? = null, + val guest: AttendanceGuest? = null, + /** `card_not_active`: recorded, but the account is suspended or expired. */ + val warning: String? = null, + val message: String? = null, +) + +@Serializable +data class AttendanceCheckInRequest(@SerialName("card_code") val cardCode: String, @SerialName("branch_id") val branchId: Int? = null) + +@Serializable +data class AttendanceGuestRequest(val name: String? = null, val reason: String? = null, @SerialName("branch_id") val branchId: Int? = null) + +@Serializable +data class AttendanceSettingsRequest(@SerialName("retention_days") val retentionDays: Int) + +@Serializable +data class AttendanceSettingsResult(val retention: AttendanceRetention = AttendanceRetention(), val message: String? = null) + +@Serializable +data class AttendancePurgeResult(val deleted: Int = 0, val retention: AttendanceRetention = AttendanceRetention(), val message: String? = null) + +// ---------- Patron CSV import ---------- + +@Serializable +data class UserImportField(val key: String = "", val label: String = "", val required: Boolean = false) + +@Serializable +data class UserImportError(val line: Int = 0, val title: String = "", val message: String = "", val type: String = "") + +/** A patron import job (`/staff/user-imports/{id}`). */ +@Serializable +data class UserImportJob( + val id: String = "", + val kind: String = "users", + @SerialName("file_name") val fileName: String = "", + /** ready | running | completed | cancelled | failed. */ + val status: String = "ready", + val started: Boolean = false, + val complete: Boolean = false, + @SerialName("total_rows") val totalRows: Int = 0, + val processed: Int = 0, + @SerialName("chunk_size") val chunkSize: Int = 10, + val headers: List = emptyList(), + val preview: List> = emptyList(), + val fields: List = emptyList(), + /** One entry per header: a field key, or "" = ignored. */ + val mapping: List = emptyList(), + @SerialName("send_invites") val sendInvites: Boolean? = null, + val created: Int = 0, + val duplicates: Int = 0, + val failed: Int = 0, + val invited: Int = 0, + @SerialName("invite_failed") val inviteFailed: Int = 0, + val errors: List = emptyList(), + @SerialName("import_id") val importId: String? = null, + @SerialName("report_available") val reportAvailable: Boolean = false, + val notice: String? = null, + @SerialName("log_error") val logError: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + @SerialName("expires_at") val expiresAt: String? = null, +) + +@Serializable +data class UserImportStartRequest(val mapping: List, @SerialName("send_invites") val sendInvites: Boolean) + +@Serializable +data class UserImportChunkRequest(val start: Int) + +@Serializable +data class UserImportCancelResult( + val id: String = "", + val cancelled: Boolean = false, + val status: String = "cancelled", + @SerialName("import_id") val importId: String? = null, + @SerialName("history_warning") val historyWarning: String? = null, +) diff --git a/app/src/main/java/com/pinakes/app/data/model/Staff090StatisticsModels.kt b/app/src/main/java/com/pinakes/app/data/model/Staff090StatisticsModels.kt new file mode 100644 index 0000000..7e0a7f0 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/Staff090StatisticsModels.kt @@ -0,0 +1,130 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive + +// The statistics page (/admin/statistics) for the app (Mobile API 1.10.0, tag +// `staff-statistics`). Each 0.9.0 section follows its module switch: a section whose module is +// off is absent from the answer (`visitors`, `fines_report`, entries of `sections`). + +@Serializable +data class Staff090StatisticsOverview( + @SerialName("books_available") val booksAvailable: Int = 0, + @SerialName("books_on_loan") val booksOnLoan: Int = 0, + @SerialName("active_loans") val activeLoans: Int = 0, + @SerialName("overdue_loans") val overdueLoans: Int = 0, + @SerialName("completed_loans") val completedLoans: Int = 0, + /** Distinct readers with at least one loan ever. */ + val borrowers: Int = 0, +) + +/** Loans of a month, `month` = YYYY-MM (months without loans are absent). */ +@Serializable +data class Staff090StatisticsMonth(val month: String = "", val total: Int = 0) + +@Serializable +data class Staff090StatisticsByStatus(val status: String = "", val label: String = "", val total: Int = 0) + +@Serializable +data class Staff090StatisticsByMedia( + @SerialName("media_type") val mediaType: String = "", + val label: String = "", + val total: Int = 0, +) + +@Serializable +data class Staff090StatisticsTopBook( + val id: Int = 0, + val title: String = "", + @SerialName("cover_url") val coverUrl: String? = null, + @SerialName("total_loans") val totalLoans: Int = 0, + @SerialName("completed_loans") val completedLoans: Int = 0, + @SerialName("active_loans") val activeLoans: Int = 0, + @SerialName("overdue_loans") val overdueLoans: Int = 0, +) + +@Serializable +data class Staff090StatisticsTopReader( + val id: Int = 0, + val name: String = "", + val email: String? = null, + @SerialName("user_type") val userType: String? = null, + @SerialName("total_loans") val totalLoans: Int = 0, + @SerialName("completed_loans") val completedLoans: Int = 0, + @SerialName("active_loans") val activeLoans: Int = 0, + @SerialName("overdue_loans") val overdueLoans: Int = 0, +) + +/** A row of a module section: `value` is already formatted for display (a number or a text with the currency). */ +@Serializable +data class Staff090StatisticsRow(val label: String = "", val value: JsonPrimitive? = null) { + val display: String get() = value?.takeUnless { it is JsonNull }?.content.orEmpty() +} + +/** A 0.9.0 table section: `patron-types`, `fines` or `stocktake`, each only while its module is on. */ +@Serializable +data class Staff090StatisticsSection( + val id: String = "", + val title: String = "", + val rows: List = emptyList(), + val note: String? = null, +) + +@Serializable +data class Staff090StatisticsVisitorDay(val date: String = "", val members: Int = 0, val guests: Int = 0, val total: Int = 0) + +/** Last [days] days of the visitor log (only while `visitor_log` is on). */ +@Serializable +data class Staff090StatisticsVisitors( + val days: Int = 0, + val members: Int = 0, + val guests: Int = 0, + val total: Int = 0, + val daily: List = emptyList(), +) + +@Serializable +data class Staff090StatisticsMoney( + @SerialName("charged_cents") val chargedCents: Int = 0, + @SerialName("collected_cents") val collectedCents: Int = 0, + @SerialName("waived_cents") val waivedCents: Int = 0, + @SerialName("reversed_cents") val reversedCents: Int = 0, +) + +@Serializable +data class Staff090StatisticsFinesMonth( + val month: String = "", + @SerialName("charged_cents") val chargedCents: Int = 0, + @SerialName("collected_cents") val collectedCents: Int = 0, + @SerialName("waived_cents") val waivedCents: Int = 0, + @SerialName("reversed_cents") val reversedCents: Int = 0, +) { + val money: Staff090StatisticsMoney get() = Staff090StatisticsMoney(chargedCents, collectedCents, waivedCents, reversedCents) +} + +/** Charged / collected / waived / reversed per month of the period (only while `fines` is on). */ +@Serializable +data class Staff090StatisticsFinesReport( + val from: String = "", + val to: String = "", + /** ISO 4217. */ + val currency: String? = null, + val months: List = emptyList(), + val totals: Staff090StatisticsMoney = Staff090StatisticsMoney(), +) + +/** `GET /staff/statistics`. */ +@Serializable +data class Staff090Statistics( + val overview: Staff090StatisticsOverview = Staff090StatisticsOverview(), + @SerialName("loans_by_month") val loansByMonth: List = emptyList(), + @SerialName("loans_by_status") val loansByStatus: List = emptyList(), + @SerialName("loans_by_media_type") val loansByMediaType: List = emptyList(), + @SerialName("top_books") val topBooks: List = emptyList(), + @SerialName("top_readers") val topReaders: List = emptyList(), + val sections: List = emptyList(), + val visitors: Staff090StatisticsVisitors? = null, + @SerialName("fines_report") val finesReport: Staff090StatisticsFinesReport? = null, +) diff --git a/app/src/main/java/com/pinakes/app/data/model/Staff090StocktakeModels.kt b/app/src/main/java/com/pinakes/app/data/model/Staff090StocktakeModels.kt new file mode 100644 index 0000000..b11eafc --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/Staff090StocktakeModels.kt @@ -0,0 +1,270 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// Inventory stocktake (Mobile API 1.10.0, Pinakes 0.9.0 /admin/inventory; tag `staff-stocktake`). +// The phone is the scanner of a revision session. Codes are the API's English ones (`found`, +// `misplaced`, `open`, `lost`, ...); labels come already translated by the server. Every field +// has a default: the server may add keys, and a missing one must never break a scan. + +/** A badge colour of the web page: family name plus the hex pair (Tailwind 800 on 100). */ +@Serializable +data class Staff090StocktakeColor( + val name: String = "gray", + val foreground: String = "", + val background: String = "", +) + +/** The beep the web scan page plays for an outcome. */ +@Serializable +data class Staff090StocktakeTone( + @SerialName("frequency_hz") val frequencyHz: Int = 0, + @SerialName("duration_ms") val durationMs: Int = 0, +) + +/** A scan outcome / report category. `missing` is never the answer to a scan (`scan_result` false, no tone). */ +@Serializable +data class Staff090StocktakeOutcome( + val code: String = "", + val label: String = "", + val color: Staff090StocktakeColor? = null, + val tone: Staff090StocktakeTone? = null, + @SerialName("scan_result") val scanResult: Boolean = true, +) + +@Serializable +data class Staff090StocktakeStatus( + val code: String = "", + val label: String = "", + val color: Staff090StocktakeColor? = null, +) + +/** What closing does with the copies nobody scanned: `none`, or `lost` (administrators only). */ +@Serializable +data class Staff090StocktakeMissingAction( + val code: String = "", + val label: String = "", + @SerialName("admin_only") val adminOnly: Boolean = false, +) + +@Serializable +data class Staff090StocktakeLevel(val id: Int = 0, val level: Int = 0) + +@Serializable +data class Staff090StocktakeShelf( + val id: Int = 0, + val code: String = "", + val name: String = "", + /** "[A] Narrativa", as the web lists it. */ + val label: String = "", + val levels: List = emptyList(), +) + +@Serializable +data class Staff090StocktakeGenre(val id: Int = 0, val label: String = "") + +@Serializable +data class Staff090StocktakeCodeLabel(val code: String = "", val label: String = "") + +@Serializable +data class Staff090StocktakeLimits( + @SerialName("max_code_length") val maxCodeLength: Int = 100, + @SerialName("max_upload_codes") val maxUploadCodes: Int = 5000, + @SerialName("max_upload_bytes") val maxUploadBytes: Long = 2_097_152, + @SerialName("upload_extensions") val uploadExtensions: List = listOf("txt", "csv"), + @SerialName("max_name_length") val maxNameLength: Int = 255, + @SerialName("max_collocation_length") val maxCollocationLength: Int = 50, + @SerialName("scans_per_minute") val scansPerMinute: Int = 600, +) + +/** `GET /staff/stocktake/options`: everything the create form and the scan screen need. */ +@Serializable +data class Staff090StocktakeOptions( + val branches: List = emptyList(), + val shelves: List = emptyList(), + val genres: List = emptyList(), + @SerialName("media_types") val mediaTypes: List = emptyList(), + val outcomes: List = emptyList(), + val statuses: List = emptyList(), + @SerialName("missing_actions") val missingActions: List = emptyList(), + /** True for administrators: the "mark the missing copies lost" close. */ + @SerialName("can_mark_lost") val canMarkLost: Boolean = false, + val limits: Staff090StocktakeLimits = Staff090StocktakeLimits(), +) + +/** The live counters of a session (`mine` = scans by the signed-in operator). */ +@Serializable +data class Staff090StocktakeSummary( + val expected: Int = 0, + val found: Int = 0, + val misplaced: Int = 0, + @SerialName("on_loan") val onLoan: Int = 0, + val missing: Int = 0, + val lost: Int = 0, + val unknown: Int = 0, + @SerialName("out_of_scope") val outOfScope: Int = 0, + val duplicates: Int = 0, + val scans: Int = 0, + val mine: Int = 0, +) + +/** The frozen selection of a session; absent keys are null. */ +@Serializable +data class Staff090StocktakeFilter( + @SerialName("shelf_id") val shelfId: Int? = null, + @SerialName("level_id") val levelId: Int? = null, + @SerialName("genre_id") val genreId: Int? = null, + @SerialName("media_type") val mediaType: String? = null, + @SerialName("collocation_from") val collocationFrom: String? = null, + @SerialName("collocation_to") val collocationTo: String? = null, +) + +/** One recorded scan. `code` is what was read, `inventory_number` the copy's code when known. */ +@Serializable +data class Staff090StocktakeScan( + val id: Int = 0, + val code: String = "", + @SerialName("inventory_number") val inventoryNumber: String? = null, + val outcome: String = "", + @SerialName("outcome_label") val outcomeLabel: String = "", + val color: Staff090StocktakeColor? = null, + @SerialName("copy_id") val copyId: Int? = null, + @SerialName("book_id") val bookId: Int? = null, + val title: String? = null, + val collocation: String? = null, + @SerialName("media_type") val mediaType: String? = null, + @SerialName("copy_status") val copyStatus: String? = null, + @SerialName("copy_status_label") val copyStatusLabel: String? = null, + /** The copy is recorded as out of circulation (lost, withdrawn...) yet it turned up. */ + @SerialName("off_shelf") val offShelf: Boolean = false, + /** Where a misplaced copy belongs, as a shelf label. */ + @SerialName("expected_shelf") val expectedShelf: String? = null, + val operator: IdName? = null, + @SerialName("scanned_at") val scannedAt: String? = null, +) + +@Serializable +data class Staff090StocktakeHistoryEvent( + val event: String = "", + val label: String = "", + @SerialName("operator_name") val operatorName: String? = null, + val at: String? = null, +) + +/** What a close did: how many copies it marked lost, and whether some are still pending. */ +@Serializable +data class Staff090StocktakeCloseResult( + val resumed: Boolean = false, + @SerialName("marked_lost") val markedLost: Int = 0, + @SerialName("pending_lost") val pendingLost: Int = 0, + val partial: Boolean = false, + val message: String = "", +) + +/** A session; the detail (and every write's answer) adds `recent_scans` and `history`. */ +@Serializable +data class Staff090StocktakeSession( + val id: Int = 0, + val name: String = "", + val status: String = "", + @SerialName("status_label") val statusLabel: String = "", + @SerialName("status_color") val statusColor: Staff090StocktakeColor? = null, + val branch: IdName? = null, + val filter: Staff090StocktakeFilter = Staff090StocktakeFilter(), + @SerialName("filter_labels") val filterLabels: List = emptyList(), + val note: String? = null, + @SerialName("expected_total") val expectedTotal: Int = 0, + @SerialName("opened_at") val openedAt: String? = null, + @SerialName("opened_by") val openedBy: IdName? = null, + @SerialName("closed_at") val closedAt: String? = null, + @SerialName("closed_by") val closedBy: IdName? = null, + @SerialName("missing_action") val missingAction: String? = null, + val summary: Staff090StocktakeSummary = Staff090StocktakeSummary(), + /** Copies a mark-lost close did not reach yet (resumable). */ + @SerialName("pending_lost") val pendingLost: Int = 0, + @SerialName("can_scan") val canScan: Boolean = false, + @SerialName("can_upload") val canUpload: Boolean = false, + @SerialName("can_close") val canClose: Boolean = false, + @SerialName("can_cancel") val canCancel: Boolean = false, + @SerialName("can_mark_lost") val canMarkLost: Boolean = false, + @SerialName("recent_scans") val recentScans: List = emptyList(), + val history: List = emptyList(), + @SerialName("close_result") val closeResult: Staff090StocktakeCloseResult? = null, + /** The server's sentence after a cancel. */ + val message: String? = null, +) { + val isOpen: Boolean get() = status == "open" +} + +/** `GET .../state`: the light payload the scan screen polls. */ +@Serializable +data class Staff090StocktakeState( + val id: Int = 0, + val status: String = "", + val summary: Staff090StocktakeSummary = Staff090StocktakeSummary(), + @SerialName("recent_scans") val recentScans: List = emptyList(), +) + +/** `POST .../scans`: the scan, what to play, the updated counters. */ +@Serializable +data class Staff090StocktakeScanResult( + val scan: Staff090StocktakeScan = Staff090StocktakeScan(), + val tone: Staff090StocktakeTone? = null, + val summary: Staff090StocktakeSummary = Staff090StocktakeSummary(), + @SerialName("session_status") val sessionStatus: String = "open", +) + +/** A report row: every expected copy with its bucket, then every scan not about an expected copy. */ +@Serializable +data class Staff090StocktakeReportRow( + val category: String = "", + @SerialName("category_label") val categoryLabel: String = "", + val color: Staff090StocktakeColor? = null, + val code: String = "", + val title: String? = null, + @SerialName("media_type") val mediaType: String? = null, + val collocation: String? = null, + @SerialName("expected_shelf") val expectedShelf: String? = null, + @SerialName("copy_status") val copyStatus: String? = null, + @SerialName("copy_status_label") val copyStatusLabel: String? = null, + @SerialName("operator_name") val operatorName: String? = null, + @SerialName("scanned_at") val scannedAt: String? = null, +) + +@Serializable +data class Staff090StocktakeUploadCount(val code: String = "", val label: String = "", val count: Int = 0) + +/** `POST .../upload`: how many codes the file held and what each became. */ +@Serializable +data class Staff090StocktakeUpload( + val total: Int = 0, + val outcomes: List = emptyList(), + val summary: Staff090StocktakeSummary = Staff090StocktakeSummary(), + val message: String = "", +) + +// ---------- Requests ---------- + +/** `POST /staff/stocktake/sessions`. Null keys are left out (the app Json drops explicit nulls). */ +@Serializable +data class Staff090StocktakeCreateRequest( + val name: String, + val note: String? = null, + @SerialName("branch_id") val branchId: Int? = null, + @SerialName("shelf_id") val shelfId: Int? = null, + @SerialName("level_id") val levelId: Int? = null, + @SerialName("genre_id") val genreId: Int? = null, + @SerialName("media_type") val mediaType: String? = null, + @SerialName("collocation_from") val collocationFrom: String? = null, + @SerialName("collocation_to") val collocationTo: String? = null, +) + +@Serializable +data class Staff090StocktakeScanRequest( + val code: String, + @SerialName("shelf_id") val shelfId: Int? = null, +) + +@Serializable +data class Staff090StocktakeCloseRequest(@SerialName("missing_action") val missingAction: String) diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt index 3c89c55..134c9af 100644 --- a/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt @@ -62,6 +62,8 @@ data class StaffLoan( @SerialName("updated_at") val updatedAt: String? = null, /** Presentation hint computed server-side; unknown values are ignored by the app. */ val actions: List = emptyList(), + /** Mobile API 1.10.0: the late fine (detail and return answers); null while fines are off. */ + @SerialName("late_fine") val lateFine: StaffLoanLateFine? = null, ) @Serializable @@ -95,6 +97,13 @@ data class StaffUser( @SerialName("active_reservations") val activeReservations: Int = 0, val eligible: Boolean = true, @SerialName("eligibility_reason") val eligibilityReason: String? = null, + // Mobile API 1.10.0 (Pinakes 0.9.0 patron fields): absent on older servers. + @SerialName("patron_type") val patronType: StaffPatronTypeRef? = null, + @SerialName("has_photo") val hasPhoto: Boolean = false, + @SerialName("photo_url") val photoUrl: String? = null, + val ledger: StaffLedgerSummary? = null, + /** The caller may change this record (patron type, photo): the web editor's rule. */ + @SerialName("can_edit") val canEdit: Boolean = false, ) /** `GET /staff/users/{id}`: the user plus open loans and active reservations. */ @@ -111,6 +120,13 @@ data class StaffUserDetail( @SerialName("active_reservations") val activeReservations: Int = 0, val eligible: Boolean = true, @SerialName("eligibility_reason") val eligibilityReason: String? = null, + // Mobile API 1.10.0 (Pinakes 0.9.0 patron fields): absent on older servers. + @SerialName("patron_type") val patronType: StaffPatronTypeRef? = null, + @SerialName("has_photo") val hasPhoto: Boolean = false, + @SerialName("photo_url") val photoUrl: String? = null, + val ledger: StaffLedgerSummary? = null, + /** The caller may change this record (patron type, photo): the web editor's rule. */ + @SerialName("can_edit") val canEdit: Boolean = false, val loans: List = emptyList(), val reservations: List = emptyList(), ) @@ -164,6 +180,9 @@ data class ReturnRequest( val outcome: String, val notes: String? = null, val penalty: Double? = null, + /** Mobile API 1.10.0: waive the late fine this return charges; needs [waiveReason]. */ + @SerialName("waive_fine") val waiveFine: Boolean? = null, + @SerialName("waive_reason") val waiveReason: String? = null, ) @Serializable @@ -218,4 +237,7 @@ data class ReservationWrite( @SerialName("expires_at") val expiresAt: String? = null, @SerialName("requested_from") val requestedFrom: String? = null, @SerialName("requested_to") val requestedTo: String? = null, + // Pinakes 0.9.0: staff may ignore the patron type's reservation limits, as the website's + // "new reservation" form allows. Null is omitted, so an older server never sees the key. + @SerialName("override_limits") val overrideLimits: Boolean? = null, ) diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt index 2bf91e1..22db266 100644 --- a/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt @@ -9,7 +9,13 @@ import kotlinx.serialization.json.JsonObject // duplicate merges, labels, CSV/LibraryThing imports, covers and bulk enrichment. @Serializable -data class CopyUpdate(val status: String? = null, val notes: String? = null) +data class CopyUpdate( + val status: String? = null, + val notes: String? = null, + // Pinakes 0.9.0: keep the copy for in-house consultation, or make it lendable again. Null is + // omitted, so an older server, which would ignore it, never sees the key. + @SerialName("consultation_only") val consultationOnly: Boolean? = null, +) // ---------- Series ---------- diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt index 9af4550..51b9d19 100644 --- a/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt @@ -39,6 +39,12 @@ data class StaffLookups( // Mobile API 1.7.0 additions. @SerialName("series_types") val seriesTypes: List = emptyList(), @SerialName("return_outcomes") val returnOutcomes: List = emptyList(), + // Mobile API 1.10.0 (Pinakes 0.9.0): present only while their module is on, so null means + // "module off" and the book form leaves the field out. + val suppliers: List? = null, + @SerialName("rda_content_types") val rdaContentTypes: List? = null, + @SerialName("rda_media_types") val rdaMediaTypes: List? = null, + @SerialName("rda_carrier_types") val rdaCarrierTypes: List? = null, ) { val languageOptions: List get() = options(languages) val mediaTypeOptions: List get() = options(mediaTypes) @@ -206,6 +212,8 @@ data class StaffCopy( val available: Boolean? = null, /** Copy-code lookup only: the open loan on this copy. */ val loan: StaffLoan? = null, + /** Kept for consultation in the library (Pinakes 0.9.0); absent on older servers. */ + @SerialName("consultation_only") val consultationOnly: Boolean = false, ) @Serializable @@ -255,6 +263,14 @@ data class StaffBook( @SerialName("is_wanted") val isWanted: Boolean = false, @SerialName("created_at") val createdAt: String? = null, @SerialName("updated_at") val updatedAt: String? = null, + // Mobile API 1.10.0 (Pinakes 0.9.0), sent only while their module is on: the supplier + // (`suppliers`) and the RDA overrides with the effective triple (`rda_types`). + @SerialName("supplier_id") val supplierId: Int? = null, + val supplier: IdName? = null, + @SerialName("rda_content_type") val rdaContentType: String? = null, + @SerialName("rda_media_type") val rdaMediaType: String? = null, + @SerialName("rda_carrier_type") val rdaCarrierType: String? = null, + @SerialName("rda_effective") val rdaEffective: RdaEffective? = null, ) /** Row of `GET /staff/books`: the public summary plus operator-only fields. */ diff --git a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt index 2054abc..858664d 100644 --- a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt +++ b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt @@ -119,6 +119,11 @@ class NetworkModule(private val session: SessionStore, private val cacheDir: Fil @Volatile private var cachedLongPluginsBaseUrl: String? = null + /** Pinakes 0.9.0 staff areas: one Retrofit service per area, created on demand by type. */ + private val cachedTypedApis = mutableMapOf, Any>() + private val cachedLongTypedApis = mutableMapOf, Any>() + private var cachedLongTypedBaseUrl: String? = null + /** * The same client with an 11-minute read timeout, for the synchronous server jobs that may * run up to 600 s (covers sync, an enrichment batch, an import chunk with scraping). Only @@ -154,6 +159,7 @@ class NetworkModule(private val session: SessionStore, private val cacheDir: Fil cachedStaffContentApi = null cachedStaffPluginsApi = null cachedStaffTier2Api = null + cachedTypedApis.clear() return retrofit } @@ -266,6 +272,38 @@ class NetworkModule(private val session: SessionStore, private val cacheDir: Fil return api } + /** + * A Retrofit service of the Pinakes 0.9.0 staff areas (Mobile API 1.10.0), same base URL + * and bearer token as [api]. Each area declares its own interface and asks for it here, + * so adding an area touches no accessor of this class. + */ + @Synchronized + fun typedApi(type: Class, baseUrl: String? = null): T { + val retrofit = retrofit(baseUrl) + @Suppress("UNCHECKED_CAST") + return cachedTypedApis.getOrPut(type) { retrofit.create(type) } as T + } + + /** [typedApi] on the long-timeout client, for uploads and chunks the server runs synchronously. */ + @Synchronized + fun longTypedApi(type: Class): T { + val target = requireNotNull(session.instanceUrl) { "No instance URL configured. Complete onboarding first." } + val normalized = if (target.endsWith("/")) target else "$target/" + if (normalized != cachedLongTypedBaseUrl) { + cachedLongTypedApis.clear() + cachedLongTypedBaseUrl = normalized + } + @Suppress("UNCHECKED_CAST") + return cachedLongTypedApis.getOrPut(type) { + Retrofit.Builder() + .baseUrl(normalized) + .client(longOkHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(type) + } as T + } + /** Drop the cached Retrofit so the next [api] call rebuilds against a new instance URL. */ @Synchronized fun invalidate() { @@ -284,6 +322,9 @@ class NetworkModule(private val session: SessionStore, private val cacheDir: Fil cachedLongBaseUrl = null cachedLongPluginsApi = null cachedLongPluginsBaseUrl = null + cachedTypedApis.clear() + cachedLongTypedApis.clear() + cachedLongTypedBaseUrl = null } /** diff --git a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt index 31ef01c..9ef8cce 100644 --- a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt @@ -159,6 +159,10 @@ interface PinakesApi { @GET("me/loans") suspend fun loans(): Envelope + /** Balance and ledger of the fines module (Mobile API 1.9.0); 404 `feature_disabled` when off. */ + @GET("me/movements") + suspend fun movements(): Envelope + @GET("me/reservations") suspend fun reservations(): Envelope> diff --git a/app/src/main/java/com/pinakes/app/data/network/Staff090CatalogueApi.kt b/app/src/main/java/com/pinakes/app/data/network/Staff090CatalogueApi.kt new file mode 100644 index 0000000..e902624 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/Staff090CatalogueApi.kt @@ -0,0 +1,97 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.ChunkRequest +import com.pinakes.app.data.model.CopyCatalogingImport +import com.pinakes.app.data.model.CopyCatalogingImportRequest +import com.pinakes.app.data.model.CopyCatalogingSearch +import com.pinakes.app.data.model.CopyCatalogingSearchRequest +import com.pinakes.app.data.model.CopyCatalogingServers +import com.pinakes.app.data.model.Envelope090 +import com.pinakes.app.data.model.MarcCancelResult +import com.pinakes.app.data.model.MarcImportJob +import com.pinakes.app.data.model.MarcOptions +import com.pinakes.app.data.model.Supplier +import com.pinakes.app.data.model.SupplierDeleteResult +import kotlinx.serialization.json.JsonObject +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Pinakes 0.9.0 cataloguing areas (Mobile API 1.10.0): suppliers (module `suppliers`), MARC + * import/export and copy cataloguing (module `marc`, z39-server plugin). Catalogue card PDFs and + * the MARC export are binary downloads through [com.pinakes.app.data.repository.StaffFilesRepository]. + */ +interface Staff090CatalogueApi { + + // ---------- Suppliers ---------- + + @GET("staff/suppliers") + suspend fun suppliers(@Query("q") q: String? = null): Envelope090> + + @GET("staff/suppliers/{id}") + suspend fun supplier(@Path("id") id: Int): Envelope090 + + @POST("staff/suppliers") + suspend fun createSupplier(@Body body: JsonObject, @Header("Idempotency-Key") key: String): Envelope090 + + @PUT("staff/suppliers/{id}") + suspend fun updateSupplier(@Path("id") id: Int, @Body body: JsonObject): Envelope090 + + @DELETE("staff/suppliers/{id}") + suspend fun deleteSupplier(@Path("id") id: Int): Envelope090 + + // ---------- MARC ---------- + + @GET("staff/marc/options") + suspend fun marcOptions(): Envelope090 + + /** Upload on the long-timeout client: the server stages every record before answering. */ + @Multipart + @POST("staff/marc/imports") + suspend fun createMarcImport( + @Part file: MultipartBody.Part, + @Part("flavour") flavour: RequestBody, + @Part("encoding") encoding: RequestBody, + @Part("duplicates") duplicates: RequestBody, + @Part("download_covers") downloadCovers: RequestBody, + @Part("supplier_id") supplierId: RequestBody?, + ): Envelope090 + + /** The caller's unexpired MARC jobs, newest first (every status), paged like `/staff/imports`. */ + @GET("staff/marc/imports") + suspend fun marcImports(@Query("limit") limit: Int? = null, @Query("cursor") cursor: String? = null): Envelope090> + + @GET("staff/marc/imports/{id}") + suspend fun marcImport(@Path("id") id: String): Envelope090 + + @POST("staff/marc/imports/{id}/chunk") + suspend fun marcImportChunk(@Path("id") id: String, @Body body: ChunkRequest): Envelope090 + + @DELETE("staff/marc/imports/{id}") + suspend fun cancelMarcImport(@Path("id") id: String): Envelope090 + + // ---------- Copy cataloguing ---------- + + @GET("staff/copy-cataloging/servers") + suspend fun copyCatalogingServers(): Envelope090 + + /** Each catalogue may take seconds to answer: long-timeout client. */ + @POST("staff/copy-cataloging/search") + suspend fun copyCatalogingSearch(@Body body: CopyCatalogingSearchRequest): Envelope090 + + @POST("staff/copy-cataloging/import") + suspend fun copyCatalogingImport( + @Body body: CopyCatalogingImportRequest, + @Header("Idempotency-Key") key: String, + ): Envelope090 +} diff --git a/app/src/main/java/com/pinakes/app/data/network/Staff090LibraryApi.kt b/app/src/main/java/com/pinakes/app/data/network/Staff090LibraryApi.kt new file mode 100644 index 0000000..a966205 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/Staff090LibraryApi.kt @@ -0,0 +1,140 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.DeleteResult +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.Staff090LibraryCalendar +import com.pinakes.app.data.model.Staff090LibraryCards +import com.pinakes.app.data.model.Staff090LibraryClosure +import com.pinakes.app.data.model.Staff090LibraryCountries +import com.pinakes.app.data.model.Staff090LibraryCustomHoliday +import com.pinakes.app.data.model.Staff090LibraryIcsImported +import com.pinakes.app.data.model.Staff090LibraryLoans +import com.pinakes.app.data.model.Staff090LibraryModules +import com.pinakes.app.data.model.Staff090LibraryPatronType +import com.pinakes.app.data.model.Staff090LibraryPresetApplied +import com.pinakes.app.data.model.Staff090LibraryRules +import com.pinakes.app.data.model.Staff090LibraryTypeDeleted +import com.pinakes.app.data.model.Staff090LibraryTypes +import com.pinakes.app.data.model.Staff090LibraryYearlySaved +import kotlinx.serialization.json.JsonObject +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Library settings of the Pinakes 0.9.0 modules (Mobile API 1.10.0): Settings → Modules, + * Loans and Cards, patron types and the rules matrix, the closures calendar. Write bodies are + * [JsonObject]s: a partial save names only the changed keys, and a JSON null is meaningful + * ("inherit" for a type or a cell), which the app-wide Json (explicitNulls = false) would drop. + */ +interface Staff090LibraryApi { + + // ---------- Settings ---------- + + @GET("staff/settings/modules") + suspend fun modules(): Envelope + + @PUT("staff/settings/modules") + suspend fun saveModules(@Body body: JsonObject): Envelope + + @GET("staff/settings/loans") + suspend fun loans(): Envelope + + @PUT("staff/settings/loans") + suspend fun saveLoans(@Body body: JsonObject): Envelope + + @GET("staff/settings/cards") + suspend fun cards(): Envelope + + @PUT("staff/settings/cards") + suspend fun saveCards(@Body body: JsonObject): Envelope + + // ---------- Patron types and rules ---------- + + @GET("staff/circulation/types") + suspend fun types(): Envelope + + /** [key] is the submission's Idempotency-Key: a replay answers the type created the first time. */ + @POST("staff/circulation/types") + suspend fun createType(@Body body: JsonObject, @Header("Idempotency-Key") key: String? = null): Envelope + + @GET("staff/circulation/types/{id}") + suspend fun type(@Path("id") id: Int): Envelope + + @PUT("staff/circulation/types/{id}") + suspend fun updateType(@Path("id") id: Int, @Body body: JsonObject): Envelope + + @DELETE("staff/circulation/types/{id}") + suspend fun deleteType(@Path("id") id: Int): Envelope + + @POST("staff/circulation/types/{id}/default") + suspend fun setDefaultType(@Path("id") id: Int): Envelope + + /** Answers the whole list in its new order. */ + @POST("staff/circulation/types/{id}/move") + suspend fun moveType(@Path("id") id: Int, @Body body: JsonObject): Envelope + + @GET("staff/circulation/rules") + suspend fun rules(): Envelope + + @PUT("staff/circulation/rules") + suspend fun saveRules(@Body body: JsonObject): Envelope + + // ---------- Closures calendar ---------- + + @GET("staff/calendar") + suspend fun calendar( + @Query("month") month: String? = null, + @Query("branch_id") branchId: Int? = null, + ): Envelope + + @PUT("staff/calendar/weekdays") + suspend fun saveWeekdays(@Body body: JsonObject): Envelope + + @POST("staff/calendar/closures") + suspend fun createClosure(@Body body: JsonObject, @Header("Idempotency-Key") key: String? = null): Envelope + + @PUT("staff/calendar/closures/{id}") + suspend fun updateClosure(@Path("id") id: Int, @Body body: JsonObject): Envelope + + @DELETE("staff/calendar/closures/{id}") + suspend fun deleteClosure(@Path("id") id: Int): Envelope + + @GET("staff/calendar/holidays/countries") + suspend fun countries( + @Query("country") country: String? = null, + @Query("region") region: String? = null, + @Query("year") year: Int? = null, + ): Envelope + + @POST("staff/calendar/holidays/preset") + suspend fun applyPreset(@Body body: JsonObject): Envelope + + @PUT("staff/calendar/holidays/yearly") + suspend fun saveYearly(@Body body: JsonObject): Envelope + + @POST("staff/calendar/custom-holidays") + suspend fun createCustom(@Body body: JsonObject, @Header("Idempotency-Key") key: String? = null): Envelope + + @PUT("staff/calendar/custom-holidays/{id}") + suspend fun updateCustom(@Path("id") id: Int, @Body body: JsonObject): Envelope + + @DELETE("staff/calendar/custom-holidays/{id}") + suspend fun deleteCustom(@Path("id") id: Int): Envelope + + @POST("staff/calendar/custom-holidays/{id}/toggle") + suspend fun toggleCustom(@Path("id") id: Int): Envelope + + @Multipart + @POST("staff/calendar/import-ics") + suspend fun importIcs(@Part file: MultipartBody.Part, @Part("branch_id") branchId: RequestBody? = null): Envelope +} diff --git a/app/src/main/java/com/pinakes/app/data/network/Staff090PatronsApi.kt b/app/src/main/java/com/pinakes/app/data/network/Staff090PatronsApi.kt new file mode 100644 index 0000000..b25892d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/Staff090PatronsApi.kt @@ -0,0 +1,127 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.AttendanceCheckInRequest +import com.pinakes.app.data.model.AttendanceGuestRequest +import com.pinakes.app.data.model.AttendanceOverview +import com.pinakes.app.data.model.AttendancePurgeResult +import com.pinakes.app.data.model.AttendanceSettingsRequest +import com.pinakes.app.data.model.AttendanceSettingsResult +import com.pinakes.app.data.model.AttendanceVisit +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.LedgerChargeRequest +import com.pinakes.app.data.model.LedgerPaymentRequest +import com.pinakes.app.data.model.LedgerWaiverRequest +import com.pinakes.app.data.model.LedgerWriteResult +import com.pinakes.app.data.model.MetaEnvelope +import com.pinakes.app.data.model.PatronPhotoState +import com.pinakes.app.data.model.PatronType +import com.pinakes.app.data.model.PatronTypeRequest +import com.pinakes.app.data.model.PatronTypeUpdateMeta +import com.pinakes.app.data.model.PatronTypesMeta +import com.pinakes.app.data.model.StaffLedger +import com.pinakes.app.data.model.StaffUser +import com.pinakes.app.data.model.UserImportCancelResult +import com.pinakes.app.data.model.UserImportChunkRequest +import com.pinakes.app.data.model.UserImportJob +import com.pinakes.app.data.model.UserImportStartRequest +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query +import retrofit2.http.Streaming + +/** + * Pinakes 0.9.0 patron tools (Mobile API 1.10.0): patron types, card renewal and photo + * (`staff-patrons`), the ledger (`staff-ledger`, module fines), the visitor log + * (`staff-attendance`, module visitor_log) and the patron CSV import (`staff-user-import`). + * PDFs and CSVs go through [StaffContentApi.download] like every staff export. + */ +interface Staff090PatronsApi { + + // ---- Patron record ---- + + @GET("staff/patron-types") + suspend fun patronTypes(): MetaEnvelope, PatronTypesMeta> + + @PUT("staff/users/{id}/patron-type") + suspend fun setPatronType(@Path("id") id: Int, @Body body: PatronTypeRequest): MetaEnvelope + + /** [key]: a retried renewal must not add the card duration twice. */ + @POST("staff/users/{id}/renew-card") + suspend fun renewCard(@Path("id") id: Int, @Header("Idempotency-Key") key: String?): Envelope + + /** The photo needs the bearer token, so it never goes through the image loader. */ + @Streaming + @GET("staff/users/{id}/photo") + suspend fun photo(@Path("id") id: Int): Response + + @Multipart + @POST("staff/users/{id}/photo") + suspend fun uploadPhoto(@Path("id") id: Int, @Part photo: MultipartBody.Part): Envelope + + @DELETE("staff/users/{id}/photo") + suspend fun deletePhoto(@Path("id") id: Int): Envelope + + // ---- Ledger (Idempotency-Key required on every write) ---- + + @GET("staff/users/{id}/ledger") + suspend fun ledger(@Path("id") id: Int, @Query("cursor") cursor: String? = null, @Query("limit") limit: Int? = null): Envelope + + @POST("staff/users/{id}/ledger/payments") + suspend fun payment(@Path("id") id: Int, @Body body: LedgerPaymentRequest, @Header("Idempotency-Key") key: String): Envelope + + @POST("staff/users/{id}/ledger/charges") + suspend fun charge(@Path("id") id: Int, @Body body: LedgerChargeRequest, @Header("Idempotency-Key") key: String): Envelope + + @POST("staff/users/{id}/ledger/waivers") + suspend fun waiver(@Path("id") id: Int, @Body body: LedgerWaiverRequest, @Header("Idempotency-Key") key: String): Envelope + + // ---- Visitor log ---- + + @GET("staff/attendance") + suspend fun attendance(@Query("days") days: Int): Envelope + + @POST("staff/attendance/check-ins") + suspend fun checkIn(@Body body: AttendanceCheckInRequest, @Header("Idempotency-Key") key: String?): Envelope + + @POST("staff/attendance/guests") + suspend fun guest(@Body body: AttendanceGuestRequest, @Header("Idempotency-Key") key: String?): Envelope + + @PUT("staff/attendance/settings") + suspend fun attendanceSettings(@Body body: AttendanceSettingsRequest): Envelope + + @POST("staff/attendance/purge") + suspend fun attendancePurge(): Envelope + + // ---- Patron CSV import ---- + + /** The caller's unexpired jobs, newest first (finished ones included until they expire). */ + @GET("staff/user-imports") + suspend fun userImports(@Query("cursor") cursor: String? = null, @Query("limit") limit: Int? = null): Envelope> + + @Multipart + @POST("staff/user-imports") + suspend fun createUserImport(@Part file: MultipartBody.Part, @Part("delimiter") delimiter: RequestBody): Envelope + + @GET("staff/user-imports/{id}") + suspend fun userImport(@Path("id") id: String): Envelope + + @POST("staff/user-imports/{id}/start") + suspend fun startUserImport(@Path("id") id: String, @Body body: UserImportStartRequest): Envelope + + @POST("staff/user-imports/{id}/chunk") + suspend fun userImportChunk(@Path("id") id: String, @Body body: UserImportChunkRequest): Envelope + + @DELETE("staff/user-imports/{id}") + suspend fun cancelUserImport(@Path("id") id: String): Envelope +} diff --git a/app/src/main/java/com/pinakes/app/data/network/Staff090StatisticsApi.kt b/app/src/main/java/com/pinakes/app/data/network/Staff090StatisticsApi.kt new file mode 100644 index 0000000..ee1e6d7 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/Staff090StatisticsApi.kt @@ -0,0 +1,20 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.Staff090Statistics +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * The statistics page for the app (Mobile API 1.10.0). The fines CSV + * (`staff/statistics/fines.csv`, only while `fines` is on) goes through [StaffContentApi.download]. + */ +interface Staff090StatisticsApi { + + /** [finesFrom] / [finesTo]: YYYY-MM-DD; the server defaults to the last twelve months and swaps a reversed range. */ + @GET("staff/statistics") + suspend fun statistics( + @Query("fines_from") finesFrom: String? = null, + @Query("fines_to") finesTo: String? = null, + ): Envelope +} diff --git a/app/src/main/java/com/pinakes/app/data/network/Staff090StocktakeApi.kt b/app/src/main/java/com/pinakes/app/data/network/Staff090StocktakeApi.kt new file mode 100644 index 0000000..d11ca10 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/Staff090StocktakeApi.kt @@ -0,0 +1,97 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.Staff090StocktakeCloseRequest +import com.pinakes.app.data.model.Staff090StocktakeCreateRequest +import com.pinakes.app.data.model.Staff090StocktakeOptions +import com.pinakes.app.data.model.Staff090StocktakeReportRow +import com.pinakes.app.data.model.Staff090StocktakeScan +import com.pinakes.app.data.model.Staff090StocktakeScanRequest +import com.pinakes.app.data.model.Staff090StocktakeScanResult +import com.pinakes.app.data.model.Staff090StocktakeSession +import com.pinakes.app.data.model.Staff090StocktakeState +import com.pinakes.app.data.model.Staff090StocktakeUpload +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Inventory stocktake (Mobile API 1.10.0, `stocktake` module). Lists page with the shared + * cursor (`meta.next_cursor`); the CSV and PDF reports go through [StaffContentApi.download]. + * Scans are rate limited at 600 a minute, as on the web scan page. + */ +interface Staff090StocktakeApi { + + @GET("staff/stocktake/options") + suspend fun options(): Envelope + + /** [status]: `open` | `closed` | `cancelled` | `all`. */ + @GET("staff/stocktake/sessions") + suspend fun sessions( + @Query("status") status: String?, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): Envelope> + + /** [key]: one per submission; a replay answers 200 with `meta.replayed` and the same session. */ + @POST("staff/stocktake/sessions") + suspend fun create( + @Body body: Staff090StocktakeCreateRequest, + @Header("Idempotency-Key") key: String? = null, + ): Envelope + + /** [mine] = 1 limits `recent_scans` to the signed-in operator. */ + @GET("staff/stocktake/sessions/{id}") + suspend fun session(@Path("id") id: Int, @Query("mine") mine: Int? = null): Envelope + + @GET("staff/stocktake/sessions/{id}/state") + suspend fun state(@Path("id") id: Int, @Query("mine") mine: Int? = null): Envelope + + @GET("staff/stocktake/sessions/{id}/scans") + suspend fun scans( + @Path("id") id: Int, + @Query("outcome") outcome: String? = null, + @Query("mine") mine: Int? = null, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): Envelope> + + /** 409 `session_closed` / `session_cancelled` once the session is no longer open. */ + @POST("staff/stocktake/sessions/{id}/scans") + suspend fun scan( + @Path("id") id: Int, + @Body body: Staff090StocktakeScanRequest, + @Header("Idempotency-Key") key: String? = null, + ): Envelope + + @GET("staff/stocktake/sessions/{id}/report") + suspend fun report( + @Path("id") id: Int, + @Query("category") category: String? = null, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): Envelope> + + /** A `.txt` / `.csv` list of codes (at most 2 MB), one scan each. */ + @Multipart + @POST("staff/stocktake/sessions/{id}/upload") + suspend fun upload( + @Path("id") id: Int, + @Part file: MultipartBody.Part, + @Part("shelf_id") shelfId: RequestBody? = null, + ): Envelope + + /** `lost` is refused to Staff with 403 `not_permitted`; posting it again on a mark-lost close finishes it. */ + @POST("staff/stocktake/sessions/{id}/close") + suspend fun close(@Path("id") id: Int, @Body body: Staff090StocktakeCloseRequest): Envelope + + @POST("staff/stocktake/sessions/{id}/cancel") + suspend fun cancel(@Path("id") id: Int): Envelope +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt index 23b978c..c345095 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt @@ -199,8 +199,8 @@ class CatalogRepository( limit: Int = 20, ): List = catalogDao.search( q = SearchText.queryPattern(filters.query), - author = SearchText.escapeLike(filters.author?.trim().orEmpty()), - publisher = SearchText.escapeLike(filters.publisher?.trim().orEmpty()), + author = SearchText.filterPattern(filters.author), + publisher = SearchText.filterPattern(filters.publisher), language = filters.language?.trim().orEmpty(), genre = genreName?.trim().orEmpty(), availableOnly = filters.availableOnly == true, diff --git a/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt index 27efa62..a6762e3 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt @@ -45,6 +45,12 @@ class LibraryRepository( return res } + /** The reader's balance and movements (fines ledger). Not cached: money must be current. */ + suspend fun movements(): ApiResult { + val api = network.api() + return apiCall { api.movements() } + } + /** The last loans the server returned, or null when nothing was ever cached. */ suspend fun loansCached(): LoansData? { val rows = cache?.loans(CachedLoan.LOAN_BUCKETS) ?: return null diff --git a/app/src/main/java/com/pinakes/app/data/repository/Staff090CatalogueRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/Staff090CatalogueRepository.kt new file mode 100644 index 0000000..5f4ae92 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/Staff090CatalogueRepository.kt @@ -0,0 +1,183 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.ChunkRequest +import com.pinakes.app.data.model.CopyCatalogingImport +import com.pinakes.app.data.model.CopyCatalogingImportRequest +import com.pinakes.app.data.model.CopyCatalogingSearch +import com.pinakes.app.data.model.CopyCatalogingSearchRequest +import com.pinakes.app.data.model.CopyCatalogingServers +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.Envelope090 +import com.pinakes.app.data.model.MarcCancelResult +import com.pinakes.app.data.model.MarcImportJob +import com.pinakes.app.data.model.MarcOptions +import com.pinakes.app.data.model.Meta +import com.pinakes.app.data.model.Supplier +import com.pinakes.app.data.model.SupplierDeleteResult +import com.pinakes.app.data.model.SupplierList +import com.pinakes.app.data.model.fieldErrors +import com.pinakes.app.data.model.metaBoolean +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.network.Staff090CatalogueApi +import com.pinakes.app.data.store.SessionStore +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.HttpException +import retrofit2.Response + +/** A write that may fail field by field: the result and the 422's `meta.fields`. */ +data class FieldsResult(val result: ApiResult, val fields: Map = emptyMap()) + +/** The choices of a MARC upload (the server's names). */ +data class MarcUploadOptions( + val flavour: String = "auto", + val encoding: String = "auto", + val duplicates: String = "skip", + val downloadCovers: Boolean = false, + /** Only while the suppliers module is on. */ + val supplierId: Int? = null, +) + +/** + * Pinakes 0.9.0 cataloguing (Mobile API 1.10.0): suppliers, MARC import, copy cataloguing. + * Every call goes through [staffCall] (a 403 `forbidden` drops the role). The envelope's meta + * is read raw, because these routes answer keys the shared [Meta] does not carry. Uploads, + * chunks and catalogue searches use the long-timeout client. + */ +@Singleton +class Staff090CatalogueRepository @Inject constructor( + private val network: NetworkModule, + private val session: SessionStore, +) { + private val api get() = network.typedApi(Staff090CatalogueApi::class.java) + private val longApi get() = network.longTypedApi(Staff090CatalogueApi::class.java) + + // ---------- suppliers ---------- + + suspend fun suppliers(query: String? = null): ApiResult { + val (res, meta) = call { api.suppliers(query.blankToNull()) } + return res.map { SupplierList(it, meta.metaBoolean("can_create") ?: false) } + } + + suspend fun supplier(id: Int): ApiResult = call { api.supplier(id) }.first + + /** Create (with [idempotencyKey]) when [id] is null, else update with [body] as the patch. */ + suspend fun saveSupplier(id: Int?, body: JsonObject, idempotencyKey: String): FieldsResult { + val (res, meta) = call { if (id == null) api.createSupplier(body, idempotencyKey) else api.updateSupplier(id, body) } + return FieldsResult(res, if (res is ApiResult.Failure) meta.fieldErrors() else emptyMap()) + } + + suspend fun deleteSupplier(id: Int): ApiResult = call { api.deleteSupplier(id) }.first + + // ---------- MARC ---------- + + suspend fun marcOptions(): ApiResult = call { api.marcOptions() }.first + + suspend fun createMarcImport(fileName: String, bytes: ByteArray, options: MarcUploadOptions): ApiResult { + val text = "text/plain".toMediaType() + val part = MultipartBody.Part.createFormData("file", fileName, bytes.toRequestBody("application/octet-stream".toMediaTypeOrNull())) + return call { + longApi.createMarcImport( + part, + options.flavour.toRequestBody(text), + options.encoding.toRequestBody(text), + options.duplicates.toRequestBody(text), + (if (options.downloadCovers) "1" else "0").toRequestBody(text), + options.supplierId?.takeIf { it > 0 }?.toString()?.toRequestBody(text), + ) + }.first + } + + /** One page of the caller's MARC jobs (finished ones included: the screen keeps the open ones). */ + suspend fun marcImports(cursor: String? = null, limit: Int = 30): ApiResult> = + call { api.marcImports(limit, cursor) }.first.toPage() + + suspend fun marcImport(id: String): ApiResult = call { api.marcImport(id) }.first + + /** One chunk from [start]; with cover downloads the server may need minutes. */ + suspend fun marcImportChunk(id: String, start: Int): ApiResult = + call { longApi.marcImportChunk(id, ChunkRequest(start = start)) }.first + + suspend fun cancelMarcImport(id: String): ApiResult = call { api.cancelMarcImport(id) }.first + + // ---------- copy cataloguing ---------- + + suspend fun copyCatalogingServers(): ApiResult = call { api.copyCatalogingServers() }.first + + suspend fun copyCatalogingSearch(request: CopyCatalogingSearchRequest): ApiResult = + call { longApi.copyCatalogingSearch(request) }.first + + suspend fun copyCatalogingImport(request: CopyCatalogingImportRequest, idempotencyKey: String): ApiResult = + call { longApi.copyCatalogingImport(request, idempotencyKey) }.first + + // ---------- plumbing ---------- + + /** + * [staffCall] on an [Envelope090]: the meta is kept raw (success or error body) and also + * decoded into the shared [Meta], so `processed`, `replayed` and the rest work as usual. + */ + private suspend fun call(block: suspend () -> Envelope090): Pair, JsonElement?> { + var raw: JsonElement? = null + val res = session.staffCall { + val envelope = try { + block() + } catch (e: HttpException) { + val response = e.response() ?: throw e + val text = runCatching { response.errorBody()?.string() }.getOrNull().orEmpty() + raw = metaOf(network.json, text) + // The body was read here: hand an identical one on to the shared parser. + throw HttpException(Response.error(text.toResponseBody(response.errorBody()?.contentType()), response.raw())) + } + raw = envelope.meta + Envelope(data = envelope.data, meta = envelope.meta?.let { m -> runCatching { network.json.decodeFromJsonElement(Meta.serializer(), m) }.getOrNull() }, error = envelope.error) + } + return res to raw + } + + companion object { + /** The `meta` of an error body, or null when it is not the API's envelope. */ + fun metaOf(json: Json, body: String): JsonElement? = + runCatching { (json.parseToJsonElement(body) as? JsonObject)?.get("meta") }.getOrNull() + } +} + +/** Download paths of the cataloguing PDFs and exports (relative to `/api/v1/`). Pure. */ +object Staff090CataloguePaths { + /** Web rule: at most 200 cards in one PDF. */ + const val MAX_CARDS = 200 + + /** Server rule: at most 1000 ids in one MARC export. */ + const val MAX_EXPORT_IDS = 1000 + + fun catalogueCard(bookId: Int): String = "staff/books/$bookId/catalogue-card.pdf" + + /** Distinct positive ids in the order given; null when none is left. */ + fun catalogueCards(ids: List): String? = + ids.filter { it > 0 }.distinct().take(MAX_CARDS).takeIf { it.isNotEmpty() }?.let { "staff/books/catalogue-cards.pdf?ids=${it.joinToString(",")}" } + + /** The whole catalogue when [ids] is null; a selection otherwise (null when empty or over the limit). */ + fun marcExport(format: String, ids: List?): String? { + val safeFormat = format.takeIf { it in FORMATS } ?: "marc21" + if (ids == null) return "staff/marc/export?format=$safeFormat" + val list = ids.filter { it > 0 }.distinct() + if (list.isEmpty() || list.size > MAX_EXPORT_IDS) return null + return "staff/marc/export?format=$safeFormat&ids=${list.joinToString(",")}" + } + + /** Mime of an export for the viewer/share sheet. */ + fun exportMime(format: String): String = if (format == "marcxml") "application/marcxml+xml" else "application/marc" + + /** The file name used when the server sends none. */ + fun exportFallbackName(format: String): String = if (format == "marcxml") "catalogo_marcxml.xml" else "catalogo_$format.mrc" + + val FORMATS = listOf("marc21", "marcxml", "unimarc") +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/Staff090LibraryRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/Staff090LibraryRepository.kt new file mode 100644 index 0000000..f94e154 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/Staff090LibraryRepository.kt @@ -0,0 +1,158 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.DeleteResult +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.Staff090LibraryCalendar +import com.pinakes.app.data.model.Staff090LibraryCards +import com.pinakes.app.data.model.Staff090LibraryClosure +import com.pinakes.app.data.model.Staff090LibraryCountries +import com.pinakes.app.data.model.Staff090LibraryCustomHoliday +import com.pinakes.app.data.model.Staff090LibraryIcsImported +import com.pinakes.app.data.model.Staff090LibraryLoans +import com.pinakes.app.data.model.Staff090LibraryModules +import com.pinakes.app.data.model.Staff090LibraryPatronType +import com.pinakes.app.data.model.Staff090LibraryPresetApplied +import com.pinakes.app.data.model.Staff090LibraryRules +import com.pinakes.app.data.model.Staff090LibraryTypeDeleted +import com.pinakes.app.data.model.Staff090LibraryTypes +import com.pinakes.app.data.model.Staff090LibraryYearlySaved +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.network.Staff090LibraryApi +import com.pinakes.app.data.store.SessionStore +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.HttpException +import retrofit2.Response +import javax.inject.Inject +import javax.inject.Singleton + +/** A write's outcome plus the per-field messages of a 422 (or a 403 on admin-only fields), `meta.fields`. */ +data class Staff090LibrarySaved(val result: ApiResult, val fields: Map = emptyMap()) + +/** + * Library settings, patron types, rules matrix and closures calendar (Mobile API 1.10.0). + * Every call goes through [staffCall]: a 403 `forbidden` (staff role gate) drops the role, a + * 403 `not_permitted` (this account may not do this action) leaves it alone. Writes also keep + * `meta.fields`, the per-field messages of a 422 or of a refused administrator-only field. + */ +@Singleton +class Staff090LibraryRepository @Inject constructor( + private val network: NetworkModule, + private val session: SessionStore, +) { + private val api get() = network.typedApi(Staff090LibraryApi::class.java) + + // ---------- Settings ---------- + + suspend fun modules(): ApiResult = session.staffCall { api.modules() } + suspend fun saveModules(body: JsonObject) = settingsWrite { api.saveModules(body) } + + suspend fun loans(): ApiResult = session.staffCall { api.loans() } + suspend fun saveLoans(body: JsonObject) = settingsWrite { api.saveLoans(body) } + + /** 404 `feature_disabled` for Staff while patron cards are off: the tab is then not shown. */ + suspend fun cards(): ApiResult = settingsCall { api.cards() } + suspend fun saveCards(body: JsonObject) = settingsWrite { api.saveCards(body) } + + // ---------- Patron types and rules ---------- + + suspend fun types(): ApiResult = settingsCall { api.types() } + suspend fun createType(body: JsonObject, key: String) = settingsWrite { api.createType(body, key) } + suspend fun updateType(id: Int, body: JsonObject) = settingsWrite { api.updateType(id, body) } + suspend fun deleteType(id: Int): ApiResult = settingsCall { api.deleteType(id) } + suspend fun setDefaultType(id: Int): ApiResult = settingsCall { api.setDefaultType(id) } + suspend fun moveType(id: Int, up: Boolean): ApiResult = + settingsCall { api.moveType(id, buildJsonObject { put("direction", JsonPrimitive(if (up) "up" else "down")) }) } + + suspend fun rules(): ApiResult = settingsCall { api.rules() } + suspend fun saveRules(cells: JsonArray) = settingsWrite { api.saveRules(buildJsonObject { put("cells", cells) }) } + + // ---------- Closures calendar ---------- + + suspend fun calendar(month: String?, branchId: Int?): ApiResult = settingsCall { api.calendar(month, branchId) } + + suspend fun saveWeekdays(days: List) = + settingsWrite { api.saveWeekdays(buildJsonObject { put("closed_weekdays", JsonArray(days.sorted().map(::JsonPrimitive))) }) } + + suspend fun createClosure(body: JsonObject, key: String) = settingsWrite { api.createClosure(body, key) } + suspend fun updateClosure(id: Int, body: JsonObject) = settingsWrite { api.updateClosure(id, body) } + suspend fun deleteClosure(id: Int): ApiResult = settingsCall { api.deleteClosure(id) } + + suspend fun countries(country: String?, region: String?, year: Int?): ApiResult = + settingsCall { api.countries(country.blankToNull(), region, year) } + + suspend fun applyPreset(country: String, region: String?, rules: List): Staff090LibrarySaved = + settingsWrite { + api.applyPreset(buildJsonObject { + put("country", JsonPrimitive(country)) + put("region", JsonPrimitive(region.orEmpty())) + put("rules", JsonArray(rules.map(::JsonPrimitive))) + }) + } + + suspend fun saveYearly(active: Map): Staff090LibrarySaved = + settingsWrite { + api.saveYearly(buildJsonObject { + put("closures", JsonArray(active.map { (id, on) -> buildJsonObject { put("id", JsonPrimitive(id)); put("active", JsonPrimitive(on)) } })) + }) + } + + suspend fun createCustom(body: JsonObject, key: String) = settingsWrite { api.createCustom(body, key) } + suspend fun updateCustom(id: Int, body: JsonObject) = settingsWrite { api.updateCustom(id, body) } + suspend fun deleteCustom(id: Int): ApiResult = settingsCall { api.deleteCustom(id) } + suspend fun toggleCustom(id: Int): ApiResult = settingsCall { api.toggleCustom(id) } + + /** `ics_file` (at most 1 MB) and an optional branch; the server answers how many closures it added. */ + suspend fun importIcs(fileName: String, bytes: ByteArray, mime: String?, branchId: Int?): Staff090LibrarySaved { + val type = (mime ?: "text/calendar").toMediaTypeOrNull() ?: "text/calendar".toMediaType() + val part = MultipartBody.Part.createFormData("ics_file", fileName, bytes.toRequestBody(type)) + val branch = branchId?.toString()?.toRequestBody("text/plain".toMediaType()) + return settingsWrite { network.longTypedApi(Staff090LibraryApi::class.java).importIcs(part, branch) } + } + + // ---------- Calls ---------- + + private suspend fun settingsCall(block: suspend () -> Envelope): ApiResult = withFields(block).result + + private suspend fun settingsWrite(block: suspend () -> Envelope): Staff090LibrarySaved = withFields(block) + + private suspend fun withFields(block: suspend () -> Envelope): Staff090LibrarySaved { + var errorBody: String? = null + val res = session.staffCall { + try { + block() + } catch (e: HttpException) { + // Keep a copy of the error body for meta.fields, and hand apiCall an equal one. + val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull() + errorBody = raw + val original = e.response() ?: throw e + throw HttpException(Response.error((raw ?: "").toResponseBody("application/json".toMediaType()), original.raw())) + } + } + return Staff090LibrarySaved(res, if (res is ApiResult.Failure) fieldErrors(errorBody) else emptyMap()) + } + + companion object { + private val lenient = Json { ignoreUnknownKeys = true; isLenient = true } + + /** `meta.fields` of an error body as field → message (keys such as `cells.0.max_loans` kept as sent). */ + fun fieldErrors(body: String?): Map { + if (body.isNullOrBlank()) return emptyMap() + val fields = runCatching { lenient.parseToJsonElement(body).jsonObject["meta"]?.jsonObject?.get("fields")?.jsonObject }.getOrNull() + ?: return emptyMap() + return fields.mapNotNull { (key, value) -> runCatching { value.jsonPrimitive.contentOrNull }.getOrNull()?.let { key to it } }.toMap() + } + } +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/Staff090PatronsRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/Staff090PatronsRepository.kt new file mode 100644 index 0000000..d30bf8c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/Staff090PatronsRepository.kt @@ -0,0 +1,162 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.AttendanceCheckInRequest +import com.pinakes.app.data.model.AttendanceGuestRequest +import com.pinakes.app.data.model.AttendanceOverview +import com.pinakes.app.data.model.AttendancePurgeResult +import com.pinakes.app.data.model.AttendanceSettingsRequest +import com.pinakes.app.data.model.AttendanceSettingsResult +import com.pinakes.app.data.model.AttendanceVisit +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.LedgerChargeRequest +import com.pinakes.app.data.model.LedgerPaymentRequest +import com.pinakes.app.data.model.LedgerWaiverRequest +import com.pinakes.app.data.model.LedgerWriteResult +import com.pinakes.app.data.model.PatronPhotoState +import com.pinakes.app.data.model.PatronTypeList +import com.pinakes.app.data.model.PatronTypeRequest +import com.pinakes.app.data.model.PatronTypeUpdate +import com.pinakes.app.data.model.StaffLedger +import com.pinakes.app.data.model.StaffUser +import com.pinakes.app.data.model.UserImportCancelResult +import com.pinakes.app.data.model.UserImportChunkRequest +import com.pinakes.app.data.model.UserImportJob +import com.pinakes.app.data.model.UserImportStartRequest +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.network.Staff090PatronsApi +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.store.SessionStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton +import javax.net.ssl.SSLException + +/** A ledger write: the three desk forms of the web reader record. */ +sealed interface LedgerWrite { + val amountCents: Int + data class Payment(override val amountCents: Int, val method: String, val note: String?) : LedgerWrite + data class Charge(override val amountCents: Int, val description: String) : LedgerWrite + data class Waiver(override val amountCents: Int, val reason: String) : LedgerWrite +} + +/** + * The Pinakes 0.9.0 patron tools (Mobile API 1.10.0): patron record, ledger, visitor log and + * patron CSV import. Every call goes through [staffCall], so a demoted operator loses the staff + * area on the first refused call. Writes that may be retried carry the caller's + * Idempotency-Key (mandatory on the ledger). + */ +@Singleton +class Staff090PatronsRepository @Inject constructor( + private val network: NetworkModule, + private val session: SessionStore, +) { + private val api get() = network.typedApi(Staff090PatronsApi::class.java) + + // ---- Patron record ---- + + suspend fun patronTypes(): ApiResult = session.staffCall { + val e = api.patronTypes() + Envelope(data = if (e.error == null) PatronTypeList(e.data.orEmpty(), e.meta?.defaultId, e.meta?.canAssign ?: false) else null, error = e.error) + } + + /** [typeId] null = the default type (sent as 0, which the server reads as "none of its own"). */ + suspend fun setPatronType(userId: Int, typeId: Int?, recomputeCardExpiry: Boolean): ApiResult = session.staffCall { + val e = api.setPatronType(userId, PatronTypeRequest(typeId ?: 0, recomputeCardExpiry)) + Envelope(data = e.data?.let { PatronTypeUpdate(it, e.meta?.cardExpiryRecomputed ?: false) }, error = e.error) + } + + suspend fun renewCard(userId: Int, idempotencyKey: String?): ApiResult = session.staffCall { api.renewCard(userId, idempotencyKey) } + + /** The photo bytes (a JPEG of at most 600 px), kept in memory only: patron data, never cached. */ + suspend fun photo(userId: Int): ApiResult = withContext(Dispatchers.IO) { + try { + val response = api.photo(userId) + if (!response.isSuccessful) { + val failure = parseErrorBody(response.errorBody()?.string(), response.code()) + if (failure.isForbidden()) session.saveRole(null) + return@withContext failure + } + val bytes = response.body()?.use { it.bytes() } ?: return@withContext ApiResult.Failure(ErrorCodes.UNKNOWN, "Empty body", response.code()) + ApiResult.Success(bytes) + } catch (e: SSLException) { + ApiResult.Failure(ErrorCodes.TLS, e.message ?: "TLS error") + } catch (e: IOException) { + ApiResult.Failure(ErrorCodes.NETWORK, e.message ?: "Network error") + } catch (e: Throwable) { + ApiResult.Failure(ErrorCodes.UNKNOWN, e.message ?: "Unexpected error") + } + } + + suspend fun uploadPhoto(userId: Int, jpeg: ByteArray): ApiResult { + val part = MultipartBody.Part.createFormData("photo", "photo.jpg", jpeg.toRequestBody("image/jpeg".toMediaType())) + return session.staffCall { api.uploadPhoto(userId, part) } + } + + suspend fun deletePhoto(userId: Int): ApiResult = session.staffCall { api.deletePhoto(userId) } + + // ---- Ledger ---- + + suspend fun ledger(userId: Int, cursor: String? = null, limit: Int = 30): ApiResult> = + session.staffCall { api.ledger(userId, cursor, limit) }.let { res -> + when (res) { + is ApiResult.Success -> { + val next = res.meta?.nextCursor?.takeIf { it.isNotBlank() && res.meta.hasMore != false } + ApiResult.Success(StaffPage(listOf(res.data), next, res.meta?.totalCount), res.meta) + } + is ApiResult.Failure -> res + } + } + + suspend fun recordLedger(userId: Int, write: LedgerWrite, idempotencyKey: String): ApiResult = session.staffCall { + when (write) { + is LedgerWrite.Payment -> api.payment(userId, LedgerPaymentRequest(write.amountCents, write.method, write.note.blankToNull()?.take(255)), idempotencyKey) + is LedgerWrite.Charge -> api.charge(userId, LedgerChargeRequest(write.amountCents, write.description.trim().take(255)), idempotencyKey) + is LedgerWrite.Waiver -> api.waiver(userId, LedgerWaiverRequest(write.amountCents, write.reason.trim().take(255)), idempotencyKey) + } + } + + // ---- Visitor log ---- + + suspend fun attendance(days: Int): ApiResult = session.staffCall { api.attendance(days) } + + suspend fun checkIn(cardCode: String, branchId: Int?, idempotencyKey: String?): ApiResult = + session.staffCall { api.checkIn(AttendanceCheckInRequest(cardCode.trim(), branchId), idempotencyKey) } + + suspend fun guest(name: String?, reason: String?, branchId: Int?, idempotencyKey: String?): ApiResult = + session.staffCall { api.guest(AttendanceGuestRequest(name.blankToNull()?.take(150), reason.blankToNull()?.take(255), branchId), idempotencyKey) } + + suspend fun saveRetention(days: Int): ApiResult = session.staffCall { api.attendanceSettings(AttendanceSettingsRequest(days)) } + + suspend fun purgeAttendance(): ApiResult = session.staffCall { api.attendancePurge() } + + // ---- Patron CSV import ---- + + /** [delimiter]: "" (detect), ";", "," or "tab". */ + suspend fun createUserImport(fileName: String, bytes: ByteArray, mime: String?, delimiter: String): ApiResult { + val type = (mime?.takeIf { it.isNotBlank() } ?: "text/csv").toMediaType() + val part = MultipartBody.Part.createFormData("file", fileName, bytes.toRequestBody(type)) + return session.staffCall { network.longTypedApi(Staff090PatronsApi::class.java).createUserImport(part, delimiter.toRequestBody("text/plain".toMediaType())) } + } + + /** Jobs started here or on another device, so one left half-way can be finished. */ + suspend fun userImports(cursor: String? = null, limit: Int = 30): ApiResult> = + session.staffCall { api.userImports(cursor, limit) }.toPage() + + suspend fun userImport(id: String): ApiResult = session.staffCall { api.userImport(id) } + + suspend fun startUserImport(id: String, mapping: List, sendInvites: Boolean): ApiResult = + session.staffCall { api.startUserImport(id, UserImportStartRequest(mapping, sendInvites)) } + + /** One chunk of 10 rows; invitations are mailed synchronously, so the long client is used. */ + suspend fun userImportChunk(id: String, start: Int): ApiResult = + session.staffCall { network.longTypedApi(Staff090PatronsApi::class.java).userImportChunk(id, UserImportChunkRequest(start)) } + + suspend fun cancelUserImport(id: String): ApiResult = session.staffCall { api.cancelUserImport(id) } +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/Staff090StatisticsRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/Staff090StatisticsRepository.kt new file mode 100644 index 0000000..5260119 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/Staff090StatisticsRepository.kt @@ -0,0 +1,33 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.Staff090Statistics +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.network.Staff090StatisticsApi +import com.pinakes.app.data.store.SessionStore +import java.net.URLEncoder +import javax.inject.Inject +import javax.inject.Singleton + +/** Loan statistics (Mobile API 1.10.0), administrators and Staff. Every call goes through [staffCall]. */ +@Singleton +class Staff090StatisticsRepository @Inject constructor( + private val network: NetworkModule, + private val session: SessionStore, +) { + private val api get() = network.typedApi(Staff090StatisticsApi::class.java) + + suspend fun statistics(finesFrom: String?, finesTo: String?): ApiResult = + session.staffCall { api.statistics(finesFrom.blankToNull(), finesTo.blankToNull()) } + + companion object { + /** The fines CSV of the period, relative to `/api/v1/` for [StaffFilesRepository.download]. */ + fun finesCsvPath(from: String?, to: String?): String { + val query = listOfNotNull( + from.blankToNull()?.let { "fines_from=" + URLEncoder.encode(it, "UTF-8") }, + to.blankToNull()?.let { "fines_to=" + URLEncoder.encode(it, "UTF-8") }, + ).joinToString("&") + return "staff/statistics/fines.csv" + if (query.isEmpty()) "" else "?$query" + } + } +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/Staff090StocktakeRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/Staff090StocktakeRepository.kt new file mode 100644 index 0000000..887ef23 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/Staff090StocktakeRepository.kt @@ -0,0 +1,80 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.Staff090StocktakeCloseRequest +import com.pinakes.app.data.model.Staff090StocktakeCreateRequest +import com.pinakes.app.data.model.Staff090StocktakeOptions +import com.pinakes.app.data.model.Staff090StocktakeReportRow +import com.pinakes.app.data.model.Staff090StocktakeScan +import com.pinakes.app.data.model.Staff090StocktakeScanRequest +import com.pinakes.app.data.model.Staff090StocktakeScanResult +import com.pinakes.app.data.model.Staff090StocktakeSession +import com.pinakes.app.data.model.Staff090StocktakeState +import com.pinakes.app.data.model.Staff090StocktakeUpload +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.network.Staff090StocktakeApi +import com.pinakes.app.data.store.SessionStore +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Inventory stocktake (Mobile API 1.10.0). Every call goes through [staffCall]: a 403 + * `forbidden` (staff role gate) drops the role, a 403 `not_permitted` (an administrator-only + * close) leaves it alone and is shown with the server's message. + */ +@Singleton +class Staff090StocktakeRepository @Inject constructor( + private val network: NetworkModule, + private val session: SessionStore, +) { + private val api get() = network.typedApi(Staff090StocktakeApi::class.java) + + suspend fun options(): ApiResult = session.staffCall { api.options() } + + /** [status] null = every session (`all`). */ + suspend fun sessions(status: String?, cursor: String? = null): ApiResult> = + session.staffCall { api.sessions(status ?: "all", cursor, PAGE) }.toPage() + + suspend fun create(body: Staff090StocktakeCreateRequest, key: String): ApiResult = + session.staffCall { api.create(body, key) } + + suspend fun session(id: Int, mine: Boolean): ApiResult = + session.staffCall { api.session(id, if (mine) 1 else null) } + + suspend fun state(id: Int, mine: Boolean): ApiResult = + session.staffCall { api.state(id, if (mine) 1 else null) } + + suspend fun scans(id: Int, outcome: String?, mine: Boolean, cursor: String? = null): ApiResult> = + session.staffCall { api.scans(id, outcome, if (mine) 1 else null, cursor, PAGE) }.toPage() + + suspend fun scan(id: Int, body: Staff090StocktakeScanRequest, key: String): ApiResult = + session.staffCall { api.scan(id, body, key) } + + suspend fun report(id: Int, category: String?, cursor: String? = null): ApiResult> = + session.staffCall { api.report(id, category, cursor, PAGE) }.toPage() + + /** A list of codes; the server runs every code at once, so the long-timeout client is used. */ + suspend fun upload(id: Int, fileName: String, bytes: ByteArray, mime: String?, shelfId: Int?): ApiResult { + val type = (mime ?: "text/plain").toMediaTypeOrNull() ?: "text/plain".toMediaType() + val part = MultipartBody.Part.createFormData("file", fileName, bytes.toRequestBody(type)) + val shelf = shelfId?.toString()?.toRequestBody("text/plain".toMediaType()) + return session.staffCall { network.longTypedApi(Staff090StocktakeApi::class.java).upload(id, part, shelf) } + } + + suspend fun close(id: Int, missingAction: String): ApiResult = + session.staffCall { network.longTypedApi(Staff090StocktakeApi::class.java).close(id, Staff090StocktakeCloseRequest(missingAction)) } + + suspend fun cancel(id: Int): ApiResult = session.staffCall { api.cancel(id) } + + companion object { + const val PAGE = 50 + + /** The report downloads, relative to `/api/v1/` for [StaffFilesRepository.download]. */ + fun reportCsvPath(id: Int) = "staff/stocktake/sessions/$id/report.csv" + fun reportPdfPath(id: Int) = "staff/stocktake/sessions/$id/report.pdf" + } +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt index 5a64683..a69eb5e 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt @@ -86,8 +86,11 @@ class StaffCirculationRepository( suspend fun cancelPickup(id: Int, reason: String?): ApiResult = session.staffCall { api.cancelPickup(id, ReasonRequest(reason.blankToNull()?.take(500))) } - suspend fun returnLoan(id: Int, outcome: String, notes: String?, penalty: Double?): ApiResult = - session.staffCall { api.returnLoan(id, ReturnRequest(outcome, notes.blankToNull(), penalty)) } + /** [waiveReason] non-null waives the late fine this return charges (Mobile API 1.10.0). */ + suspend fun returnLoan(id: Int, outcome: String, notes: String?, penalty: Double?, waiveReason: String? = null): ApiResult = + session.staffCall { + api.returnLoan(id, ReturnRequest(outcome, notes.blankToNull(), penalty, waiveFine = waiveReason?.let { true }, waiveReason = waiveReason?.trim()?.take(255))) + } suspend fun returnByCode(code: String, outcome: String, notes: String?, penalty: Double?): ApiResult = session.staffCall { api.returnByCode(ReturnByCodeRequest(CopyCodeCodec.normalize(code), outcome, notes.blankToNull(), penalty)) } diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt index 61afb8b..ca55b93 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt @@ -59,8 +59,8 @@ class StaffContentRepository( suspend fun copy(id: Int): ApiResult = session.staffCall { api.copy(id) } - suspend fun updateCopy(id: Int, status: String?, notes: String?): ApiResult = - session.staffCall { api.updateCopy(id, CopyUpdate(status = status.blankToNull(), notes = notes?.trim())) } + suspend fun updateCopy(id: Int, status: String?, notes: String?, consultationOnly: Boolean? = null): ApiResult = + session.staffCall { api.updateCopy(id, CopyUpdate(status = status.blankToNull(), notes = notes?.trim(), consultationOnly = consultationOnly)) } suspend fun deleteCopy(id: Int): ApiResult = session.staffCall { api.deleteCopy(id) } diff --git a/app/src/main/java/com/pinakes/app/data/staff/Staff090Areas.kt b/app/src/main/java/com/pinakes/app/data/staff/Staff090Areas.kt new file mode 100644 index 0000000..44dce43 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/Staff090Areas.kt @@ -0,0 +1,68 @@ +package com.pinakes.app.data.staff + +import com.pinakes.app.data.store.InstanceFeatures + +/** + * Which Pinakes 0.9.0 staff tools (Mobile API 1.10.0) the app shows. Each one needs the staff + * area, the server's `staff_` flag and a connection: they all act on the server, so + * offline they are hidden, never shown disabled. Whether the user may change a setting is the + * server's answer (`can_configure`, `can_edit`, ...); [admin] only hides the screens an administrator + * alone can use. Pure. + */ +data class Staff090Areas( + val admin: Boolean = false, + /** Patron type, card renewal and the other tools on a patron's record. */ + val patrons: Boolean = false, + val patronTypes: Boolean = false, + val patronCards: Boolean = false, + val patronPhotos: Boolean = false, + val ledger: Boolean = false, + val stocktake: Boolean = false, + val librarySettings: Boolean = false, + val circulationRules: Boolean = false, + val closuresCalendar: Boolean = false, + val suppliers: Boolean = false, + val rdaTypes: Boolean = false, + val catalogueCards: Boolean = false, + val marc: Boolean = false, + val attendance: Boolean = false, + val statistics: Boolean = false, + val userImport: Boolean = false, +) { + val anyPatronDesk: Boolean get() = attendance || userImport + /** The hub's Cataloguing rows (catalogue cards live on the book screen, not in the hub). */ + val anyCatalogue: Boolean get() = suppliers || marc + val anyLibrary: Boolean get() = librarySettings || circulationRules || closuresCalendar + + companion object { + val None = Staff090Areas() + + fun of(role: String?, features: InstanceFeatures, online: Boolean): Staff090Areas { + if (!online) return None + val f = features.staff090 + val admin = role == "admin" + return Staff090Areas( + admin = admin, + patrons = f.staffPatrons, + patronTypes = f.staffPatrons && features.circulationRulesAvailable, + patronCards = f.staffPatrons && f.patronCards, + patronPhotos = f.staffPatrons && f.patronPhotos, + ledger = f.staffLedger, + stocktake = f.staffStocktake, + librarySettings = f.staffLibrarySettings, + // Patron types, loan rules and the closures calendar are configured by + // administrators only (the web's mayConfigure()): a Staff account would get + // nothing but a refusal, so the entries are an administrator's. + circulationRules = f.staffCirculationRules && admin, + closuresCalendar = f.staffClosuresCalendar && admin, + suppliers = f.staffSuppliers, + rdaTypes = f.rdaTypes, + catalogueCards = f.staffCatalogueCards, + marc = f.staffMarc, + attendance = f.staffAttendance, + statistics = f.staffStatistics, + userImport = f.staffUserImport, + ) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/Staff090CatalogueErrors.kt b/app/src/main/java/com/pinakes/app/data/staff/Staff090CatalogueErrors.kt new file mode 100644 index 0000000..4e54211 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/Staff090CatalogueErrors.kt @@ -0,0 +1,37 @@ +package com.pinakes.app.data.staff + +import androidx.annotation.StringRes +import com.pinakes.app.R +import com.pinakes.app.data.network.ApiResult + +/** + * Error codes of the 0.9.0 cataloguing routes in the app's own words. Checked before + * [StaffErrors]; a code neither table knows (`validation`, whose server text names the field) + * shows the server message. Pure. + */ +object Staff090CatalogueErrors { + + /** + * [copyCataloguing]: the failure comes from a copy cataloguing call, where 410 `gone` means + * the stored search expired (on a MARC chunk it keeps the import wording of [StaffErrors]). + */ + @StringRes + fun messageRes(failure: ApiResult.Failure, copyCataloguing: Boolean = false): Int? = when (failure.code) { + "schema_missing" -> R.string.staff090_catalogue_err_schema_missing + "copy_cataloging_unavailable" -> R.string.staff090_catalogue_err_copy_unavailable + "job_cancelled" -> R.string.staff090_catalogue_err_job_cancelled + "gone" -> if (copyCataloguing) R.string.staff090_catalogue_err_search_gone else null + // 403 `not_permitted`: one action refused to a staff account that keeps its role. Only + // the server knows which permission is missing, so its text is shown; unlike + // `forbidden`, it never drops the stored role (staffCall checks `forbidden` alone). + "not_permitted" -> null + else -> null + } + + /** The unavailability reason of `copy_cataloging` (`plugin_inactive`), or a generic text. */ + @StringRes + fun copyCatalogingReasonRes(reason: String?): Int = when (reason) { + "plugin_inactive" -> R.string.staff090_catalogue_copy_reason_plugin + else -> R.string.staff090_catalogue_err_copy_unavailable + } +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/Staff090CatalogueForms.kt b/app/src/main/java/com/pinakes/app/data/staff/Staff090CatalogueForms.kt new file mode 100644 index 0000000..a2ef17c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/Staff090CatalogueForms.kt @@ -0,0 +1,263 @@ +package com.pinakes.app.data.staff + +import com.pinakes.app.data.model.MarcImportJob +import com.pinakes.app.data.model.StaffBook +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.model.Supplier +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +// Pure rules of the 0.9.0 cataloguing screens (suppliers, book supplier/RDA fields, MARC import, +// copy cataloguing). No Android, no network: unit-tested in server090. + +// ============================================================================================= +// Suppliers +// ============================================================================================= + +/** The supplier form's fields, with the column limits the server enforces (422 past them). */ +enum class SupplierField(val key: String, val maxLength: Int) { + NAME("name", 255), + CONTACT_PERSON("contact_person", 150), + EMAIL("email", 255), + PHONE("phone", 30), + WEBSITE("website", 255), + ADDRESS("address", 2000), + VAT_NUMBER("vat_number", 20), + NOTES("notes", 5000), +} + +/** What a field fails on before the request is sent; the server's own text wins after it. */ +enum class SupplierProblem { REQUIRED, INVALID } + +/** The create/edit form of a supplier. Every value is the text as typed. */ +data class SupplierForm( + val name: String = "", + val contactPerson: String = "", + val email: String = "", + val phone: String = "", + val website: String = "", + val address: String = "", + val vatNumber: String = "", + val notes: String = "", +) { + fun value(field: SupplierField): String = when (field) { + SupplierField.NAME -> name + SupplierField.CONTACT_PERSON -> contactPerson + SupplierField.EMAIL -> email + SupplierField.PHONE -> phone + SupplierField.WEBSITE -> website + SupplierField.ADDRESS -> address + SupplierField.VAT_NUMBER -> vatNumber + SupplierField.NOTES -> notes + } + + fun with(field: SupplierField, text: String): SupplierForm { + val v = text.take(field.maxLength) + return when (field) { + SupplierField.NAME -> copy(name = v) + SupplierField.CONTACT_PERSON -> copy(contactPerson = v) + SupplierField.EMAIL -> copy(email = v) + SupplierField.PHONE -> copy(phone = v) + SupplierField.WEBSITE -> copy(website = v) + SupplierField.ADDRESS -> copy(address = v) + SupplierField.VAT_NUMBER -> copy(vatNumber = v) + SupplierField.NOTES -> copy(notes = v) + } + } + + /** + * The checks the app can make on its own: a name, an e-mail that looks like one and a + * website without spaces (the server adds `https://` to a bare domain and checks the rest). + */ + fun validate(): Map = buildMap { + if (name.isBlank()) put(SupplierField.NAME, SupplierProblem.REQUIRED) + if (email.isNotBlank() && !EMAIL.matches(email.trim())) put(SupplierField.EMAIL, SupplierProblem.INVALID) + if (website.isNotBlank() && website.trim().contains(' ')) put(SupplierField.WEBSITE, SupplierProblem.INVALID) + SupplierField.entries.forEach { f -> if (value(f).trim().length > f.maxLength) put(f, SupplierProblem.INVALID) } + } + + val valid: Boolean get() = validate().isEmpty() + + /** `POST /staff/suppliers`: the name and every other non-blank field, trimmed. */ + fun createJson(): JsonObject = buildJsonObject { + SupplierField.entries.forEach { f -> + val v = value(f).trim() + if (f == SupplierField.NAME || v.isNotEmpty()) put(f.key, JsonPrimitive(v)) + } + } + + /** + * `PUT /staff/suppliers/{id}`: only the fields that changed from [original]. A cleared field + * goes as JSON null (an absent key keeps the stored value). + */ + fun patchJson(original: Supplier): JsonObject { + val before = from(original) + return buildJsonObject { + SupplierField.entries.forEach { f -> + val v = value(f).trim() + if (v != before.value(f).trim()) put(f.key, if (v.isEmpty() && f != SupplierField.NAME) JsonNull else JsonPrimitive(v)) + } + } + } + + companion object { + private val EMAIL = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") + + fun from(s: Supplier) = SupplierForm( + name = s.name, contactPerson = s.contactPerson.orEmpty(), email = s.email.orEmpty(), phone = s.phone.orEmpty(), + website = s.website.orEmpty(), address = s.address.orEmpty(), vatNumber = s.vatNumber.orEmpty(), notes = s.notes.orEmpty(), + ) + + /** A server field key of `meta.fields` → our field (null for an unknown key). */ + fun fieldOf(key: String): SupplierField? = SupplierField.entries.firstOrNull { it.key == key } + } +} + +/** Which supplier controls the screen shows: the server's `can_*` answers, never a guess. */ +data class SupplierControls(val create: Boolean, val edit: Boolean, val delete: Boolean) { + companion object { + fun of(canCreate: Boolean, supplier: Supplier?): SupplierControls = + SupplierControls(create = canCreate, edit = supplier?.canEdit == true, delete = supplier?.canDelete == true) + } +} + +// ============================================================================================= +// Book form: supplier and RDA types +// ============================================================================================= + +/** The 0.9.0 cataloguing values of a book. Null supplier = none; null RDA type = derived. */ +data class BookCataloguing( + val supplierId: Int? = null, + val rdaContentType: String? = null, + val rdaMediaType: String? = null, + val rdaCarrierType: String? = null, +) { + companion object { + fun from(book: StaffBook) = BookCataloguing( + supplierId = book.supplierId?.takeIf { it > 0 }, + rdaContentType = book.rdaContentType?.takeIf { it.isNotBlank() }, + rdaMediaType = book.rdaMediaType?.takeIf { it.isNotBlank() }, + rdaCarrierType = book.rdaCarrierType?.takeIf { it.isNotBlank() }, + ) + } +} + +/** + * Which 0.9.0 modules the book form edits. The server's lookups carry the lists only while + * the module is on, so their presence is the switch: off, the keys are never sent. + */ +data class CataloguingModules(val suppliers: Boolean = false, val rda: Boolean = false) { + companion object { + val Off = CataloguingModules() + + fun from(lookups: StaffLookups) = CataloguingModules( + suppliers = lookups.suppliers != null, + rda = lookups.rdaContentTypes != null && lookups.rdaMediaTypes != null && lookups.rdaCarrierTypes != null, + ) + } +} + +/** Adds the supplier and RDA keys to the `StaffBookWrite` bodies. */ +object BookCataloguingWrite { + + /** A create sends what is set: a supplier, and each RDA override that is not "derived". */ + fun putCreate(builder: JsonObjectBuilder, values: BookCataloguing, modules: CataloguingModules) { + if (modules.suppliers) values.supplierId?.let { builder.put("supplier_id", JsonPrimitive(it)) } + if (modules.rda) { + values.rdaContentType?.let { builder.put("rda_content_type", JsonPrimitive(it)) } + values.rdaMediaType?.let { builder.put("rda_media_type", JsonPrimitive(it)) } + values.rdaCarrierType?.let { builder.put("rda_carrier_type", JsonPrimitive(it)) } + } + } + + /** An update sends what changed; back to none/derived goes as JSON null (clears it). */ + fun putUpdate(builder: JsonObjectBuilder, before: BookCataloguing, after: BookCataloguing, modules: CataloguingModules) { + fun text(v: String?) = v?.let { JsonPrimitive(it) } ?: JsonNull + if (modules.suppliers && after.supplierId != before.supplierId) { + builder.put("supplier_id", after.supplierId?.let { JsonPrimitive(it) } ?: JsonNull) + } + if (modules.rda) { + if (after.rdaContentType != before.rdaContentType) builder.put("rda_content_type", text(after.rdaContentType)) + if (after.rdaMediaType != before.rdaMediaType) builder.put("rda_media_type", text(after.rdaMediaType)) + if (after.rdaCarrierType != before.rdaCarrierType) builder.put("rda_carrier_type", text(after.rdaCarrierType)) + } + } +} + +// ============================================================================================= +// MARC import +// ============================================================================================= + +object MarcImportRules { + /** The server's accepted extensions, used when /options sends none. */ + val DEFAULT_EXTENSIONS = listOf("mrc", "marc", "iso", "dat", "xml", "marcxml") + + /** The document picker cannot filter by extension: MARC has no registered mime type. */ + val PICKER_MIME_TYPES = arrayOf("*/*") + + fun acceptsFileName(name: String, extensions: List): Boolean { + val ext = name.substringAfterLast('.', "").lowercase() + return ext.isNotEmpty() && ext in extensions.ifEmpty { DEFAULT_EXTENSIONS }.map { it.lowercase() } + } + + /** + * The jobs the import tab offers to resume or cancel: not finished (ready or running), and + * not the one already on screen. A job started on another device is one of them. + */ + fun openJobs(jobs: List, shownId: String?): List = + jobs.filter { !ImportProgress.isFinished(it.asImportJob()) && it.id != shownId } + + /** The encoding only applies to ISO 2709: a MARCXML file declares its own. */ + fun encodingApplies(fileName: String?): Boolean { + val ext = fileName?.substringAfterLast('.', "")?.lowercase() ?: return true + return ext != "xml" && ext != "marcxml" + } +} + +// ============================================================================================= +// Copy cataloguing +// ============================================================================================= + +/** One import request, compared by value so a retry of the same selection keeps its key. */ +data class CopyImportSubmission(val searchId: String, val ids: List, val duplicates: String, val covers: Boolean) + +object CopyCatalogingRules { + const val MAX_TERM = 200 + val MAX_RANGE = 1..25 + const val DEFAULT_MAX = 10 + const val DEFAULT_IMPORT_LIMIT = 100 + + fun canSearch(servers: Set, term: String, max: Int): Boolean = + servers.isNotEmpty() && term.isNotBlank() && term.trim().length <= MAX_TERM && max in MAX_RANGE + + /** Toggle [id]; a new pick past [limit] is refused (the server imports 100 at a time). */ + fun toggle(selected: Set, id: String, limit: Int = DEFAULT_IMPORT_LIMIT): Set = when { + id in selected -> selected - id + selected.size >= limit -> selected + else -> selected + id + } + + /** + * A scanned EAN-13 as a search: a Bookland code (978/979) by ISBN, an ISSN barcode (977) by + * the ISSN it carries, anything else on every field. Null when the code is not an EAN-13. + */ + fun fromScan(raw: String): Pair? { + val code = raw.filter(Char::isDigit) + if (code.length != 13 || !IsbnCodec.checkEan13(code)) return null + return when { + code.startsWith("978") || code.startsWith("979") -> "isbn" to code + code.startsWith("977") -> "issn" to issnOf(code.substring(3, 10)) + else -> "any" to code + } + } + + /** The ISSN of its seven digits: mod-11 check character, `X` for 10. */ + fun issnOf(seven: String): String { + val sum = seven.mapIndexed { i, c -> (c - '0') * (8 - i) }.sum() + val check = (11 - sum % 11) % 11 + return "${seven.take(4)}-${seven.drop(4)}${if (check == 10) "X" else check.toString()}" + } +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/Staff090LibraryErrors.kt b/app/src/main/java/com/pinakes/app/data/staff/Staff090LibraryErrors.kt new file mode 100644 index 0000000..686646c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/Staff090LibraryErrors.kt @@ -0,0 +1,40 @@ +package com.pinakes.app.data.staff + +import androidx.annotation.StringRes +import com.pinakes.app.R +import com.pinakes.app.data.network.ApiResult + +/** + * Error codes of the library settings areas (Mobile API 1.10.0): settings, patron types and + * rules, closures calendar. Codes it does not know return null and go on to + * [StaffErrors] / the server message. Pure. + */ +object Staff090LibraryErrors { + + /** 403: this staff account may not do this one thing (an administrator may); the role stays. */ + const val NOT_PERMITTED = "not_permitted" + + @StringRes + fun messageRes(code: String): Int? = when (code) { + "calendar_always_closed" -> R.string.staff090_library_err_always_closed + "preset_closure" -> R.string.staff090_library_err_preset_closure + "schema_not_ready" -> R.string.staff090_library_err_schema_not_ready + "file_too_large" -> R.string.staff090_library_err_ics_too_large + else -> null + } + + /** + * The server explains `not_permitted` itself (which role or permission it takes): its + * message is shown, our generic wording only when it sent none. + */ + fun isNotPermitted(failure: ApiResult.Failure): Boolean = failure.fromEnvelope && failure.code == NOT_PERMITTED + + /** + * True when the failure of a screen's first load means the screen is not for this user or + * this server: the area refused this account (`not_permitted`) or the module is off for + * Staff (404 `feature_disabled`). The screen closes with the server's message; the staff + * area stays. A refused action inside a screen only shows the message. + */ + fun closesScreen(failure: ApiResult.Failure): Boolean = + failure.fromEnvelope && (failure.code == NOT_PERMITTED || failure.code == "feature_disabled") +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/Staff090PatronsErrors.kt b/app/src/main/java/com/pinakes/app/data/staff/Staff090PatronsErrors.kt new file mode 100644 index 0000000..0d291c1 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/Staff090PatronsErrors.kt @@ -0,0 +1,33 @@ +package com.pinakes.app.data.staff + +import androidx.annotation.StringRes +import com.pinakes.app.R +import com.pinakes.app.data.network.ApiResult + +/** + * The error codes of the Pinakes 0.9.0 patron tools mapped to the app's wording, checked before + * [StaffErrors]. Unknown codes return null: the caller shows the server message (a 422 + * `validation` always carries the precise one). Pure. + */ +object Staff090PatronsErrors { + + @StringRes + fun messageRes(failure: ApiResult.Failure): Int? = when (failure.code) { + "card_not_renewable" -> R.string.staff090_patrons_err_card_not_renewable + "amount_exceeds_balance" -> R.string.staff090_patrons_err_amount_exceeds_balance + "card_not_found" -> R.string.staff090_patrons_err_card_not_found + "not_started" -> R.string.staff090_patrons_err_not_started + "already_started" -> R.string.staff090_patrons_err_already_started + "job_cancelled" -> R.string.staff090_patrons_err_job_cancelled + "file_missing" -> R.string.staff090_patrons_err_file_missing + "import_not_finished" -> R.string.staff090_patrons_err_import_not_finished + // The visitor log answers 503 not_available until the 0.9.0 database update has run; + // the copy desk's 409 not_available means something else and keeps its wording. + // 403 not_permitted: ONE action refused to a Staff account that keeps its role (another + // account's patron record, administrator-only settings). The server's message says why; + // unlike `forbidden` it never drops the staff role (isForbidden() is false for it). + "not_permitted" -> null + "not_available" -> if (failure.httpStatus == 503) R.string.staff090_patrons_err_db_update_pending else null + else -> null + } +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/Staff090PatronsRules.kt b/app/src/main/java/com/pinakes/app/data/staff/Staff090PatronsRules.kt new file mode 100644 index 0000000..fe66235 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/Staff090PatronsRules.kt @@ -0,0 +1,192 @@ +package com.pinakes.app.data.staff + +import com.pinakes.app.data.model.AttendanceVisit +import com.pinakes.app.data.model.StaffLedger +import com.pinakes.app.data.model.StaffLoanLateFine +import com.pinakes.app.data.model.StaffUserDetail +import com.pinakes.app.data.model.UserImportField +import com.pinakes.app.data.model.UserImportJob +import com.pinakes.app.data.network.ApiResult + +/** + * Amount typed at the desk → whole cents, exactly: the decimal string is parsed digit by digit, + * never through a floating-point number ("0.29" is 29 cents, not 28). Comma or dot as the + * decimal separator, at most two decimals, no sign, no thousands separators. Pure. + */ +object MoneyInput { + sealed interface Parsed { + data object Empty : Parsed + data object Invalid : Parsed + data object TooLarge : Parsed + data class Cents(val value: Int) : Parsed + } + + private val PATTERN = Regex("""^(\d{1,9})(?:[.,](\d{1,2}))?$""") + + fun parse(raw: String, maxCents: Int = 99_999_999): Parsed { + val text = raw.trim() + if (text.isEmpty()) return Parsed.Empty + val m = PATTERN.matchEntire(text) ?: return Parsed.Invalid + val units = m.groupValues[1].toLong() + val decimals = m.groupValues[2].padEnd(2, '0').toLong() + val cents = units * 100 + decimals + return when { + cents <= 0 -> Parsed.Invalid + cents > maxCents -> Parsed.TooLarge + else -> Parsed.Cents(cents.toInt()) + } + } + + /** Cents → the text the amount field starts from ("12.50"), never localised. */ + fun text(cents: Int): String = "%d.%02d".format(java.util.Locale.ROOT, cents / 100, kotlin.math.abs(cents % 100)) +} + +/** The three ledger forms of the desk. */ +enum class LedgerAction { PAYMENT, CHARGE, WAIVER } + +/** Which parts of the patron record a desk operator sees. Hidden, never disabled. Pure. */ +data class PatronTools( + val section: Boolean, + val photo: Boolean, + val photoEdit: Boolean, + val patronType: Boolean, + val patronTypeEdit: Boolean, + val renewCard: Boolean, + val printCard: Boolean, + val ledger: Boolean, +) { + companion object { + fun of(areas: Staff090Areas, user: StaffUserDetail): PatronTools { + val photo = areas.patronPhotos + val type = areas.patronTypes && user.patronType != null + // Readers carry a card; administrators and staff accounts answer 409 card_not_renewable. + val reader = user.role == null || user.role in setOf("standard", "premium") + val renew = areas.patrons && reader + val print = areas.patronCards && !user.cardNumber.isNullOrBlank() + val ledger = areas.ledger && user.ledger != null + return PatronTools( + section = photo || type || renew || print || ledger, + photo = photo, + photoEdit = photo && user.canEdit, + patronType = type, + patronTypeEdit = type && user.canEdit, + renewCard = renew, + printCard = print, + ledger = ledger, + ) + } + } +} + +/** The ledger actions the server allows, in the order the desk offers them. Pure. */ +object LedgerRules { + fun actions(ledger: StaffLedger): List = buildList { + if (ledger.canRecordPayment && ledger.balanceCents > 0) add(LedgerAction.PAYMENT) + if (ledger.canCharge) add(LedgerAction.CHARGE) + if (ledger.canWaive && ledger.balanceCents > 0) add(LedgerAction.WAIVER) + } + + /** Payments and waivers never exceed what the patron owes (the server re-checks: 409). */ + fun maxCents(action: LedgerAction, ledger: StaffLedger): Int = when (action) { + LedgerAction.CHARGE -> ledger.maxAmountCents + LedgerAction.PAYMENT, LedgerAction.WAIVER -> minOf(ledger.balanceCents.coerceAtLeast(0), ledger.maxAmountCents) + } + + /** The form can be sent: a valid amount within [maxCents] and the text the action requires. */ + fun canSubmit(action: LedgerAction, amount: MoneyInput.Parsed, method: String?, text: String): Boolean = + amount is MoneyInput.Parsed.Cents && when (action) { + LedgerAction.PAYMENT -> !method.isNullOrBlank() + LedgerAction.CHARGE, LedgerAction.WAIVER -> text.isNotBlank() + } +} + +/** The return form's late fine: shown only when a return today would charge one. Pure. */ +object LateFineRules { + fun shows(fine: StaffLoanLateFine?): Boolean = fine != null && fine.automaticFines && fine.accruingCents > 0 + + /** A waiver needs its reason (the server answers 422 waive_reason otherwise). */ + fun canConfirm(waive: Boolean, reason: String): Boolean = !waive || reason.isNotBlank() + + /** The reason sent with the return, or null when the fine is charged. */ + fun waiveReason(fine: StaffLoanLateFine?, waive: Boolean, reason: String): String? = + if (shows(fine) && waive) reason.trim().takeIf { it.isNotEmpty() } else null +} + +/** How the desk shows a check-in. */ +enum class VisitOutcome { WELCOME, WARNING, GUEST, UNKNOWN_CARD, ERROR } + +/** Visitor log decisions. Pure. */ +object AttendanceRules { + /** A visit's tone is due once: [tick] counts the visits, [played] the last one sounded. */ + fun tonePending(tick: Int, played: Int): Boolean = tick > played + + fun outcome(visit: AttendanceVisit): VisitOutcome = when { + visit.type == "guest" -> VisitOutcome.GUEST + visit.warning != null || visit.member?.cardActive == false || visit.member?.cardExpired == true -> VisitOutcome.WARNING + else -> VisitOutcome.WELCOME + } + + fun outcome(failure: ApiResult.Failure): VisitOutcome = if (failure.code == "card_not_found") VisitOutcome.UNKNOWN_CARD else VisitOutcome.ERROR + + /** A short confirmation beep for a welcome, a lower double tone for anything to look at. */ + fun successTone(outcome: VisitOutcome): Boolean = outcome == VisitOutcome.WELCOME || outcome == VisitOutcome.GUEST + + /** The scanned card code as the server matches it: trimmed, inner text untouched. */ + fun normalizeCard(raw: String): String = raw.trim() + + /** Bar widths 0..1 for a series (the largest value fills the row). */ + fun barFractions(values: List): List { + val max = values.maxOrNull()?.takeIf { it > 0 } ?: return values.map { 0f } + return values.map { (it.coerceAtLeast(0).toFloat() / max).coerceIn(0f, 1f) } + } + + /** A retention typed in the settings: 0..[max] whole days, else null. */ + fun parseRetention(raw: String, max: Int): Int? = raw.trim().toIntOrNull()?.takeIf { it in 0..max } +} + +/** + * The patron CSV import: the mapping editor and the chunk driver's rules. The chunk loop itself + * follows [ImportProgress] (out_of_order → resume from `meta.processed`, job_busy → wait, gone → + * stop). Pure. + */ +object UserImportRules { + val DELIMITERS = listOf("", ";", ",", "tab") + + private val FINAL = setOf("completed", "failed", "cancelled") + + fun isFinished(job: UserImportJob): Boolean = job.complete || job.status in FINAL + + fun percent(job: UserImportJob): Int = when { + job.totalRows <= 0 -> if (isFinished(job)) 100 else 0 + else -> ((job.processed.coerceIn(0, job.totalRows) * 100L) / job.totalRows).toInt() + } + + fun next(job: UserImportJob): ChunkStep = if (isFinished(job)) ChunkStep.Done else ChunkStep.Next(job.processed) + + /** Same rules as the catalogue import, plus `not_started` and `job_cancelled` that stop the loop. */ + fun afterFailure(failure: ApiResult.Failure, start: Int, busyAttempts: Int): ChunkStep = ImportProgress.afterFailure(failure, start, busyAttempts) + + /** The jobs still to finish (the list also carries completed and cancelled ones until they expire). */ + fun openJobs(jobs: List): List = jobs.filterNot(::isFinished) + + /** The mapping aligned with the headers (missing entries are "ignored"). */ + fun aligned(job: UserImportJob, mapping: List = job.mapping): List = + List(job.headers.size) { i -> mapping.getOrNull(i).orEmpty() } + + /** Map column [column] to [key] ("" = ignore); a field used elsewhere is taken from that column. */ + fun assign(mapping: List, column: Int, key: String): List = + mapping.mapIndexed { i, current -> if (i == column) key else if (key.isNotEmpty() && current == key) "" else current } + + /** Required fields not mapped to any column. */ + fun missingRequired(fields: List, mapping: List): List = + fields.filter { it.required && it.key !in mapping } + + fun canStart(job: UserImportJob, mapping: List): Boolean = + !job.started && !isFinished(job) && missingRequired(job.fields, mapping).isEmpty() + + /** Accepted file names (the server re-checks): .csv, .tsv, .txt. */ + fun acceptsFileName(name: String): Boolean = name.substringAfterLast('.', "").lowercase() in setOf("csv", "tsv", "txt") + + /** The server's upload limit (5 MB). */ + const val MAX_BYTES = 5L * 1024 * 1024 +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/Staff090StocktakeErrors.kt b/app/src/main/java/com/pinakes/app/data/staff/Staff090StocktakeErrors.kt new file mode 100644 index 0000000..0ddb098 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/Staff090StocktakeErrors.kt @@ -0,0 +1,37 @@ +package com.pinakes.app.data.staff + +import androidx.annotation.StringRes +import com.pinakes.app.R +import com.pinakes.app.data.network.ApiResult + +/** + * Error codes of the stocktake and statistics areas (Mobile API 1.10.0). Codes it does not + * know return null and go on to [StaffErrors] / the server message. Pure. + */ +object Staff090StocktakeErrors { + + /** 403: this staff account may not do this one action (closing with `lost`); the role stays. */ + const val NOT_PERMITTED = "not_permitted" + const val SESSION_CLOSED = "session_closed" + const val SESSION_CANCELLED = "session_cancelled" + const val REPORT_TOO_LARGE = "report_too_large" + + @StringRes + fun messageRes(code: String): Int? = when (code) { + SESSION_CLOSED -> R.string.staff090_stocktake_err_session_closed + SESSION_CANCELLED -> R.string.staff090_stocktake_err_session_cancelled + REPORT_TOO_LARGE -> R.string.staff090_stocktake_err_report_too_large + "invalid_state" -> R.string.staff090_stocktake_err_invalid_state + else -> null + } + + /** The server explains `not_permitted` itself (who may do it): its message is shown. */ + fun isNotPermitted(failure: ApiResult.Failure): Boolean = failure.fromEnvelope && failure.code == NOT_PERMITTED + + /** The session stopped being open meanwhile (another operator closed or cancelled it): reload it. */ + fun sessionEnded(failure: ApiResult.Failure): Boolean = + failure.fromEnvelope && (failure.code == SESSION_CLOSED || failure.code == SESSION_CANCELLED) + + /** Module switched off on the server while the screen was open: 404 `feature_disabled`. */ + fun moduleOff(failure: ApiResult.Failure): Boolean = failure.fromEnvelope && failure.code == "feature_disabled" +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/StaffAreas.kt b/app/src/main/java/com/pinakes/app/data/staff/StaffAreas.kt index dba4401..3647327 100644 --- a/app/src/main/java/com/pinakes/app/data/staff/StaffAreas.kt +++ b/app/src/main/java/com/pinakes/app/data/staff/StaffAreas.kt @@ -24,6 +24,8 @@ data class StaffAreas( val works: Boolean = false, val digital: Boolean = false, val libraryThing: Boolean = false, + /** Pinakes 0.9.0 tools (Mobile API 1.10.0). */ + val s090: Staff090Areas = Staff090Areas.None, ) { /** At least one entry of the "Books" section beyond the Phase 1 ones. */ val anyBooksTool: Boolean get() = series || merges || imports || covers || labels @@ -53,6 +55,7 @@ data class StaffAreas( works = features.staffFrbrAvailable, digital = features.staffDigitalLibraryAvailable, libraryThing = features.staffLibraryThingAvailable && features.staffImportsAvailable, + s090 = Staff090Areas.of(role, features, online), ) } } diff --git a/app/src/main/java/com/pinakes/app/data/staff/StaffBookForm.kt b/app/src/main/java/com/pinakes/app/data/staff/StaffBookForm.kt index d5d0f27..3573073 100644 --- a/app/src/main/java/com/pinakes/app/data/staff/StaffBookForm.kt +++ b/app/src/main/java/com/pinakes/app/data/staff/StaffBookForm.kt @@ -64,6 +64,8 @@ data class StaffBookForm( val coverUrl: String = "", /** Create only: author biography from the scraper (`scraped.author_bio`). */ val authorBio: String = "", + /** Pinakes 0.9.0: supplier and RDA types, sent only while their module is on. */ + val cataloguing: BookCataloguing = BookCataloguing(), ) { /** Every field that currently blocks saving. [isEdit] skips create-only checks. */ fun validate(isEdit: Boolean = false): Set = buildSet { @@ -129,6 +131,7 @@ data class StaffBookForm( // (the screen still shows the stored label) instead of an invalid form. shelfId = book.location?.takeIf { it.levelId != null }?.shelfId, levelId = book.location?.takeIf { it.shelfId != null }?.levelId, + cataloguing = BookCataloguing.from(book), ) /** @@ -188,7 +191,7 @@ data class StaffBookForm( */ object StaffBookWrite { - fun create(form: StaffBookForm): JsonObject = buildJsonObject { + fun create(form: StaffBookForm, modules: CataloguingModules = CataloguingModules.Off): JsonObject = buildJsonObject { textFields(form).forEach { (key, value) -> if (value.isNotBlank()) put(key, value.trim()) } numberField(form.year)?.let { put("year", it) } numberField(form.pages)?.let { put("pages", it) } @@ -204,9 +207,10 @@ object StaffBookWrite { } if (StaffBookForm.isHttpUrl(form.coverUrl)) put("cover_url", form.coverUrl.trim()) if (form.authorBio.isNotBlank()) put("scraped", buildJsonObject { put("author_bio", form.authorBio.trim()) }) + BookCataloguingWrite.putCreate(this, form.cataloguing, modules) } - fun update(original: StaffBook, form: StaffBookForm): JsonObject { + fun update(original: StaffBook, form: StaffBookForm, modules: CataloguingModules = CataloguingModules.Off): JsonObject { val before = StaffBookForm.from(original) return buildJsonObject { val old = textFields(before).toMap() @@ -240,6 +244,7 @@ object StaffBookWrite { else -> Unit } } + BookCataloguingWrite.putUpdate(this, before.cataloguing, form.cataloguing, modules) } } diff --git a/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt b/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt index 5f42665..ac3436b 100644 --- a/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt +++ b/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt @@ -17,6 +17,11 @@ object StaffErrors { "not_eligible" -> R.string.staff_err_not_eligible "not_available" -> R.string.staff_err_not_available "max_loans_reached" -> R.string.staff_err_max_loans + // Circulation rules of the borrower's patron type (Pinakes 0.9.0). + "media_limit_reached" -> R.string.staff_err_media_limit + "not_loanable" -> R.string.staff_err_not_loanable + "reservations_disabled" -> R.string.staff_err_reservations_disabled + "reservation_limit_reached" -> R.string.staff_err_reservation_limit "loan_closed" -> R.string.staff_err_loan_closed "concurrent_retry" -> R.string.staff_err_concurrent_retry "not_returnable" -> R.string.staff_err_not_returnable @@ -43,7 +48,25 @@ object StaffErrors { else -> null } - /** The resource for [failure], when its code is one of ours. */ + /** + * The resource for [failure], when its code is one of ours. A 409 `not_eligible` carries the + * core reason in `meta.reason` (`debt_block`, `card_expired`, `user_suspended`): the precise + * wording when we know it, the generic one otherwise. + */ @StringRes - fun messageRes(failure: ApiResult.Failure): Int? = messageRes(failure.code) + fun messageRes(failure: ApiResult.Failure): Int? = + if (failure.code == "not_eligible") eligibilityRes(failure.meta?.reason) ?: messageRes(failure.code) + else messageRes(failure.code) + + /** + * Wording for an `eligibility_reason` (user lists, loan create) or a `not_eligible` reason; + * null for a reason this app version does not know (the caller shows the raw value). + */ + @StringRes + fun eligibilityRes(reason: String?): Int? = when (reason) { + "debt_block" -> R.string.staff_err_debt_block + "card_expired" -> R.string.staff_err_card_expired + "user_suspended" -> R.string.staff_err_user_suspended + else -> null + } } diff --git a/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt b/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt index 97bbc8d..eae279f 100644 --- a/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt +++ b/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt @@ -70,6 +70,23 @@ data class InstanceFeatures( val staffFrbrAvailable: Boolean = false, val staffDigitalLibraryAvailable: Boolean = false, val staffLibraryThingAvailable: Boolean = false, + /** + * Pinakes 0.9.0 toggles (`features.fines`, `closures_calendar`, `circulation_rules`). Hidden + * until a server advertises them: an older server has no ledger, no closures calendar and + * no patron-type rules, and the app then behaves exactly as before. + */ + val finesAvailable: Boolean = false, + /** + * Informational: the calendar honours `closed_dates` whatever this says, because the server + * already sends an empty list with the toggle off, and a Mobile API 1.9.0 server sends closures + * before the flag existed (ignoring them would offer days the server then moves). + */ + val closuresCalendarAvailable: Boolean = false, + val circulationRulesAvailable: Boolean = false, + /** `features.consultation_only`: consultation-only copies apply on their own switch. */ + val consultationOnlyAvailable: Boolean = false, + /** Pinakes 0.9.0 modules and staff areas (Mobile API 1.10.0). */ + val staff090: Staff090Flags = Staff090Flags.None, ) { /** Library tab (loans + reservations) is shown only when at least one of them is enabled. */ val showLibrary: Boolean get() = loans || reservations @@ -82,9 +99,69 @@ data class InstanceFeatures( /** Book reviews (read + write) surfaced on book detail and the "My reviews" page. */ val showReviews: Boolean get() = reviews + /** + * Whether the book page asks the server up front if the reader may borrow or join the + * waitlist: patron-type rules close the waitlist, and consultation-only copies close the + * loan, each on its own switch since Pinakes 0.9.0. + */ + val viewerRulesAvailable: Boolean get() = circulationRulesAvailable || consultationOnlyAvailable + + /** The balance and movements page (fines ledger on, loans offered). */ + val showMovements: Boolean get() = finesAvailable && loans && !catalogueMode + companion object { /** Safe default before/without `/health`: app features enabled, registration hidden. */ val AllEnabled = InstanceFeatures() + + /** + * The flags a `/health` payload grants. Pure, so the mapping is unit-tested without the + * encrypted preferences; the plugin probes ([bookClub], [periodicals]) keep their own value. + */ + fun from(health: HealthPayload, bookClub: Boolean = false, periodicals: Boolean = false): InstanceFeatures { + val f = health.features + val app = health.appAccessEnabled + val circulation = !health.catalogueMode + return InstanceFeatures( + catalogueMode = health.catalogueMode, + catalog = f.catalog, + loans = f.loans, + reservations = f.reservations, + wishlist = f.wishlist, + messages = f.messages, + notifications = f.notifications, + push = f.push, + reviews = f.reviews, + registrationEnabled = health.registrationEnabled, + bookClubAvailable = bookClub, + periodicalsAvailable = periodicals, + archivesAvailable = f.archives && app, + desiderataAvailable = f.desiderata && app, + staffCatalogAvailable = f.staffCatalog && app, + catalogSyncAvailable = f.catalogSync && app, + calendarFeedAvailable = f.calendarFeed && app && circulation, + staffCirculationAvailable = f.staffCirculation && app && circulation, + staffCopiesAvailable = f.staffCopies && app, + staffSeriesAvailable = f.staffSeries && app, + staffMergesAvailable = f.staffMerges && app, + staffLabelsAvailable = f.staffLabels && app, + staffImportsAvailable = f.staffImports && app, + staffCoversAvailable = f.staffCovers && app, + staffDesiderataAvailable = f.staffDesiderata && app, + staffArchivesAvailable = f.staffArchives && app, + staffPeriodicalsAvailable = f.staffPeriodicals && app, + staffBookClubAvailable = f.staffBookClub && app, + staffFrbrAvailable = f.staffFrbr && app, + staffDigitalLibraryAvailable = f.staffDigitalLibrary && app, + staffLibraryThingAvailable = f.staffLibraryThing && app, + // The server already turns these off in catalogue mode; the app does too, so a + // stale or inconsistent payload can never show circulation in a read-only catalogue. + finesAvailable = f.fines && app && circulation, + closuresCalendarAvailable = f.closuresCalendar && app && circulation, + circulationRulesAvailable = f.circulationRules && app && circulation, + consultationOnlyAvailable = f.consultationOnly && app && circulation, + staff090 = Staff090Flags.from(f, appAccess = app, circulation = circulation), + ) + } } } @@ -112,39 +189,10 @@ class FeatureStore(context: Context) { * [setPeriodicalsAvailable]) and are preserved, not clobbered. */ fun update(health: HealthPayload) { - val f = health.features - val value = InstanceFeatures( - catalogueMode = health.catalogueMode, - catalog = f.catalog, - loans = f.loans, - reservations = f.reservations, - wishlist = f.wishlist, - messages = f.messages, - notifications = f.notifications, - push = f.push, - reviews = f.reviews, - registrationEnabled = health.registrationEnabled, - bookClubAvailable = prefs.getBoolean(KEY_BOOK_CLUB, false), - periodicalsAvailable = prefs.getBoolean(KEY_PERIODICALS, false), - archivesAvailable = f.archives && health.appAccessEnabled, - desiderataAvailable = f.desiderata && health.appAccessEnabled, - staffCatalogAvailable = f.staffCatalog && health.appAccessEnabled, - catalogSyncAvailable = f.catalogSync && health.appAccessEnabled, - calendarFeedAvailable = f.calendarFeed && health.appAccessEnabled && !health.catalogueMode, - staffCirculationAvailable = f.staffCirculation && health.appAccessEnabled && !health.catalogueMode, - staffCopiesAvailable = f.staffCopies && health.appAccessEnabled, - staffSeriesAvailable = f.staffSeries && health.appAccessEnabled, - staffMergesAvailable = f.staffMerges && health.appAccessEnabled, - staffLabelsAvailable = f.staffLabels && health.appAccessEnabled, - staffImportsAvailable = f.staffImports && health.appAccessEnabled, - staffCoversAvailable = f.staffCovers && health.appAccessEnabled, - staffDesiderataAvailable = f.staffDesiderata && health.appAccessEnabled, - staffArchivesAvailable = f.staffArchives && health.appAccessEnabled, - staffPeriodicalsAvailable = f.staffPeriodicals && health.appAccessEnabled, - staffBookClubAvailable = f.staffBookClub && health.appAccessEnabled, - staffFrbrAvailable = f.staffFrbr && health.appAccessEnabled, - staffDigitalLibraryAvailable = f.staffDigitalLibrary && health.appAccessEnabled, - staffLibraryThingAvailable = f.staffLibraryThing && health.appAccessEnabled, + val value = InstanceFeatures.from( + health, + bookClub = prefs.getBoolean(KEY_BOOK_CLUB, false), + periodicals = prefs.getBoolean(KEY_PERIODICALS, false), ) prefs.edit() .putBoolean(KEY_KNOWN, true) @@ -177,6 +225,11 @@ class FeatureStore(context: Context) { .putBoolean(KEY_STAFF_FRBR, value.staffFrbrAvailable) .putBoolean(KEY_STAFF_DIGITAL, value.staffDigitalLibraryAvailable) .putBoolean(KEY_STAFF_LIBRARYTHING, value.staffLibraryThingAvailable) + .putBoolean(KEY_FINES, value.finesAvailable) + .putBoolean(KEY_CLOSURES_CALENDAR, value.closuresCalendarAvailable) + .putBoolean(KEY_CIRCULATION_RULES, value.circulationRulesAvailable) + .putBoolean(KEY_CONSULTATION_ONLY, value.consultationOnlyAvailable) + .putString(KEY_STAFF090, staff090Json.encodeToString(Staff090Flags.serializer(), value.staff090)) .apply() _features.value = value } @@ -236,6 +289,13 @@ class FeatureStore(context: Context) { staffFrbrAvailable = prefs.getBoolean(KEY_STAFF_FRBR, false), staffDigitalLibraryAvailable = prefs.getBoolean(KEY_STAFF_DIGITAL, false), staffLibraryThingAvailable = prefs.getBoolean(KEY_STAFF_LIBRARYTHING, false), + finesAvailable = prefs.getBoolean(KEY_FINES, false), + closuresCalendarAvailable = prefs.getBoolean(KEY_CLOSURES_CALENDAR, false), + circulationRulesAvailable = prefs.getBoolean(KEY_CIRCULATION_RULES, false), + consultationOnlyAvailable = prefs.getBoolean(KEY_CONSULTATION_ONLY, false), + staff090 = prefs.getString(KEY_STAFF090, null) + ?.let { runCatching { staff090Json.decodeFromString(Staff090Flags.serializer(), it) }.getOrNull() } + ?: Staff090Flags.None, ) } @@ -271,5 +331,11 @@ class FeatureStore(context: Context) { private const val KEY_STAFF_FRBR = "f_staff_frbr" private const val KEY_STAFF_DIGITAL = "f_staff_digital_library" private const val KEY_STAFF_LIBRARYTHING = "f_staff_librarything" + private const val KEY_FINES = "f_fines" + private const val KEY_CLOSURES_CALENDAR = "f_closures_calendar" + private const val KEY_CIRCULATION_RULES = "f_circulation_rules" + private const val KEY_CONSULTATION_ONLY = "f_consultation_only" + private const val KEY_STAFF090 = "f_staff090" + private val staff090Json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } } } diff --git a/app/src/main/java/com/pinakes/app/data/store/Staff090Flags.kt b/app/src/main/java/com/pinakes/app/data/store/Staff090Flags.kt new file mode 100644 index 0000000..5bd2d26 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/store/Staff090Flags.kt @@ -0,0 +1,80 @@ +package com.pinakes.app.data.store + +import com.pinakes.app.data.model.HealthFeatures +import kotlinx.serialization.Serializable + +/** + * Pinakes 0.9.0 modules and their staff areas (Mobile API 1.10.0), as `/health` grants them. + * Every flag already includes the app-access gate, and the circulation ones are off in + * catalogue mode. Kept apart from [InstanceFeatures]' own fields and persisted as one JSON + * value, so a new 0.9.0 flag is one field here and one line in [from]. Pure. + */ +@Serializable +data class Staff090Flags( + // Modules (Settings -> Modules / Loans) + val stocktake: Boolean = false, + val marc: Boolean = false, + val visitorLog: Boolean = false, + val catalogueCards: Boolean = false, + val suppliers: Boolean = false, + val rdaTypes: Boolean = false, + val patronCards: Boolean = false, + val userImport: Boolean = false, + val patronPhotos: Boolean = false, + val automaticCardExpiry: Boolean = false, + val automaticFines: Boolean = false, + val debtBlock: Boolean = false, + val lossPenalties: Boolean = false, + val attendanceRetention: Boolean = false, + // Staff areas (the server mounted the routes and the module is on) + val staffPatrons: Boolean = false, + val staffLedger: Boolean = false, + val staffStocktake: Boolean = false, + val staffLibrarySettings: Boolean = false, + val staffCirculationRules: Boolean = false, + val staffClosuresCalendar: Boolean = false, + val staffSuppliers: Boolean = false, + val staffCatalogueCards: Boolean = false, + val staffMarc: Boolean = false, + val staffAttendance: Boolean = false, + val staffStatistics: Boolean = false, + val staffUserImport: Boolean = false, +) { + companion object { + val None = Staff090Flags() + + fun from(f: HealthFeatures, appAccess: Boolean, circulation: Boolean): Staff090Flags { + if (!appAccess) return None + return Staff090Flags( + stocktake = f.stocktake, + marc = f.marc, + visitorLog = f.visitorLog && circulation, + catalogueCards = f.catalogueCards, + suppliers = f.suppliers, + rdaTypes = f.rdaTypes, + patronCards = f.patronCards, + userImport = f.userImport, + patronPhotos = f.patronPhotos, + automaticCardExpiry = f.automaticCardExpiry && circulation, + automaticFines = f.automaticFines && circulation, + debtBlock = f.debtBlock && circulation, + lossPenalties = f.lossPenalties && circulation, + attendanceRetention = f.attendanceRetention, + staffPatrons = f.staffPatrons, + staffLedger = f.staffLedger && circulation, + staffStocktake = f.staffStocktake, + staffLibrarySettings = f.staffLibrarySettings, + // Configuration stays reachable with the module off (the server lets an + // administrator configure a switched-off module, as the web does). + staffCirculationRules = f.staffCirculationRules, + staffClosuresCalendar = f.staffClosuresCalendar, + staffSuppliers = f.staffSuppliers, + staffCatalogueCards = f.staffCatalogueCards, + staffMarc = f.staffMarc, + staffAttendance = f.staffAttendance && circulation, + staffStatistics = f.staffStatistics, + staffUserImport = f.staffUserImport, + ) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/common/CardBarcode.kt b/app/src/main/java/com/pinakes/app/ui/common/CardBarcode.kt new file mode 100644 index 0000000..df65b2d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/common/CardBarcode.kt @@ -0,0 +1,65 @@ +package com.pinakes.app.ui.common + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.google.zxing.oned.Code128Writer + +/** + * The library card code as a Code128 barcode, the default of the printed card (Pinakes 0.9.0, + * Settings → Cards) and what the desk and the visitor-log kiosk scan. Pure, unit-tested. + */ +object CardBarcode { + /** Quiet zone each side, in modules, so a scanner finds the start of the code. */ + const val QUIET_ZONE = 10 + + /** + * The bars of [code] (true = black module), or null when Code128 cannot hold it: as on the + * printed card, only printable ASCII is encoded, and the text line still shows the code. + */ + fun modules(code: String?): BooleanArray? { + val value = code?.trim().orEmpty() + if (value.isEmpty() || value.length > 80 || value.any { it.code !in 0x20..0x7E }) return null + return runCatching { Code128Writer().encode(value) }.getOrNull()?.takeIf { it.isNotEmpty() } + } +} + +/** + * Draws [code] as black bars on white (whatever the theme: scanners need the contrast), or + * nothing when it cannot be encoded. + */ +@Composable +fun CardBarcodeImage(code: String, description: String, modifier: Modifier = Modifier) { + val bars = remember(code) { CardBarcode.modules(code) } ?: return + Canvas( + modifier + .fillMaxWidth() + .height(72.dp) + .background(Color.White) + .padding(vertical = 8.dp) + .semantics { contentDescription = description }, + ) { + val total = bars.size + 2 * CardBarcode.QUIET_ZONE + val module = size.width / total + bars.forEachIndexed { i, black -> + if (black) { + drawRect( + color = Color.Black, + topLeft = Offset((i + CardBarcode.QUIET_ZONE) * module, 0f), + size = Size(module, size.height), + ) + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt b/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt index 3e1f878..605ee93 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt @@ -44,6 +44,7 @@ fun MainScaffold( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, + onOpenMovements: () -> Unit = {}, onOpenBookClub: () -> Unit, onOpenPeriodicals: () -> Unit, onOpenArticles: () -> Unit, @@ -122,6 +123,7 @@ fun MainScaffold( onOpenNotifications = onOpenNotifications, onOpenContact = onOpenContact, onOpenMyReviews = onOpenMyReviews, + onOpenMovements = onOpenMovements, onOpenBookClub = onOpenBookClub, onOpenPeriodicals = onOpenPeriodicals, onOpenArticles = onOpenArticles, diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt index d37fca1..d55a7d8 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt @@ -1,6 +1,8 @@ package com.pinakes.app.ui.navigation import androidx.compose.animation.AnimatedContentTransitionScope +import com.pinakes.app.ui.screens.staff.s090.Staff090Transitions +import com.pinakes.app.ui.screens.staff.s090.staff090Graph import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -42,6 +44,7 @@ import com.pinakes.app.ui.screens.periodicals.StandaloneArticlesScreen import com.pinakes.app.ui.screens.periodicals.StandaloneArticleScreen import com.pinakes.app.ui.screens.periodicals.PeriodicalsScreen import com.pinakes.app.ui.screens.reviews.MyReviewsScreen +import com.pinakes.app.ui.screens.movements.MovementsScreen import com.pinakes.app.ui.screens.staff.StaffAuthorsScreen import com.pinakes.app.ui.screens.staff.StaffBookEditScreen import com.pinakes.app.ui.screens.staff.StaffBooksScreen @@ -153,6 +156,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onOpenNotifications = { navController.navigate(Routes.NOTIFICATIONS) }, onOpenContact = { navController.navigate(Routes.CONTACT) }, onOpenMyReviews = { navController.navigate(Routes.MY_REVIEWS) }, + onOpenMovements = { navController.navigate(Routes.MOVEMENTS) }, onOpenBookClub = { navController.navigate(Routes.BOOK_CLUB) }, onOpenPeriodicals = { navController.navigate(Routes.PERIODICALS) }, onOpenArticles = { navController.navigate(Routes.standaloneArticles()) }, @@ -216,6 +220,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onPeriodicals = { navController.navigate(Routes.STAFF_PERIODICALS) }, onClubs = { navController.navigate(Routes.STAFF_CLUBS) }, onWorks = { navController.navigate(Routes.STAFF_WORKS) }, + onStaff090 = { navController.navigate(it) }, ), ) } @@ -281,6 +286,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onNavigateUp = { navController.popBackStack() }, onOpenLoan = { navController.navigate(Routes.staffLoan(it)) }, onNewLoan = { navController.navigate(Routes.staffLoanNew(userId = it)) }, + onOpenLedger = { navController.navigate(com.pinakes.app.ui.screens.staff.s090.Staff090Routes.patronLedger(it)) }, ) } // ---- Staff book tools (Mobile API 1.7.0) ---- @@ -489,6 +495,14 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { ) } + composable( + Routes.MOVEMENTS, + enterTransition = slideIn, + popExitTransition = slideOut, + ) { + MovementsScreen(onNavigateUp = { navController.popBackStack() }) + } + composable( Routes.BOOK_CLUB, enterTransition = slideIn, @@ -613,5 +627,8 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { ) { IssueDetailScreen(onNavigateUp = { navController.popBackStack() }, onOpenArticle = { navController.navigate(Routes.standaloneArticle(it)) }, onFindArticles = { navController.navigate(Routes.standaloneArticles(issueId = it)) }) } + + // ---- Pinakes 0.9.0 staff tools (Mobile API 1.10.0) ---- + staff090Graph(navController, Staff090Transitions(slideIn, slideOut)) } } diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt index 34f627d..dbb0d9e 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt @@ -20,6 +20,8 @@ object Routes { const val NOTIFICATIONS = "notifications" const val CONTACT = "contact" const val MY_REVIEWS = "my-reviews" + /** Balance and movements of the fines ledger (Pinakes 0.9.0, `features.fines`). */ + const val MOVEMENTS = "movements" const val BOOK_DETAIL = "book/{bookId}" fun bookDetail(bookId: Int): String = "book/$bookId" diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index d2d9986..6e5d941 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -141,6 +141,10 @@ fun BookDetailScreen( ) CirculationRequestKind.LoanReadyForPickup -> stringResource(R.string.snackbar_loan_ready_for_pickup) + CirculationRequestKind.LoanScheduled -> stringResource( + R.string.snackbar_loan_scheduled, + formatDisplayDate(confirmation.startDate ?: confirmation.desiredDate), + ) CirculationRequestKind.LoanPending -> stringResource( R.string.snackbar_request_for_date, formatDisplayDate(confirmation.desiredDate), @@ -202,6 +206,7 @@ fun BookDetailScreen( showWishlist = features.showWishlist && !state.offlineNow, showReviews = features.showReviews && !state.offlineNow, onReserve = vm::openLoanSheet, + rules = state.rules, onToggleWishlist = vm::toggleWishlist, onFindWorks = onFindWorks, onRefreshFile = vm::refreshDigitalFile, @@ -276,8 +281,9 @@ internal fun LoanRequestSheetContent( today: LocalDate? = null, ) { // Pre-select earliest_available when offered, else null until the user picks. + // A closed day is never offered: the first requestable day from it (closures calendar). var selectedIso by remember(calendar) { - mutableStateOf(calendar?.earliestAvailable?.takeIf { it.isNotBlank() }) + mutableStateOf(calendar?.earliestAvailable?.takeIf { it.isNotBlank() }?.let { calendar.firstRequestableDay(it) }) } // A fully-booked day the reader tapped: explained under the calendar until the next pick. var blockedIso by remember(calendar) { mutableStateOf(null) } @@ -384,7 +390,10 @@ internal fun LoanRequestSheetContent( ) } } - val blockedText = blockedIso?.let { stringResource(R.string.book_loan_blocked_day, formatDisplayDate(it)) } + val blockedText = blockedIso?.let { + if (calendar?.isClosed(it) == true) stringResource(R.string.book_loan_closed_day, formatDisplayDate(it)) + else stringResource(R.string.book_loan_blocked_day, formatDisplayDate(it)) + } val explanation = listOfNotNull(blockedText, limitText).joinToString(" ") if (explanation.isNotEmpty()) { Text( @@ -428,6 +437,8 @@ internal fun DetailContent( onReserve: () -> Unit, onToggleWishlist: () -> Unit, onShowMessage: (String) -> Unit, + /** The reader's circulation rules for this title (Pinakes 0.9.0); null = not known, usual button. */ + rules: ViewerRules? = null, onFindWorks: (String, Int?) -> Unit = { _, _ -> }, /** Revalidates a signed file on the server, preserving this page while the request runs. */ onRefreshFile: suspend (DigitalAttachment) -> ApiResult = { @@ -585,12 +596,34 @@ internal fun DetailContent( if (showLoanButton || showWishlist) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { if (showLoanButton) { + // Consultation only, or no waitlist for this reader's patron type: the button + // stays, disabled, with the reason under it (it does not just disappear). + val action = CirculationRules.loanAction(book.available, rules) PrimaryButton( - label = if (book.available) stringResource(R.string.book_request_loan) else stringResource(R.string.book_reserve), + label = when (action) { + LoanAction.Request -> stringResource(R.string.book_request_loan) + LoanAction.Reserve -> stringResource(R.string.book_reserve) + LoanAction.ConsultationOnly -> stringResource(R.string.book_consultation_only) + LoanAction.ReservationsDisabled -> stringResource(R.string.book_reserve) + }, onClick = onReserve, + enabled = CirculationRules.canRequest(action), loading = reserveBusy, modifier = Modifier.fillMaxWidth().testTag("detail-loan"), ) + val reason = when (action) { + LoanAction.ConsultationOnly -> stringResource(R.string.book_consultation_only_hint) + LoanAction.ReservationsDisabled -> stringResource(R.string.book_reservations_disabled_hint) + else -> null + } + if (reason != null) { + Text( + reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag("detail-loan-reason"), + ) + } } if (showWishlist) { SecondaryButton( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt index c3e43d0..9a40468 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.drop import com.pinakes.app.data.repository.CatalogRepository import com.pinakes.app.data.repository.LibraryRepository import com.pinakes.app.data.repository.WishlistRepository +import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.R import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.navigation.Routes @@ -49,6 +50,11 @@ data class BookDetailUiState( val syncedAt: String? = null, /** Device connectivity: a page loaded online hides its loan actions when it drops. */ val connected: Boolean = true, + /** + * What this reader's patron type allows on the title (Pinakes 0.9.0), from the availability + * payload; null until known (older server, rules off, fetch failed): the usual button then. + */ + val rules: ViewerRules? = null, ) { /** Cached page or no connection: no loan/reservation affordance, no current availability. */ val offlineNow: Boolean get() = offline || !connected @@ -57,6 +63,8 @@ data class BookDetailUiState( data class RequestConfirmation( val desiredDate: String, val kind: CirculationRequestKind, + /** The server's start day of a scheduled loan ([CirculationRequestKind.LoanScheduled]). */ + val startDate: String? = null, ) @HiltViewModel @@ -66,6 +74,7 @@ class BookDetailViewModel @Inject constructor( private val library: LibraryRepository, private val wishlist: WishlistRepository, private val connectivity: ConnectivityObserver, + private val featureStore: FeatureStore, ) : ViewModel() { // The book id arrives as a navigation argument; Hilt populates SavedStateHandle from it. @@ -95,7 +104,7 @@ class BookDetailViewModel @Inject constructor( wishlisted = res.data.personalHistory?.hasWishlisted ?: it.wishlisted, offline = false, ) - } + }.also { loadRules() } is ApiResult.Failure -> { val cached = if (res.code == ErrorCodes.NETWORK) catalog.bookOffline(bookId) else null val syncedAt = if (cached != null) catalog.lastSyncedAt() else null @@ -108,6 +117,21 @@ class BookDetailViewModel @Inject constructor( } } + /** + * With the server's circulation rules or consultation-only copies on, learn up front whether + * the title is consultation only or the waitlist is closed for this reader, so the button says + * so before a tap. Without either flag (an older server) nothing is fetched and the page + * behaves as it always did. + */ + private fun loadRules() { + if (!featureStore.features.value.viewerRulesAvailable) return + viewModelScope.launch { + (catalog.bookAvailability(bookId) as? ApiResult.Success)?.data?.let { cal -> + _state.update { it.copy(rules = ViewerRules.of(cal)) } + } + } + } + /** Refresh in place: a failed renewal must not replace the page or reuse a cached URL. */ suspend fun refreshDigitalFile(file: DigitalAttachment): ApiResult { return when (val result = catalog.book(bookId, forceRefresh = true)) { @@ -164,7 +188,8 @@ class BookDetailViewModel @Inject constructor( viewModelScope.launch { when (val res = catalog.bookAvailability(bookId)) { is ApiResult.Success -> _state.update { - it.copy(availabilityLoading = false, availability = res.data, availabilityFallback = false) + it.copy(availabilityLoading = false, availability = res.data, availabilityFallback = false, + rules = ViewerRules.of(res.data)) } is ApiResult.Failure -> _state.update { it.copy(availabilityLoading = false, availability = null, availabilityFallback = true) @@ -195,7 +220,7 @@ class BookDetailViewModel @Inject constructor( reserveBusy = false, snackbar = null, snackbarRes = null, - requestConfirmation = RequestConfirmation(desiredDate, res.data.kind), + requestConfirmation = RequestConfirmation(desiredDate, res.data.kind, res.data.startDate), showLoanSheet = false, availability = null, availabilityFallback = false, @@ -212,13 +237,17 @@ class BookDetailViewModel @Inject constructor( // A `not_available` refusal carries the server's own explanation (the // longest loan still possible, and from which day the book is booked): // that message, not the generic conflict text, is what the reader sees. + // Patron-type refusals (Pinakes 0.9.0) get our wording and turn the button + // into its disabled state; unknown codes keep the server's message. + val ownText = CirculationRules.requestErrorRes(res) + val rules = CirculationRules.rulesAfterRefusal(_state.value.rules, res.code) when { - res.code == ErrorCodes.CONFLICT -> - _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = R.string.snackbar_request_conflict) } + ownText != null -> + _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = ownText, rules = rules) } res.serverMessage() != null -> - _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = res.serverMessage(), snackbarRes = null) } + _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = res.serverMessage(), snackbarRes = null, rules = rules) } else -> - _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = R.string.snackbar_request_error) } + _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = R.string.snackbar_request_error, rules = rules) } } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/CirculationRules.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/CirculationRules.kt new file mode 100644 index 0000000..a2d3e73 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/CirculationRules.kt @@ -0,0 +1,84 @@ +package com.pinakes.app.ui.screens.detail + +import androidx.annotation.StringRes +import com.pinakes.app.R +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes + +/** + * What the reader's patron type allows on a title (Pinakes 0.9.0 circulation rules), read off + * `GET /catalog/books/{id}/availability`. Both default to the pre-0.9.0 behaviour, so an older + * server, or a page whose availability was not fetched, keeps the usual button. + */ +data class ViewerRules( + /** False: the title is consultation only for this reader. */ + val loanable: Boolean = true, + /** False: the reader's patron type cannot join a waitlist. */ + val reservationsEnabled: Boolean = true, +) { + companion object { + fun of(calendar: AvailabilityCalendar): ViewerRules = + ViewerRules(loanable = calendar.loanable, reservationsEnabled = calendar.reservationsEnabled) + } +} + +/** The loan button on the book page. */ +enum class LoanAction { + /** A copy is free now: "Request loan". */ + Request, + + /** No copy is free: "Reserve" (joins the waitlist). */ + Reserve, + + /** Consultation only for this reader: no request, a disabled button that says so. */ + ConsultationOnly, + + /** No copy is free and the reader's patron type has no reservations: nothing to ask for. */ + ReservationsDisabled, +} + +object CirculationRules { + + /** The button for a title that is [available] now, under [rules] (null = not known yet). */ + fun loanAction(available: Boolean, rules: ViewerRules?): LoanAction = when { + rules?.loanable == false -> LoanAction.ConsultationOnly + available -> LoanAction.Request + rules?.reservationsEnabled == false -> LoanAction.ReservationsDisabled + else -> LoanAction.Reserve + } + + /** True when the button opens the request sheet; false when it is shown disabled with a reason. */ + fun canRequest(action: LoanAction): Boolean = action == LoanAction.Request || action == LoanAction.Reserve + + /** What a refusal teaches about the reader's rules: the button then shows the reason at once. */ + fun rulesAfterRefusal(current: ViewerRules?, code: String): ViewerRules? = when (code) { + "not_loanable" -> (current ?: ViewerRules()).copy(loanable = false) + "reservations_disabled" -> (current ?: ViewerRules()).copy(reservationsEnabled = false) + else -> current + } + + /** + * Our wording for a refused POST /reservations, or null when the server's own message is the + * better text (an unknown code, or `not_available` with the longest loan still possible). + * `not_eligible` names its cause only when the server passes `meta.reason`; without it the + * server message (which says whether the card, the account or the balance is the reason) wins. + */ + @StringRes + fun requestErrorRes(failure: ApiResult.Failure): Int? = when (failure.code) { + "not_loanable" -> R.string.request_err_not_loanable + "reservations_disabled" -> R.string.request_err_reservations_disabled + "reservation_limit_reached" -> R.string.request_err_reservation_limit + "media_limit_reached" -> R.string.request_err_media_limit + "max_loans_reached" -> R.string.request_err_max_loans + "not_eligible" -> when (failure.meta?.reason) { + "debt_block" -> R.string.request_err_debt_block + "card_expired" -> R.string.request_err_card_expired + "user_suspended" -> R.string.request_err_suspended + else -> null + } + // The generic 409 without a server code (a proxy page): the dates clash. + ErrorCodes.CONFLICT -> R.string.snackbar_request_conflict + else -> null + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt index ef79bfb..ecd814e 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt @@ -67,7 +67,7 @@ import java.util.Locale private data class DayTint(val container: Color, val onContainer: Color, val rule: Color = Color.Transparent) /** Per-day availability state used to colour a calendar cell. */ -private enum class DayKind { Free, Partial, Full, PastOrUnknown } +internal enum class DayKind { Free, Partial, Full, Closed, PastOrUnknown } @Composable private fun tintFor(kind: DayKind, dark: Boolean): DayTint = when (kind) { @@ -78,6 +78,9 @@ private fun tintFor(kind: DayKind, dark: Boolean): DayTint = when (kind) { else ruled(DueSoonContainerLight, DueSoonOnContainerLight) DayKind.Full -> ruled(MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer) + // The library is closed: a neutral grey, never the red of a booked day (nothing is on loan). + DayKind.Closed -> + ruled(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant) DayKind.PastOrUnknown -> DayTint(Color.Transparent, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)) } @@ -87,7 +90,7 @@ private fun ruled(container: Color, onContainer: Color) = DayTint(container, onContainer, mix(onContainer, container, .22f)) /** Index per-day data by ISO date for O(1) lookup, and the set of unavailable dates. */ -private class CalendarModel(private val source: AvailabilityCalendar) { +internal class CalendarModel(private val source: AvailabilityCalendar) { private val byDate: Map = source.days.associate { it.date to it.state } // date -> state private val unavailable: Set = source.unavailableDates.toSet() @@ -99,6 +102,8 @@ private class CalendarModel(private val source: AvailabilityCalendar) { fun kind(date: LocalDate, today: LocalDate): DayKind { val iso = date.toString() if (date.isBefore(today)) return DayKind.PastOrUnknown + // Closures calendar: no start or pickup on a day the library is closed. + if (source.isClosed(iso)) return DayKind.Closed // Same predicate as loanWindowFrom(): a red day is a day that caps the loan. if (source.isFullyBooked(iso)) return DayKind.Full return when (byDate[iso]) { @@ -134,7 +139,10 @@ fun LoanCalendar( anyFutureDay: Boolean = false, /** The day the grid treats as today; null reads the device clock once. Tests pin it. */ today: LocalDate? = null, - /** A fully-booked future day cannot be picked; tapping it calls this so the sheet can say why. */ + /** + * A fully-booked or closed future day cannot be picked; tapping it calls this so the sheet + * can say why (it tells the two apart with [AvailabilityCalendar.isClosed]). + */ onBlockedTap: ((isoDate: String) -> Unit)? = null, ) { val dark = LocalPinakesColors.current.darkMode @@ -171,7 +179,7 @@ fun LoanCalendar( ) if (!anyFutureDay) { Spacer(Modifier.height(Spacing.md)) - Legend(dark = dark) + Legend(dark = dark, showClosed = calendar.closedDates.isNotEmpty()) } } } @@ -316,8 +324,9 @@ private fun DayCell( .then( when { selectable -> Modifier.clickable { onSelect(iso) } - // Not pickable, but a tap must still explain why (every copy is booked). - onBlockedTap != null && kind == DayKind.Full -> Modifier.clickable { onBlockedTap(iso) } + // Not pickable, but a tap must still explain why (every copy is booked, or the + // library is closed that day). + onBlockedTap != null && (kind == DayKind.Full || kind == DayKind.Closed) -> Modifier.clickable { onBlockedTap(iso) } else -> Modifier } ) @@ -334,7 +343,7 @@ private fun DayCell( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun Legend(dark: Boolean) { +private fun Legend(dark: Boolean, showClosed: Boolean = false) { // Wraps on narrow screens and with long translations instead of running off the edge. FlowRow( Modifier.fillMaxWidth(), @@ -345,6 +354,10 @@ private fun Legend(dark: Boolean) { LegendItem(MaterialTheme.colorScheme.surface, stringResource(R.string.book_loan_legend_available), MaterialTheme.colorScheme.outline) tintFor(DayKind.Partial, dark).let { LegendItem(it.container, stringResource(R.string.book_loan_legend_limited), it.rule) } tintFor(DayKind.Full, dark).let { LegendItem(it.container, stringResource(R.string.book_loan_legend_unavailable), it.rule) } + // Only when the library publishes closures: an empty key would explain nothing. + if (showClosed) { + tintFor(DayKind.Closed, dark).let { LegendItem(it.container, stringResource(R.string.book_loan_legend_closed), it.rule) } + } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt index 06dc6ed..161714a 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt @@ -41,6 +41,43 @@ internal fun AvailabilityCalendar.isFullyBooked(isoDate: String): Boolean { return day.state == "full" || (day.state == "free" && day.available <= 0) } +/** A day the library is closed (closures calendar, Mobile API 1.9.0): never a start or a return. */ +internal fun AvailabilityCalendar.isClosed(isoDate: String): Boolean = closedDates.contains(isoDate) + +/** How far [openDayFrom] looks for an open day: closures longer than this are not real. */ +private const val MAX_CLOSED_RUN = 60 + +/** + * The nearest open day from [date] (itself when open), walking [forward] or back, or null when + * none exists within [MAX_CLOSED_RUN] days or before [notBefore] (going back). + */ +internal fun AvailabilityCalendar.openDayFrom(date: LocalDate, forward: Boolean, notBefore: LocalDate? = null): LocalDate? { + var d = date + repeat(MAX_CLOSED_RUN) { + if (notBefore != null && d.isBefore(notBefore)) return null + if (!isClosed(d.toString())) return d + d = if (forward) d.plusDays(1) else d.minusDays(1) + } + return null +} + +/** + * The day the request sheet pre-selects: [earliestIso] (the server's `earliest_available`), or + * the first day after it that is neither closed nor fully booked. Null when nothing qualifies + * in the fetched window or [earliestIso] is not a date. + */ +internal fun AvailabilityCalendar.firstRequestableDay(earliestIso: String?): String? { + val start = runCatching { LocalDate.parse(earliestIso) }.getOrNull() ?: return null + if (closedDates.isEmpty()) return earliestIso + var d = start + repeat(MAX_CLOSED_RUN) { + val iso = d.toString() + if (!isClosed(iso) && !isFullyBooked(iso)) return iso + d = d.plusDays(1) + } + return null +} + /** * The window from [startIso] (yyyy-MM-dd), or null when the duration is unknown (an older * server omits `loan_duration_days`) or the start is not a date: then the server's default @@ -56,6 +93,8 @@ internal fun loanWindowFrom(startIso: String, calendar: AvailabilityCalendar?, l if (loanDurationDays == null || loanDurationDays < 1) return null val start = runCatching { LocalDate.parse(startIso) }.getOrNull() ?: return null if (calendar?.maxLoanDurationDays == null) { + // closed_dates comes with Mobile API 1.9.0, which always sends max_loan_duration_days: + // a server without the maximum has no closures to apply here. return LoanWindow( endDate = start.plusDays(loanDurationDays.toLong()).toString(), firstBlocked = null, @@ -64,23 +103,52 @@ internal fun loanWindowFrom(startIso: String, calendar: AvailabilityCalendar?, l requiresEndDate = false, ) } - val durationDays = calendar?.maxLoanDurationDays?.takeIf { it > 0 } + // Non-null from here: maxLoanDurationDays was read off it. + val cal = calendar ?: return null + val durationDays = cal.maxLoanDurationDays?.takeIf { it > 0 } ?.let { minOf(loanDurationDays, it) } ?: loanDurationDays var firstBlocked: String? = null for (offset in 1..durationDays) { val day = start.plusDays(offset.toLong()).toString() - if (calendar?.isFullyBooked(day) == true) { + if (cal.isFullyBooked(day)) { firstBlocked = day break } } val defaultEnd = start.plusDays(durationDays.toLong()) - val end = firstBlocked?.let { LocalDate.parse(it).minusDays(1) } ?: defaultEnd + var rawEnd = firstBlocked?.let { LocalDate.parse(it).minusDays(1) } ?: defaultEnd + // A default return on a closed day moves to the next open day, as the server's due date + // does. When that walk meets a fully-booked day, the loan would run into the commitment: + // the booked day caps it, as if it lay inside the duration (the server's renewal cap does + // the same with closures). A return already cut to max_loan_duration_days is the limit + // itself: walking forward would send an end_date past the maximum, so it moves back below. + val cutToMaximum = durationDays < loanDurationDays + if (firstBlocked == null && !cutToMaximum && cal.isClosed(rawEnd.toString())) { + var d = rawEnd + while (cal.isClosed(d.toString()) && ChronoUnit.DAYS.between(rawEnd, d) < 60) { + if (cal.isFullyBooked(d.toString())) break + d = d.plusDays(1) + } + if (cal.isFullyBooked(d.toString())) { + firstBlocked = d.toString() + rawEnd = d.minusDays(1) + } else { + rawEnd = d + } + } + val shortened = rawEnd.isBefore(start.plusDays(loanDurationDays.toLong())) + // A shortened return (sent as end_date) cannot move past the day that shortened it, so on a + // closed day it moves back to the last open day after the start; when there is none, no + // loan fits from this start. + val end = when { + !cal.isClosed(rawEnd.toString()) -> rawEnd + else -> cal.openDayFrom(rawEnd, forward = false, notBefore = start.plusDays(1)) ?: start + } return LoanWindow( endDate = end.toString(), firstBlocked = firstBlocked, maxDays = ChronoUnit.DAYS.between(start, end).toInt(), capped = firstBlocked != null, - requiresEndDate = end.isBefore(start.plusDays(loanDurationDays.toLong())), + requiresEndDate = shortened || end.isBefore(rawEnd), ) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsScreen.kt new file mode 100644 index 0000000..410933e --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsScreen.kt @@ -0,0 +1,225 @@ +package com.pinakes.app.ui.screens.movements + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pinakes.app.R +import com.pinakes.app.data.model.AccruedFine +import com.pinakes.app.data.model.Movement +import com.pinakes.app.data.model.MovementsPayload +import com.pinakes.app.ui.common.AppLocale +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.common.resolvedMessage +import com.pinakes.app.ui.components.EmptyState +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.SectionHeader +import com.pinakes.app.ui.theme.Spacing + +/** + * The reader's balance and movements (fines ledger, Pinakes 0.9.0). Reached from the profile + * only while `/health` advertises `features.fines`; if the library turns fines off meanwhile + * the server answers 404 `feature_disabled` and the page simply closes. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MovementsScreen( + onNavigateUp: () -> Unit, +) { + val vm: MovementsViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val features by app.features.collectAsStateWithLifecycle() + LaunchedEffect(state.disabled, features.showMovements) { + if (state.disabled || !features.showMovements) onNavigateUp() + } + // A failed refresh keeps the last balance on screen, but says it may be out of date. + val snackbar = remember { SnackbarHostState() } + val refreshFailedText = stringResource(R.string.movements_refresh_failed) + LaunchedEffect(state.refreshFailed) { + if (state.refreshFailed) { + snackbar.showSnackbar(refreshFailedText) + vm.consumeRefreshFailed() + } + } + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.title_movements), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = vm::refresh, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + when (val content = state.content) { + is UiState.Loading -> LoadingState(label = stringResource(R.string.movements_loading)) + is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::load) + is UiState.Success -> MovementsContent(content.data) + } + } + } +} + +@Composable +private fun MovementsContent(data: MovementsPayload) { + val locale = AppLocale.current() + val money = { cents: Int -> MoneyFormat.format(cents, data.currency, locale) } + LazyColumn( + Modifier.fillMaxSize().testTag("movements-list"), + contentPadding = PaddingValues(vertical = Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + item(key = "balance") { BalanceCard(data, money) } + // The total shows even when the server sends it without the per-loan rows. + if (data.accruedTotalCents != 0 || data.accrued.isNotEmpty()) { + item(key = "accrued-title") { + SectionHeader(title = stringResource(R.string.movements_accrued_title, money(data.accruedTotalCents))) + } + // Keys fall back to the position: a row without its id must not collide with another. + itemsIndexed(data.accrued, key = { i, a -> MoneyFormat.rowKey("accrued", i, a.loanId) }) { _, a -> AccruedRow(a, money) } + } + item(key = "items-title") { SectionHeader(title = stringResource(R.string.movements_history_title)) } + if (data.items.isEmpty()) { + item(key = "empty") { + EmptyState( + title = stringResource(R.string.movements_empty_title), + subtitle = stringResource(R.string.movements_empty_subtitle), + icon = Icons.Outlined.AccountBalanceWallet, + modifier = Modifier.testTag("movements-empty"), + ) + } + } else { + itemsIndexed(data.items, key = { i, m -> MoneyFormat.rowKey("movement", i, m.id) }) { _, m -> MovementRow(m, money) } + } + } +} + +/** [staff]: the same card read by a librarian at the desk, so the wording speaks about the patron. */ +@Composable +internal fun BalanceCard(data: MovementsPayload, money: (Int) -> String, staff: Boolean = false) { + val kind = MoneyFormat.balanceKind(data.balanceTotalCents) + Column(Modifier.padding(horizontal = Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().testTag("movements-balance"), + ) { + Column(Modifier.padding(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text(stringResource(R.string.movements_balance), style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + money(kotlin.math.abs(data.balanceTotalCents)), + style = MaterialTheme.typography.headlineMedium, + color = if (kind == BalanceKind.Owed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + ) + Text( + stringResource( + when (kind) { + BalanceKind.Owed -> if (staff) R.string.staff090_patrons_balance_owed_staff else R.string.movements_balance_owed + BalanceKind.Credit -> if (staff) R.string.staff090_patrons_balance_credit_staff else R.string.movements_balance_credit + BalanceKind.Settled -> R.string.movements_balance_settled + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (data.debtBlocked) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth().testTag("movements-debt-blocked"), + ) { + Column(Modifier.padding(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text(stringResource(R.string.movements_debt_blocked_title), style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onErrorContainer) + Text( + data.debtBlockThresholdCents?.let { stringResource(if (staff) R.string.staff090_patrons_debt_blocked_body_staff else R.string.movements_debt_blocked_body, money(it)) } + ?: stringResource(if (staff) R.string.staff090_patrons_debt_blocked_body_plain_staff else R.string.movements_debt_blocked_body_plain), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } + } +} + +@Composable +internal fun AccruedRow(fine: AccruedFine, money: (Int) -> String) { + LedgerRow( + title = fine.title?.takeIf { it.isNotBlank() } ?: stringResource(R.string.movements_untitled), + subtitle = stringResource(R.string.movements_accrued_row, DateFormat.date(fine.dueAt), fine.daysLate), + amount = money(fine.amountCents), + owed = true, + ) +} + +@Composable +internal fun MovementRow(m: Movement, money: (Int) -> String) { + val title = m.typeLabel?.takeIf { it.isNotBlank() } ?: m.type + val detail = listOfNotNull( + m.bookTitle?.takeIf { it.isNotBlank() }, + m.description?.takeIf { it.isNotBlank() && it != title }, + m.paymentMethod?.takeIf { it.isNotBlank() }, + m.createdAt?.let { DateFormat.date(it) }, + ).joinToString(" · ") + LedgerRow(title = title, subtitle = detail, amount = money(m.amountCents), owed = m.amountCents > 0) +} + +@Composable +internal fun LedgerRow(title: String, subtitle: String, amount: String, owed: Boolean) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.lg), + ) { + Row(Modifier.padding(Spacing.md), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (subtitle.isNotBlank()) { + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, overflow = TextOverflow.Ellipsis) + } + } + Text( + amount, + style = MaterialTheme.typography.titleSmall, + color = if (owed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = Spacing.md), + ) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsViewModel.kt new file mode 100644 index 0000000..fc53f2c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/movements/MovementsViewModel.kt @@ -0,0 +1,121 @@ +package com.pinakes.app.ui.screens.movements + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.MovementsPayload +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.LibraryRepository +import com.pinakes.app.ui.common.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import java.math.BigDecimal +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class MovementsUiState( + val content: UiState = UiState.Loading, + val refreshing: Boolean = false, + /** + * The library turned the fines module off (404 `feature_disabled`, or a server without the + * route): the page leaves at once instead of showing an error, as the profile entry is gone too. + */ + val disabled: Boolean = false, + /** A pull-to-refresh failed: the page keeps the last balance and says it may be out of date. */ + val refreshFailed: Boolean = false, +) { + companion object { + /** + * The state after a fetch. Pure: a 404 (`feature_disabled` or `not_found` from a server + * without the route) means "no ledger here", never an error; a failed refresh keeps the + * page already on screen. + */ + fun after(previous: MovementsUiState, result: ApiResult): MovementsUiState = when (result) { + is ApiResult.Success -> previous.copy(content = UiState.Success(result.data), refreshing = false, disabled = false, refreshFailed = false) + is ApiResult.Failure -> when { + result.code == "feature_disabled" || result.httpStatus == 404 -> previous.copy(refreshing = false, disabled = true) + previous.content is UiState.Success -> previous.copy(refreshing = false, refreshFailed = true) + else -> previous.copy(content = UiState.Error(result.message, result.code, R.string.movements_error_load), refreshing = false) + } + } + } +} + +/** What the reader owes, in one place: the sign decides the wording of the balance card. */ +enum class BalanceKind { Owed, Settled, Credit } + +object MoneyFormat { + + /** + * A LazyColumn key for a ledger row: its id when the server sent one, else its position. + * Ids default to 0 when omitted, and Compose throws on a duplicate key. + */ + fun rowKey(prefix: String, index: Int, id: Int): String = if (id > 0) "$prefix-$id" else "$prefix-at-$index" + + fun balanceKind(cents: Int): BalanceKind = when { + cents > 0 -> BalanceKind.Owed + cents < 0 -> BalanceKind.Credit + else -> BalanceKind.Settled + } + + /** + * [cents] in [currency] (an ISO 4217 code such as "EUR") for [locale]. A currency the + * platform does not know (a symbol such as "€" typed in the settings) is appended to the + * plain decimal instead, so the amount is never lost to a formatting failure. + */ + fun format(cents: Int, currency: String?, locale: Locale): String { + val amount = BigDecimal.valueOf(cents.toLong(), 2) + val code = currency?.trim().orEmpty() + val iso = runCatching { Currency.getInstance(code.uppercase(Locale.ROOT)) }.getOrNull() + if (iso != null) { + val nf = NumberFormat.getCurrencyInstance(locale) + nf.currency = iso + nf.minimumFractionDigits = 2 + nf.maximumFractionDigits = 2 + return nf.format(amount) + } + val nf = NumberFormat.getNumberInstance(locale).apply { + minimumFractionDigits = 2 + maximumFractionDigits = 2 + } + val plain = nf.format(amount) + return if (code.isEmpty()) plain else "$plain $code" + } +} + +/** Balance and movements of the fines ledger (Pinakes 0.9.0). */ +@HiltViewModel +class MovementsViewModel @Inject constructor( + private val library: LibraryRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(MovementsUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load() } + + fun load() { + _state.update { it.copy(content = UiState.Loading) } + fetch() + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + fetch() + } + + fun consumeRefreshFailed() = _state.update { it.copy(refreshFailed = false) } + + private fun fetch() { + viewModelScope.launch { + val res = library.movements() + _state.update { MovementsUiState.after(it, res) } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt index d028923..428c0f5 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Logout import androidx.compose.material.icons.outlined.BrightnessMedium +import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.DevicesOther @@ -93,6 +94,7 @@ fun ProfileScreen( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, + onOpenMovements: () -> Unit, onOpenBookClub: () -> Unit, onOpenPeriodicals: () -> Unit, onOpenArticles: () -> Unit, @@ -137,6 +139,7 @@ fun ProfileScreen( onOpenNotifications = onOpenNotifications, onOpenContact = onOpenContact, onOpenMyReviews = onOpenMyReviews, + onOpenMovements = onOpenMovements, onOpenBookClub = onOpenBookClub, onOpenPeriodicals = onOpenPeriodicals, onOpenArticles = onOpenArticles, @@ -152,6 +155,7 @@ fun ProfileScreen( showNotifications = features.notifications, showContact = features.messages, showReviews = features.showReviews, + showMovements = features.showMovements, showBookClub = features.bookClubAvailable, showPeriodicals = features.periodicalsAvailable, ) @@ -230,6 +234,7 @@ private fun ProfileContent( onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, + onOpenMovements: () -> Unit, onOpenBookClub: () -> Unit, onOpenPeriodicals: () -> Unit, onOpenArticles: () -> Unit, @@ -242,6 +247,7 @@ private fun ProfileContent( showNotifications: Boolean, showContact: Boolean, showReviews: Boolean, + showMovements: Boolean, showBookClub: Boolean, showPeriodicals: Boolean, showDesiderata: Boolean, @@ -291,7 +297,9 @@ private fun ProfileContent( // Read-only membership info (only shown when the server provides it). val cardNumber = profile.codiceTessera?.takeIf { it.isNotBlank() } val cardExpiry = profile.cardExpiresAt?.takeIf { it.isNotBlank() } - if (cardNumber != null || cardExpiry != null) { + // Patron type (Pinakes 0.9.0 circulation rules), when /me names it. + val patronType = profile.patronType?.let { it.name?.takeIf(String::isNotBlank) ?: it.code?.takeIf(String::isNotBlank) } + if (cardNumber != null || cardExpiry != null || patronType != null) { Spacer(Modifier.height(Spacing.xl)) Surface( shape = MaterialTheme.shapes.medium, @@ -301,6 +309,15 @@ private fun ProfileContent( Column(Modifier.padding(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { cardNumber?.let { ReadOnlyRow(stringResource(R.string.profile_card_number), it) } cardExpiry?.let { ReadOnlyRow(stringResource(R.string.profile_card_expires), DateFormat.date(it)) } + patronType?.let { ReadOnlyRow(stringResource(R.string.profile_patron_type), it) } + // The card's code as the printed card carries it, for the desk or kiosk scanner. + cardNumber?.let { + com.pinakes.app.ui.common.CardBarcodeImage( + code = it, + description = stringResource(R.string.profile_card_barcode, it), + modifier = Modifier.padding(top = Spacing.sm), + ) + } } } } @@ -313,6 +330,9 @@ private fun ProfileContent( if (showReviews) { ActionRow(Icons.Outlined.RateReview, stringResource(R.string.profile_action_my_reviews), onClick = onOpenMyReviews) } + if (showMovements) { + ActionRow(Icons.Outlined.AccountBalanceWallet, stringResource(R.string.profile_action_movements), onClick = onOpenMovements) + } if (showCalendar) { ActionRow(Icons.Outlined.CalendarMonth, stringResource(R.string.profile_action_calendar), onClick = onOpenCalendar) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt index e33a4ba..6d6ab4d 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt @@ -311,6 +311,8 @@ private fun BookForm(state: StaffBookEditUiState, vm: StaffBookEditViewModel) { ) // Digital Library plugin (`staff_digital_library`): eBook / audiobook upload. com.pinakes.app.ui.screens.staff.content.StaffDigitalSection(onMessage = vm::showMessage, onForbidden = vm::markForbidden) + // Pinakes 0.9.0: catalogue card PDF and MARC export of this record. + com.pinakes.app.ui.screens.staff.s090.catalogue.Staff090CatalogueBookTools(onMessage = vm::showMessage, onForbidden = vm::markForbidden) } else { QuantityStepper(stringResource(R.string.staff_copies), f.copies, { n -> vm.edit { it.copy(copies = n) } }, range = StaffBookForm.COPIES_RANGE) @@ -321,6 +323,10 @@ private fun BookForm(state: StaffBookEditUiState, vm: StaffBookEditViewModel) { TextRow(stringResource(R.string.staff_field_acquisition_date), f.acquisitionDate, placeholder = "2026-01-31") { v -> vm.edit { it.copy(acquisitionDate = v) } } LookupField(stringResource(R.string.staff_field_acquisition_type), f.acquisitionType, state.lookups.acquisitionTypeOptions, { v -> vm.edit { it.copy(acquisitionType = v) } }, Modifier.fillMaxWidth()) + // Pinakes 0.9.0: supplier and RDA types, each only while the server's lookups carry it. + com.pinakes.app.ui.screens.staff.s090.catalogue.Staff090CatalogueBookFields(state.lookups, state.original, f.cataloguing) { c -> + vm.edit { it.copy(cataloguing = c) } + } PinakesTextField( value = f.price, onValueChange = { v -> vm.edit { it.copy(price = v) } }, label = stringResource(R.string.staff_field_price), isError = FormField.PRICE in errors, errorText = stringResource(R.string.staff_field_invalid), diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt index cb69076..0047a7a 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt @@ -22,6 +22,7 @@ import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.StaffRepository import com.pinakes.app.data.repository.isDuplicate import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.CataloguingModules import com.pinakes.app.data.staff.CoverImageEncoder import com.pinakes.app.data.staff.FormField import com.pinakes.app.data.staff.IsbnCodec @@ -335,10 +336,10 @@ class StaffBookEditViewModel @Inject constructor( _state.update { it.copy(saving = false) } return@launch } - val patch = StaffBookWrite.update(original, s.form) + val patch = StaffBookWrite.update(original, s.form, CataloguingModules.from(s.lookups)) if (patch.isEmpty()) ApiResult.Success(original) else staff.updateBook(bookId, patch) } else { - staff.createBook(StaffBookWrite.create(createForm)) + staff.createBook(StaffBookWrite.create(createForm, CataloguingModules.from(s.lookups))) } when (result) { is ApiResult.Success -> { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt index 855644d..47e9ed3 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt @@ -224,6 +224,8 @@ internal fun DatePickerField( onChange: (String?) -> Unit, allowClear: Boolean = false, enabled: Boolean = true, + /** Days the dialog refuses (yyyy-MM-dd), e.g. the library's closures; empty = none. */ + disabledDates: Set = emptySet(), ) { var open by remember { mutableStateOf(false) } PickerRow( @@ -235,7 +237,16 @@ internal fun DatePickerField( ) if (open) { val initial = value?.let { runCatching { LocalDate.parse(it) }.getOrNull() } - val pickerState = rememberDatePickerState(initialSelectedDateMillis = initial?.atStartOfDay(ZoneOffset.UTC)?.toInstant()?.toEpochMilli()) + val selectable = remember(disabledDates) { + object : androidx.compose.material3.SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean = + Instant.ofEpochMilli(utcTimeMillis).atZone(ZoneOffset.UTC).toLocalDate().toString() !in disabledDates + } + } + val pickerState = rememberDatePickerState( + initialSelectedDateMillis = initial?.atStartOfDay(ZoneOffset.UTC)?.toInstant()?.toEpochMilli(), + selectableDates = selectable, + ) DatePickerDialog( onDismissRequest = { open = false }, confirmButton = { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt index 48492ac..6cd4aeb 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt @@ -61,6 +61,8 @@ data class StaffHubPhase2( val onPeriodicals: () -> Unit = {}, val onClubs: () -> Unit = {}, val onWorks: () -> Unit = {}, + /** Pinakes 0.9.0 tools: receives a [com.pinakes.app.ui.screens.staff.s090.Staff090Routes] entry. */ + val onStaff090: (String) -> Unit = {}, ) /** @@ -152,6 +154,8 @@ fun StaffHubScreen( if (areas.bookClub) StaffActionRow(Icons.Outlined.Groups, stringResource(R.string.staff_clubs), stringResource(R.string.staff_clubs_subtitle), phase2.onClubs) if (areas.works) StaffActionRow(Icons.Outlined.AccountTree, stringResource(R.string.staff_works), stringResource(R.string.staff_works_subtitle), phase2.onWorks) } + + com.pinakes.app.ui.screens.staff.s090.Staff090HubSections(areas.s090, phase2.onStaff090) } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt index ace9952..0dc876e 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt @@ -142,12 +142,16 @@ internal fun ReturnSheet( saving: Boolean, onConfirm: (outcome: ReturnOutcome, notes: String, penalty: Double?) -> Unit, onDismiss: () -> Unit, + /** Mobile API 1.10.0: the loan's late fine; with [onConfirmWaiving] the desk may waive it. */ + lateFine: com.pinakes.app.data.model.StaffLoanLateFine? = null, + onConfirmWaiving: ((outcome: ReturnOutcome, notes: String, penalty: Double?, waiveReason: String?) -> Unit)? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var outcome by rememberSaveable { mutableStateOf(ReturnOutcome.RETURNED) } var notes by rememberSaveable { mutableStateOf("") } var penalty by rememberSaveable { mutableStateOf("") } val parsed = LoanActions.parsePenalty(penalty, outcome) + val fine = com.pinakes.app.ui.screens.staff.s090.patrons.rememberReturnFineState(lateFine.takeIf { onConfirmWaiving != null }) ModalBottomSheet(onDismissRequest = { if (!saving) onDismiss() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), @@ -181,9 +185,13 @@ internal fun ReturnSheet( modifier = Modifier.fillMaxWidth().testTag("penalty-field"), ) } + fine.Content() PrimaryButton( - stringResource(R.string.staff_loan_action_return), loading = saving, enabled = parsed !is PenaltyInput.Invalid, - onClick = { onConfirm(outcome, notes, (parsed as? PenaltyInput.Amount)?.value) }, + stringResource(R.string.staff_loan_action_return), loading = saving, enabled = parsed !is PenaltyInput.Invalid && fine.canConfirm, + onClick = { + val amount = (parsed as? PenaltyInput.Amount)?.value + if (onConfirmWaiving != null) onConfirmWaiving(outcome, notes, amount, fine.waiveReason()) else onConfirm(outcome, notes, amount) + }, modifier = Modifier.fillMaxWidth().testTag("return-confirm"), ) Spacer(Modifier.height(Spacing.lg)) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt index dccc753..1882e36 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt @@ -33,6 +33,7 @@ import com.pinakes.app.R import com.pinakes.app.data.model.AvailabilityCalendar import com.pinakes.app.data.model.LoanCreateRequest import com.pinakes.app.data.model.ReservationWrite +import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.StaffCirculationRepository import com.pinakes.app.data.repository.StaffRepository @@ -68,6 +69,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch enum class DeskPicker { USER, BOOK } @@ -83,19 +85,57 @@ data class LoanCreateUiState( val notes: String = "", val calendar: AvailabilityCalendar? = null, val calendarFailed: Boolean = false, + /** The availability (and its closures) is being fetched: submit waits for it. */ + val calendarLoading: Boolean = false, + /** Number of the latest availability request: only its answer is applied. */ + val calendarRequest: Int = 0, val picker: DeskPicker? = null, val saving: Boolean = false, val error: StaffMessage? = null, val forbidden: Boolean = false, /** Loan id after a successful create (the screen opens it); -1 for a reservation. */ val createdLoanId: Int? = null, + /** Patron-type rules are on (`features.circulation_rules`): their limits can be overridden. */ + val limitsOverridable: Boolean = false, + /** Ignore the patron type's reservation limits (a new reservation only). */ + val overrideLimits: Boolean = false, ) { val today: String get() = LocalDate.now().toString() /** "Hand over now" only makes sense for a loan starting today. */ val canHandOver: Boolean get() = !reservation && (start == null || start == today) - val canSubmit: Boolean get() = user != null && book != null && !saving + val canSubmit: Boolean get() = user != null && book != null && !saving && !calendarLoading + + /** Closed days of the library (closures calendar) from the availability payload. */ + val closedDates: Set get() = calendar?.closedDates?.toSet().orEmpty() + + /** + * The start or end that falls on a day the library is closed, else null. A loan without a + * start begins today, so today is checked then too. + */ + val closedDateChosen: String? get() = listOfNotNull(start ?: today.takeIf { !reservation }, end).firstOrNull { it in closedDates } + + /** + * The reservation sent to the server. The override is sent only when staff ticked it and the + * rules it overrides are on, the same condition under which the website shows the box. + */ + fun reservationWrite(userId: Int, bookId: Int): ReservationWrite = ReservationWrite( + userId = userId, bookId = bookId, requestedFrom = start, requestedTo = end, + overrideLimits = true.takeIf { overrideLimits && limitsOverridable }, + ) + + /** A new availability request starts: it supersedes any one still in flight. */ + fun startCalendarRequest(): LoanCreateUiState = copy(calendarLoading = true, calendarRequest = calendarRequest + 1) + + /** + * The answer to availability request [request]. An answer to an older request (the staff + * changed book or user meanwhile) is dropped: it neither replaces the calendar nor ends the + * loading of the latest one. + */ + fun withCalendarResult(request: Int, calendar: AvailabilityCalendar?, forbidden: Boolean = false): LoanCreateUiState = + if (request != calendarRequest) this + else copy(calendar = calendar, calendarFailed = calendar == null, calendarLoading = false, forbidden = this.forbidden || forbidden) } /** @@ -107,12 +147,14 @@ data class LoanCreateUiState( class StaffLoanCreateViewModel @Inject constructor( private val circulation: StaffCirculationRepository, private val staff: StaffRepository, + featureStore: FeatureStore, savedState: SavedStateHandle, ) : ViewModel() { private val _state = MutableStateFlow( LoanCreateUiState( reservation = savedState.get(Routes.ARG_RESERVATION_MODE) ?: false, copyCode = savedState.get(Routes.ARG_COPY_CODE).orEmpty(), + limitsOverridable = featureStore.features.value.circulationRulesAvailable, ), ) val state: StateFlow = _state.asStateFlow() @@ -174,9 +216,11 @@ class StaffLoanCreateViewModel @Inject constructor( /** Availability for the chosen book, from this user's point of view (their own holds count as free). */ private suspend fun loadCalendar() { val book = _state.value.book ?: return + val request = _state.updateAndGet { it.startCalendarRequest() }.calendarRequest when (val res = circulation.availability(book.id, _state.value.user?.id)) { - is ApiResult.Success -> _state.update { it.copy(calendar = res.data, calendarFailed = false) } - is ApiResult.Failure -> _state.update { it.copy(calendar = null, calendarFailed = true, forbidden = res.isForbidden()) } + is ApiResult.Success -> _state.update { it.withCalendarResult(request, res.data) } + // A failed fetch keeps the old fallback: the form can be sent, the server decides. + is ApiResult.Failure -> _state.update { it.withCalendarResult(request, null, forbidden = res.isForbidden()) } } } @@ -185,20 +229,26 @@ class StaffLoanCreateViewModel @Inject constructor( fun setEnd(value: String?) = _state.update { it.copy(end = value) } fun setHandOver(value: Boolean) = _state.update { it.copy(handOverNow = value && it.canHandOver) } fun setNotes(value: String) = _state.update { it.copy(notes = value) } + fun setOverrideLimits(value: Boolean) = _state.update { it.copy(overrideLimits = value) } fun submit() { val s = _state.value val user = s.user ?: return val book = s.book ?: return - if (s.saving) return + if (!s.canSubmit) return if (s.start != null && s.end != null && s.end < s.start) { _state.update { it.copy(error = StaffMessage(R.string.staff_loan_dates_order)) } return } + // Neither a start nor a return on a day the library is closed (closures calendar). + s.closedDateChosen?.let { day -> + _state.update { it.copy(error = StaffMessage(R.string.staff_loan_closed_day, args = listOf(DateFormat.date(day)))) } + return + } _state.update { it.copy(saving = true, error = null) } viewModelScope.launch { if (s.reservation) { - val body = ReservationWrite(userId = user.id, bookId = book.id, requestedFrom = s.start, requestedTo = s.end) + val body = s.reservationWrite(user.id, book.id) val res = withIdempotency(body) { key -> circulation.createReservation(body, key) } _state.update { when (res) { @@ -276,7 +326,10 @@ fun StaffLoanCreateScreen(onNavigateUp: () -> Unit, onCreated: (loanId: Int) -> stringResource(R.string.staff_pick_user_placeholder), onClick = { vm.openPicker(DeskPicker.USER) }, ) state.user?.note?.let { - Text(stringResource(R.string.staff_user_not_eligible, it), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + // The note is the server's eligibility_reason: a known one in our words. + val text = com.pinakes.app.data.staff.StaffErrors.eligibilityRes(it)?.let { res -> stringResource(res) } + ?: stringResource(R.string.staff_user_not_eligible, it) + Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } FormSectionTitle(stringResource(R.string.staff_pick_book)) PickedField(stringResource(R.string.staff_pick_book), state.book?.title, stringResource(R.string.staff_pick_book_placeholder), @@ -289,8 +342,8 @@ fun StaffLoanCreateScreen(onNavigateUp: () -> Unit, onCreated: (loanId: Int) -> ) } FormSectionTitle(stringResource(R.string.staff_loan_dates)) - DatePickerField(stringResource(if (state.reservation) R.string.staff_reservation_from else R.string.staff_loan_loaned), state.start, vm::setStart, allowClear = true) - DatePickerField(stringResource(if (state.reservation) R.string.staff_reservation_to else R.string.staff_loan_due), state.end, vm::setEnd, allowClear = true) + DatePickerField(stringResource(if (state.reservation) R.string.staff_reservation_from else R.string.staff_loan_loaned), state.start, vm::setStart, allowClear = true, disabledDates = state.closedDates) + DatePickerField(stringResource(if (state.reservation) R.string.staff_reservation_to else R.string.staff_loan_due), state.end, vm::setEnd, allowClear = true, disabledDates = state.closedDates) Text( stringResource(if (state.reservation) R.string.staff_reservation_dates_hint else R.string.staff_loan_dates_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -305,6 +358,12 @@ fun StaffLoanCreateScreen(onNavigateUp: () -> Unit, onCreated: (loanId: Int) -> modifier = Modifier.testTag("loan-calendar"), ) } + if (state.reservation && state.limitsOverridable) { + SwitchRow( + stringResource(R.string.staff_reservation_override_limits), state.overrideLimits, vm::setOverrideLimits, + subtitle = stringResource(R.string.staff_reservation_override_limits_hint), + ) + } if (!state.reservation) { SwitchRow( stringResource(R.string.staff_hand_over_now), state.handOverNow && state.canHandOver, vm::setHandOver, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt index e711d78..4386c0d 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt @@ -180,11 +180,12 @@ class StaffLoanDetailViewModel @Inject constructor( } } - fun returnLoan(outcome: ReturnOutcome, notes: String, penalty: Double?) { + /** [waiveReason] non-null waives the late fine (Mobile API 1.10.0). */ + fun returnLoan(outcome: ReturnOutcome, notes: String, penalty: Double?, waiveReason: String? = null) { val id = _state.value.loan?.id ?: return _state.update { it.copy(pending = null) } mutate(LoanAction.RETURN, R.string.staff_loan_returned, retryOnConcurrent = true) { - circulation.returnLoan(id, outcome.wire, notes, penalty) + circulation.returnLoan(id, outcome.wire, notes, penalty, waiveReason) } } @@ -410,8 +411,10 @@ fun StaffLoanDetailScreen(onNavigateUp: () -> Unit, onOpenUser: (Int) -> Unit, o LoanAction.RETURN -> ReturnSheet( title = stringResource(R.string.staff_return_title), saving = state.busy == LoanAction.RETURN, - onConfirm = vm::returnLoan, + onConfirm = { outcome, notes, penalty -> vm.returnLoan(outcome, notes, penalty) }, onDismiss = vm::dismissPending, + lateFine = state.loan?.lateFine, + onConfirmWaiving = vm::returnLoan, ) else -> Unit } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffUserScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffUserScreen.kt index 2cb1b44..38acd2d 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffUserScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffUserScreen.kt @@ -101,7 +101,7 @@ internal fun userStatusLabel(status: String?): Int? = when (status) { /** A borrower at the desk: card, status, open loans and active reservations. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun StaffUserScreen(onNavigateUp: () -> Unit, onOpenLoan: (Int) -> Unit, onNewLoan: (Int) -> Unit) { +fun StaffUserScreen(onNavigateUp: () -> Unit, onOpenLoan: (Int) -> Unit, onNewLoan: (Int) -> Unit, onOpenLedger: (Int) -> Unit = {}) { val vm: StaffUserViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() val app: AppViewModel = hiltViewModel() @@ -133,12 +133,20 @@ fun StaffUserScreen(onNavigateUp: () -> Unit, onOpenLoan: (Int) -> Unit, onNewLo InfoLine(stringResource(R.string.staff_reservations), user.activeReservations.toString()) if (!user.eligible) { Text( - stringResource(R.string.staff_user_not_eligible, user.eligibilityReason ?: user.status.orEmpty()), + // A known reason (debt block, expired card, suspension) in our words. + com.pinakes.app.data.staff.StaffErrors.eligibilityRes(user.eligibilityReason)?.let { stringResource(it) } + ?: stringResource(R.string.staff_user_not_eligible, user.eligibilityReason ?: user.status.orEmpty()), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } } } + // Pinakes 0.9.0: photo, patron type, card renewal and printout, balance. + item(key = "s090-patron") { + com.pinakes.app.ui.screens.staff.s090.patrons.Staff090PatronSection( + user, areas.s090, onChanged = vm::refresh, onOpenLedger = onOpenLedger, onForbidden = onNavigateUp, + ) + } item(key = "new") { PrimaryButton(stringResource(R.string.staff_loan_new), onClick = { onNewLoan(user.id) }, enabled = user.eligible, modifier = Modifier.fillMaxWidth().padding(top = Spacing.md)) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt index 9339117..5a8c9e2 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt @@ -58,6 +58,7 @@ import com.pinakes.app.data.repository.StaffFilesRepository import com.pinakes.app.data.repository.StaffRepository import com.pinakes.app.data.repository.isDuplicate import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.staff.CopyCodeCodec import com.pinakes.app.ui.common.AppViewModel import com.pinakes.app.ui.components.ConfirmDialog @@ -70,6 +71,7 @@ import com.pinakes.app.ui.navigation.Routes import com.pinakes.app.ui.screens.staff.ExportFileDialog import com.pinakes.app.ui.screens.staff.LookupField import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.SwitchRow import com.pinakes.app.ui.screens.staff.resolve import com.pinakes.app.ui.screens.staff.toStaffMessage import com.pinakes.app.ui.theme.Spacing @@ -83,7 +85,23 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** The copy being edited (sheet) or created (id 0). */ -data class CopyDraft(val id: Int = 0, val code: String = "", val status: String = "", val notes: String = "", val originalStatus: String = "") +data class CopyDraft( + val id: Int = 0, + val code: String = "", + val status: String = "", + val notes: String = "", + val originalStatus: String = "", + /** Kept for in-house consultation (Pinakes 0.9.0 `consultation_only`), as edited and as loaded. */ + val consultationOnly: Boolean = false, + val originalConsultationOnly: Boolean = false, +) { + /** + * The consultation flag to send: only for an existing copy, only when it changed, and only + * when the server applies consultation-only copies ([editable]); otherwise null (left out). + */ + fun consultationChange(editable: Boolean): Boolean? = + consultationOnly.takeIf { editable && id > 0 && it != originalConsultationOnly } +} data class CopiesUiState( val copies: List = emptyList(), @@ -97,6 +115,8 @@ data class CopiesUiState( val forbidden: Boolean = false, /** The reloaded record after a copy change, for the editor's counters. */ val refreshed: StaffBook? = null, + /** The server applies consultation-only copies (`features.consultation_only`): the switch shows. */ + val consultationEditable: Boolean = false, ) /** @@ -109,10 +129,11 @@ class StaffCopiesViewModel @Inject constructor( private val staff: StaffRepository, private val content: StaffContentRepository, private val files: StaffFilesRepository, + featureStore: FeatureStore, savedState: SavedStateHandle, ) : ViewModel() { private val bookId: Int = savedState.get(Routes.ARG_BOOK_ID) ?: 0 - private val _state = MutableStateFlow(CopiesUiState()) + private val _state = MutableStateFlow(CopiesUiState(consultationEditable = featureStore.features.value.consultationOnlyAvailable)) val state: StateFlow = _state.asStateFlow() /** The editor already loaded the record: start from its copies. */ @@ -133,7 +154,13 @@ class StaffCopiesViewModel @Inject constructor( fun startAdd() = _state.update { it.copy(editing = CopyDraft(status = "disponibile"), formError = null) } fun startEdit(copy: StaffCopy) = _state.update { - it.copy(editing = CopyDraft(copy.id, copy.inventoryNumber.orEmpty(), copy.status.orEmpty(), copy.notes.orEmpty(), copy.status.orEmpty()), formError = null) + it.copy( + editing = CopyDraft( + copy.id, copy.inventoryNumber.orEmpty(), copy.status.orEmpty(), copy.notes.orEmpty(), copy.status.orEmpty(), + consultationOnly = copy.consultationOnly, originalConsultationOnly = copy.consultationOnly, + ), + formError = null, + ) } fun edit(transform: (CopyDraft) -> CopyDraft) = _state.update { s -> s.copy(editing = s.editing?.let(transform), formError = null) } @@ -152,7 +179,8 @@ class StaffCopiesViewModel @Inject constructor( } else { // Send the status only when it changed: an unchanged circulation state is not a request. val status = draft.status.takeIf { it != draft.originalStatus } - when (val res = content.updateCopy(draft.id, status, draft.notes)) { + val consultation = draft.consultationChange(_state.value.consultationEditable) + when (val res = content.updateCopy(draft.id, status, draft.notes, consultation)) { is ApiResult.Success -> null is ApiResult.Failure -> res } @@ -218,6 +246,17 @@ internal fun copyStateLabel(copy: StaffCopy, lookups: StaffLookups): String = ?: lookups.copyStateOptions.firstOrNull { it.value == copy.status }?.label ?: copy.status.orEmpty() +/** + * The second line of a copy row: its state, "consultation only" when the copy is kept in the + * library (Pinakes 0.9.0 `consultation_only`), then its notes. Blank parts are dropped. + */ +internal fun copySummary(copy: StaffCopy, stateLabel: String, consultationLabel: String): String = + listOfNotNull( + stateLabel.takeIf { it.isNotBlank() }, + consultationLabel.takeIf { copy.consultationOnly && it.isNotBlank() }, + copy.notes?.takeIf { it.isNotBlank() }, + ).joinToString(" · ") + /** * The per-copy list of the book editor. Shown only when the server offers per-copy management * (`staff_copies`); label buttons only with `staff_labels`. Messages go to [onMessage] (the @@ -270,7 +309,7 @@ fun StaffCopiesSection( Column(Modifier.weight(1f)) { Text(copy.inventoryNumber ?: stringResource(R.string.staff_copy_no_code), style = MaterialTheme.typography.bodyLarge) Text( - listOfNotNull(copyStateLabel(copy, lookups).takeIf { it.isNotBlank() }, copy.notes?.takeIf { it.isNotBlank() }).joinToString(" · "), + copySummary(copy, copyStateLabel(copy, lookups), stringResource(R.string.staff_copy_consultation_only)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -337,6 +376,13 @@ private fun CopyEditorSheet(draft: CopyDraft, state: CopiesUiState, lookups: Sta value = draft.notes, onValueChange = { v -> vm.edit { it.copy(notes = v) } }, label = stringResource(R.string.staff_field_notes), singleLine = false, maxLength = 1000, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).testTag("copy-notes"), ) + if (draft.id > 0 && state.consultationEditable) { + SwitchRow( + stringResource(R.string.staff_copy_consultation_switch), draft.consultationOnly, + { v -> vm.edit { it.copy(consultationOnly = v) } }, + subtitle = stringResource(R.string.staff_copy_consultation_switch_hint), + ) + } PrimaryButton(stringResource(R.string.action_save), onClick = vm::save, loading = state.saving, modifier = Modifier.fillMaxWidth().testTag("copy-save")) if (draft.id > 0) { if (labels) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/Staff090HubSections.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/Staff090HubSections.kt new file mode 100644 index 0000000..88e78f2 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/Staff090HubSections.kt @@ -0,0 +1,55 @@ +package com.pinakes.app.ui.screens.staff.s090 + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BarChart +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.FactCheck +import androidx.compose.material.icons.outlined.GroupAdd +import androidx.compose.material.icons.outlined.HowToReg +import androidx.compose.material.icons.outlined.LocalShipping +import androidx.compose.material.icons.outlined.Rule +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.SwapVert +import androidx.compose.material.icons.outlined.TravelExplore +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.pinakes.app.R +import com.pinakes.app.data.staff.Staff090Areas +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.StaffActionRow + +/** The staff hub's Pinakes 0.9.0 sections; [onOpen] receives a [Staff090Routes] entry. */ +@Composable +internal fun Staff090HubSections(areas: Staff090Areas, onOpen: (String) -> Unit) { + if (areas.stocktake || areas.anyPatronDesk) { + FormSectionTitle(stringResource(R.string.staff090_section_desk)) + if (areas.attendance) StaffActionRow(Icons.Outlined.HowToReg, stringResource(R.string.staff090_attendance), + stringResource(R.string.staff090_attendance_subtitle)) { onOpen(Staff090Routes.ATTENDANCE) } + if (areas.stocktake) StaffActionRow(Icons.Outlined.FactCheck, stringResource(R.string.staff090_stocktake), + stringResource(R.string.staff090_stocktake_subtitle)) { onOpen(Staff090Routes.STOCKTAKE) } + if (areas.userImport) StaffActionRow(Icons.Outlined.GroupAdd, stringResource(R.string.staff090_user_import), + stringResource(R.string.staff090_user_import_subtitle)) { onOpen(Staff090Routes.USER_IMPORT) } + } + if (areas.anyCatalogue) { + FormSectionTitle(stringResource(R.string.staff090_section_cataloguing)) + if (areas.marc) { + StaffActionRow(Icons.Outlined.SwapVert, stringResource(R.string.staff090_marc), + stringResource(R.string.staff090_marc_subtitle)) { onOpen(Staff090Routes.MARC) } + StaffActionRow(Icons.Outlined.TravelExplore, stringResource(R.string.staff090_copy_cataloging), + stringResource(R.string.staff090_copy_cataloging_subtitle)) { onOpen(Staff090Routes.COPY_CATALOGING) } + } + if (areas.suppliers) StaffActionRow(Icons.Outlined.LocalShipping, stringResource(R.string.staff090_suppliers), + stringResource(R.string.staff090_suppliers_subtitle)) { onOpen(Staff090Routes.SUPPLIERS) } + } + if (areas.anyLibrary || areas.statistics) { + FormSectionTitle(stringResource(R.string.staff090_section_library)) + if (areas.statistics) StaffActionRow(Icons.Outlined.BarChart, stringResource(R.string.staff090_statistics), + stringResource(R.string.staff090_statistics_subtitle)) { onOpen(Staff090Routes.STATISTICS) } + if (areas.closuresCalendar) StaffActionRow(Icons.Outlined.CalendarMonth, stringResource(R.string.staff090_calendar), + stringResource(R.string.staff090_calendar_subtitle)) { onOpen(Staff090Routes.CLOSURES_CALENDAR) } + if (areas.circulationRules) StaffActionRow(Icons.Outlined.Rule, stringResource(R.string.staff090_circulation_rules), + stringResource(R.string.staff090_circulation_rules_subtitle)) { onOpen(Staff090Routes.CIRCULATION_RULES) } + if (areas.librarySettings) StaffActionRow(Icons.Outlined.Settings, stringResource(R.string.staff090_settings), + stringResource(R.string.staff090_settings_subtitle)) { onOpen(Staff090Routes.LIBRARY_SETTINGS) } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/Staff090Nav.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/Staff090Nav.kt new file mode 100644 index 0000000..3a04105 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/Staff090Nav.kt @@ -0,0 +1,46 @@ +package com.pinakes.app.ui.screens.staff.s090 + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import com.pinakes.app.ui.screens.staff.s090.catalogue.catalogueGraph +import com.pinakes.app.ui.screens.staff.s090.library.libraryGraph +import com.pinakes.app.ui.screens.staff.s090.patrons.patronsGraph +import com.pinakes.app.ui.screens.staff.s090.stocktake.stocktakeGraph + +/** + * Entry routes of the Pinakes 0.9.0 staff tools (Mobile API 1.10.0). Each area registers + * its own screens in its package (`stocktake`, `patrons`, `library`, `catalogue`); this file + * only names the doors other screens open. + */ +object Staff090Routes { + const val STOCKTAKE = "staff/s090/stocktake" + const val STATISTICS = "staff/s090/statistics" + const val ATTENDANCE = "staff/s090/attendance" + const val USER_IMPORT = "staff/s090/user-import" + const val LIBRARY_SETTINGS = "staff/s090/settings" + const val CIRCULATION_RULES = "staff/s090/circulation-rules" + const val CLOSURES_CALENDAR = "staff/s090/calendar" + const val SUPPLIERS = "staff/s090/suppliers" + const val MARC = "staff/s090/marc" + const val COPY_CATALOGING = "staff/s090/copy-cataloging" + + const val ARG_USER_ID = "userId" + const val PATRON_LEDGER = "staff/s090/patrons/{$ARG_USER_ID}/ledger" + fun patronLedger(userId: Int) = "staff/s090/patrons/$userId/ledger" +} + +/** The NavHost's screen transitions, handed to the area graphs. */ +class Staff090Transitions( + val enter: AnimatedContentTransitionScope<*>.() -> EnterTransition, + val exit: AnimatedContentTransitionScope<*>.() -> ExitTransition, +) + +fun NavGraphBuilder.staff090Graph(nav: NavHostController, transitions: Staff090Transitions) { + stocktakeGraph(nav, transitions) + patronsGraph(nav, transitions) + libraryGraph(nav, transitions) + catalogueGraph(nav, transitions) +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/Staff090Placeholder.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/Staff090Placeholder.kt new file mode 100644 index 0000000..af49d33 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/Staff090Placeholder.kt @@ -0,0 +1,21 @@ +package com.pinakes.app.ui.screens.staff.s090 + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.pinakes.app.ui.components.PinakesTopBar + +/** Temporary body of a 0.9.0 staff screen while its area is being built. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun Staff090Placeholder(title: String, onNavigateUp: () -> Unit) { + Scaffold(topBar = { PinakesTopBar(title = title, onNavigateUp = onNavigateUp) }) { padding -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text(title) } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/CatalogueGraph.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/CatalogueGraph.kt new file mode 100644 index 0000000..e6d3724 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/CatalogueGraph.kt @@ -0,0 +1,24 @@ +package com.pinakes.app.ui.screens.staff.s090.catalogue + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.s090.Staff090Routes +import com.pinakes.app.ui.screens.staff.s090.Staff090Transitions + +/** Cataloguing (Pinakes 0.9.0): suppliers, MARC import/export, copy cataloguing. */ +internal fun NavGraphBuilder.catalogueGraph(nav: NavHostController, t: Staff090Transitions) { + composable(Staff090Routes.SUPPLIERS, enterTransition = t.enter, popExitTransition = t.exit) { + Staff090CatalogueSuppliersScreen(onNavigateUp = { nav.popBackStack() }) + } + composable(Staff090Routes.MARC, enterTransition = t.enter, popExitTransition = t.exit) { + Staff090CatalogueMarcScreen(onNavigateUp = { nav.popBackStack() }) + } + composable(Staff090Routes.COPY_CATALOGING, enterTransition = t.enter, popExitTransition = t.exit) { + Staff090CatalogueCopyCatalogingScreen( + onNavigateUp = { nav.popBackStack() }, + onOpenBook = { nav.navigate(Routes.staffBookEdit(it)) }, + ) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueBookSection.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueBookSection.kt new file mode 100644 index 0000000..62f220c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueBookSection.kt @@ -0,0 +1,257 @@ +package com.pinakes.app.ui.screens.staff.s090.catalogue + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Print +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.StaffBook +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.Staff090CataloguePaths +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.BookCataloguing +import com.pinakes.app.data.staff.CataloguingModules +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.ExportFileDialog +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.LookupField +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +// The 0.9.0 cataloguing parts of the staff book screen: the supplier and RDA fields of the form, +// and the catalogue card / MARC export of the record. + +/** The three RDA kinds: JSON key of the effective triple and the list in the lookups. */ +private enum class RdaKind { CONTENT, MEDIA, CARRIER } + +/** + * Supplier picker (module `suppliers`) and the RDA content / media / carrier selects (module + * `rda_types`) of the book form. Each is shown only while the server's lookups carry its + * list; "derived" (null) names the value the server derives from the media type. + */ +@Composable +fun Staff090CatalogueBookFields(lookups: StaffLookups, original: StaffBook?, value: BookCataloguing, onChange: (BookCataloguing) -> Unit) { + val modules = CataloguingModules.from(lookups) + if (modules.suppliers) { + val suppliers = lookups.suppliers.orEmpty().asOptions() + val current = value.supplierId?.toString().orEmpty() + LookupField( + label = stringResource(R.string.staff090_catalogue_supplier), value = current, + // A supplier the list no longer has (just deleted elsewhere) still shows its name. + displayValue = suppliers.firstOrNull { it.value == current }?.label ?: original?.supplier?.takeIf { it.id == value.supplierId }?.name ?: current, + options = suppliers, editable = false, + onValueChange = { v -> onChange(value.copy(supplierId = v.toIntOrNull()?.takeIf { it > 0 })) }, + modifier = Modifier.fillMaxWidth().testTag("book-supplier"), + ) + } + if (modules.rda) { + Text(stringResource(R.string.staff090_catalogue_rda_title), style = MaterialTheme.typography.labelLarge) + Text(stringResource(R.string.staff090_catalogue_rda_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + RdaKind.entries.forEach { kind -> + val raw = when (kind) { + RdaKind.CONTENT -> lookups.rdaContentTypes + RdaKind.MEDIA -> lookups.rdaMediaTypes + RdaKind.CARRIER -> lookups.rdaCarrierTypes + } + val list = StaffLookups.options(raw.orEmpty()) + val effective = when (kind) { + RdaKind.CONTENT -> original?.rdaEffective?.contentType + RdaKind.MEDIA -> original?.rdaEffective?.mediaType + RdaKind.CARRIER -> original?.rdaEffective?.carrierType + } + val stored = when (kind) { + RdaKind.CONTENT -> value.rdaContentType + RdaKind.MEDIA -> value.rdaMediaType + RdaKind.CARRIER -> value.rdaCarrierType + } + val ownOverride = when (kind) { + RdaKind.CONTENT -> original?.rdaContentType + RdaKind.MEDIA -> original?.rdaMediaType + RdaKind.CARRIER -> original?.rdaCarrierType + } + // The effective value is the derived one only where the stored record has no override. + val derivedLabel = rdaDerivedLabel(list, effective.takeIf { original != null && ownOverride == null }) + val options = listOf(LookupOption("", derivedLabel)) + list + LookupField( + label = stringResource( + when (kind) { + RdaKind.CONTENT -> R.string.staff090_catalogue_rda_content + RdaKind.MEDIA -> R.string.staff090_catalogue_rda_media + RdaKind.CARRIER -> R.string.staff090_catalogue_rda_carrier + }, + ), + value = stored.orEmpty(), displayValue = if (stored == null) derivedLabel else list.labelOf(stored), + options = options, editable = false, allowClear = false, + onValueChange = { v -> + val code = v.takeIf { it.isNotBlank() } + onChange( + when (kind) { + RdaKind.CONTENT -> value.copy(rdaContentType = code) + RdaKind.MEDIA -> value.copy(rdaMediaType = code) + RdaKind.CARRIER -> value.copy(rdaCarrierType = code) + }, + ) + }, + modifier = Modifier.fillMaxWidth().testTag("book-rda-${kind.name.lowercase()}"), + ) + } + } +} + +/** "Derived (Text)" with the server's derived value, or "Derived from the media type" when unknown. */ +@Composable +private fun rdaDerivedLabel(list: List, derived: String?): String { + val label = derived?.let { code -> list.firstOrNull { it.value == code }?.label ?: code } + return if (label != null) stringResource(R.string.staff090_catalogue_rda_derived_value, label) + else stringResource(R.string.staff090_catalogue_rda_derived) +} + +// ============================================================================================= +// Catalogue card and MARC export of one record +// ============================================================================================= + +data class BookToolsUiState( + val downloading: Boolean = false, + val file: File? = null, + val mime: String = "application/pdf", + val message: StaffMessage? = null, + val forbidden: Boolean = false, +) + +@HiltViewModel +class Staff090CatalogueBookToolsViewModel @Inject constructor( + private val files: StaffFilesRepository, + savedState: SavedStateHandle, +) : ViewModel() { + private val bookId: Int = savedState.get(Routes.ARG_BOOK_ID) ?: 0 + private val _state = MutableStateFlow(BookToolsUiState()) + val state: StateFlow = _state.asStateFlow() + + fun catalogueCard() = download(Staff090CataloguePaths.catalogueCard(bookId), "scheda_$bookId.pdf", "application/pdf") + + fun exportMarc(format: String) { + val path = Staff090CataloguePaths.marcExport(format, listOf(bookId)) ?: return + download(path, Staff090CataloguePaths.exportFallbackName(format), Staff090CataloguePaths.exportMime(format)) + } + + private fun download(path: String, name: String, mime: String) { + if (bookId <= 0 || _state.value.downloading) return + _state.update { it.copy(downloading = true) } + viewModelScope.launch { + val res = files.download(path, name) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(downloading = false, file = res.data, mime = mime) + is ApiResult.Failure -> it.copy(downloading = false, forbidden = res.isForbidden(), message = res.toCatalogueMessage(R.string.staff_download_failed)) + } + } + } + } + + fun closeFile() = _state.update { it.copy(file = null) } + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** + * The record's catalogue card PDF (`staff_catalogue_cards`) and its MARC export (`staff_marc`). + * The staff book list has no selection mode, so the multi-book versions live in the MARC + * screen's export tab. + */ +@Composable +fun Staff090CatalogueBookTools(onMessage: (String) -> Unit, onForbidden: () -> Unit) { + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + if (!areas.s090.catalogueCards && !areas.s090.marc) return + val vm: Staff090CatalogueBookToolsViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val message = state.message?.resolve() + LaunchedEffect(state.message) { if (!message.isNullOrBlank()) { onMessage(message); vm.consumeMessage() } } + LaunchedEffect(state.forbidden) { if (state.forbidden) onForbidden() } + var chooseFormat by rememberSaveable { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier.testTag("catalogue-tools")) { + FormSectionTitle(stringResource(R.string.staff090_section_cataloguing)) + if (areas.s090.catalogueCards) { + SecondaryButton( + stringResource(R.string.staff090_catalogue_card), onClick = vm::catalogueCard, enabled = !state.downloading, + leadingIcon = Icons.Outlined.Print, modifier = Modifier.fillMaxWidth().testTag("book-catalogue-card"), + ) + } + if (areas.s090.marc) { + SecondaryButton( + stringResource(R.string.staff090_catalogue_marc_export_book), onClick = { chooseFormat = true }, enabled = !state.downloading, + leadingIcon = Icons.Outlined.Download, modifier = Modifier.fillMaxWidth().testTag("book-marc-export"), + ) + } + } + if (chooseFormat) { + var format by rememberSaveable { mutableStateOf(Staff090CataloguePaths.FORMATS.first()) } + AlertDialog( + onDismissRequest = { chooseFormat = false }, + shape = MaterialTheme.shapes.large, + title = { Text(stringResource(R.string.staff090_catalogue_marc_format), style = MaterialTheme.typography.titleMedium) }, + text = { + Column { + Staff090CataloguePaths.FORMATS.forEach { f -> + Row( + Modifier.fillMaxWidth().selectable(selected = format == f, role = Role.RadioButton, onClick = { format = f }).padding(vertical = Spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = format == f, onClick = null) + Text(stringResource(formatLabel(f)), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = Spacing.sm)) + } + } + } + }, + confirmButton = { PrimaryButton(stringResource(R.string.staff090_catalogue_marc_export_run), onClick = { chooseFormat = false; vm.exportMarc(format) }) }, + dismissButton = { PinakesTextButton(stringResource(R.string.action_cancel), onClick = { chooseFormat = false }) }, + ) + } + state.file?.let { ExportFileDialog(it, state.mime, stringResource(R.string.staff090_section_cataloguing), onDismiss = vm::closeFile) } +} + +private fun formatLabel(format: String): Int = when (format) { + "marcxml" -> R.string.staff090_catalogue_format_marcxml + "unimarc" -> R.string.staff090_catalogue_format_unimarc + else -> R.string.staff090_catalogue_format_marc21 +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueCopyCatalogingScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueCopyCatalogingScreen.kt new file mode 100644 index 0000000..b9d5f30 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueCopyCatalogingScreen.kt @@ -0,0 +1,418 @@ +package com.pinakes.app.ui.screens.staff.s090.catalogue + +import android.os.SystemClock +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.MenuBook +import androidx.compose.material.icons.outlined.CheckBox +import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.QrCodeScanner +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.TravelExplore +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.CopyCatalogingAvailability +import com.pinakes.app.data.model.CopyCatalogingImport +import com.pinakes.app.data.model.CopyCatalogingImportRequest +import com.pinakes.app.data.model.CopyCatalogingResult +import com.pinakes.app.data.model.CopyCatalogingSearch +import com.pinakes.app.data.model.CopyCatalogingSearchRequest +import com.pinakes.app.data.model.CopyCatalogingServers +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.Staff090CatalogueRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.CopyCatalogingRules +import com.pinakes.app.data.staff.CopyImportSubmission +import com.pinakes.app.data.staff.ScanDebouncer +import com.pinakes.app.data.staff.Staff090CatalogueErrors +import com.pinakes.app.data.staff.SubmissionKeys +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.EmptyState +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.LookupField +import com.pinakes.app.ui.screens.staff.QuantityStepper +import com.pinakes.app.ui.screens.staff.StaffActionRow +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.scan.BarcodeAnalyzer +import com.pinakes.app.ui.screens.staff.scan.ScannerBox +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class CopyCatalogingUiState( + val loading: Boolean = true, + val error: StaffMessage? = null, + /** From /staff/marc/options; false shows the reason instead of the form. */ + val availability: CopyCatalogingAvailability? = null, + val config: CopyCatalogingServers? = null, + val servers: Set = emptySet(), + val index: String = "isbn", + val term: String = "", + val max: Int = CopyCatalogingRules.DEFAULT_MAX, + val scanning: Boolean = false, + val searching: Boolean = false, + val searchError: StaffMessage? = null, + val search: CopyCatalogingSearch? = null, + val selected: Set = emptySet(), + val duplicates: String = "skip", + val covers: Boolean = false, + val importing: Boolean = false, + val importError: StaffMessage? = null, + val result: CopyCatalogingImport? = null, + val forbidden: Boolean = false, + val message: StaffMessage? = null, +) { + val canSearch: Boolean get() = !searching && CopyCatalogingRules.canSearch(servers, term, max) + val importLimit: Int get() = config?.maxImportIds ?: CopyCatalogingRules.DEFAULT_IMPORT_LIMIT +} + +/** + * Copy cataloguing (module `marc` + z39-server plugin): search other catalogues (SRU servers, + * SBN, national catalogues, this installation) and import the chosen records. The search is + * kept on the server under `search_id`; the import names it and carries an Idempotency-Key kept + * across retries of the same selection. + */ +@HiltViewModel +class Staff090CatalogueCopyCatalogingViewModel @Inject constructor( + private val repo: Staff090CatalogueRepository, +) : ViewModel() { + private val _state = MutableStateFlow(CopyCatalogingUiState()) + val state: StateFlow = _state.asStateFlow() + private val keys = SubmissionKeys() + private val debouncer = ScanDebouncer() + + init { load() } + + fun load() { + _state.update { it.copy(loading = true, error = null) } + viewModelScope.launch { + val options = repo.marcOptions() + if (options is ApiResult.Failure) { + _state.update { it.copy(loading = false, forbidden = options.isForbidden(), error = options.toCatalogueMessage(R.string.staff_error_load, copyCataloguing = true)) } + return@launch + } + val availability = (options as ApiResult.Success).data.copyCataloging + if (!availability.available) { + _state.update { it.copy(loading = false, availability = availability) } + return@launch + } + when (val res = repo.copyCatalogingServers()) { + is ApiResult.Success -> _state.update { + it.copy( + loading = false, availability = availability, config = res.data, + index = res.data.indexes.firstOrNull { i -> i.value == it.index }?.value ?: res.data.indexes.firstOrNull()?.value ?: it.index, + ) + } + is ApiResult.Failure -> _state.update { + // The plugin went away between the two calls: show its reason, not an error. + if (res.code == "copy_cataloging_unavailable") it.copy(loading = false, availability = CopyCatalogingAvailability(false, "plugin_inactive")) + else it.copy(loading = false, forbidden = res.isForbidden(), error = res.toCatalogueMessage(R.string.staff_error_load, copyCataloguing = true)) + } + } + } + } + + fun toggleServer(key: String) = _state.update { s -> s.copy(servers = if (key in s.servers) s.servers - key else s.servers + key) } + fun setIndex(v: String) = _state.update { it.copy(index = v) } + fun setTerm(v: String) = _state.update { it.copy(term = v.take(CopyCatalogingRules.MAX_TERM), searchError = null) } + fun setMax(v: Int) = _state.update { it.copy(max = v.coerceIn(CopyCatalogingRules.MAX_RANGE)) } + fun setScanning(v: Boolean) = _state.update { it.copy(scanning = v) } + + /** A barcode under the camera: two identical reads, then it becomes the search term. */ + fun onDecoded(raw: String) { + if (!_state.value.scanning) return + val (index, term) = CopyCatalogingRules.fromScan(raw) ?: return + debouncer.offer(term, SystemClock.elapsedRealtime()) ?: return + val known = _state.value.config?.indexes?.any { it.value == index } != false + _state.update { it.copy(index = if (known) index else "any", term = term, scanning = false, searchError = null) } + if (_state.value.canSearch) search() + } + + fun search() { + val s = _state.value + if (!s.canSearch) return + _state.update { it.copy(searching = true, searchError = null, result = null, importError = null) } + viewModelScope.launch { + val request = CopyCatalogingSearchRequest(servers = s.config?.servers?.map { it.key }?.filter { it in s.servers } ?: s.servers.toList(), index = s.index, term = s.term.trim(), max = s.max) + when (val res = repo.copyCatalogingSearch(request)) { + is ApiResult.Success -> _state.update { it.copy(searching = false, search = res.data, selected = emptySet()) } + is ApiResult.Failure -> _state.update { + it.copy(searching = false, forbidden = res.isForbidden(), searchError = res.toCatalogueMessage(R.string.staff090_catalogue_copy_search_failed, copyCataloguing = true)) + } + } + } + } + + fun toggleResult(id: String) = _state.update { s -> s.copy(selected = CopyCatalogingRules.toggle(s.selected, id, s.importLimit)) } + fun setDuplicates(v: String) = _state.update { it.copy(duplicates = v) } + fun setCovers(v: Boolean) = _state.update { it.copy(covers = v) } + + fun import() { + val s = _state.value + val search = s.search ?: return + if (s.importing || s.selected.isEmpty()) return + // Result order, so the same selection is the same request whatever the tap order. + val ids = search.results.map { it.id }.filter { it in s.selected } + val submission = CopyImportSubmission(search.searchId, ids, s.duplicates, s.covers) + _state.update { it.copy(importing = true, importError = null) } + viewModelScope.launch { + val res = repo.copyCatalogingImport(CopyCatalogingImportRequest(search.searchId, ids, s.duplicates, s.covers), keys.keyFor(submission)) + when (res) { + is ApiResult.Success -> { + keys.reset() + _state.update { it.copy(importing = false, result = res.data, selected = emptySet()) } + } + is ApiResult.Failure -> { + if (res.code == SubmissionKeys.CONFLICT) keys.reset() + _state.update { + it.copy(importing = false, forbidden = res.isForbidden(), importError = res.toCatalogueMessage(R.string.staff090_catalogue_copy_import_failed, copyCataloguing = true)) + } + } + } + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun Staff090CatalogueCopyCatalogingScreen(onNavigateUp: () -> Unit, onOpenBook: (Int) -> Unit) { + val vm: Staff090CatalogueCopyCatalogingViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.s090.marc, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff090_copy_cataloging), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + val config = state.config + when { + state.loading -> LoadingState(modifier = Modifier.padding(padding)) + state.error != null -> ErrorState(message = state.error?.resolve().orEmpty(), onRetry = vm::load, modifier = Modifier.padding(padding)) + state.availability?.available == false || config == null -> EmptyState( + title = stringResource(R.string.staff090_catalogue_copy_unavailable_title), + subtitle = stringResource(Staff090CatalogueErrors.copyCatalogingReasonRes(state.availability?.reason)), + icon = Icons.Outlined.TravelExplore, modifier = Modifier.padding(padding).testTag("copy-unavailable"), + ) + else -> LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + item(key = "form") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + FormSectionTitle(stringResource(R.string.staff090_catalogue_copy_servers)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + config.servers.forEach { server -> + FilterChip( + selected = server.key in state.servers, onClick = { vm.toggleServer(server.key) }, label = { Text(server.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer), + modifier = Modifier.testTag("copy-server-${server.key}"), + ) + } + } + val indexes = config.indexes.asOptions() + LookupField( + label = stringResource(R.string.staff090_catalogue_copy_index), value = state.index, displayValue = indexes.labelOf(state.index), + options = indexes, editable = false, allowClear = false, onValueChange = vm::setIndex, modifier = Modifier.fillMaxWidth(), + ) + PinakesTextField( + value = state.term, onValueChange = vm::setTerm, label = stringResource(R.string.staff090_catalogue_copy_term), + maxLength = CopyCatalogingRules.MAX_TERM, + keyboardOptions = KeyboardOptions(keyboardType = if (state.index == "isbn" || state.index == "issn") KeyboardType.Ascii else KeyboardType.Text, imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { vm.search() }), + trailingIcon = { + IconButton(onClick = { vm.setScanning(!state.scanning) }) { + Icon(Icons.Outlined.QrCodeScanner, contentDescription = stringResource(R.string.staff090_catalogue_copy_scan)) + } + }, + modifier = Modifier.fillMaxWidth().testTag("copy-term"), + ) + if (state.scanning) { + ScannerBox( + formats = BarcodeAnalyzer.ISBN_FORMATS, onCode = vm::onDecoded, looking = false, + permissionText = stringResource(R.string.staff090_catalogue_copy_scan_permission), + ) + } + QuantityStepper(stringResource(R.string.staff090_catalogue_copy_max), state.max, vm::setMax, range = CopyCatalogingRules.MAX_RANGE) + state.searchError?.let { InlineErrorBanner(message = it.resolve()) } + PrimaryButton( + stringResource(R.string.staff090_catalogue_copy_search), onClick = vm::search, loading = state.searching, enabled = state.canSearch, + leadingIcon = Icons.Outlined.Search, modifier = Modifier.fillMaxWidth().testTag("copy-search"), + ) + } + } + state.search?.let { search -> + item(key = "report") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + FormSectionTitle(stringResource(R.string.staff090_catalogue_copy_results)) + InfoCard { + search.servers.forEach { r -> + InfoLine(r.name.ifBlank { r.key }, r.error ?: stringResource(R.string.staff090_catalogue_copy_server_count, r.count, r.total)) + } + } + if (search.results.isEmpty()) { + Text(stringResource(R.string.staff090_catalogue_copy_no_results), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + items(search.results, key = { "r${it.id}" }) { result -> + ResultRow(result, selected = result.id in state.selected) { vm.toggleResult(result.id) } + } + if (search.results.isNotEmpty()) { + item(key = "import") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.sm)) { + val modes = config.duplicateModes.asOptions() + LookupField( + label = stringResource(R.string.staff090_catalogue_marc_duplicates), value = state.duplicates, displayValue = modes.labelOf(state.duplicates), + options = modes, editable = false, allowClear = false, onValueChange = vm::setDuplicates, modifier = Modifier.fillMaxWidth(), + ) + config.duplicateModes.firstOrNull { it.value == state.duplicates }?.description?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + SwitchRow(stringResource(R.string.staff090_catalogue_copy_covers), state.covers, vm::setCovers) + state.importError?.let { InlineErrorBanner(message = it.resolve()) } + PrimaryButton( + stringResource(R.string.staff090_catalogue_copy_import, state.selected.size), onClick = vm::import, loading = state.importing, + enabled = state.selected.isNotEmpty(), leadingIcon = Icons.Outlined.Download, modifier = Modifier.fillMaxWidth().testTag("copy-import"), + ) + } + } + } + } + state.result?.let { result -> + item(key = "result") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + FormSectionTitle(stringResource(R.string.staff090_catalogue_copy_result)) + InfoCard(Modifier.testTag("copy-result")) { + InfoLine(stringResource(R.string.staff_import_imported), result.imported.toString()) + InfoLine(stringResource(R.string.staff_import_updated), result.updated.toString()) + InfoLine(stringResource(R.string.staff090_catalogue_marc_skipped), result.skipped.toString()) + InfoLine(stringResource(R.string.staff_import_errors), result.failed.toString()) + InfoLine(stringResource(R.string.staff090_catalogue_marc_covers), result.covers.toString().takeIf { result.covers > 0 }) + } + } + } + items(result.items, key = { "i${it.id}" }) { item -> + val action = stringResource(actionLabel(item.action)) + val title = item.title.ifBlank { item.id } + val bookId = item.bookId + if (bookId != null && bookId > 0) { + StaffActionRow(Icons.AutoMirrored.Outlined.MenuBook, title, listOfNotNull(action, item.message).joinToString(" · ")) { onOpenBook(bookId) } + } else { + InfoCard(Modifier.padding(vertical = Spacing.xs)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text(listOfNotNull(action, item.message).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + item.warnings.forEach { w -> Text(w, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = Spacing.lg)) } + } + } + item(key = "bottom") { Spacer(Modifier.height(Spacing.xxl)) } + } + } + } +} + +@Composable +internal fun ResultRow(result: CopyCatalogingResult, selected: Boolean, onToggle: () -> Unit) { + Surface( + onClick = onToggle, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("copy-result-${result.id}"), + ) { + Row(Modifier.padding(Spacing.md), verticalAlignment = Alignment.CenterVertically) { + Icon( + if (selected) Icons.Outlined.CheckBox else Icons.Outlined.CheckBoxOutlineBlank, + contentDescription = stringResource(if (selected) R.string.staff090_catalogue_copy_selected else R.string.staff090_catalogue_copy_not_selected), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(Spacing.sm)) + Column(Modifier.weight(1f)) { + Text(result.title.ifBlank { result.id }, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + result.subtitle?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } + Text( + listOfNotNull(result.authors, result.year, result.publisher).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, + ) + Text( + listOfNotNull(result.isbn, result.server).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + if (result.existingId != null) { + Text(stringResource(R.string.staff090_catalogue_in_catalogue), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) + } + } + } + } +} + +/** The import outcome of one record, in the app's words. */ +internal fun actionLabel(action: String): Int = when (action) { + "created" -> R.string.staff090_catalogue_copy_action_created + "updated" -> R.string.staff090_catalogue_copy_action_updated + "skipped" -> R.string.staff090_catalogue_copy_action_skipped + else -> R.string.staff090_catalogue_copy_action_failed +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueMarcScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueMarcScreen.kt new file mode 100644 index 0000000..79b9271 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueMarcScreen.kt @@ -0,0 +1,739 @@ +package com.pinakes.app.ui.screens.staff.s090.catalogue + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import com.pinakes.app.ui.common.DateFormat +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.Lifecycle +import androidx.compose.runtime.DisposableEffect +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Print +import androidx.compose.material.icons.outlined.UploadFile +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.MarcImportJob +import com.pinakes.app.data.model.MarcOptions +import com.pinakes.app.data.model.ValueLabel +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.MarcUploadOptions +import com.pinakes.app.data.repository.Staff090CatalogueRepository +import com.pinakes.app.data.repository.Staff090CataloguePaths +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.repository.StaffRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.ChunkStep +import com.pinakes.app.data.staff.ImportProgress +import com.pinakes.app.data.staff.MarcImportRules +import com.pinakes.app.data.staff.PickedEntity +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.ExportFileDialog +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.LookupField +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.PickedChips +import com.pinakes.app.ui.screens.staff.PickerRow +import com.pinakes.app.ui.screens.staff.ProgressLine +import com.pinakes.app.ui.screens.staff.SearchController +import com.pinakes.app.ui.screens.staff.SearchPickerSheet +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.content.PickedFile +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +enum class MarcTab(val label: Int) { IMPORT(R.string.staff090_catalogue_marc_import), EXPORT(R.string.staff090_catalogue_marc_export) } + +/** The export formats of the server, used until /options answers. */ +private val DEFAULT_EXPORT_FORMATS = listOf(ValueLabel("marc21", "MARC 21"), ValueLabel("marcxml", "MARCXML"), ValueLabel("unimarc", "UNIMARC")) + +data class MarcUiState( + val tab: MarcTab = MarcTab.IMPORT, + val options: MarcOptions? = null, + val optionsLoading: Boolean = true, + val optionsError: StaffMessage? = null, + // Import form + val file: PickedFile? = null, + val flavour: String = "auto", + val encoding: String = "auto", + val duplicates: String = "skip", + val supplierId: Int? = null, + val downloadCovers: Boolean = false, + val uploading: Boolean = false, + val importError: StaffMessage? = null, + // The job: preview, then chunks + val job: MarcImportJob? = null, + /** The caller's MARC jobs from the server (`GET /staff/marc/imports`), any device. */ + val jobs: List = emptyList(), + val jobsCursor: String? = null, + val cancellingId: String? = null, + val running: Boolean = false, + /** The server answered 410 `gone`: the job ended elsewhere. */ + val ended: Boolean = false, + // Export + val format: String = "marc21", + val wholeCatalogue: Boolean = true, + val chosen: List = emptyList(), + val picking: Boolean = false, + val exporting: Boolean = false, + val export: File? = null, + val exportMime: String = "application/marc", + val forbidden: Boolean = false, + val message: StaffMessage? = null, +) { + /** The upload answered and no record was imported yet: show the preview and the go button. */ + val previewing: Boolean get() = job != null && !running && job.processed == 0 && job.status == "ready" && !ended + val finished: Boolean get() = job?.let { ImportProgress.isFinished(it.asImportJob()) } == true || ended + + /** Unfinished jobs other than the one on screen: resumable here whoever started them. */ + val openJobs: List get() = MarcImportRules.openJobs(jobs, job?.id) +} + +/** + * MARC import and export (module `marc`). The import is the CSV import's contract: the upload + * stages the file and answers a preview; then the app drives chunks from the server's + * `processed` with [ImportProgress] (out-of-order recovery, busy retry, 410 gone), resumable + * after process death through the saved job id. The export downloads the whole catalogue or the + * chosen books (at most 1000), and the chosen books' catalogue cards (at most 200). + */ +@HiltViewModel +class Staff090CatalogueMarcViewModel @Inject constructor( + private val repo: Staff090CatalogueRepository, + private val staff: StaffRepository, + private val files: StaffFilesRepository, + @ApplicationContext private val context: Context, + private val savedState: SavedStateHandle, +) : ViewModel() { + private val _state = MutableStateFlow(MarcUiState()) + val state: StateFlow = _state.asStateFlow() + private var loop: Job? = null + + val books = SearchController(viewModelScope, minLength = 2) { q -> + when (val res = staff.books(q, cursor = null, limit = 30)) { + is ApiResult.Success -> ApiResult.Success(res.data.items.map { PickItem(it.id, it.title, listOfNotNull(it.author, it.year?.toString()).joinToString(" · ")) }) + is ApiResult.Failure -> res + } + }.also { c -> c.onFailure = { f -> if (f.isForbidden()) _state.update { it.copy(forbidden = true) } } } + + init { + loadOptions() + savedState.get(KEY_JOB)?.let(::restore) + } + + /** + * The caller's jobs on the server: an import started on another device, or whose id this + * app lost, can be resumed or cancelled from here. [more] reads the next page. + */ + fun loadJobs(more: Boolean = false) { + val cursor = if (more) _state.value.jobsCursor ?: return else null + viewModelScope.launch { + when (val res = repo.marcImports(cursor)) { + is ApiResult.Success -> _state.update { + it.copy(jobs = if (more) (it.jobs + res.data.items).distinctBy { j -> j.id } else res.data.items, jobsCursor = res.data.nextCursor) + } + // The list is a convenience: a failure leaves the form usable. + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden()) } + } + } + } + + /** Take [job] on screen and continue its chunks from the server's position. */ + fun resumeJob(job: MarcImportJob) { + if (_state.value.running) return + loop?.cancel() + savedState[KEY_JOB] = job.id + _state.update { it.copy(job = job, ended = false, importError = null) } + resume() + } + + /** Cancel a listed job (not the one on screen) and drop it from the list. */ + fun cancelListed(job: MarcImportJob) { + if (_state.value.cancellingId != null) return + _state.update { it.copy(cancellingId = job.id) } + viewModelScope.launch { + when (val res = repo.cancelMarcImport(job.id)) { + is ApiResult.Success -> _state.update { s -> + s.copy(cancellingId = null, jobs = s.jobs.filterNot { it.id == job.id }, message = StaffMessage(R.string.staff_import_status_cancelled)) + } + is ApiResult.Failure -> _state.update { + it.copy(cancellingId = null, forbidden = res.isForbidden(), message = res.toCatalogueMessage(R.string.staff_error_save)) + } + } + } + } + + fun loadOptions() { + _state.update { it.copy(optionsLoading = true, optionsError = null) } + viewModelScope.launch { + when (val res = repo.marcOptions()) { + is ApiResult.Success -> _state.update { s -> + s.copy( + options = res.data, optionsLoading = false, + format = res.data.exportFormats.firstOrNull()?.value ?: s.format, + // A supplier that disappeared (or a module switched off) is not offered. + supplierId = s.supplierId?.takeIf { id -> res.data.suppliers?.any { it.id == id } == true }, + ) + } + is ApiResult.Failure -> _state.update { it.copy(optionsLoading = false, forbidden = res.isForbidden(), optionsError = res.toCatalogueMessage(R.string.staff_error_load)) } + } + } + } + + private fun restore(id: String) { + viewModelScope.launch { + when (val res = repo.marcImport(id)) { + is ApiResult.Success -> { + _state.update { it.copy(job = res.data) } + if (res.data.processed > 0 && !ImportProgress.isFinished(res.data.asImportJob())) resume() + } + // The job expired or was purged: start over. + is ApiResult.Failure -> { + savedState.remove(KEY_JOB) + _state.update { it.copy(forbidden = res.isForbidden()) } + } + } + } + } + + fun selectTab(tab: MarcTab) = _state.update { it.copy(tab = tab) } + + // ---- Import form ---- + + fun setFlavour(v: String) = _state.update { it.copy(flavour = v) } + fun setEncoding(v: String) = _state.update { it.copy(encoding = v) } + fun setDuplicates(v: String) = _state.update { it.copy(duplicates = v) } + fun setSupplier(v: String) = _state.update { it.copy(supplierId = v.toIntOrNull()?.takeIf { id -> id > 0 }) } + fun setDownloadCovers(v: Boolean) = _state.update { it.copy(downloadCovers = v) } + + /** Read the picked document; refuse what the server refuses anyway (extension, size). */ + fun onPicked(uri: Uri?) { + uri ?: return + val options = _state.value.options + val max = options?.maxFileBytes ?: ImportProgress.MAX_BYTES + viewModelScope.launch { + val read = withContext(Dispatchers.IO) { runCatching { read(uri, max) }.getOrNull() } + _state.update { + when { + read == null -> it.copy(importError = StaffMessage(R.string.staff_import_read_failed), file = null) + read.file == null -> it.copy(importError = StaffMessage(R.string.staff_import_too_big), file = null) + !MarcImportRules.acceptsFileName(read.file.name, options?.extensions.orEmpty()) -> + it.copy(importError = StaffMessage(R.string.staff090_catalogue_marc_wrong_ext), file = null) + else -> it.copy(file = read.file, importError = null) + } + } + } + } + + private class Read(val file: PickedFile?) + + private fun read(uri: Uri, max: Long): Read? { + val resolver = context.contentResolver + var name = uri.lastPathSegment?.substringAfterLast('/') ?: "import.mrc" + var size = -1L + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> + if (c.moveToFirst()) { + c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let { i -> c.getString(i)?.let { name = it } } + c.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }?.let { i -> if (!c.isNull(i)) size = c.getLong(i) } + } + } + if (size > max) return Read(null) + val out = java.io.ByteArrayOutputStream() + resolver.openInputStream(uri)?.use { input -> + val buffer = ByteArray(64 * 1024) + var total = 0L + while (true) { + val n = input.read(buffer) + if (n < 0) break + total += n + if (total > max) return Read(null) + out.write(buffer, 0, n) + } + } ?: return null + return Read(PickedFile(name, resolver.getType(uri), out.toByteArray())) + } + + fun upload() { + val s = _state.value + val file = s.file ?: return + if (s.uploading) return + _state.update { it.copy(uploading = true, importError = null) } + viewModelScope.launch { + val options = MarcUploadOptions( + flavour = s.flavour, encoding = if (MarcImportRules.encodingApplies(file.name)) s.encoding else "auto", + duplicates = s.duplicates, downloadCovers = s.downloadCovers, + supplierId = s.supplierId.takeIf { s.options?.suppliers != null }, + ) + when (val res = repo.createMarcImport(file.name, file.bytes, options)) { + is ApiResult.Success -> { + savedState[KEY_JOB] = res.data.id + _state.update { it.copy(uploading = false, file = null, job = res.data, ended = false) } + } + is ApiResult.Failure -> _state.update { it.copy(uploading = false, forbidden = res.isForbidden(), importError = res.toCatalogueMessage(R.string.staff_import_failed)) } + } + } + } + + // ---- Chunk driver (the CSV import's, on the MARC routes) ---- + + fun resume() { + val job = _state.value.job ?: return + if (loop?.isActive == true || ImportProgress.isFinished(job.asImportJob()) || _state.value.ended) return + _state.update { it.copy(running = true, importError = null) } + loop = viewModelScope.launch { + var step: ChunkStep = ImportProgress.next(job.asImportJob()) + var busy = 0 + while (isActive) { + val start = when (val s = step) { + is ChunkStep.Next -> s.start + is ChunkStep.Wait -> { delay(3_000); s.start } + ChunkStep.Done -> break + is ChunkStep.Stop -> { + _state.update { it.copy(forbidden = s.failure.isForbidden(), importError = s.failure.toCatalogueMessage(R.string.staff_import_failed)) } + if (s.failure.code == "gone" || s.failure.httpStatus == 410 || s.failure.code == "job_cancelled") refreshAfterGone(job.id) + break + } + } + when (val res = repo.marcImportChunk(job.id, start)) { + is ApiResult.Success -> { + busy = 0 + _state.update { it.copy(job = res.data) } + step = ImportProgress.next(res.data.asImportJob()) + } + is ApiResult.Failure -> { + if (res.code == "job_busy") busy++ + step = ImportProgress.afterFailure(res, start, busy) + res.meta?.processed?.let { p -> _state.update { s -> s.copy(job = s.job?.copy(processed = p)) } } + } + } + } + _state.update { it.copy(running = false) } + } + } + + private suspend fun refreshAfterGone(id: String) { + _state.update { it.copy(ended = true) } + (repo.marcImport(id) as? ApiResult.Success)?.let { res -> _state.update { it.copy(job = res.data) } } + } + + fun pause() { + loop?.cancel() + _state.update { it.copy(running = false) } + } + + /** Cancel the job and drop its staged records (also "discard" on the preview). */ + fun cancelJob() { + val id = _state.value.job?.id ?: return + pause() + viewModelScope.launch { + when (val res = repo.cancelMarcImport(id)) { + is ApiResult.Success -> { + savedState.remove(KEY_JOB) + _state.update { s -> s.copy(job = null, ended = false, jobs = s.jobs.filterNot { it.id == id }, message = StaffMessage(R.string.staff_import_status_cancelled)) } + } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden(), importError = res.toCatalogueMessage(R.string.staff_error_save)) } + } + } + } + + /** A finished job: back to an empty form. */ + fun newImport() { + pause() + savedState.remove(KEY_JOB) + _state.update { it.copy(job = null, ended = false, importError = null) } + loadJobs() + } + + // ---- Export ---- + + fun setFormat(v: String) = _state.update { it.copy(format = v) } + fun setWholeCatalogue(v: Boolean) = _state.update { it.copy(wholeCatalogue = v) } + + fun openPicker() { + books.clear() + _state.update { it.copy(picking = true) } + } + + fun closePicker() = _state.update { it.copy(picking = false) } + + fun toggleBook(item: PickItem) = _state.update { s -> + val chosen = if (s.chosen.any { it.id == item.id }) s.chosen.filterNot { it.id == item.id } + else if (s.chosen.size >= Staff090CataloguePaths.MAX_EXPORT_IDS) s.chosen else s.chosen + item + s.copy(chosen = chosen) + } + + fun removeBook(id: Int) = _state.update { s -> s.copy(chosen = s.chosen.filterNot { it.id == id }) } + + fun export() { + val s = _state.value + val path = Staff090CataloguePaths.marcExport(s.format, if (s.wholeCatalogue) null else s.chosen.map { it.id }) ?: return + download(path, Staff090CataloguePaths.exportFallbackName(s.format), Staff090CataloguePaths.exportMime(s.format)) + } + + fun printCards() { + val path = Staff090CataloguePaths.catalogueCards(_state.value.chosen.map { it.id }) ?: return + download(path, "schede_catalografiche.pdf", "application/pdf") + } + + private fun download(path: String, name: String, mime: String) { + if (_state.value.exporting) return + _state.update { it.copy(exporting = true) } + viewModelScope.launch { + val res = files.download(path, name) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(exporting = false, export = res.data, exportMime = mime) + is ApiResult.Failure -> it.copy(exporting = false, forbidden = res.isForbidden(), message = res.toCatalogueMessage(R.string.staff_download_failed)) + } + } + } + } + + fun closeExport() = _state.update { it.copy(export = null) } + fun consumeMessage() = _state.update { it.copy(message = null) } + + companion object { + const val KEY_JOB = "marcJobId" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Staff090CatalogueMarcScreen(onNavigateUp: () -> Unit) { + val vm: Staff090CatalogueMarcViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.s090.marc, onNavigateUp) + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) vm.loadJobs() } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument(), vm::onPicked) + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff090_marc), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + PrimaryTabRow(selectedTabIndex = state.tab.ordinal) { + MarcTab.entries.forEach { tab -> + Tab(selected = tab == state.tab, onClick = { vm.selectTab(tab) }, text = { Text(stringResource(tab.label)) }, modifier = Modifier.testTag("marc-tab-${tab.name.lowercase()}")) + } + } + when { + state.optionsLoading && state.options == null -> LoadingState() + state.options == null -> ErrorState(message = state.optionsError?.resolve() ?: stringResource(R.string.staff_error_load), onRetry = vm::loadOptions) + else -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + if (state.tab == MarcTab.IMPORT) importTab(state, vm, onPick = { picker.launch(MarcImportRules.PICKER_MIME_TYPES) }) + else exportTab(state, vm, cards = areas.s090.catalogueCards) + } + } + } + } + + if (state.picking) { + val s by vm.books.state.collectAsStateWithLifecycle() + SearchPickerSheet( + title = stringResource(R.string.staff090_catalogue_marc_pick_books), placeholder = stringResource(R.string.staff_books_search), + state = s, onQuery = vm.books::onQuery, onPick = vm::toggleBook, onDismiss = vm::closePicker, + selected = state.chosen.map { it.id }.toSet(), onConfirm = vm::closePicker, hint = stringResource(R.string.staff_search_min_chars), + ) + } + state.export?.let { ExportFileDialog(it, state.exportMime, stringResource(R.string.staff090_marc), onDismiss = vm::closeExport) } +} + +private fun LazyListScope.importTab(state: MarcUiState, vm: Staff090CatalogueMarcViewModel, onPick: () -> Unit) { + val options = state.options ?: return + val job = state.job + if (job == null) { + // The list keeps its first visible item in place when items are inserted before it: + // the open jobs arrive after the form is drawn, so a fixed first row keeps them in view + // instead of above the top of the screen. + item(key = "import-hint") { + Text( + stringResource(R.string.staff090_catalogue_marc_import_hint), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = Spacing.md), + ) + } + openJobs(state, vm) + item(key = "form") { ImportForm(state, options, vm, onPick) } + return + } + item(key = "job") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.md)) { + state.importError?.let { InlineErrorBanner(message = it.resolve()) } + if (state.previewing) { + Text(stringResource(R.string.staff090_catalogue_marc_preview), style = MaterialTheme.typography.titleMedium, modifier = Modifier.testTag("marc-status")) + InfoCard { + InfoLine(stringResource(R.string.staff_import_file), job.fileName) + InfoLine(stringResource(R.string.staff090_catalogue_marc_records), job.totalRows.toString()) + InfoLine(stringResource(R.string.staff090_catalogue_marc_container), stringResource(if (job.container == "xml") R.string.staff090_catalogue_marc_container_xml else R.string.staff090_catalogue_marc_container_iso)) + InfoLine(stringResource(R.string.staff090_catalogue_marc_unreadable), job.unreadable.toString().takeIf { job.unreadable > 0 }) + InfoLine(stringResource(R.string.staff090_catalogue_marc_duplicates), options.duplicateModes.asOptions().labelOf(job.duplicates)) + InfoLine(stringResource(R.string.staff090_catalogue_supplier), options.suppliers?.firstOrNull { it.id == job.supplierId }?.name) + } + job.truncated?.let { InlineErrorBanner(message = it) } + PrimaryButton( + stringResource(R.string.staff090_catalogue_marc_run, job.totalRows), onClick = vm::resume, + leadingIcon = Icons.Outlined.UploadFile, modifier = Modifier.fillMaxWidth().testTag("marc-run"), + ) + SecondaryButton(stringResource(R.string.staff090_catalogue_marc_discard), onClick = vm::cancelJob, modifier = Modifier.fillMaxWidth()) + if (job.preview.isNotEmpty()) FormSectionTitle(stringResource(R.string.staff090_catalogue_marc_first_records)) + } else { + val finished = state.finished + Text( + stringResource( + when { + job.status == "cancelled" -> R.string.staff_import_status_cancelled + job.status == "failed" -> R.string.staff_import_status_failed + state.ended && !ImportProgress.isFinished(job.asImportJob()) -> R.string.staff_import_status_gone + finished -> R.string.staff_import_status_done + state.running -> R.string.staff_import_status_running + else -> R.string.staff_import_status_paused + }, + ), + style = MaterialTheme.typography.titleMedium, modifier = Modifier.testTag("marc-status"), + ) + ProgressLine(job.processed, job.totalRows, stringResource(R.string.staff090_catalogue_marc_progress, job.processed, job.totalRows)) + InfoCard { + InfoLine(stringResource(R.string.staff_import_imported), job.imported.toString()) + InfoLine(stringResource(R.string.staff_import_updated), job.updated.toString()) + InfoLine(stringResource(R.string.staff090_catalogue_marc_skipped), job.skipped.toString()) + InfoLine(stringResource(R.string.staff090_catalogue_marc_covers), job.covers.toString().takeIf { job.downloadCovers }) + InfoLine(stringResource(R.string.staff_import_authors_created), job.authorsCreated.toString()) + InfoLine(stringResource(R.string.staff_import_publishers_created), job.publishersCreated.toString()) + InfoLine(stringResource(R.string.staff_import_errors), job.failed.toString()) + } + if (!finished) { + if (state.running) SecondaryButton(stringResource(R.string.staff_import_pause), onClick = vm::pause, modifier = Modifier.fillMaxWidth()) + else PrimaryButton(stringResource(R.string.staff_import_resume), onClick = vm::resume, modifier = Modifier.fillMaxWidth()) + SecondaryButton(stringResource(R.string.staff_import_cancel), onClick = vm::cancelJob, modifier = Modifier.fillMaxWidth()) + } else { + PrimaryButton(stringResource(R.string.staff090_catalogue_marc_new), onClick = vm::newImport, leadingIcon = Icons.Outlined.Add, modifier = Modifier.fillMaxWidth().testTag("marc-new")) + } + if (job.errorLines.isNotEmpty()) FormSectionTitle(stringResource(R.string.staff_import_errors)) + } + } + } + if (state.previewing) { + items(job.preview, key = { "p${it.position}" }) { row -> + InfoCard(Modifier.padding(vertical = Spacing.xs)) { + if (row.error != null) { + Text(stringResource(R.string.staff090_catalogue_marc_unreadable_row, row.position), style = MaterialTheme.typography.titleSmall) + Text(row.error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } else { + Text(row.title, style = MaterialTheme.typography.titleSmall) + row.subtitle?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + Text( + listOfNotNull(row.authors, row.publisher, row.year, row.isbn).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (row.existingId != null) { + Text(stringResource(R.string.staff090_catalogue_in_catalogue), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) + } + } + } + } + } else { + items(job.errorLines.take(200)) { line -> + Text(line, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = Spacing.xxs)) + } + } + item(key = "bottom") { Spacer(Modifier.height(Spacing.xxl)) } +} + +/** Unfinished imports on the server (this device or another one), each with Resume and Cancel. */ +private fun LazyListScope.openJobs(state: MarcUiState, vm: Staff090CatalogueMarcViewModel) { + val open = state.openJobs + if (open.isEmpty()) return + item(key = "open-title") { FormSectionTitle(stringResource(R.string.staff_import_open_jobs)) } + items(open, key = { "open-${it.id}" }) { job -> + InfoCard(Modifier.padding(vertical = Spacing.xs).testTag("marc-open-${job.id}")) { + Text(job.fileName, style = MaterialTheme.typography.titleSmall) + InfoLine(stringResource(R.string.staff_import_result), stringResource(R.string.staff090_catalogue_marc_progress, job.processed, job.totalRows)) + InfoLine(stringResource(R.string.staff_import_when), job.createdAt?.let(DateFormat::dateTime)) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.xs)) { + PrimaryButton( + stringResource(R.string.staff_import_resume), onClick = { vm.resumeJob(job) }, enabled = !state.running, + modifier = Modifier.weight(1f).testTag("marc-open-resume-${job.id}"), + ) + SecondaryButton( + stringResource(R.string.staff_import_cancel), onClick = { vm.cancelListed(job) }, enabled = state.cancellingId == null, + modifier = Modifier.weight(1f).testTag("marc-open-cancel-${job.id}"), + ) + } + } + } + if (state.jobsCursor != null) { + item(key = "open-more") { SecondaryButton(stringResource(R.string.action_load_more), onClick = { vm.loadJobs(more = true) }, modifier = Modifier.fillMaxWidth()) } + } +} + +@Composable +private fun ImportForm(state: MarcUiState, options: MarcOptions, vm: Staff090CatalogueMarcViewModel, onPick: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.sm)) { + state.importError?.let { InlineErrorBanner(message = it.resolve()) } + PickerRow( + label = stringResource(R.string.staff_import_file), value = state.file?.name, + placeholder = stringResource(R.string.staff_import_choose_file), onClick = onPick, + ) + val flavours = options.flavours.asOptions() + LookupField( + label = stringResource(R.string.staff090_catalogue_marc_flavour), value = state.flavour, displayValue = flavours.labelOf(state.flavour), + options = flavours, editable = false, allowClear = false, onValueChange = vm::setFlavour, modifier = Modifier.fillMaxWidth(), + ) + if (MarcImportRules.encodingApplies(state.file?.name)) { + val encodings = options.encodings.asOptions() + LookupField( + label = stringResource(R.string.staff090_catalogue_marc_encoding), value = state.encoding, displayValue = encodings.labelOf(state.encoding), + options = encodings, editable = false, allowClear = false, onValueChange = vm::setEncoding, modifier = Modifier.fillMaxWidth(), + ) + } + val modes = options.duplicateModes.asOptions() + LookupField( + label = stringResource(R.string.staff090_catalogue_marc_duplicates), value = state.duplicates, displayValue = modes.labelOf(state.duplicates), + options = modes, editable = false, allowClear = false, onValueChange = vm::setDuplicates, modifier = Modifier.fillMaxWidth(), + ) + options.duplicateModes.firstOrNull { it.value == state.duplicates }?.description?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + options.suppliers?.let { list -> + val suppliers = list.asOptions() + LookupField( + label = stringResource(R.string.staff090_catalogue_supplier), value = state.supplierId?.toString().orEmpty(), + displayValue = suppliers.labelOf(state.supplierId?.toString()), options = suppliers, editable = false, + onValueChange = vm::setSupplier, modifier = Modifier.fillMaxWidth().testTag("marc-supplier"), + ) + } + SwitchRow( + stringResource(R.string.staff090_catalogue_marc_covers_switch), state.downloadCovers, vm::setDownloadCovers, + subtitle = stringResource(R.string.staff090_catalogue_marc_covers_hint), + ) + PrimaryButton( + stringResource(R.string.staff090_catalogue_marc_upload), onClick = vm::upload, loading = state.uploading, enabled = state.file != null, + leadingIcon = Icons.Outlined.UploadFile, modifier = Modifier.fillMaxWidth().testTag("marc-upload"), + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +private fun LazyListScope.exportTab(state: MarcUiState, vm: Staff090CatalogueMarcViewModel, cards: Boolean) { + val options = state.options ?: return + item(key = "export") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.md)) { + FormSectionTitle(stringResource(R.string.staff090_catalogue_marc_format)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + options.exportFormats.ifEmpty { DEFAULT_EXPORT_FORMATS }.forEach { f -> + FilterChip( + selected = state.format == f.value, onClick = { vm.setFormat(f.value) }, label = { Text(f.label) }, + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer), + ) + } + } + FormSectionTitle(stringResource(R.string.staff090_catalogue_marc_scope)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + listOf(true to R.string.staff090_catalogue_marc_scope_all, false to R.string.staff090_catalogue_marc_scope_chosen).forEach { (whole, label) -> + FilterChip( + selected = state.wholeCatalogue == whole, onClick = { vm.setWholeCatalogue(whole) }, label = { Text(stringResource(label)) }, + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer), + modifier = Modifier.testTag(if (whole) "marc-scope-all" else "marc-scope-chosen"), + ) + } + } + if (state.wholeCatalogue) { + Text(stringResource(R.string.staff090_catalogue_marc_scope_all_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + Text( + stringResource(R.string.staff090_catalogue_marc_chosen_count, state.chosen.size, options.maxExportIds), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + PickedChips(state.chosen.map { PickedEntity(it.id, it.title) }) { vm.removeBook(it.id) } + SecondaryButton(stringResource(R.string.staff090_catalogue_marc_add_books), onClick = vm::openPicker, leadingIcon = Icons.Outlined.Add, modifier = Modifier.fillMaxWidth().testTag("marc-add-books")) + } + PrimaryButton( + stringResource(R.string.staff090_catalogue_marc_export_run), onClick = vm::export, loading = state.exporting, + enabled = state.wholeCatalogue || state.chosen.isNotEmpty(), leadingIcon = Icons.Outlined.Download, + modifier = Modifier.fillMaxWidth().testTag("marc-export"), + ) + if (cards && !state.wholeCatalogue) { + SecondaryButton( + stringResource(R.string.staff090_catalogue_cards_print, minOf(state.chosen.size, Staff090CataloguePaths.MAX_CARDS)), onClick = vm::printCards, + enabled = state.chosen.isNotEmpty() && !state.exporting, leadingIcon = Icons.Outlined.Print, modifier = Modifier.fillMaxWidth().testTag("marc-cards"), + ) + if (state.chosen.size > Staff090CataloguePaths.MAX_CARDS) { + Text(stringResource(R.string.staff090_catalogue_cards_limit, Staff090CataloguePaths.MAX_CARDS), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueSuppliersScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueSuppliersScreen.kt new file mode 100644 index 0000000..056db1f --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueSuppliersScreen.kt @@ -0,0 +1,423 @@ +package com.pinakes.app.ui.screens.staff.s090.catalogue + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.LocalShipping +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.Supplier +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.Staff090CatalogueRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.SubmissionKeys +import com.pinakes.app.data.staff.SupplierControls +import com.pinakes.app.data.staff.SupplierField +import com.pinakes.app.data.staff.SupplierForm +import com.pinakes.app.data.staff.SupplierProblem +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SearchField +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.StaffFab +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** The open create/edit sheet: the record being edited (null = new) and the form. */ +data class SupplierSheet( + val original: Supplier? = null, + val form: SupplierForm = SupplierForm(), + /** The server's per-field messages of the last 422 (`meta.fields`). */ + val serverErrors: Map = emptyMap(), + /** Set after the first save attempt, so untouched empty fields do not start red. */ + val showErrors: Boolean = false, +) { + val readOnly: Boolean get() = original != null && !original.canEdit +} + +data class SuppliersUiState( + val items: List = emptyList(), + val canCreate: Boolean = false, + val query: String = "", + val loading: Boolean = true, + val refreshing: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + val sheet: SupplierSheet? = null, + val saving: Boolean = false, + val formError: StaffMessage? = null, + val confirmDelete: Supplier? = null, + val deletingId: Int? = null, + val message: StaffMessage? = null, +) + +/** + * Suppliers (`/staff/suppliers`, module `suppliers`): the list with its book counts, a sheet + * to create or edit one (the server's field errors under each field) and delete. Create carries + * an Idempotency-Key kept across retries of the same form. + */ +@HiltViewModel +class Staff090CatalogueSuppliersViewModel @Inject constructor( + private val repo: Staff090CatalogueRepository, +) : ViewModel() { + private val _state = MutableStateFlow(SuppliersUiState()) + val state: StateFlow = _state.asStateFlow() + private val keys = SubmissionKeys() + private var searchJob: Job? = null + private var generation = 0 + + init { reload() } + + fun reload(debounce: Boolean = false) { + searchJob?.cancel() + val gen = ++generation + val query = _state.value.query + searchJob = viewModelScope.launch { + if (debounce) delay(300) + _state.update { it.copy(loading = it.items.isEmpty() && !it.refreshing, error = null) } + val res = repo.suppliers(query) + if (gen != generation) return@launch + _state.update { + when (res) { + is ApiResult.Success -> it.copy(items = res.data.items, canCreate = res.data.canCreate, loading = false, refreshing = false) + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toCatalogueMessage(R.string.staff_error_load)) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + reload() + } + + fun onQuery(value: String) { + _state.update { it.copy(query = value) } + reload(debounce = true) + } + + fun startCreate() = _state.update { it.copy(sheet = SupplierSheet(form = SupplierForm(name = it.query.trim())), formError = null) } + + fun open(supplier: Supplier) = _state.update { it.copy(sheet = SupplierSheet(original = supplier, form = SupplierForm.from(supplier)), formError = null) } + + fun edit(field: SupplierField, value: String) = _state.update { s -> + s.copy(sheet = s.sheet?.let { it.copy(form = it.form.with(field, value), serverErrors = it.serverErrors - field) }, formError = null) + } + + fun closeSheet() { + if (!_state.value.saving) _state.update { it.copy(sheet = null, formError = null) } + } + + fun save() { + val sheet = _state.value.sheet ?: return + if (_state.value.saving || sheet.readOnly) return + if (!sheet.form.valid) { + _state.update { it.copy(sheet = sheet.copy(showErrors = true)) } + return + } + val original = sheet.original + val body = if (original == null) sheet.form.createJson() else sheet.form.patchJson(original) + if (original != null && body.isEmpty()) { + _state.update { it.copy(sheet = null) } + return + } + _state.update { it.copy(saving = true, sheet = sheet.copy(showErrors = true), formError = null) } + viewModelScope.launch { + val key = keys.keyFor(sheet.form) + val saved = repo.saveSupplier(original?.id, body, key) + when (val res = saved.result) { + is ApiResult.Success -> { + keys.reset() + _state.update { s -> + // A replayed create answers the supplier already listed: replace, never duplicate. + val items = (s.items.filterNot { it.id == res.data.id } + res.data).sortedBy { it.name.lowercase() } + s.copy(saving = false, sheet = null, items = items, message = StaffMessage(R.string.staff090_catalogue_supplier_saved)) + } + } + is ApiResult.Failure -> { + if (res.code == SubmissionKeys.CONFLICT) keys.reset() + val fields = saved.fields.mapNotNull { (k, v) -> SupplierForm.fieldOf(k)?.let { it to v } }.toMap() + _state.update { + it.copy( + saving = false, + forbidden = res.isForbidden(), + sheet = it.sheet?.copy(serverErrors = fields), + // A field error is shown under its field; anything else on top. + formError = if (fields.isEmpty()) res.toCatalogueMessage(R.string.staff_error_save) else null, + ) + } + } + } + } + } + + fun askDelete(supplier: Supplier) = _state.update { it.copy(confirmDelete = supplier) } + fun dismissDelete() = _state.update { it.copy(confirmDelete = null) } + + fun delete() { + val supplier = _state.value.confirmDelete ?: return + _state.update { it.copy(confirmDelete = null, deletingId = supplier.id) } + viewModelScope.launch { + when (val res = repo.deleteSupplier(supplier.id)) { + is ApiResult.Success -> _state.update { s -> + s.copy( + deletingId = null, items = s.items.filterNot { it.id == supplier.id }, + sheet = s.sheet?.takeIf { it.original?.id != supplier.id }, + message = StaffMessage(R.string.staff090_catalogue_supplier_deleted, args = listOf(res.data.booksUnlinked)), + ) + } + is ApiResult.Failure -> _state.update { + it.copy(deletingId = null, forbidden = res.isForbidden(), message = res.toCatalogueMessage(R.string.staff_error_save)) + } + } + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Staff090CatalogueSuppliersScreen(onNavigateUp: () -> Unit) { + val vm: Staff090CatalogueSuppliersViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.s090.suppliers, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff090_suppliers), onNavigateUp = onNavigateUp) }, + floatingActionButton = { + if (SupplierControls.of(state.canCreate, null).create) { + StaffFab(Icons.Outlined.Add, stringResource(R.string.staff090_catalogue_supplier_new), vm::startCreate) + } + }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + SearchField( + query = state.query, onQueryChange = vm::onQuery, placeholder = stringResource(R.string.staff090_catalogue_supplier_search), + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.lg, vertical = Spacing.sm).testTag("supplier-search"), + ) + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize()) { + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = state.items.isEmpty(), + emptyTitle = stringResource(R.string.staff090_catalogue_suppliers_empty), emptyIcon = Icons.Outlined.LocalShipping, + onRetry = vm::refresh, emptySubtitle = stringResource(R.string.staff090_suppliers_subtitle), + ) { + val listState = rememberLazyListState() + // A supplier created before the first row would land above the top (the list + // keeps its first visible row in place): stay at the top while the user is there. + val first = state.items.firstOrNull()?.id + LaunchedEffect(first) { if (listState.firstVisibleItemIndex <= 1) listState.scrollToItem(0) } + LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 96.dp)) { + items(state.items, key = { it.id }) { supplier -> + SupplierRow( + supplier = supplier, deleting = state.deletingId == supplier.id, + onOpen = { vm.open(supplier) }, onDelete = { vm.askDelete(supplier) }, + ) + } + } + } + } + } + } + + state.sheet?.let { sheet -> + SupplierSheetContent( + sheet = sheet, saving = state.saving, error = state.formError, + onEdit = vm::edit, onSave = vm::save, onDismiss = vm::closeSheet, + ) + } + state.confirmDelete?.let { supplier -> + ConfirmDialog( + title = stringResource(R.string.staff090_catalogue_supplier_delete), + body = if (supplier.bookCount > 0) stringResource(R.string.staff090_catalogue_supplier_delete_books, supplier.name, supplier.bookCount) + else stringResource(R.string.staff090_catalogue_supplier_delete_confirm, supplier.name), + confirmLabel = stringResource(R.string.staff090_catalogue_supplier_delete), + onConfirm = vm::delete, + onDismiss = vm::dismissDelete, + ) + } +} + +@Composable +internal fun SupplierRow(supplier: Supplier, deleting: Boolean, onOpen: () -> Unit, onDelete: () -> Unit) { + var menu by remember { mutableStateOf(false) } + val controls = SupplierControls.of(canCreate = false, supplier = supplier) + Surface( + onClick = onOpen, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("supplier-${supplier.id}"), + ) { + Row(Modifier.padding(start = Spacing.lg, top = Spacing.sm, bottom = Spacing.sm, end = Spacing.xs), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(supplier.name, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text( + listOfNotNull( + stringResource(R.string.staff090_catalogue_supplier_books, supplier.bookCount), + supplier.contactPerson, supplier.email ?: supplier.phone, + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, overflow = TextOverflow.Ellipsis, + ) + } + if (controls.edit || controls.delete) { + Box { + IconButton(onClick = { menu = true }, enabled = !deleting) { + Icon(Icons.Outlined.MoreVert, contentDescription = stringResource(R.string.cd_more_options)) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + if (controls.edit) { + DropdownMenuItem(text = { Text(stringResource(R.string.staff090_catalogue_supplier_edit)) }, onClick = { menu = false; onOpen() }) + } + if (controls.delete) { + DropdownMenuItem( + text = { Text(stringResource(R.string.staff090_catalogue_supplier_delete), color = MaterialTheme.colorScheme.error) }, + onClick = { menu = false; onDelete() }, + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SupplierSheetContent( + sheet: SupplierSheet, + saving: Boolean, + error: StaffMessage?, + onEdit: (SupplierField, String) -> Unit, + onSave: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val problems = if (sheet.showErrors) sheet.form.validate() else emptyMap() + ModalBottomSheet(onDismissRequest = { if (!saving) onDismiss() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text( + stringResource( + when { + sheet.original == null -> R.string.staff090_catalogue_supplier_new + sheet.readOnly -> R.string.staff090_catalogue_supplier + else -> R.string.staff090_catalogue_supplier_edit + }, + ), + style = MaterialTheme.typography.headlineSmall, + ) + sheet.original?.let { Text(stringResource(R.string.staff090_catalogue_supplier_books, it.bookCount), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + error?.let { InlineErrorBanner(message = it.resolve()) } + SupplierField.entries.forEach { field -> + val problem = problems[field] + val server = sheet.serverErrors[field] + PinakesTextField( + value = sheet.form.value(field), onValueChange = { onEdit(field, it) }, + label = stringResource(fieldLabel(field)), + isError = problem != null || server != null, + errorText = server ?: stringResource(if (problem == SupplierProblem.REQUIRED) R.string.staff_field_required else R.string.staff_field_invalid), + maxLength = field.maxLength, enabled = !sheet.readOnly, + singleLine = field != SupplierField.ADDRESS && field != SupplierField.NOTES, + keyboardOptions = when (field) { + SupplierField.EMAIL -> KeyboardOptions(keyboardType = KeyboardType.Email) + SupplierField.PHONE -> KeyboardOptions(keyboardType = KeyboardType.Phone) + SupplierField.WEBSITE -> KeyboardOptions(keyboardType = KeyboardType.Uri) + else -> KeyboardOptions.Default + }, + modifier = Modifier.fillMaxWidth().testTag("supplier-field-${field.key}") + .then(if (field == SupplierField.NOTES || field == SupplierField.ADDRESS) Modifier.heightIn(min = 96.dp) else Modifier), + ) + } + if (!sheet.readOnly) { + PrimaryButton(stringResource(R.string.action_save), onClick = onSave, loading = saving, modifier = Modifier.fillMaxWidth().testTag("supplier-save")) + } + Spacer(Modifier.height(Spacing.lg)) + } + } +} + +private fun fieldLabel(field: SupplierField): Int = when (field) { + SupplierField.NAME -> R.string.staff_field_name + SupplierField.CONTACT_PERSON -> R.string.staff090_catalogue_supplier_contact + SupplierField.EMAIL -> R.string.staff_field_email + SupplierField.PHONE -> R.string.staff_field_phone + SupplierField.WEBSITE -> R.string.staff_field_website + SupplierField.ADDRESS -> R.string.staff_field_address + SupplierField.VAT_NUMBER -> R.string.staff090_catalogue_supplier_vat + SupplierField.NOTES -> R.string.staff_field_notes +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueUi.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueUi.kt new file mode 100644 index 0000000..b08018c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/catalogue/Staff090CatalogueUi.kt @@ -0,0 +1,28 @@ +package com.pinakes.app.ui.screens.staff.s090.catalogue + +import androidx.annotation.StringRes +import com.pinakes.app.data.model.DuplicateMode +import com.pinakes.app.data.model.IdName +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.ValueLabel +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.staff.Staff090CatalogueErrors +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.toStaffMessage + +// Small helpers shared by the cataloguing screens (suppliers, MARC, copy cataloguing). + +/** Our wording for a cataloguing code, then the shared staff mapping, then the server text. */ +internal fun ApiResult.Failure.toCatalogueMessage(@StringRes fallback: Int, copyCataloguing: Boolean = false): StaffMessage = + Staff090CatalogueErrors.messageRes(this, copyCataloguing)?.let { StaffMessage(it) } ?: toStaffMessage(fallback) + +internal fun List.asOptions(): List = map { LookupOption(it.value, it.label.ifBlank { it.value }) } + +@JvmName("duplicateModesAsOptions") +internal fun List.asOptions(): List = map { LookupOption(it.value, it.label.ifBlank { it.value }) } + +@JvmName("idNamesAsOptions") +internal fun List.asOptions(): List = map { LookupOption(it.id.toString(), it.name) } + +/** The label of [value] among [options], or the value itself. */ +internal fun List.labelOf(value: String?): String = firstOrNull { it.value == value }?.label ?: value.orEmpty() diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/CalendarSheets.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/CalendarSheets.kt new file mode 100644 index 0000000..73ee3a4 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/CalendarSheets.kt @@ -0,0 +1,300 @@ +package com.pinakes.app.ui.screens.staff.s090.library + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.pinakes.app.R +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.Staff090LibraryCalendar +import com.pinakes.app.data.model.Staff090LibraryHoliday +import com.pinakes.app.ui.common.AppLocale +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.DatePickerField +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.PickerRow +import com.pinakes.app.ui.screens.staff.SearchPickerSheet +import com.pinakes.app.ui.screens.staff.SearchState +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.theme.Spacing +import java.time.Month +import java.time.format.TextStyle + +// The calendar's bottom sheets: a closure, one of the library's own holidays, the national preset. + +@Composable +private fun branchOptions(cal: Staff090LibraryCalendar): List = + listOf(LookupOption("", stringResource(R.string.staff090_library_all_branches))) + cal.branches.map { LookupOption(it.id.toString(), it.name) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ClosureSheet(form: ClosureForm, cal: Staff090LibraryCalendar, state: ClosuresCalendarUiState, vm: ClosuresCalendarViewModel) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val errors = state.fieldErrors + ModalBottomSheet(onDismissRequest = { if (!state.saving) vm.closeClosure() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(if (form.id == null) R.string.staff090_library_closure_new else R.string.staff090_library_closure_edit), style = MaterialTheme.typography.headlineSmall) + state.formError?.let { InlineErrorBanner(message = it.resolve()) } + DatePickerField(stringResource(R.string.staff090_library_closure_start), form.startDate, { v -> vm.changeClosure { it.copy(startDate = v) } }) + DatePickerField(stringResource(R.string.staff090_library_closure_end), form.endDate, { v -> vm.changeClosure { it.copy(endDate = v) } }, allowClear = true) + val dateError = errors["start_date"] ?: errors["end_date"] ?: if (form.startDate != null && !form.datesValid) stringResource(R.string.staff090_library_closure_end_before) else null + dateError?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } + Text(stringResource(R.string.staff090_library_closure_end_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + PinakesTextField( + value = form.description, onValueChange = { v -> vm.changeClosure { it.copy(description = v) } }, + label = stringResource(R.string.staff_field_description), maxLength = 255, + isError = "description" in errors, errorText = errors["description"].orEmpty(), modifier = Modifier.fillMaxWidth().testTag("closure-description"), + ) + SwitchRow(stringResource(R.string.staff090_library_closure_yearly_switch), form.yearly, { v -> vm.changeClosure { it.copy(yearly = v) } }, + subtitle = stringResource(R.string.staff090_library_closure_yearly_hint, cal.limits.yearlySpanDays)) + if (cal.branches.isNotEmpty()) { + ChoiceField(stringResource(R.string.staff090_library_branch), form.branchId?.toString().orEmpty(), branchOptions(cal), { v -> vm.changeClosure { it.copy(branchId = v.toIntOrNull()) } }) + errors["branch_id"]?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } + } + PrimaryButton(stringResource(R.string.action_save), onClick = vm::saveClosure, loading = state.saving, enabled = form.valid, + modifier = Modifier.fillMaxWidth().testTag("closure-save")) + if (form.id != null) { + val closure = cal.closures.firstOrNull { it.id == form.id } + if (closure != null) SecondaryButton(stringResource(R.string.staff090_library_delete), onClick = { vm.askDeleteClosure(closure) }, modifier = Modifier.fillMaxWidth()) + } + Spacer(Modifier.height(Spacing.lg)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CustomHolidaySheet(form: CustomForm, cal: Staff090LibraryCalendar, state: ClosuresCalendarUiState, vm: ClosuresCalendarViewModel) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val options = cal.customHolidayOptions + val calendar = options.calendars.firstOrNull { it.code == form.calendar } + val invalid = CustomForms.invalid(form, options.maxDurationDays, options.maxDates, calendar?.months?.size ?: 12) + val errors = state.fieldErrors + val fields = CustomForms.fields(form.kind) + fun bad(key: String) = key in invalid || key in errors + ModalBottomSheet(onDismissRequest = { if (!state.saving) vm.closeCustom() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(if (form.id == null) R.string.staff090_library_custom_new else R.string.staff090_library_custom_edit), style = MaterialTheme.typography.headlineSmall) + state.formError?.let { InlineErrorBanner(message = it.resolve()) } + PinakesTextField( + value = form.name, onValueChange = { v -> vm.changeCustom { it.copy(name = v) } }, label = stringResource(R.string.staff_field_name), + maxLength = 255, isError = "name" in errors, errorText = errors["name"].orEmpty(), placeholder = stringResource(R.string.staff090_library_custom_name_hint), + modifier = Modifier.fillMaxWidth().testTag("custom-name"), + ) + ChoiceField( + stringResource(R.string.staff090_library_custom_kind), form.kind, + options.kinds.map { LookupOption(it, kindLabel(it)?.let { r -> stringResource(r) } ?: it) }, + { v -> vm.changeCustom { it.copy(kind = v) } }, modifier = Modifier.fillMaxWidth().testTag("custom-kind"), + ) + if (CustomField.CALENDAR in fields) { + val calendars = options.calendars.filter { it.available || it.code == form.calendar } + ChoiceField(stringResource(R.string.staff090_library_custom_calendar), form.calendar, calendars.map { LookupOption(it.code, it.label) }, + { v -> vm.changeCustom { it.copy(calendar = v, month = "1") } }) + Text(stringResource(R.string.staff090_library_custom_calendar_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (CustomField.OCCURRENCE in fields) { + ChoiceField(stringResource(R.string.staff090_library_custom_occurrence), form.occurrence.toString(), + options.occurrences.ifEmpty { listOf(1, 2, 3, 4, 5, -1) }.map { LookupOption(it.toString(), stringResource(occurrenceLabel(it))) }, + { v -> vm.changeCustom { it.copy(occurrence = v.toIntOrNull() ?: 1) } }) + } + if (CustomField.WEEKDAY in fields) { + ChoiceField(stringResource(R.string.staff090_library_custom_weekday), form.weekday.toString(), + CalendarGrid.MONDAY_FIRST.map { LookupOption(it.toString(), weekdayName(it, TextStyle.FULL)) }, + { v -> vm.changeCustom { it.copy(weekday = v.toIntOrNull() ?: 1) } }) + } + if (CustomField.MONTH in fields) { + val months = if (form.kind == "calendar") calendar?.months.orEmpty().mapIndexed { i, name -> LookupOption((i + 1).toString(), name) } + else (1..12).map { m -> LookupOption(m.toString(), Month.of(m).getDisplayName(TextStyle.FULL_STANDALONE, AppLocale.current()).replaceFirstChar { it.titlecase(AppLocale.current()) }) } + ChoiceField(stringResource(R.string.staff090_library_custom_month), form.month, months, { v -> vm.changeCustom { it.copy(month = v) } }) + if (bad("month")) Text(errors["month"] ?: stringResource(R.string.staff_field_invalid), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + if (CustomField.DAY in fields) { + NumberField(stringResource(R.string.staff090_library_custom_day), form.day, { v -> vm.changeCustom { it.copy(day = v) } }, invalid = bad("day"), + hint = errors["day"] ?: stringResource(R.string.staff090_library_range, 1, CustomForms.maxDay(form.kind)), modifier = Modifier.fillMaxWidth().testTag("custom-day")) + } + if (CustomField.OFFSET in fields) { + NumberField(stringResource(R.string.staff090_library_custom_offset), form.offset, { v -> vm.changeCustom { it.copy(offset = v) } }, invalid = bad("offset_days"), + hint = errors["offset_days"] ?: stringResource(R.string.staff090_library_custom_offset_hint), signed = true) + } + if (CustomField.DATES in fields) { + PinakesTextField( + value = form.dates, onValueChange = { v -> vm.changeCustom { it.copy(dates = v) } }, label = stringResource(R.string.staff090_library_custom_dates), + singleLine = false, isError = bad("dates"), errorText = errors["dates"] ?: stringResource(R.string.staff090_library_custom_dates_hint, options.maxDates), + supportingText = stringResource(R.string.staff090_library_custom_dates_hint, options.maxDates), + modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp).testTag("custom-dates"), + ) + } + FormSectionTitle(stringResource(R.string.staff090_library_custom_more)) + NumberField(stringResource(R.string.staff090_library_custom_duration), form.duration, { v -> vm.changeCustom { it.copy(duration = v) } }, invalid = bad("duration_days"), + hint = errors["duration_days"] ?: stringResource(R.string.staff090_library_range, 1, options.maxDurationDays)) + ChoiceField( + stringResource(R.string.staff090_library_custom_weekend), form.weekendRule.orEmpty(), + listOf(LookupOption("", stringResource(R.string.staff090_library_weekend_none))) + + options.weekendRules.map { LookupOption(it, weekendLabel(it)?.let { r -> stringResource(r) } ?: it) }, + { v -> vm.changeCustom { it.copy(weekendRule = v.ifBlank { null }) } }, + ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + NumberField(stringResource(R.string.staff090_library_custom_year_from), form.yearFrom, { v -> vm.changeCustom { it.copy(yearFrom = v) } }, invalid = bad("year_from"), + hint = errors["year_from"].orEmpty(), modifier = Modifier.weight(1f)) + NumberField(stringResource(R.string.staff090_library_custom_year_until), form.yearUntil, { v -> vm.changeCustom { it.copy(yearUntil = v) } }, invalid = bad("year_until"), + hint = errors["year_until"].orEmpty(), modifier = Modifier.weight(1f)) + } + if (cal.branches.isNotEmpty()) { + ChoiceField(stringResource(R.string.staff090_library_branch), form.branchId?.toString().orEmpty(), branchOptions(cal), { v -> vm.changeCustom { it.copy(branchId = v.toIntOrNull()) } }) + } + SwitchRow(stringResource(R.string.staff090_library_active), form.active, { v -> vm.changeCustom { it.copy(active = v) } }) + PrimaryButton(stringResource(R.string.action_save), onClick = vm::saveCustom, loading = state.saving, enabled = invalid.isEmpty(), + modifier = Modifier.fillMaxWidth().testTag("custom-save")) + if (form.id != null) { + val holiday = cal.customHolidays.firstOrNull { it.id == form.id } + if (holiday != null) SecondaryButton(stringResource(R.string.staff090_library_delete), onClick = { vm.askDeleteCustom(holiday) }, modifier = Modifier.fillMaxWidth()) + } + Spacer(Modifier.height(Spacing.lg)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun HolidaySheet(h: HolidayPickerState, vm: ClosuresCalendarViewModel) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val preset = h.preset + var picking by remember { mutableStateOf(false) } + var query by rememberSaveable { mutableStateOf("") } + ModalBottomSheet(onDismissRequest = { if (!h.applying) vm.closeHolidays() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(R.string.staff090_library_holidays_national), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.staff090_library_holidays_help), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + h.error?.let { InlineErrorBanner(message = it.resolve()) } + PickerRow( + label = stringResource(R.string.staff090_library_country), value = preset.countryName.ifBlank { preset.country }, + placeholder = stringResource(R.string.staff090_library_country), onClick = { if (h.countries.isNotEmpty()) picking = true }, + ) + if (preset.regions.isNotEmpty()) { + ChoiceField( + stringResource(R.string.staff090_library_region), preset.region, + listOf(LookupOption("", stringResource(R.string.staff090_library_region_all))) + preset.regions.map { LookupOption(it.code, it.name) }, + { v -> vm.previewHolidays(preset.country, v, preset.year) }, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + val years = preset.years + IconButton(onClick = { vm.previewHolidays(preset.country, preset.region, preset.year - 1) }, enabled = years == null || preset.year > years.min) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = stringResource(R.string.staff090_library_year_prev)) + } + Text(stringResource(R.string.staff090_library_preview_year, preset.year), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, modifier = Modifier.weight(1f)) + IconButton(onClick = { vm.previewHolidays(preset.country, preset.region, preset.year + 1) }, enabled = years == null || preset.year < years.max) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = stringResource(R.string.staff090_library_year_next)) + } + } + preset.dataYears?.takeIf { it.size == 2 }?.let { (from, to) -> + Text(stringResource(R.string.staff090_library_data_years, from, to), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (h.loading) { + LoadingState(modifier = Modifier.heightIn(max = 120.dp)) + } else { + preset.holidays.forEach { holiday -> + SwitchRow(holiday.name, holiday.id in h.selected, { vm.toggleHoliday(holiday.id) }, subtitle = holidaySubtitle(holiday)) + } + } + PrimaryButton( + stringResource(R.string.staff090_library_holidays_apply, h.selected.count { id -> preset.holidays.any { it.id == id } }), + onClick = vm::applyHolidays, loading = h.applying, enabled = !h.loading && preset.schemaReady, + modifier = Modifier.fillMaxWidth().testTag("holidays-apply"), + ) + Spacer(Modifier.height(Spacing.lg)) + } + } + if (picking) { + val matches = h.countries.filter { query.isBlank() || it.name.contains(query, ignoreCase = true) || it.code.equals(query.trim(), ignoreCase = true) } + SearchPickerSheet( + title = stringResource(R.string.staff090_library_country), placeholder = stringResource(R.string.staff090_library_country_search), + state = SearchState(query = query, results = matches.mapIndexed { i, c -> PickItem(i, c.name, c.code) }, searched = true), + onQuery = { query = it }, + onPick = { item -> matches.getOrNull(item.id)?.let { vm.previewHolidays(it.code, null, preset.year) }; picking = false; query = "" }, + onDismiss = { picking = false }, + ) + } +} + +@Composable +private fun holidaySubtitle(h: Staff090LibraryHoliday): String { + val parts = mutableListOf() + (h.dates.ifEmpty { listOfNotNull(h.date) }).takeIf { it.isNotEmpty() }?.let { d -> parts += d.joinToString(", ") { DateFormat.date(it) } } + if (h.yearly) parts += stringResource(R.string.staff090_library_holiday_yearly) + if (h.regional) parts += stringResource(R.string.staff090_library_holiday_regional) + if (h.optional) parts += stringResource(R.string.staff090_library_holiday_optional) + if (h.estimated) parts += stringResource(R.string.staff090_library_holiday_estimated) + return parts.joinToString(" · ") +} + +private fun kindLabel(kind: String) = when (kind) { + "fixed" -> R.string.staff090_library_kind_fixed + "nth_weekday" -> R.string.staff090_library_kind_nth_weekday + "easter" -> R.string.staff090_library_kind_easter + "orthodox_easter" -> R.string.staff090_library_kind_orthodox_easter + "calendar" -> R.string.staff090_library_kind_calendar + "dates" -> R.string.staff090_library_kind_dates + else -> null +} + +private fun occurrenceLabel(n: Int) = when (n) { + 1 -> R.string.staff090_library_nth_1 + 2 -> R.string.staff090_library_nth_2 + 3 -> R.string.staff090_library_nth_3 + 4 -> R.string.staff090_library_nth_4 + 5 -> R.string.staff090_library_nth_5 + else -> R.string.staff090_library_nth_last +} + +private fun weekendLabel(rule: String) = when (rule) { + "next_monday" -> R.string.staff090_library_weekend_next_monday + "us" -> R.string.staff090_library_weekend_us + else -> null +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/ClosuresCalendarScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/ClosuresCalendarScreen.kt new file mode 100644 index 0000000..1aa6559 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/ClosuresCalendarScreen.kt @@ -0,0 +1,754 @@ +package com.pinakes.app.ui.screens.staff.s090.library + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.UploadFile +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.Staff090LibraryCalendar +import com.pinakes.app.data.model.Staff090LibraryClosure +import com.pinakes.app.data.model.Staff090LibraryCustomHoliday +import com.pinakes.app.data.model.Staff090LibraryDay +import com.pinakes.app.data.model.Staff090LibraryHolidayPreset +import com.pinakes.app.data.model.Staff090LibraryCodeName +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.Staff090LibraryRepository +import com.pinakes.app.data.staff.SubmissionKeys +import com.pinakes.app.ui.common.AppLocale +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.components.AvailabilityChip +import com.pinakes.app.ui.components.AvailabilityStatus +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.PickerRow +import com.pinakes.app.ui.screens.staff.StaffActionRow +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.content.PickedFile +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.DayOfWeek +import java.time.YearMonth +import java.time.format.TextStyle +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** The national holiday picker: a country (and region) previewed for a year, the rules ticked. */ +data class HolidayPickerState( + val countries: List = emptyList(), + val preset: Staff090LibraryHolidayPreset = Staff090LibraryHolidayPreset(), + val selected: Set = emptySet(), + /** The operator ticked or unticked a rule: a new preview of the same country keeps the choice. */ + val touched: Boolean = false, + val loading: Boolean = true, + val applying: Boolean = false, + val error: StaffMessage? = null, +) + +data class ClosuresCalendarUiState( + val cal: Staff090LibraryCalendar? = null, + /** The month and branch the operator is looking at (null = the server's default). */ + val month: String? = null, + val branchId: Int? = null, + val loading: Boolean = true, + val refreshing: Boolean = false, + val error: StaffMessage? = null, + val exit: StaffMessage? = null, + val busy: Boolean = false, + val message: StaffMessage? = null, + val weekdays: Set = emptySet(), + val yearly: Map = emptyMap(), + val day: Staff090LibraryDay? = null, + val closureForm: ClosureForm? = null, + val customForm: CustomForm? = null, + val fieldErrors: Map = emptyMap(), + val formError: StaffMessage? = null, + val saving: Boolean = false, + val confirmClosure: Staff090LibraryClosure? = null, + val confirmCustom: Staff090LibraryCustomHoliday? = null, + val holidays: HolidayPickerState? = null, + val ics: PickedFile? = null, + val icsBranch: Int? = null, + val icsError: StaffMessage? = null, + val icsUploading: Boolean = false, +) { + val weekdaysChanged: Boolean get() = cal != null && weekdays != cal.closedWeekdays.toSet() + val yearlyChanged: Map get() = cal?.let { c -> CalendarGrid.yearly(c.closures).filter { yearly[it.id] != null && yearly[it.id] != it.active }.associate { it.id to yearly.getValue(it.id) } }.orEmpty() +} + +@HiltViewModel +class ClosuresCalendarViewModel @Inject constructor( + private val repo: Staff090LibraryRepository, + @ApplicationContext private val context: Context, +) : ViewModel() { + private val _state = MutableStateFlow(ClosuresCalendarUiState()) + val state: StateFlow = _state.asStateFlow() + private val closureKeys = SubmissionKeys() + private val customKeys = SubmissionKeys() + + init { load() } + + fun load() { + val s = _state.value + _state.update { it.copy(loading = it.cal == null && !it.refreshing, error = null) } + viewModelScope.launch { + val res = repo.calendar(s.month, s.branchId) + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + cal = res.data, loading = false, refreshing = false, month = res.data.month, + // Another month, branch or a save elsewhere must not drop unsaved ticks. + weekdays = CalendarGrid.reseedWeekdays(it.weekdays, it.cal?.closedWeekdays?.toSet(), res.data.closedWeekdays.toSet()), + yearly = CalendarGrid.reseedYearly(it.yearly, it.cal?.closures, res.data.closures), + ) + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, exit = res.exitMessage(), error = res.toLibraryMessage(R.string.staff_error_load)) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + load() + } + + fun shiftMonth(delta: Long) { + val current = _state.value.cal?.month ?: return + _state.update { it.copy(month = CalendarGrid.shift(current, delta)) } + load() + } + + fun setBranch(id: Int?) { + _state.update { it.copy(branchId = id) } + load() + } + + fun openDay(day: Staff090LibraryDay) = _state.update { it.copy(day = day) } + fun closeDay() = _state.update { it.copy(day = null) } + + // ---------- weekly closed days ---------- + + fun toggleWeekday(day: Int) = _state.update { s -> s.copy(weekdays = if (day in s.weekdays) s.weekdays - day else s.weekdays + day) } + + fun saveWeekdays() { + val s = _state.value + if (s.busy || !s.weekdaysChanged) return + if (!CalendarGrid.weekdaysValid(s.weekdays)) { + _state.update { it.copy(message = StaffMessage(R.string.staff090_library_weekdays_all_closed)) } + return + } + write { repo.saveWeekdays(s.weekdays.toList()).let { it.result } } + } + + // ---------- yearly switches ---------- + + fun setYearly(id: Int, on: Boolean) = _state.update { it.copy(yearly = it.yearly + (id to on)) } + + fun saveYearly() { + val changed = _state.value.yearlyChanged + if (_state.value.busy || changed.isEmpty()) return + write { repo.saveYearly(changed).result } + } + + // ---------- closures ---------- + + fun startClosure(day: String? = null) = _state.update { + it.copy(closureForm = ClosureForm(startDate = day, branchId = it.branchId), fieldErrors = emptyMap(), formError = null, day = null) + } + + fun editClosure(c: Staff090LibraryClosure) = _state.update { it.copy(closureForm = ClosureForm.from(c), fieldErrors = emptyMap(), formError = null, day = null) } + fun changeClosure(transform: (ClosureForm) -> ClosureForm) = _state.update { s -> s.copy(closureForm = s.closureForm?.let(transform), formError = null) } + fun closeClosure() = _state.update { it.copy(closureForm = null, fieldErrors = emptyMap()) } + + fun saveClosure() { + val form = _state.value.closureForm ?: return + if (_state.value.saving || !form.valid) return + _state.update { it.copy(saving = true, formError = null, fieldErrors = emptyMap()) } + viewModelScope.launch { + val body = form.body() + val saved = if (form.id == null) { + repo.createClosure(body, closureKeys.keyFor(body)).also { r -> + if (r.result is ApiResult.Success || (r.result as? ApiResult.Failure)?.code == SubmissionKeys.CONFLICT) closureKeys.reset() + } + } else repo.updateClosure(form.id, body) + formDone(saved.result, saved.fields) { it.copy(closureForm = null) } + } + } + + fun askDeleteClosure(c: Staff090LibraryClosure) = _state.update { it.copy(confirmClosure = c, closureForm = null) } + fun deleteClosure() { + val c = _state.value.confirmClosure ?: return + _state.update { it.copy(confirmClosure = null) } + write(R.string.staff090_library_deleted) { repo.deleteClosure(c.id) } + } + + // ---------- custom holidays ---------- + + fun startCustom() = _state.update { + val options = it.cal?.customHolidayOptions + it.copy(customForm = CustomForm(kind = options?.kinds?.firstOrNull() ?: "fixed", calendar = options?.calendars?.firstOrNull { c -> c.available }?.code.orEmpty(), branchId = it.branchId), + fieldErrors = emptyMap(), formError = null) + } + + fun editCustom(h: Staff090LibraryCustomHoliday) = _state.update { it.copy(customForm = CustomForm.from(h), fieldErrors = emptyMap(), formError = null) } + fun changeCustom(transform: (CustomForm) -> CustomForm) = _state.update { s -> s.copy(customForm = s.customForm?.let(transform), formError = null) } + fun closeCustom() = _state.update { it.copy(customForm = null, fieldErrors = emptyMap()) } + + fun saveCustom() { + val s = _state.value + val form = s.customForm ?: return + val options = s.cal?.customHolidayOptions ?: return + val months = options.calendars.firstOrNull { it.code == form.calendar }?.months?.size ?: 12 + if (s.saving || CustomForms.invalid(form, options.maxDurationDays, options.maxDates, months).isNotEmpty()) return + _state.update { it.copy(saving = true, formError = null, fieldErrors = emptyMap()) } + viewModelScope.launch { + val body = CustomForms.body(form) + val saved = if (form.id == null) { + repo.createCustom(body, customKeys.keyFor(body)).also { r -> + if (r.result is ApiResult.Success || (r.result as? ApiResult.Failure)?.code == SubmissionKeys.CONFLICT) customKeys.reset() + } + } else repo.updateCustom(form.id, body) + formDone(saved.result, saved.fields) { it.copy(customForm = null) } + } + } + + fun toggleCustom(h: Staff090LibraryCustomHoliday) = write(null) { repo.toggleCustom(h.id) } + + fun askDeleteCustom(h: Staff090LibraryCustomHoliday) = _state.update { it.copy(confirmCustom = h, customForm = null) } + fun deleteCustom() { + val h = _state.value.confirmCustom ?: return + _state.update { it.copy(confirmCustom = null) } + write(R.string.staff090_library_deleted) { repo.deleteCustom(h.id) } + } + + // ---------- national holidays ---------- + + fun openHolidays() { + val preset = _state.value.cal?.holidayPreset ?: return + _state.update { it.copy(holidays = HolidayPickerState(preset = preset, selected = preset.holidays.filter { h -> h.checked }.map { h -> h.id }.toSet())) } + previewHolidays(preset.country, preset.region, preset.year) + } + + fun previewHolidays(country: String, region: String?, year: Int?) { + _state.update { it.copy(holidays = it.holidays?.copy(loading = true, error = null)) } + viewModelScope.launch { + val res = repo.countries(country, region, year) + _state.update { s -> + val h = s.holidays ?: return@update s + when (res) { + is ApiResult.Success -> s.copy(holidays = h.copy( + countries = res.data.countries, preset = res.data.preset, loading = false, + selected = CalendarGrid.reseedHolidays( + h.selected, h.touched, sameCountry = h.preset.country == res.data.preset.country, + listed = res.data.holidays.map { it.id }, serverChecked = res.data.holidays.filter { it.checked }.map { it.id }.toSet(), + ), + )) + is ApiResult.Failure -> s.copy(holidays = h.copy(loading = false, error = res.toLibraryMessage(R.string.staff_error_load))) + } + } + } + } + + fun toggleHoliday(id: String) = _state.update { s -> + s.copy(holidays = s.holidays?.let { h -> h.copy(selected = if (id in h.selected) h.selected - id else h.selected + id, touched = true) }) + } + + fun closeHolidays() = _state.update { it.copy(holidays = null) } + + fun applyHolidays() { + val h = _state.value.holidays ?: return + if (h.applying || h.loading) return + _state.update { it.copy(holidays = h.copy(applying = true, error = null)) } + viewModelScope.launch { + // Only rules of the previewed list (the server keeps the selection per country). + val rules = h.preset.holidays.map { it.id }.filter { it in h.selected } + val saved = repo.applyPreset(h.preset.country, h.preset.region, rules) + when (val res = saved.result) { + is ApiResult.Success -> { + _state.update { it.copy(holidays = null, message = res.data.message.takeIf { m -> m.isNotBlank() }?.let { m -> StaffMessage(text = m) } ?: StaffMessage(R.string.staff090_library_saved)) } + load() + } + is ApiResult.Failure -> _state.update { it.copy(holidays = it.holidays?.copy(applying = false, error = res.toLibraryMessage(R.string.staff_error_save))) } + } + } + } + + // ---------- ICS import ---------- + + fun onIcsPicked(uri: Uri?) { + uri ?: return + val max = _state.value.cal?.limits?.icsMaxBytes ?: 1_048_576 + viewModelScope.launch { + val read = withContext(Dispatchers.IO) { runCatching { readIcs(uri, max) }.getOrNull() } + _state.update { + when { + read == null -> it.copy(ics = null, icsError = StaffMessage(R.string.staff090_library_ics_read_failed)) + read.second -> it.copy(ics = null, icsError = StaffMessage(R.string.staff090_library_err_ics_too_large)) + else -> it.copy(ics = read.first, icsError = null) + } + } + } + } + + /** The picked file and whether it is over [max] bytes (then it is not read past the limit). */ + private fun readIcs(uri: Uri, max: Int): Pair? { + val resolver = context.contentResolver + var name = uri.lastPathSegment?.substringAfterLast('/') ?: "calendar.ics" + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> + if (c.moveToFirst()) { + c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let { i -> c.getString(i)?.let { name = it } } + c.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }?.let { i -> if (!c.isNull(i) && c.getLong(i) > max) return null to true } + } + } + val out = java.io.ByteArrayOutputStream() + resolver.openInputStream(uri)?.use { input -> + val buffer = ByteArray(32 * 1024) + var total = 0L + while (true) { + val n = input.read(buffer) + if (n < 0) break + total += n + if (total > max) return null to true + out.write(buffer, 0, n) + } + } ?: return null + return PickedFile(name, resolver.getType(uri), out.toByteArray()) to false + } + + fun setIcsBranch(id: Int?) = _state.update { it.copy(icsBranch = id) } + + fun importIcs() { + val s = _state.value + val file = s.ics ?: return + if (s.icsUploading) return + _state.update { it.copy(icsUploading = true, icsError = null) } + viewModelScope.launch { + val saved = repo.importIcs(file.name, file.bytes, file.mime, s.icsBranch) + when (val res = saved.result) { + is ApiResult.Success -> { + _state.update { + it.copy(icsUploading = false, ics = null, message = res.data.message.takeIf { m -> m.isNotBlank() }?.let { m -> StaffMessage(text = m) } + ?: StaffMessage(R.string.staff090_library_ics_added, args = listOf(res.data.added))) + } + load() + } + is ApiResult.Failure -> _state.update { + it.copy(icsUploading = false, + icsError = saved.fields["ics_file"]?.let { m -> StaffMessage(text = m) } ?: res.toLibraryMessage(R.string.staff090_library_ics_failed)) + } + } + } + } + + // ---------- shared ---------- + + private fun formDone(res: ApiResult, fields: Map, close: (ClosuresCalendarUiState) -> ClosuresCalendarUiState) { + when (res) { + is ApiResult.Success -> { + _state.update { close(it).copy(saving = false, message = StaffMessage(R.string.staff090_library_saved)) } + load() + } + is ApiResult.Failure -> _state.update { it.copy(saving = false, fieldErrors = fields, formError = res.toLibraryMessage(R.string.staff_error_save)) } + } + } + + private fun write(done: Int? = R.string.staff090_library_saved, call: suspend () -> ApiResult<*>) { + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + when (val res = call()) { + is ApiResult.Success -> { + _state.update { it.copy(busy = false, message = done?.let { r -> StaffMessage(r) }) } + load() + } + is ApiResult.Failure -> _state.update { it.copy(busy = false, message = res.toLibraryMessage(R.string.staff_error_save)) } + } + } + } + + fun dismissConfirm() = _state.update { it.copy(confirmClosure = null, confirmCustom = null) } + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** The closures calendar of the 0.9.0 web admin, section by section. */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ClosuresCalendarScreen(onNavigateUp: () -> Unit) { + val vm: ClosuresCalendarViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + LeaveWhen(!areas.s090.closuresCalendar, onNavigateUp) + LibraryExit(state.exit, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument(), vm::onIcsPicked) + val cal = state.cal + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff090_calendar), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize().padding(padding)) { + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = false, + emptyTitle = "", emptyIcon = Icons.Outlined.CalendarMonth, onRetry = vm::refresh, + ) { + val c = cal ?: return@StaffLoadBody + val branches = listOf(LookupOption("", stringResource(R.string.staff090_library_all_branches))) + + c.branches.map { LookupOption(it.id.toString(), it.name) } + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + if (!c.moduleEnabled) { + item(key = "off") { + InfoCard(Modifier.padding(top = Spacing.sm)) { + Text(stringResource(R.string.staff090_library_calendar_module_off), style = MaterialTheme.typography.bodyMedium) + } + } + } + item(key = "month") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier.padding(top = Spacing.sm)) { + if (c.branches.isNotEmpty()) { + ChoiceField(stringResource(R.string.staff090_library_branch_preview), state.branchId?.toString().orEmpty(), branches, + { v -> vm.setBranch(v.toIntOrNull()) }) + } + MonthGrid(c, onPrev = { vm.shiftMonth(-1) }, onNext = { vm.shiftMonth(1) }, onDay = vm::openDay) + } + } + + item(key = "weekly") { + FormSectionTitle(stringResource(R.string.staff090_library_weekly)) + Text(stringResource(R.string.staff090_library_weekly_help), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier.padding(vertical = Spacing.xs)) { + CalendarGrid.MONDAY_FIRST.forEach { d -> + FilterChip( + selected = d in state.weekdays, onClick = { vm.toggleWeekday(d) }, + label = { Text(weekdayName(d, TextStyle.SHORT)) }, + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer), + modifier = Modifier.testTag("weekday-$d"), + ) + } + } + if (state.weekdaysChanged) { + PrimaryButton(stringResource(R.string.staff090_library_save_weekdays), onClick = vm::saveWeekdays, loading = state.busy, + modifier = Modifier.fillMaxWidth().testTag("weekdays-save")) + } + } + + item(key = "closures-title") { + FormSectionTitle(stringResource(R.string.staff090_library_closures)) + SecondaryButton(stringResource(R.string.staff090_library_closure_new), onClick = { vm.startClosure() }, leadingIcon = Icons.Outlined.Add, + modifier = Modifier.fillMaxWidth().testTag("closure-new")) + } + if (c.closures.isEmpty()) { + item(key = "closures-empty") { + Text(stringResource(R.string.staff090_library_closures_empty), style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = Spacing.sm)) + } + } + items(c.closures, key = { "closure-${it.id}" }) { closure -> ClosureRow(closure) { vm.editClosure(closure) } } + + val yearly = CalendarGrid.yearly(c.closures) + if (yearly.isNotEmpty()) { + item(key = "yearly") { + FormSectionTitle(stringResource(R.string.staff090_library_yearly)) + Text(stringResource(R.string.staff090_library_yearly_help), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + yearly.forEach { y -> + SwitchRow(y.description.ifBlank { stringResource(R.string.staff090_library_closure_untitled) }, state.yearly[y.id] ?: y.active, + { on -> vm.setYearly(y.id, on) }, subtitle = dateRange(y)) + } + if (state.yearlyChanged.isNotEmpty()) { + PrimaryButton(stringResource(R.string.action_save), onClick = vm::saveYearly, loading = state.busy, modifier = Modifier.fillMaxWidth().testTag("yearly-save")) + } + } + } + + item(key = "holidays") { + FormSectionTitle(stringResource(R.string.staff090_library_holidays)) + val preset = c.holidayPreset + StaffActionRow( + Icons.Outlined.Flag, stringResource(R.string.staff090_library_holidays_national), + if (preset.saved) stringResource(R.string.staff090_library_holidays_saved, preset.countryName.ifBlank { preset.country }, preset.holidays.count { it.checked }) + else stringResource(R.string.staff090_library_holidays_none), + ) { vm.openHolidays() } + } + + if (c.customHolidaysAvailable) { + item(key = "custom-title") { + FormSectionTitle(stringResource(R.string.staff090_library_custom)) + Text(stringResource(R.string.staff090_library_custom_help), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + SecondaryButton(stringResource(R.string.staff090_library_custom_new), onClick = vm::startCustom, leadingIcon = Icons.Outlined.Add, + modifier = Modifier.fillMaxWidth().padding(top = Spacing.xs).testTag("custom-new")) + } + items(c.customHolidays, key = { "custom-${it.id}" }) { h -> + CustomRow(h, busy = state.busy, onOpen = { vm.editCustom(h) }, onToggle = { vm.toggleCustom(h) }) + } + } + + item(key = "ics") { + FormSectionTitle(stringResource(R.string.staff090_library_ics)) + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(stringResource(R.string.staff090_library_ics_help), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + state.icsError?.let { InlineErrorBanner(message = it.resolve()) } + PickerRow( + label = stringResource(R.string.staff090_library_ics_file), value = state.ics?.name, + placeholder = stringResource(R.string.staff_import_choose_file), onClick = { picker.launch(ICS_TYPES) }, + ) + if (c.branches.isNotEmpty()) { + ChoiceField(stringResource(R.string.staff090_library_branch), state.icsBranch?.toString().orEmpty(), branches, { v -> vm.setIcsBranch(v.toIntOrNull()) }) + } + PrimaryButton( + stringResource(R.string.staff090_library_ics_import), onClick = vm::importIcs, loading = state.icsUploading, enabled = state.ics != null, + leadingIcon = Icons.Outlined.UploadFile, modifier = Modifier.fillMaxWidth().testTag("ics-import"), + ) + } + } + } + } + } + } + + if (cal != null) { + state.day?.let { day -> DayDialog(day, cal, onAddClosure = { vm.startClosure(day.date) }, onDismiss = vm::closeDay) } + state.closureForm?.let { form -> ClosureSheet(form, cal, state, vm) } + state.customForm?.let { form -> CustomHolidaySheet(form, cal, state, vm) } + state.holidays?.let { h -> HolidaySheet(h, vm) } + } + state.confirmClosure?.let { c -> + ConfirmDialog( + title = stringResource(R.string.staff090_library_closure_delete), body = stringResource(R.string.staff090_library_closure_delete_body, dateRange(c)), + confirmLabel = stringResource(R.string.staff090_library_delete), onConfirm = vm::deleteClosure, onDismiss = vm::dismissConfirm, + ) + } + state.confirmCustom?.let { h -> + ConfirmDialog( + title = stringResource(R.string.staff090_library_custom_delete), body = stringResource(R.string.staff090_library_custom_delete_body, h.name), + confirmLabel = stringResource(R.string.staff090_library_delete), onConfirm = vm::deleteCustom, onDismiss = vm::dismissConfirm, + ) + } +} + +internal val ICS_TYPES = arrayOf("text/calendar", "text/x-vcalendar", "application/ics", "text/plain", "application/octet-stream") + +/** A weekday name for the server's 0 = Sunday … 6 = Saturday. */ +internal fun weekdayName(day: Int, style: TextStyle): String = + DayOfWeek.of(if (day == 0) 7 else day).getDisplayName(style, AppLocale.current()) + .replaceFirstChar { if (it.isLowerCase()) it.titlecase(AppLocale.current()) else it.toString() } + +/** "1 Nov 2026" or "1 Nov 2026 – 3 Nov 2026". */ +internal fun dateRange(c: Staff090LibraryClosure): String = + if (c.endDate.isBlank() || c.endDate == c.startDate) DateFormat.date(c.startDate) else "${DateFormat.date(c.startDate)} – ${DateFormat.date(c.endDate)}" + +private fun monthTitle(month: String): String = runCatching { + val ym = YearMonth.parse(month) + val locale = AppLocale.current() + ym.month.getDisplayName(TextStyle.FULL_STANDALONE, locale).replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() } + " " + ym.year +}.getOrDefault(month) + +@Composable +internal fun MonthGrid(cal: Staff090LibraryCalendar, onPrev: () -> Unit, onNext: () -> Unit, onDay: (Staff090LibraryDay) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onPrev) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = stringResource(R.string.cd_prev_month)) } + Text(monthTitle(cal.month), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, modifier = Modifier.weight(1f).testTag("calendar-month")) + IconButton(onClick = onNext) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = stringResource(R.string.cd_next_month)) } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + CalendarGrid.MONDAY_FIRST.forEach { d -> + Text(weekdayName(d, TextStyle.NARROW), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, modifier = Modifier.weight(1f)) + } + } + CalendarGrid.weeks(cal.days).forEach { week -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + week.forEach { day -> + Box(Modifier.weight(1f).aspectRatio(1f), contentAlignment = Alignment.Center) { + if (day != null) DayCell(day, today = day.date == cal.today, onClick = { onDay(day) }) + } + } + } + } + // Key: a closed day is the neutral grey of the reader's loan calendar. + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.sizeIn(maxWidth = 12.dp, maxHeight = 12.dp).fillMaxSize().clip(CircleShape).background(MaterialTheme.colorScheme.surfaceVariant) + .border(1.dp, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.22f), CircleShape)) + Text(stringResource(R.string.staff090_library_legend_closed), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = Spacing.xs)) + } + } +} + +@Composable +private fun DayCell(day: Staff090LibraryDay, today: Boolean, onClick: () -> Unit) { + val container = if (day.closed) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent + val content = if (day.closed) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface + val rule = when { + today -> MaterialTheme.colorScheme.primary + day.closed -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.22f) + else -> Color.Transparent + } + Box( + Modifier.sizeIn(maxWidth = 44.dp, maxHeight = 44.dp).fillMaxSize().padding(1.dp) + .testTag("calendar-day-${day.date}") + .clip(CircleShape).background(container) + .then(if (rule != Color.Transparent) Modifier.border(1.dp, rule, CircleShape) else Modifier) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text(day.date.takeLast(2).trimStart('0'), style = MaterialTheme.typography.bodyMedium, + fontWeight = if (today) FontWeight.Bold else FontWeight.Normal, color = content) + } +} + +@Composable +private fun ClosureRow(c: Staff090LibraryClosure, onOpen: () -> Unit) { + Surface( + onClick = onOpen, enabled = c.editable, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("closure-${c.id}"), + ) { + Column(Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text(c.description.ifBlank { stringResource(R.string.staff090_library_closure_untitled) }, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(listOfNotNull(dateRange(c), c.branchName).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (c.yearly || c.presetRule != null || !c.active) { + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + if (c.presetRule != null) AvailabilityChip(AvailabilityStatus.Digital, stringResource(R.string.staff090_library_closure_preset)) + if (c.yearly) AvailabilityChip(AvailabilityStatus.ReservedReady, stringResource(R.string.staff090_library_closure_yearly)) + if (!c.active) AvailabilityChip(AvailabilityStatus.Unavailable, stringResource(R.string.staff090_library_inactive)) + } + } + } + } +} + +@Composable +private fun CustomRow(h: Staff090LibraryCustomHoliday, busy: Boolean, onOpen: () -> Unit, onToggle: () -> Unit) { + Surface( + onClick = onOpen, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("custom-${h.id}"), + ) { + Row(Modifier.padding(start = Spacing.lg, top = Spacing.sm, bottom = Spacing.sm, end = Spacing.sm), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text(h.name, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(listOfNotNull(h.description.takeIf { it.isNotBlank() }, h.branchName).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + h.nextDates.takeIf { it.isNotEmpty() }?.let { dates -> + Text(stringResource(R.string.staff090_library_custom_next, dates.take(3).joinToString(", ") { DateFormat.date(it) }), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Switch(checked = h.active, onCheckedChange = { onToggle() }, enabled = !busy, modifier = Modifier.testTag("custom-toggle-${h.id}")) + } + } +} + +@Composable +private fun DayDialog(day: Staff090LibraryDay, cal: Staff090LibraryCalendar, onAddClosure: () -> Unit, onDismiss: () -> Unit) { + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + shape = MaterialTheme.shapes.large, + title = { Text(DateFormat.date(day.date), style = MaterialTheme.typography.titleMedium) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier.testTag("day-reasons")) { + if (!day.closed) { + Text(stringResource(R.string.staff090_library_day_open), style = MaterialTheme.typography.bodyMedium) + } else { + day.reasons.forEach { r -> + // The weekly closure's server name is generic: the weekday says more. + val what = if (r.type == "weekly") weekdayName(day.weekday, TextStyle.FULL) else r.name + Text(stringResource(reasonLabel(r.type), what), style = MaterialTheme.typography.bodyMedium) + } + if (day.reasons.isEmpty()) Text(stringResource(R.string.staff090_library_day_closed), style = MaterialTheme.typography.bodyMedium) + } + } + }, + confirmButton = { PinakesTextButton(stringResource(R.string.staff090_library_close), onClick = onDismiss) }, + dismissButton = if (cal.canConfigure) ({ PinakesTextButton(stringResource(R.string.staff090_library_closure_new), onClick = onAddClosure) }) else null, + ) +} + +private fun reasonLabel(type: String) = when (type) { + "weekly" -> R.string.staff090_library_reason_weekly + "closure" -> R.string.staff090_library_reason_closure + "holiday" -> R.string.staff090_library_reason_holiday + "custom_holiday" -> R.string.staff090_library_reason_custom + else -> R.string.staff090_library_reason_other +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/LibraryForms.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/LibraryForms.kt new file mode 100644 index 0000000..b93281b --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/LibraryForms.kt @@ -0,0 +1,601 @@ +package com.pinakes.app.ui.screens.staff.s090.library + +import com.pinakes.app.data.model.Staff090LibraryCardSettings +import com.pinakes.app.data.model.Staff090LibraryClosure +import com.pinakes.app.data.model.Staff090LibraryCustomHoliday +import com.pinakes.app.data.model.Staff090LibraryDay +import com.pinakes.app.data.model.Staff090LibraryLoans +import com.pinakes.app.data.model.Staff090LibraryModule +import com.pinakes.app.data.model.Staff090LibraryPatronType +import com.pinakes.app.data.model.Staff090LibraryRange +import com.pinakes.app.data.model.Staff090LibraryRuleCell +import com.pinakes.app.data.model.Staff090LibraryTypeValues +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import java.math.BigDecimal +import java.text.DecimalFormatSymbols +import java.time.LocalDate +import java.util.Locale + +// The pure side of the library settings screens: what the operator typed, what it means for +// the server (a partial JSON body, cents, null = inherit) and what the screen shows. No Android. + +/** A typed amount: empty (no amount, i.e. inherit), valid cents, or not an amount. */ +sealed interface CentsInput { + data object Empty : CentsInput + data class Valid(val cents: Int) : CentsInput + data object Invalid : CentsInput +} + +object LibraryMoney { + private val AMOUNT = Regex("""^\d{1,9}([.,]\d{1,2})?$""") + + /** + * "2", "2.5", "2,50" → cents, exactly (the decimal string is parsed, never a float). Either + * separator is accepted, no thousands separator, no sign; more than two decimals is refused. + */ + fun parse(text: String, max: Long = Long.MAX_VALUE): CentsInput { + val t = text.trim() + if (t.isEmpty()) return CentsInput.Empty + if (!AMOUNT.matches(t)) return CentsInput.Invalid + val cents = BigDecimal(t.replace(',', '.')).movePointRight(2) + return if (cents > BigDecimal.valueOf(minOf(max, Int.MAX_VALUE.toLong()))) CentsInput.Invalid else CentsInput.Valid(cents.toInt()) + } + + /** Cents as the text the amount field starts with, in the locale's decimal separator; "" for null. */ + fun input(cents: Int?, locale: Locale): String { + if (cents == null) return "" + val plain = BigDecimal.valueOf(cents.toLong(), 2).toPlainString() + val sep = DecimalFormatSymbols.getInstance(locale).decimalSeparator + return if (sep == '.') plain else plain.replace('.', sep) + } +} + +/** A typed whole number: empty, valid, or out of [range] / not a number. */ +sealed interface IntInput { + data object Empty : IntInput + data class Valid(val value: Int) : IntInput + data object Invalid : IntInput + + companion object { + fun parse(text: String, range: Staff090LibraryRange?, allowNegative: Boolean = false): IntInput { + val t = text.trim() + if (t.isEmpty()) return Empty + if (!(if (allowNegative) Regex("^-?\\d{1,9}$") else Regex("^\\d{1,9}$")).matches(t)) return Invalid + val v = t.toLong() + if (range != null && (v < range.min || v > range.max)) return Invalid + return Valid(v.toInt()) + } + } +} + +// ============================================================================================= +// Settings → Modules +// ============================================================================================= + +object ModulesForm { + /** The modules of one group, in the server's order. */ + fun group(modules: List, group: String) = modules.filter { it.group == group } + + /** Why a module that is switched on does not work, or what it depends on. */ + sealed interface Note { + data class NeedsParent(val parentLabel: String, val parentOn: Boolean) : Note + data object CatalogueMode : Note + data class Follows(val label: String) : Note + } + + fun notes(module: Staff090LibraryModule, all: List, catalogueMode: Boolean): List { + val byKey = all.associateBy { it.key } + val out = mutableListOf() + module.parent?.let { p -> out += Note.NeedsParent(byKey[p]?.label ?: p, byKey[p]?.effective == true) } + if (module.circulation && catalogueMode) out += Note.CatalogueMode + module.inheritsFrom?.takeIf { it != module.parent }?.let { p -> out += Note.Follows(byKey[p]?.label ?: p) } + return out + } + + /** `{features: {changed…}, attendance_retention_days?}`; null when nothing changed. */ + fun body(original: Map, current: Map, retentionOriginal: Int, retentionText: String): JsonObject? { + val changed = current.filter { (k, v) -> original[k] != v } + val retention = (IntInput.parse(retentionText, null) as? IntInput.Valid)?.value + val retentionChanged = retention != null && retention != retentionOriginal + if (changed.isEmpty() && !retentionChanged) return null + return buildJsonObject { + if (changed.isNotEmpty()) put("features", buildJsonObject { changed.forEach { (k, v) -> put(k, JsonPrimitive(v)) } }) + if (retentionChanged) put("attendance_retention_days", JsonPrimitive(retention)) + } + } +} + +// ============================================================================================= +// Settings → Loans +// ============================================================================================= + +enum class LoanFieldKind { INT, BOOL, CHOICE, MONEY, TIMEZONE } + +/** One field of the loans tab, in the web page's order. */ +data class LoanField(val key: String, val kind: LoanFieldKind, val section: LoanSection) + +enum class LoanSection { APPROVAL, DURATION, PICKUP, CARD, RENEWALS, LIMITS, RECALLS, FINES } + +object LoansForm { + /** + * The loan fields after a re-read of the server (a module saved on another tab): a field the + * operator edited and did not save keeps the edit, every other one takes the server's value. + */ + fun reseed(draft: Map, before: Map, server: Map): Map = + server.mapValues { (key, value) -> draft[key]?.takeIf { it != before[key] } ?: value } + + val FIELDS = listOf( + LoanField("auto_approve_requests", LoanFieldKind.BOOL, LoanSection.APPROVAL), + LoanField("loan_duration_days", LoanFieldKind.INT, LoanSection.DURATION), + LoanField("max_loan_duration_days", LoanFieldKind.INT, LoanSection.DURATION), + LoanField("app_timezone", LoanFieldKind.TIMEZONE, LoanSection.DURATION), + LoanField("pickup_expiry_days", LoanFieldKind.INT, LoanSection.PICKUP), + LoanField("pickup_reminder_days", LoanFieldKind.INT, LoanSection.PICKUP), + LoanField("card_expiry_warning_days", LoanFieldKind.INT, LoanSection.CARD), + LoanField("card_renewal_base", LoanFieldKind.CHOICE, LoanSection.CARD), + LoanField("max_renewals", LoanFieldKind.INT, LoanSection.RENEWALS), + LoanField("max_active_loans_per_user", LoanFieldKind.INT, LoanSection.LIMITS), + LoanField("allow_multiple_loans_same_book", LoanFieldKind.BOOL, LoanSection.LIMITS), + LoanField("recall_auto_enabled", LoanFieldKind.BOOL, LoanSection.RECALLS), + LoanField("recall_interval_days", LoanFieldKind.INT, LoanSection.RECALLS), + LoanField("recall_max_count", LoanFieldKind.INT, LoanSection.RECALLS), + LoanField("fine_grace_mode", LoanFieldKind.CHOICE, LoanSection.FINES), + LoanField("daily_fine_cents", LoanFieldKind.MONEY, LoanSection.FINES), + LoanField("fine_grace_days", LoanFieldKind.INT, LoanSection.FINES), + LoanField("fine_max_per_loan_cents", LoanFieldKind.MONEY, LoanSection.FINES), + LoanField("debt_block_threshold_cents", LoanFieldKind.MONEY, LoanSection.FINES), + LoanField("fine_charged_email", LoanFieldKind.BOOL, LoanSection.FINES), + ) + + /** The fields this server sent, known ones first in page order (an unknown key is left out). */ + fun fields(loans: Staff090LibraryLoans): List = FIELDS.filter { it.key in loans.settings } + + fun editable(loans: Staff090LibraryLoans, key: String): Boolean = key in loans.editableFields + + /** The stored value as the text the form edits ("true"/"false" for switches, "" for no amount). */ + fun initial(loans: Staff090LibraryLoans, locale: Locale): Map = fields(loans).associate { f -> + val raw = loans.settings[f.key] as? JsonPrimitive + f.key to when (f.kind) { + LoanFieldKind.MONEY -> LibraryMoney.input(raw?.intOrNull, locale) + LoanFieldKind.BOOL -> (raw?.booleanOrNull ?: false).toString() + else -> if (raw == null || raw is JsonNull) "" else raw.contentOrNull.orEmpty() + } + } + + /** Field → true when its text cannot be sent. */ + fun invalid(loans: Staff090LibraryLoans, values: Map): Set = fields(loans).filter { f -> + val text = values[f.key].orEmpty() + when (f.kind) { + LoanFieldKind.INT -> IntInput.parse(text, loans.limits[f.key]) !is IntInput.Valid + LoanFieldKind.MONEY -> LibraryMoney.parse(text, loans.limits["money_max_cents"]?.max ?: Long.MAX_VALUE) is CentsInput.Invalid + LoanFieldKind.CHOICE -> loans.options[f.key]?.let { text !in it } ?: false + LoanFieldKind.TIMEZONE -> text.isBlank() + LoanFieldKind.BOOL -> false + } + }.map { it.key }.toSet() + + /** Only the editable fields whose value changed, typed as the server wants them; null when none. */ + fun body(loans: Staff090LibraryLoans, original: Map, values: Map): JsonObject? { + val changed = fields(loans).filter { editable(loans, it.key) && values[it.key] != original[it.key] } + if (changed.isEmpty()) return null + return buildJsonObject { + changed.forEach { f -> + val text = values[f.key].orEmpty() + put(f.key, when (f.kind) { + LoanFieldKind.INT -> (IntInput.parse(text, null) as? IntInput.Valid)?.let { JsonPrimitive(it.value) } ?: JsonNull + LoanFieldKind.BOOL -> JsonPrimitive(text.toBoolean()) + LoanFieldKind.MONEY -> (LibraryMoney.parse(text) as? CentsInput.Valid)?.let { JsonPrimitive(it.cents) } ?: JsonNull + LoanFieldKind.CHOICE, LoanFieldKind.TIMEZONE -> JsonPrimitive(text) + }) + } + } + } +} + +// ============================================================================================= +// Settings → Cards +// ============================================================================================= + +object CardsForm { + /** The changed card settings; null when none. Titles are sent trimmed. */ + fun body(original: Staff090LibraryCardSettings, current: Staff090LibraryCardSettings): JsonObject? { + val out = buildJsonObject { + if (current.layout != original.layout) put("layout", JsonPrimitive(current.layout)) + if (current.codeType != original.codeType) put("code_type", JsonPrimitive(current.codeType)) + if (current.format != original.format) put("format", JsonPrimitive(current.format)) + if (current.title.trim() != original.title) put("title", JsonPrimitive(current.title.trim())) + if (current.backText.trim() != original.backText) put("back_text", JsonPrimitive(current.backText.trim())) + if (current.showPhoto != original.showPhoto) put("show_photo", JsonPrimitive(current.showPhoto)) + if (current.showLogo != original.showLogo) put("show_logo", JsonPrimitive(current.showLogo)) + if (current.showExpiry != original.showExpiry) put("show_expiry", JsonPrimitive(current.showExpiry)) + } + return out.takeIf { it.isNotEmpty() } + } +} + +// ============================================================================================= +// Patron types +// ============================================================================================= + +/** The numeric fields of a patron type, in the web form's order. */ +enum class TypeNumber(val key: String, val money: Boolean = false) { + MAX_LOANS("max_loans"), + LOAN_DURATION("loan_duration_days"), + MAX_REQUEST_DURATION("max_request_duration_days"), + MAX_RENEWALS("max_renewals"), + RENEWAL_DAYS("renewal_days"), + MAX_RESERVATIONS("max_reservations"), + CARD_DURATION("card_duration_days"), + GRACE_DAYS("grace_days"), + DAILY_FINE("daily_fine_cents", money = true), + MAX_FINE("max_fine_per_loan_cents", money = true), + DEBT_BLOCK("debt_block_threshold_cents", money = true); + + fun of(t: Staff090LibraryPatronType): Int? = when (this) { + MAX_LOANS -> t.maxLoans; LOAN_DURATION -> t.loanDurationDays; MAX_REQUEST_DURATION -> t.maxRequestDurationDays + MAX_RENEWALS -> t.maxRenewals; RENEWAL_DAYS -> t.renewalDays; MAX_RESERVATIONS -> t.maxReservations + CARD_DURATION -> t.cardDurationDays; GRACE_DAYS -> t.graceDays; DAILY_FINE -> t.dailyFineCents + MAX_FINE -> t.maxFinePerLoanCents; DEBT_BLOCK -> t.debtBlockThresholdCents + } + + fun of(v: Staff090LibraryTypeValues): Int? = when (this) { + MAX_LOANS -> v.maxLoans; LOAN_DURATION -> v.loanDurationDays; MAX_REQUEST_DURATION -> v.maxRequestDurationDays + MAX_RENEWALS -> v.maxRenewals; RENEWAL_DAYS -> v.renewalDays; MAX_RESERVATIONS -> v.maxReservations + CARD_DURATION -> v.cardDurationDays; GRACE_DAYS -> v.graceDays; DAILY_FINE -> v.dailyFineCents + MAX_FINE -> v.maxFinePerLoanCents; DEBT_BLOCK -> v.debtBlockThresholdCents + } +} + +/** Patron type form; numbers stay text, empty = inherit the global value. */ +data class TypeForm( + val id: Int? = null, + val name: String = "", + val code: String = "", + val description: String = "", + val reservationsEnabled: Boolean = true, + val active: Boolean = true, + val isDefault: Boolean = false, + val numbers: Map = emptyMap(), +) { + fun text(n: TypeNumber) = numbers[n].orEmpty() + + companion object { + val CODE = Regex("^[a-z0-9_-]{0,40}$") + + fun from(t: Staff090LibraryPatronType, locale: Locale) = TypeForm( + id = t.id, name = t.name, code = t.code, description = t.description.orEmpty(), + reservationsEnabled = t.reservationsEnabled, active = t.active, isDefault = t.isDefault, + numbers = TypeNumber.entries.associateWith { n -> if (n.money) LibraryMoney.input(n.of(t), locale) else n.of(t)?.toString().orEmpty() }, + ) + } +} + +object TypeForms { + fun invalid(form: TypeForm, limits: Map): Set { + val out = mutableSetOf() + if (form.name.isBlank() || form.name.trim().length > 100) out += "name" + if (!TypeForm.CODE.matches(form.code.trim())) out += "code" + if (form.description.length > 2000) out += "description" + TypeNumber.entries.forEach { n -> + val text = form.text(n) + val bad = if (n.money) LibraryMoney.parse(text, limits["money_max_cents"]?.max ?: Long.MAX_VALUE) is CentsInput.Invalid + else IntInput.parse(text, limits[n.key]) is IntInput.Invalid + if (bad) out += n.key + } + return out + } + + private fun number(n: TypeNumber, text: String): JsonElement = + if (n.money) (LibraryMoney.parse(text) as? CentsInput.Valid)?.let { JsonPrimitive(it.cents) } ?: JsonNull + else (IntInput.parse(text, null) as? IntInput.Valid)?.let { JsonPrimitive(it.value) } ?: JsonNull + + /** A new type: every key the operator filled (an empty number is left to inherit). */ + fun createBody(form: TypeForm): JsonObject = buildJsonObject { + put("name", JsonPrimitive(form.name.trim())) + form.code.trim().takeIf { it.isNotEmpty() }?.let { put("code", JsonPrimitive(it)) } + form.description.trim().takeIf { it.isNotEmpty() }?.let { put("description", JsonPrimitive(it)) } + put("reservations_enabled", JsonPrimitive(form.reservationsEnabled)) + put("active", JsonPrimitive(form.active)) + if (form.isDefault) put("is_default", JsonPrimitive(true)) + TypeNumber.entries.forEach { n -> number(n, form.text(n)).takeIf { it !is JsonNull }?.let { put(n.key, it) } } + } + + /** An edit: only what changed; a cleared number goes as null (back to inheriting). */ + fun updateBody(original: TypeForm, form: TypeForm): JsonObject? { + val out = buildJsonObject { + if (form.name.trim() != original.name) put("name", JsonPrimitive(form.name.trim())) + if (form.code.trim() != original.code) put("code", JsonPrimitive(form.code.trim())) + if (form.description.trim() != original.description) { + put("description", form.description.trim().takeIf { it.isNotEmpty() }?.let(::JsonPrimitive) ?: JsonNull) + } + if (form.reservationsEnabled != original.reservationsEnabled) put("reservations_enabled", JsonPrimitive(form.reservationsEnabled)) + if (form.active != original.active) put("active", JsonPrimitive(form.active)) + if (form.isDefault && !original.isDefault) put("is_default", JsonPrimitive(true)) + TypeNumber.entries.forEach { n -> + val now = number(n, form.text(n)) + if (now != number(n, original.text(n))) put(n.key, now) + } + } + return out.takeIf { it.isNotEmpty() } + } + + /** + * The value an empty field falls back to. While the type leaves the field empty its + * `effective` policy is exactly that; once the type sets its own value `effective` holds + * that value instead, so the fallback comes from the globals (an empty renewal period + * still follows the loan duration the type would get). + */ + fun inherited(n: TypeNumber, type: Staff090LibraryPatronType?, globals: Staff090LibraryTypeValues): Int? { + if (type == null || n.of(type) == null) return type?.effective?.let { n.of(it) } ?: n.of(globals) + return n.of(globals) ?: if (n == TypeNumber.RENEWAL_DAYS) { + TypeNumber.LOAN_DURATION.of(type) ?: TypeNumber.LOAN_DURATION.of(globals) + } else null + } +} + +// ============================================================================================= +// Rules matrix +// ============================================================================================= + +enum class RuleNumber(val key: String, val money: Boolean = false) { + MAX_LOANS("max_loans"), + LOAN_DURATION("loan_duration_days"), + MAX_RENEWALS("max_renewals"), + RENEWAL_DAYS("renewal_days"), + GRACE_DAYS("grace_days"), + DAILY_FINE("daily_fine_cents", money = true), + MAX_FINE("max_fine_per_loan_cents", money = true); + + fun of(c: Staff090LibraryRuleCell): Int? = when (this) { + MAX_LOANS -> c.maxLoans; LOAN_DURATION -> c.loanDurationDays; MAX_RENEWALS -> c.maxRenewals + RENEWAL_DAYS -> c.renewalDays; GRACE_DAYS -> c.graceDays; DAILY_FINE -> c.dailyFineCents; MAX_FINE -> c.maxFinePerLoanCents + } + + fun inherited(c: Staff090LibraryRuleCell): Int? = when (this) { + MAX_LOANS -> c.inherited.maxLoans; LOAN_DURATION -> c.inherited.loanDurationDays; MAX_RENEWALS -> c.inherited.maxRenewals + RENEWAL_DAYS -> c.inherited.renewalDays; GRACE_DAYS -> c.inherited.graceDays + DAILY_FINE -> c.inherited.dailyFineCents; MAX_FINE -> c.inherited.maxFinePerLoanCents + } +} + +/** One cell being edited: loanable null = inherit; numbers as text, empty = inherit. */ +data class CellForm(val loanable: Boolean? = null, val numbers: Map = emptyMap()) { + fun text(n: RuleNumber) = numbers[n].orEmpty() + + companion object { + fun from(c: Staff090LibraryRuleCell, locale: Locale) = CellForm( + loanable = c.loanable, + numbers = RuleNumber.entries.associateWith { n -> if (n.money) LibraryMoney.input(n.of(c), locale) else n.of(c)?.toString().orEmpty() }, + ) + } +} + +object RulesForm { + fun key(typeId: Int, media: String) = "$typeId|$media" + + fun invalid(form: CellForm, limits: Map): Set = RuleNumber.entries.filter { n -> + val text = form.text(n) + if (n.money) LibraryMoney.parse(text, limits["money_max_cents"]?.max ?: Long.MAX_VALUE) is CentsInput.Invalid + else IntInput.parse(text, limits[n.key]) is IntInput.Invalid + }.map { it.key }.toSet() + + private fun number(n: RuleNumber, text: String): JsonElement = + if (n.money) (LibraryMoney.parse(text) as? CentsInput.Valid)?.let { JsonPrimitive(it.cents) } ?: JsonNull + else (IntInput.parse(text, null) as? IntInput.Valid)?.let { JsonPrimitive(it.value) } ?: JsonNull + + /** One cell of `PUT /staff/circulation/rules`: its address and only the changed values; null when none. */ + fun cellBody(cell: Staff090LibraryRuleCell, form: CellForm, locale: Locale): JsonObject? { + val original = CellForm.from(cell, locale) + val values = buildJsonObject { + if (form.loanable != original.loanable) put("loanable", form.loanable?.let(::JsonPrimitive) ?: JsonNull) + RuleNumber.entries.forEach { n -> + val now = number(n, form.text(n)) + if (now != number(n, original.text(n))) put(n.key, now) + } + } + if (values.isEmpty()) return null + return JsonObject(mapOf("type_id" to JsonPrimitive(cell.typeId), "media" to JsonPrimitive(cell.media)) + values) + } + + /** The changed cells among [edits] (key → form), in matrix order. */ + fun body(cells: List, edits: Map, locale: Locale): JsonArray = + JsonArray(cells.mapNotNull { c -> edits[key(c.typeId, c.media)]?.let { cellBody(c, it, locale) } }) + + /** True when a cell differs from what it inherits (it has its own value somewhere). */ + fun customised(c: Staff090LibraryRuleCell): Boolean = c.loanable != null || RuleNumber.entries.any { it.of(c) != null } +} + +// ============================================================================================= +// Closures calendar +// ============================================================================================= + +object CalendarGrid { + /** + * The month as rows of seven, weeks starting on Monday: null for the blank cells before the + * first day and after the last. `weekday` is the server's 0 = Sunday … 6 = Saturday. + */ + fun weeks(days: List): List> { + if (days.isEmpty()) return emptyList() + val lead = (days.first().weekday + 6) % 7 + val cells: List = List(lead) { null } + days + val padded = cells + List((7 - cells.size % 7) % 7) { null } + return padded.chunked(7) + } + + /** Monday-first order of the server's weekday numbers. */ + val MONDAY_FIRST = listOf(1, 2, 3, 4, 5, 6, 0) + + /** "2026-11" moved by [delta] months. */ + fun shift(month: String, delta: Long): String = runCatching { + LocalDate.parse("$month-01").plusMonths(delta).toString().substring(0, 7) + }.getOrDefault(month) + + /** Closures that are toggled as a whole each year (yearly ones not made by a preset). */ + fun yearly(closures: List) = closures.filter { it.yearly && it.presetRule == null } + + /** At least one weekday must stay open (the server refuses all seven). */ + fun weekdaysValid(closed: Set) = closed.size < 7 && closed.all { it in 0..6 } + + /** + * The weekday ticks after a reload (another month, another branch, a save elsewhere): the + * server's days, unless the operator changed them since the last answer ([before]), in which + * case the unsaved ticks stay. + */ + fun reseedWeekdays(draft: Set, before: Set?, server: Set): Set = + if (before == null || draft == before) server else draft + + /** + * The yearly switches after a reload: each switch the operator flipped and did not save keeps + * its position (while the closure still exists); every other one takes the server's. + */ + fun reseedYearly(draft: Map, before: List?, server: List): Map { + val old = yearly(before.orEmpty()).associate { it.id to it.active } + return yearly(server).associate { c -> + val mine = draft[c.id] + c.id to if (mine != null && c.id in old && mine != old[c.id]) mine else c.active + } + } + + /** + * The ticked national holidays after a new preview: another year or region of the same + * country keeps what the operator ticked or unticked (for the rules still listed); a new + * country, or a list the operator has not touched, takes the server's selection. + */ + fun reseedHolidays(selected: Set, touched: Boolean, sameCountry: Boolean, listed: List, serverChecked: Set): Set = + if (touched && sameCountry) selected.filter { it in listed }.toSet() else serverChecked +} + +/** Closure form (create and edit). */ +data class ClosureForm( + val id: Int? = null, + val startDate: String? = null, + val endDate: String? = null, + val description: String = "", + val yearly: Boolean = false, + val branchId: Int? = null, +) { + val datesValid: Boolean get() = startDate != null && (endDate == null || endDate >= startDate) + val valid: Boolean get() = datesValid && description.length <= 255 + + fun body(): JsonObject = buildJsonObject { + put("start_date", JsonPrimitive(startDate.orEmpty())) + put("end_date", endDate?.let(::JsonPrimitive) ?: JsonNull) + put("description", JsonPrimitive(description.trim())) + put("yearly", JsonPrimitive(yearly)) + put("branch_id", branchId?.let(::JsonPrimitive) ?: JsonNull) + } + + companion object { + fun from(c: Staff090LibraryClosure) = ClosureForm( + id = c.id, startDate = c.startDate, endDate = c.endDate.takeIf { it.isNotBlank() && it != c.startDate }, + description = c.description, yearly = c.yearly, branchId = c.branchId, + ) + } +} + +/** The fields of a custom holiday that its kind uses, besides name, duration, weekend rule, years, branch. */ +enum class CustomField { MONTH, DAY, WEEKDAY, OCCURRENCE, OFFSET, CALENDAR, DATES } + +/** Custom holiday form; numbers as text. */ +data class CustomForm( + val id: Int? = null, + val name: String = "", + val kind: String = "fixed", + val month: String = "1", + val day: String = "1", + val weekday: Int = 1, + val occurrence: Int = 1, + val offset: String = "0", + val calendar: String = "", + val dates: String = "", + val duration: String = "1", + val weekendRule: String? = null, + val yearFrom: String = "", + val yearUntil: String = "", + val branchId: Int? = null, + val active: Boolean = true, +) { + companion object { + fun from(h: Staff090LibraryCustomHoliday) = CustomForm( + id = h.id, name = h.name, kind = h.kind, month = h.month?.toString() ?: "1", day = h.day?.toString() ?: "1", + weekday = h.weekday ?: 1, occurrence = h.occurrence ?: 1, offset = h.offsetDays?.toString() ?: "0", + calendar = h.calendar.orEmpty(), dates = h.dates.joinToString("\n"), duration = h.durationDays.toString(), + weekendRule = h.weekendRule, yearFrom = h.yearFrom?.toString().orEmpty(), yearUntil = h.yearUntil?.toString().orEmpty(), + branchId = h.branchId, active = h.active, + ) + } +} + +object CustomForms { + private val DATE = Regex("""^\d{4}-\d{2}-\d{2}$""") + + fun fields(kind: String): Set = when (kind) { + "fixed" -> setOf(CustomField.MONTH, CustomField.DAY) + "nth_weekday" -> setOf(CustomField.OCCURRENCE, CustomField.WEEKDAY, CustomField.MONTH) + "easter", "orthodox_easter" -> setOf(CustomField.OFFSET) + "calendar" -> setOf(CustomField.CALENDAR, CustomField.MONTH, CustomField.DAY) + "dates" -> setOf(CustomField.DATES) + else -> emptySet() + } + + /** The dates typed one per line (or separated by commas/spaces), trimmed, without blanks. */ + fun dates(text: String): List = text.split('\n', ',', ';', ' ').map { it.trim() }.filter { it.isNotEmpty() } + + /** Days of [month] the form allows: 31 for the Gregorian kinds, 30 in another calendar. */ + fun maxDay(kind: String) = if (kind == "calendar") 30 else 31 + + fun invalid(form: CustomForm, maxDuration: Int, maxDates: Int, monthsInCalendar: Int): Set { + val out = mutableSetOf() + val fields = fields(form.kind) + if (form.name.isBlank() || form.name.length > 255) out += "name" + if (form.kind.isBlank()) out += "kind" + val maxMonth = if (form.kind == "calendar") monthsInCalendar.coerceAtLeast(1) else 12 + if (CustomField.MONTH in fields && IntInput.parse(form.month, com.pinakes.app.data.model.Staff090LibraryRange(1, maxMonth.toLong())) !is IntInput.Valid) out += "month" + if (CustomField.DAY in fields && IntInput.parse(form.day, com.pinakes.app.data.model.Staff090LibraryRange(1, maxDay(form.kind).toLong())) !is IntInput.Valid) out += "day" + if (CustomField.OFFSET in fields && IntInput.parse(form.offset, com.pinakes.app.data.model.Staff090LibraryRange(-200, 200), allowNegative = true) !is IntInput.Valid) out += "offset_days" + if (CustomField.CALENDAR in fields && form.calendar.isBlank()) out += "calendar" + if (CustomField.DATES in fields) { + val list = dates(form.dates) + if (list.isEmpty() || list.size > maxDates || list.any { !DATE.matches(it) || runCatching { LocalDate.parse(it) }.isFailure }) out += "dates" + } + if (IntInput.parse(form.duration, com.pinakes.app.data.model.Staff090LibraryRange(1, maxDuration.toLong())) !is IntInput.Valid) out += "duration_days" + val years = com.pinakes.app.data.model.Staff090LibraryRange(1900, 2199) + val from = IntInput.parse(form.yearFrom, years) + val until = IntInput.parse(form.yearUntil, years) + if (from is IntInput.Invalid) out += "year_from" + if (until is IntInput.Invalid) out += "year_until" + if (from is IntInput.Valid && until is IntInput.Valid && until.value < from.value) out += "year_until" + return out + } + + /** The whole holiday as the server stores it: the kind's fields, the others null. */ + fun body(form: CustomForm): JsonObject { + val fields = fields(form.kind) + fun int(text: String, on: Boolean) = if (on) text.trim().toIntOrNull()?.let(::JsonPrimitive) ?: JsonNull else JsonNull + return buildJsonObject { + put("name", JsonPrimitive(form.name.trim())) + put("kind", JsonPrimitive(form.kind)) + put("month", int(form.month, CustomField.MONTH in fields)) + put("day", int(form.day, CustomField.DAY in fields)) + put("weekday", if (CustomField.WEEKDAY in fields) JsonPrimitive(form.weekday) else JsonNull) + put("occurrence", if (CustomField.OCCURRENCE in fields) JsonPrimitive(form.occurrence) else JsonNull) + put("offset_days", int(form.offset, CustomField.OFFSET in fields)) + put("calendar", if (CustomField.CALENDAR in fields) JsonPrimitive(form.calendar) else JsonNull) + put("dates", if (CustomField.DATES in fields) JsonArray(dates(form.dates).map(::JsonPrimitive)) else JsonArray(emptyList())) + put("duration_days", form.duration.trim().toIntOrNull()?.let(::JsonPrimitive) ?: JsonPrimitive(1)) + put("weekend_rule", form.weekendRule?.let(::JsonPrimitive) ?: JsonNull) + put("year_from", form.yearFrom.trim().toIntOrNull()?.let(::JsonPrimitive) ?: JsonNull) + put("year_until", form.yearUntil.trim().toIntOrNull()?.let(::JsonPrimitive) ?: JsonNull) + put("branch_id", form.branchId?.let(::JsonPrimitive) ?: JsonNull) + put("active", JsonPrimitive(form.active)) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/LibraryGraph.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/LibraryGraph.kt new file mode 100644 index 0000000..f704be6 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/LibraryGraph.kt @@ -0,0 +1,27 @@ +package com.pinakes.app.ui.screens.staff.s090.library + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import com.pinakes.app.ui.screens.staff.s090.Staff090Routes +import com.pinakes.app.ui.screens.staff.s090.Staff090Transitions + +/** Library area routes beyond the hub's entry doors. */ +internal object LibraryRoutes { + const val RULES_MATRIX = "staff/s090/circulation-rules/matrix" +} + +internal fun NavGraphBuilder.libraryGraph(nav: NavHostController, t: Staff090Transitions) { + composable(Staff090Routes.LIBRARY_SETTINGS, enterTransition = t.enter, popExitTransition = t.exit) { + LibrarySettingsScreen(onNavigateUp = { nav.popBackStack() }) + } + composable(Staff090Routes.CIRCULATION_RULES, enterTransition = t.enter, popExitTransition = t.exit) { + PatronTypesScreen(onNavigateUp = { nav.popBackStack() }, onOpenMatrix = { nav.navigate(LibraryRoutes.RULES_MATRIX) }) + } + composable(LibraryRoutes.RULES_MATRIX, enterTransition = t.enter, popExitTransition = t.exit) { + RulesMatrixScreen(onNavigateUp = { nav.popBackStack() }) + } + composable(Staff090Routes.CLOSURES_CALENDAR, enterTransition = t.enter, popExitTransition = t.exit) { + ClosuresCalendarScreen(onNavigateUp = { nav.popBackStack() }) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/LibrarySettingsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/LibrarySettingsScreen.kt new file mode 100644 index 0000000..b953d0f --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/LibrarySettingsScreen.kt @@ -0,0 +1,536 @@ +package com.pinakes.app.ui.screens.staff.s090.library + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.Staff090LibraryCardSettings +import com.pinakes.app.data.model.Staff090LibraryCards +import com.pinakes.app.data.model.Staff090LibraryLoans +import com.pinakes.app.data.model.Staff090LibraryModule +import com.pinakes.app.data.model.Staff090LibraryModules +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.AuthRepository +import com.pinakes.app.data.repository.Staff090LibraryRepository +import com.pinakes.app.ui.common.AppLocale +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.LookupField +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +enum class SettingsTab { MODULES, LOANS, CARDS } + +data class LibrarySettingsUiState( + val tab: SettingsTab = SettingsTab.MODULES, + val loading: Boolean = true, + val error: StaffMessage? = null, + /** Set when the screen must close (role gone): the reason shown on the way out. */ + val exit: StaffMessage? = null, + val modules: Staff090LibraryModules? = null, + val moduleValues: Map = emptyMap(), + val retention: String = "", + val loans: Staff090LibraryLoans? = null, + val loanOriginal: Map = emptyMap(), + val loanValues: Map = emptyMap(), + /** `meta.fields` of the last refused save: field → message. */ + val fieldErrors: Map = emptyMap(), + /** Null when this user may not see the card settings (module off, not an administrator). */ + val cards: Staff090LibraryCards? = null, + val cardValues: Staff090LibraryCardSettings = Staff090LibraryCardSettings(), + val saving: Boolean = false, + val formError: StaffMessage? = null, + val message: StaffMessage? = null, +) { + val tabs: List get() = listOfNotNull(SettingsTab.MODULES, SettingsTab.LOANS, if (cards != null) SettingsTab.CARDS else null) + + val modulesBody get() = modules?.let { ModulesForm.body(it.modules.associate { m -> m.key to m.stored }, moduleValues, it.attendanceRetentionDays, retention) } + val retentionInvalid: Boolean get() = modules?.let { IntInput.parse(retention, com.pinakes.app.data.model.Staff090LibraryRange(0, it.attendanceRetentionMaxDays.toLong())) !is IntInput.Valid } ?: false + val loanInvalid: Set get() = loans?.let { LoansForm.invalid(it, loanValues) }.orEmpty() + val loansBody get() = loans?.let { LoansForm.body(it, loanOriginal, loanValues) } + val cardsBody get() = cards?.let { CardsForm.body(it.settings, cardValues) } + + /** Whether the open tab shows a save action at all (hidden, not disabled, without permission). */ + val canSave: Boolean get() = when (tab) { + SettingsTab.MODULES -> modules?.canConfigure == true + SettingsTab.LOANS -> loans?.editableFields?.isNotEmpty() == true + SettingsTab.CARDS -> cards?.canConfigure == true + } + + val hasChanges: Boolean get() = when (tab) { + SettingsTab.MODULES -> modulesBody != null && !retentionInvalid + SettingsTab.LOANS -> loansBody != null && loanInvalid.none { LoansForm.editable(loans!!, it) } + SettingsTab.CARDS -> cardsBody != null + } +} + +@HiltViewModel +class LibrarySettingsViewModel @Inject constructor( + private val repo: Staff090LibraryRepository, + private val auth: AuthRepository, +) : ViewModel() { + private val _state = MutableStateFlow(LibrarySettingsUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load() } + + fun load() { + _state.update { it.copy(loading = it.modules == null, error = null) } + viewModelScope.launch { + val modules = async { repo.modules() } + val loans = async { repo.loans() } + val cards = async { repo.cards() } + val m = modules.await() + val l = loans.await() + val c = cards.await() + _state.update { s -> + val failure = (m as? ApiResult.Failure) ?: (l as? ApiResult.Failure) + if (failure != null) { + return@update s.copy(loading = false, exit = failure.exitMessage(), error = failure.toLibraryMessage(R.string.staff_error_load)) + } + var next = s.copy(loading = false, error = null) + next = next.withModules((m as ApiResult.Success).data).withLoans((l as ApiResult.Success).data) + // Cards: a 404 feature_disabled (module off, Staff) only hides the tab. + next = if (c is ApiResult.Success) next.withCards(c.data) else next.copy(cards = null) + if (next.tab !in next.tabs) next.copy(tab = SettingsTab.MODULES) else next + } + } + } + + private fun LibrarySettingsUiState.withModules(m: Staff090LibraryModules) = + copy(modules = m, moduleValues = m.modules.associate { it.key to it.stored }, retention = m.attendanceRetentionDays.toString()) + + private fun LibrarySettingsUiState.withLoans(l: Staff090LibraryLoans): LibrarySettingsUiState { + val initial = LoansForm.initial(l, AppLocale.current()) + return copy(loans = l, loanOriginal = initial, loanValues = initial) + } + + private fun LibrarySettingsUiState.withCards(c: Staff090LibraryCards) = copy(cards = c, cardValues = c.settings) + + /** [withLoans] for a background re-read: unsaved edits of the loans tab stay. */ + private fun LibrarySettingsUiState.withLoansKeepingDrafts(l: Staff090LibraryLoans): LibrarySettingsUiState { + val initial = LoansForm.initial(l, AppLocale.current()) + return copy(loans = l, loanOriginal = initial, loanValues = LoansForm.reseed(loanValues, loanOriginal, initial)) + } + + /** [withCards] for a background re-read: an unsaved edit of the cards tab stays. */ + private fun LibrarySettingsUiState.withCardsKeepingDrafts(c: Staff090LibraryCards) = + copy(cards = c, cardValues = if (cards == null || cardValues == cards.settings) c.settings else cardValues) + + fun setTab(tab: SettingsTab) = _state.update { it.copy(tab = tab, formError = null, fieldErrors = emptyMap()) } + fun setModule(key: String, on: Boolean) = _state.update { it.copy(moduleValues = it.moduleValues + (key to on), formError = null) } + fun setRetention(value: String) = _state.update { it.copy(retention = value, formError = null) } + fun setLoan(key: String, value: String) = _state.update { it.copy(loanValues = it.loanValues + (key to value), fieldErrors = it.fieldErrors - key, formError = null) } + fun editCards(transform: (Staff090LibraryCardSettings) -> Staff090LibraryCardSettings) = _state.update { it.copy(cardValues = transform(it.cardValues), formError = null) } + + fun save() { + val s = _state.value + if (s.saving || !s.canSave || !s.hasChanges) return + _state.update { it.copy(saving = true, formError = null, fieldErrors = emptyMap()) } + viewModelScope.launch { + when (s.tab) { + SettingsTab.MODULES -> { + val saved = repo.saveModules(s.modulesBody ?: return@launch) + finish(saved.result, saved.fields) { st, data -> st.withModules(data) } + // A module switched on or off changes what the loans tab and the cards tab show. + // The hub's entries follow /health: re-read it so a module switched off + // disappears now instead of on the next app start. + if (saved.result is ApiResult.Success) { + refreshSecondary() + auth.refreshHealth() + } + } + SettingsTab.LOANS -> { + val saved = repo.saveLoans(s.loansBody ?: return@launch) + finish(saved.result, saved.fields) { st, data -> st.withLoans(data) } + } + SettingsTab.CARDS -> { + val saved = repo.saveCards(s.cardsBody ?: return@launch) + finish(saved.result, saved.fields) { st, data -> st.withCards(data) } + } + } + } + } + + private fun refreshSecondary() { + viewModelScope.launch { + val c = repo.cards() + // A modules save must not drop what the operator typed on the other tabs. + (repo.loans() as? ApiResult.Success)?.data?.let { l -> _state.update { it.withLoansKeepingDrafts(l) } } + _state.update { if (c is ApiResult.Success) it.withCardsKeepingDrafts(c.data) else it.copy(cards = null, tab = if (it.tab == SettingsTab.CARDS) SettingsTab.MODULES else it.tab) } + } + } + + private fun finish(res: ApiResult, fields: Map, apply: (LibrarySettingsUiState, T) -> LibrarySettingsUiState) { + _state.update { + when (res) { + is ApiResult.Success -> apply(it, res.data).copy(saving = false, message = StaffMessage(R.string.staff090_library_saved)) + is ApiResult.Failure -> it.copy( + saving = false, fieldErrors = fields, + // Fields named by the server show under their input; the banner carries the message. + formError = res.toLibraryMessage(R.string.staff_error_save), + ) + } + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** Settings → Modules, Loans and Cards of the 0.9.0 web admin. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LibrarySettingsScreen(onNavigateUp: () -> Unit) { + val vm: LibrarySettingsViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + LeaveWhen(!areas.s090.librarySettings, onNavigateUp) + LibraryExit(state.exit, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff090_settings), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + // The save action stays in reach under a long tab; hidden (not disabled) without permission. + bottomBar = { + if (!state.loading && state.error == null && state.canSave) { + Surface(color = MaterialTheme.colorScheme.background) { + PrimaryButton( + stringResource(R.string.action_save), onClick = vm::save, loading = state.saving, enabled = state.hasChanges, + modifier = Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = Spacing.lg, vertical = Spacing.sm).testTag("settings-save"), + ) + } + } + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + if (!state.loading && state.error == null) { + PrimaryScrollableTabRow(selectedTabIndex = state.tabs.indexOf(state.tab).coerceAtLeast(0), edgePadding = Spacing.lg) { + state.tabs.forEach { tab -> + Tab(selected = tab == state.tab, onClick = { vm.setTab(tab) }, text = { Text(stringResource(tabLabel(tab))) }, + modifier = Modifier.testTag("settings-tab-${tab.name.lowercase()}")) + } + } + } + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = false, + emptyTitle = "", emptyIcon = Icons.Outlined.Settings, onRetry = vm::load, + ) { + val list = rememberLazyListState() + // A refused save explains itself in the banner at the top: bring it into view. + LaunchedEffect(state.formError) { if (state.formError != null) list.animateScrollToItem(0) } + LazyColumn(Modifier.fillMaxSize(), state = list, contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + item(key = "banner") { state.formError?.let { InlineErrorBanner(message = it.resolve(), modifier = Modifier.padding(top = Spacing.sm)) } } + when (state.tab) { + SettingsTab.MODULES -> state.modules?.let { m -> item(key = "modules") { ModulesTab(m, state, vm::setModule, vm::setRetention) } } + SettingsTab.LOANS -> state.loans?.let { l -> item(key = "loans") { LoansTab(l, state, vm::setLoan) } } + SettingsTab.CARDS -> state.cards?.let { c -> item(key = "cards") { CardsTab(c, state, vm::editCards) } } + } + } + } + } + } +} + +private fun tabLabel(tab: SettingsTab) = when (tab) { + SettingsTab.MODULES -> R.string.staff090_library_tab_modules + SettingsTab.LOANS -> R.string.staff090_library_tab_loans + SettingsTab.CARDS -> R.string.staff090_library_tab_cards +} + +/** Settings → Modules: switches for an administrator, the plain state otherwise. Stateless. */ +@Composable +internal fun ModulesTab(m: Staff090LibraryModules, state: LibrarySettingsUiState, onModule: (String, Boolean) -> Unit, onRetention: (String) -> Unit) { + Column { + if (!m.canConfigure) { + InfoCard(Modifier.padding(top = Spacing.sm)) { + Text(stringResource(R.string.staff090_library_modules_read_only), style = MaterialTheme.typography.bodyMedium) + } + } + if (m.catalogueMode) { + InfoCard(Modifier.padding(top = Spacing.sm)) { + Text(stringResource(R.string.staff090_library_catalogue_mode), style = MaterialTheme.typography.bodyMedium) + } + } + listOf("modules" to R.string.staff090_library_group_modules, "loans" to R.string.staff090_library_group_loans).forEach { (group, title) -> + val list = ModulesForm.group(m.modules, group) + if (list.isEmpty()) return@forEach + FormSectionTitle(stringResource(title)) + list.forEach { module -> + ModuleRow(module, m, state.moduleValues[module.key] ?: module.stored, onModule) + if (module.key == "attendance_retention") RetentionRow(m, state, onRetention) + } + } + } +} + +@Composable +private fun ModuleRow(module: Staff090LibraryModule, m: Staff090LibraryModules, checked: Boolean, onModule: (String, Boolean) -> Unit) { + val notes = ModulesForm.notes(module, m.modules, m.catalogueMode).map { note -> + when (note) { + is ModulesForm.Note.NeedsParent -> stringResource( + if (note.parentOn) R.string.staff090_library_module_needs else R.string.staff090_library_module_needs_off, note.parentLabel, + ) + ModulesForm.Note.CatalogueMode -> stringResource(R.string.staff090_library_module_catalogue_off) + is ModulesForm.Note.Follows -> stringResource(R.string.staff090_library_module_follows, note.label) + } + } + val subtitle = (listOf(module.help) + notes).filter { it.isNotBlank() }.joinToString("\n") + if (m.canConfigure) { + SwitchRow(module.label, checked, { onModule(module.key, it) }, subtitle = subtitle) + } else { + ReadOnlySetting( + module.label, + stringResource(if (module.effective) R.string.staff090_library_on else R.string.staff090_library_off), + subtitle, + ) + } +} + +@Composable +private fun RetentionRow(m: Staff090LibraryModules, state: LibrarySettingsUiState, onRetention: (String) -> Unit) { + val label = stringResource(R.string.staff090_library_retention_days) + if (m.canConfigure) { + NumberField( + label, state.retention, onRetention, invalid = state.retentionInvalid, + hint = stringResource(R.string.staff090_library_retention_hint, m.attendanceRetentionMaxDays), + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("retention-days"), + ) + } else { + ReadOnlySetting(label, m.attendanceRetentionDays.toString(), stringResource(R.string.staff090_library_retention_hint, m.attendanceRetentionMaxDays)) + } +} + +/** Settings → Loans: inputs for the fields this user may save, the value for the others. Stateless. */ +@Composable +internal fun LoansTab(l: Staff090LibraryLoans, state: LibrarySettingsUiState, onLoan: (String, String) -> Unit) { + val fields = LoansForm.fields(l) + val invalid = state.loanInvalid + val adminOnlyShown = fields.any { it.key in l.adminOnlyFields && !LoansForm.editable(l, it.key) } + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + LoanSection.entries.forEach { section -> + val inSection = fields.filter { it.section == section } + if (inSection.isEmpty()) return@forEach + FormSectionTitle(stringResource(loanSectionTitle(section))) + if (section == LoanSection.FINES && adminOnlyShown) { + Text(stringResource(R.string.staff090_library_fines_admin_only), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + inSection.forEach { field -> LoanFieldRow(l, field, state.loanValues[field.key].orEmpty(), field.key in invalid, state.fieldErrors[field.key], onLoan) } + } + } +} + +@Composable +private fun LoanFieldRow(l: Staff090LibraryLoans, field: LoanField, value: String, invalid: Boolean, serverError: String?, onLoan: (String, String) -> Unit) { + val label = stringResource(loanFieldLabel(field.key)) + val help = loanFieldHelp(field.key)?.let { stringResource(it) }.orEmpty() + val range = l.limits[field.key] + val editable = LoansForm.editable(l, field.key) + val tag = Modifier.fillMaxWidth().testTag("loan-${field.key}") + if (!editable) { + val shown = when (field.kind) { + LoanFieldKind.BOOL -> stringResource(if (value.toBoolean()) R.string.staff090_library_on else R.string.staff090_library_off) + LoanFieldKind.MONEY -> money((LibraryMoney.parse(value) as? CentsInput.Valid)?.cents, l.currency) + LoanFieldKind.CHOICE -> loanOptionLabel(field.key, value)?.let { stringResource(it) } ?: value + else -> value.ifBlank { "—" } + } + ReadOnlySetting(label, shown, help) + return + } + when (field.kind) { + LoanFieldKind.BOOL -> SwitchRow(label, value.toBoolean(), { onLoan(field.key, it.toString()) }, subtitle = help) + LoanFieldKind.INT -> NumberField( + label, value, { onLoan(field.key, it) }, invalid = invalid || serverError != null, + hint = serverError ?: listOf(help, range?.let { stringResource(R.string.staff090_library_range, it.min, it.max) }.orEmpty()).filter { it.isNotBlank() }.joinToString(" "), + modifier = tag, + ) + LoanFieldKind.MONEY -> MoneyField( + label, value, l.currency, { onLoan(field.key, it) }, invalid = invalid || serverError != null, + hint = serverError ?: help, modifier = tag, + ) + LoanFieldKind.CHOICE -> ChoiceField( + label, value, l.options[field.key].orEmpty().map { LookupOption(it, loanOptionLabel(field.key, it)?.let { r -> stringResource(r) } ?: it) }, + { onLoan(field.key, it) }, modifier = tag, + ) + LoanFieldKind.TIMEZONE -> { + val zones = remember { timeZones() } + LookupField( + label = label, value = value, options = zones, editable = true, allowClear = false, + onValueChange = { onLoan(field.key, it) }, modifier = tag, + ) + if (help.isNotBlank() || serverError != null) { + Text(serverError ?: help, style = MaterialTheme.typography.bodySmall, + color = if (serverError != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +/** Region-based time zone ids (what PHP's DateTimeZone::listIdentifiers() offers). */ +private fun timeZones(): List = java.time.ZoneId.getAvailableZoneIds() + .filter { '/' in it && !it.startsWith("Etc/") && !it.startsWith("SystemV/") && it.first().isUpperCase() } + .sorted().map { LookupOption(it, it) } + +/** Settings → Cards. Stateless. */ +@Composable +internal fun CardsTab(c: Staff090LibraryCards, state: LibrarySettingsUiState, onCards: ((Staff090LibraryCardSettings) -> Staff090LibraryCardSettings) -> Unit) { + val v = state.cardValues + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + if (!c.moduleEnabled) { + InfoCard(Modifier.padding(top = Spacing.sm)) { + Text(stringResource(R.string.staff090_library_cards_module_off), style = MaterialTheme.typography.bodyMedium) + } + } + FormSectionTitle(stringResource(R.string.staff090_library_cards_layout_section)) + ChoiceField(stringResource(R.string.staff090_library_card_layout), v.layout, + c.options["layout"].orEmpty().map { LookupOption(it, cardOptionLabel(it)?.let { r -> stringResource(r) } ?: it) }, + { x -> onCards { it.copy(layout = x) } }, modifier = Modifier.fillMaxWidth().testTag("card-layout")) + ChoiceField(stringResource(R.string.staff090_library_card_code_type), v.codeType, + c.options["code_type"].orEmpty().map { LookupOption(it, cardOptionLabel(it)?.let { r -> stringResource(r) } ?: it) }, + { x -> onCards { it.copy(codeType = x) } }) + ChoiceField(stringResource(R.string.staff090_library_card_format), v.format, + c.options["format"].orEmpty().map { LookupOption(it, cardOptionLabel(it)?.let { r -> stringResource(r) } ?: it) }, + { x -> onCards { it.copy(format = x) } }) + FormSectionTitle(stringResource(R.string.staff090_library_cards_text_section)) + PinakesTextField( + value = v.title, onValueChange = { x -> onCards { it.copy(title = x) } }, label = stringResource(R.string.staff090_library_card_title), + maxLength = c.limits.titleMax, supportingText = stringResource(R.string.staff090_library_card_title_hint), + isError = state.fieldErrors["title"] != null, errorText = state.fieldErrors["title"].orEmpty(), modifier = Modifier.fillMaxWidth(), + ) + PinakesTextField( + value = v.backText, onValueChange = { x -> onCards { it.copy(backText = x) } }, label = stringResource(R.string.staff090_library_card_back_text), + maxLength = c.limits.backTextMax, singleLine = false, supportingText = stringResource(R.string.staff090_library_card_back_text_hint), + isError = state.fieldErrors["back_text"] != null, errorText = state.fieldErrors["back_text"].orEmpty(), + modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp), + ) + FormSectionTitle(stringResource(R.string.staff090_library_cards_show_section)) + SwitchRow(stringResource(R.string.staff090_library_card_show_photo), v.showPhoto, { x -> onCards { it.copy(showPhoto = x) } }) + SwitchRow(stringResource(R.string.staff090_library_card_show_logo), v.showLogo, { x -> onCards { it.copy(showLogo = x) } }) + SwitchRow(stringResource(R.string.staff090_library_card_show_expiry), v.showExpiry, { x -> onCards { it.copy(showExpiry = x) } }) + } +} + +private fun loanSectionTitle(s: LoanSection) = when (s) { + LoanSection.APPROVAL -> R.string.staff090_library_loans_approval + LoanSection.DURATION -> R.string.staff090_library_loans_duration + LoanSection.PICKUP -> R.string.staff090_library_loans_pickup + LoanSection.CARD -> R.string.staff090_library_loans_card + LoanSection.RENEWALS -> R.string.staff090_library_loans_renewals + LoanSection.LIMITS -> R.string.staff090_library_loans_limits + LoanSection.RECALLS -> R.string.staff090_library_loans_recalls + LoanSection.FINES -> R.string.staff090_library_loans_fines +} + +internal fun loanFieldLabel(key: String) = when (key) { + "auto_approve_requests" -> R.string.staff090_library_loan_auto_approve + "loan_duration_days" -> R.string.staff090_library_loan_duration + "max_loan_duration_days" -> R.string.staff090_library_loan_max_duration + "app_timezone" -> R.string.staff090_library_loan_timezone + "pickup_expiry_days" -> R.string.staff090_library_loan_pickup_days + "pickup_reminder_days" -> R.string.staff090_library_loan_pickup_reminder + "card_expiry_warning_days" -> R.string.staff090_library_loan_card_warning + "card_renewal_base" -> R.string.staff090_library_loan_card_renewal_base + "max_renewals" -> R.string.staff090_library_loan_max_renewals + "max_active_loans_per_user" -> R.string.staff090_library_loan_max_active + "allow_multiple_loans_same_book" -> R.string.staff090_library_loan_multiple_copies + "recall_auto_enabled" -> R.string.staff090_library_loan_recalls + "recall_interval_days" -> R.string.staff090_library_loan_recall_interval + "recall_max_count" -> R.string.staff090_library_loan_recall_max + "fine_grace_mode" -> R.string.staff090_library_loan_grace_mode + "daily_fine_cents" -> R.string.staff090_library_loan_daily_fine + "fine_grace_days" -> R.string.staff090_library_loan_grace_days + "fine_max_per_loan_cents" -> R.string.staff090_library_loan_max_fine + "debt_block_threshold_cents" -> R.string.staff090_library_loan_debt_block + "fine_charged_email" -> R.string.staff090_library_loan_fine_email + else -> R.string.staff090_library_loan_unknown +} + +private fun loanFieldHelp(key: String): Int? = when (key) { + "auto_approve_requests" -> R.string.staff090_library_loan_auto_approve_help + "loan_duration_days" -> R.string.staff090_library_loan_duration_help + "max_loan_duration_days" -> R.string.staff090_library_loan_max_duration_help + "app_timezone" -> R.string.staff090_library_loan_timezone_help + "pickup_expiry_days" -> R.string.staff090_library_loan_pickup_days_help + "pickup_reminder_days" -> R.string.staff090_library_loan_pickup_reminder_help + "card_expiry_warning_days" -> R.string.staff090_library_loan_card_warning_help + "max_renewals" -> R.string.staff090_library_loan_max_renewals_help + "max_active_loans_per_user" -> R.string.staff090_library_loan_max_active_help + "allow_multiple_loans_same_book" -> R.string.staff090_library_loan_multiple_copies_help + "recall_auto_enabled" -> R.string.staff090_library_loan_recalls_help + "daily_fine_cents" -> R.string.staff090_library_loan_daily_fine_help + "fine_grace_days" -> R.string.staff090_library_loan_grace_days_help + "fine_max_per_loan_cents" -> R.string.staff090_library_loan_max_fine_help + "debt_block_threshold_cents" -> R.string.staff090_library_loan_debt_block_help + "fine_charged_email" -> R.string.staff090_library_loan_fine_email_help + else -> null +} + +private fun loanOptionLabel(key: String, value: String): Int? = when ("$key=$value") { + "fine_grace_mode=threshold" -> R.string.staff090_library_grace_threshold + "fine_grace_mode=deduct" -> R.string.staff090_library_grace_deduct + "card_renewal_base=today" -> R.string.staff090_library_renewal_today + "card_renewal_base=expiry" -> R.string.staff090_library_renewal_expiry + else -> null +} + +private fun cardOptionLabel(value: String): Int? = when (value) { + "classic" -> R.string.staff090_library_card_layout_classic + "minimal" -> R.string.staff090_library_card_layout_minimal + "portrait" -> R.string.staff090_library_card_layout_portrait + "code128" -> R.string.staff090_library_card_code_barcode + "qr" -> R.string.staff090_library_card_code_qr + "single" -> R.string.staff090_library_card_format_single + "sheet" -> R.string.staff090_library_card_format_sheet + else -> null +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/LibraryUi.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/LibraryUi.kt new file mode 100644 index 0000000..0b476bf --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/LibraryUi.kt @@ -0,0 +1,116 @@ +package com.pinakes.app.ui.screens.staff.s090.library + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import com.pinakes.app.R +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.staff.Staff090LibraryErrors +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.screens.movements.MoneyFormat +import com.pinakes.app.ui.screens.staff.LookupField +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.common.AppLocale +import com.pinakes.app.ui.common.serverMessage +import com.pinakes.app.ui.theme.Spacing + +// Small pieces shared by the library settings screens; everything else is the staff kit. + +/** Our wording for the codes of these areas, else the usual staff mapping (server text included). */ +internal fun ApiResult.Failure.toLibraryMessage(fallback: Int): StaffMessage = when { + Staff090LibraryErrors.isNotPermitted(this) -> serverMessage()?.let { StaffMessage(text = it) } ?: StaffMessage(R.string.staff090_library_admin_only) + else -> Staff090LibraryErrors.messageRes(code)?.let { StaffMessage(it) } ?: toStaffMessage(fallback) +} + +/** + * Why the screen closes after its first load failed (area refused to this account, module off + * for Staff): the server's own message, else ours. Null while the screen may stay. + */ +internal fun ApiResult.Failure.exitMessage(): StaffMessage? = + if (!Staff090LibraryErrors.closesScreen(this)) null + else serverMessage()?.let { StaffMessage(text = it) } ?: StaffMessage(R.string.staff090_library_admin_only) + +/** Leaves the screen with [message] in a toast (the screen that would host a snackbar is going away). */ +@Composable +internal fun LibraryExit(message: StaffMessage?, onNavigateUp: () -> Unit) { + val context = LocalContext.current + val text = message?.let { + when { + !it.text.isNullOrBlank() -> it.text + it.res != null -> context.getString(it.res, *it.args.toTypedArray()) + else -> null + } + } + LaunchedEffect(message) { + if (message != null) { + if (!text.isNullOrBlank()) Toast.makeText(context, text, Toast.LENGTH_LONG).show() + onNavigateUp() + } + } +} + +/** A setting shown without a control (no permission to change it): title, help, value. */ +@Composable +internal fun ReadOnlySetting(title: String, value: String, subtitle: String? = null) { + Row(Modifier.fillMaxWidth().padding(vertical = Spacing.xs), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) + if (!subtitle.isNullOrBlank()) { + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = Spacing.md)) + } +} + +/** A whole-number field; [hint] says what an empty value means or the allowed range. */ +@Composable +internal fun NumberField(label: String, value: String, onChange: (String) -> Unit, invalid: Boolean, modifier: Modifier = Modifier, hint: String = "", signed: Boolean = false) { + PinakesTextField( + value = value, + onValueChange = { v -> onChange(v.filterIndexed { i, c -> c.isDigit() || (signed && c == '-' && i == 0) }) }, + label = label, isError = invalid, errorText = stringResource(R.string.staff_field_invalid), supportingText = hint, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), maxLength = 9, modifier = Modifier.fillMaxWidth().then(modifier), + ) +} + +/** An amount field in [currency]; the text is parsed to cents exactly ([LibraryMoney]). */ +@Composable +internal fun MoneyField(label: String, value: String, currency: String?, onChange: (String) -> Unit, invalid: Boolean, modifier: Modifier = Modifier, hint: String = "") { + PinakesTextField( + value = value, + onValueChange = { v -> onChange(v.filter { it.isDigit() || it == ',' || it == '.' }) }, + label = if (currency.isNullOrBlank()) label else stringResource(R.string.staff090_library_money_label, label, currency), + isError = invalid, errorText = stringResource(R.string.staff090_library_money_invalid), supportingText = hint, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), maxLength = 12, modifier = Modifier.fillMaxWidth().then(modifier), + ) +} + +/** A pick-one field over fixed options (value → label). */ +@Composable +internal fun ChoiceField(label: String, value: String, options: List, onChange: (String) -> Unit, modifier: Modifier = Modifier) { + LookupField( + label = label, value = value, displayValue = options.firstOrNull { it.value == value }?.label ?: value, + options = options, editable = false, allowClear = false, onValueChange = onChange, modifier = Modifier.fillMaxWidth().then(modifier), + ) +} + +/** [cents] in the server currency, in the app language; "—" for no amount. */ +internal fun money(cents: Int?, currency: String?): String = + cents?.let { MoneyFormat.format(it, currency, AppLocale.current()) } ?: "—" diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/PatronTypesScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/PatronTypesScreen.kt new file mode 100644 index 0000000..69a8ed5 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/PatronTypesScreen.kt @@ -0,0 +1,430 @@ +package com.pinakes.app.ui.screens.staff.s090.library + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Badge +import androidx.compose.material.icons.outlined.GridOn +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.Staff090LibraryPatronType +import com.pinakes.app.data.model.Staff090LibraryTypes +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.Staff090LibraryRepository +import com.pinakes.app.data.staff.SubmissionKeys +import com.pinakes.app.ui.common.AppLocale +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.AvailabilityChip +import com.pinakes.app.ui.components.AvailabilityStatus +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.StaffActionRow +import com.pinakes.app.ui.screens.staff.StaffFab +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class PatronTypesUiState( + val data: Staff090LibraryTypes? = null, + val loading: Boolean = true, + val refreshing: Boolean = false, + val error: StaffMessage? = null, + val exit: StaffMessage? = null, + /** The sheet: a new type (id null) or an edit, with the values it started from. */ + val form: TypeForm? = null, + val original: TypeForm? = null, + val fieldErrors: Map = emptyMap(), + val formError: StaffMessage? = null, + val saving: Boolean = false, + val busy: Boolean = false, + val confirmDelete: Staff090LibraryPatronType? = null, + val message: StaffMessage? = null, +) { + val editing: Staff090LibraryPatronType? get() = form?.id?.let { id -> data?.types?.firstOrNull { it.id == id } } + val invalid: Set get() = form?.let { TypeForms.invalid(it, data?.limits.orEmpty()) }.orEmpty() + val canSave: Boolean get() = form != null && invalid.isEmpty() && (form.id == null || original?.let { TypeForms.updateBody(it, form) } != null) +} + +@HiltViewModel +class PatronTypesViewModel @Inject constructor( + private val repo: Staff090LibraryRepository, +) : ViewModel() { + private val _state = MutableStateFlow(PatronTypesUiState()) + val state: StateFlow = _state.asStateFlow() + private val keys = SubmissionKeys() + + init { load() } + + fun load() { + _state.update { it.copy(loading = it.data == null && !it.refreshing, error = null) } + viewModelScope.launch { + val res = repo.types() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(data = res.data, loading = false, refreshing = false) + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, exit = res.exitMessage(), error = res.toLibraryMessage(R.string.staff_error_load)) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + load() + } + + fun startCreate() = _state.update { it.copy(form = TypeForm(), original = null, fieldErrors = emptyMap(), formError = null) } + + fun startEdit(t: Staff090LibraryPatronType) { + val form = TypeForm.from(t, AppLocale.current()) + _state.update { it.copy(form = form, original = form, fieldErrors = emptyMap(), formError = null) } + } + + fun edit(transform: (TypeForm) -> TypeForm) = _state.update { s -> s.copy(form = s.form?.let(transform), formError = null) } + fun closeForm() = _state.update { it.copy(form = null, original = null, fieldErrors = emptyMap()) } + + fun save() { + val s = _state.value + val form = s.form ?: return + if (s.saving || !s.canSave) return + _state.update { it.copy(saving = true, formError = null, fieldErrors = emptyMap()) } + viewModelScope.launch { + val saved = if (form.id == null) { + val body = TypeForms.createBody(form) + repo.createType(body, keys.keyFor(body)).also { r -> + if (r.result is ApiResult.Success || (r.result as? ApiResult.Failure)?.code == SubmissionKeys.CONFLICT) keys.reset() + } + } else { + repo.updateType(form.id, TypeForms.updateBody(s.original ?: form, form) ?: return@launch) + } + when (val res = saved.result) { + is ApiResult.Success -> { + _state.update { it.copy(saving = false, form = null, original = null, message = StaffMessage(R.string.staff090_library_saved)) } + // A new default or a changed name affects the whole list (badges, order): reload it. + load() + } + is ApiResult.Failure -> _state.update { + it.copy(saving = false, fieldErrors = saved.fields, formError = res.toLibraryMessage(R.string.staff_error_save)) + } + } + } + } + + fun askDelete(t: Staff090LibraryPatronType) = _state.update { it.copy(confirmDelete = t) } + fun dismissDelete() = _state.update { it.copy(confirmDelete = null) } + + fun delete() { + val t = _state.value.confirmDelete ?: return + _state.update { it.copy(confirmDelete = null, busy = true) } + viewModelScope.launch { + val res = repo.deleteType(t.id) + when (res) { + is ApiResult.Success -> { + _state.update { it.copy(busy = false, message = StaffMessage(R.string.staff090_library_type_deleted, args = listOf(t.name, res.data.usersReassigned))) } + load() + } + // The default type: the server's own explanation (it names what to do first). + is ApiResult.Failure -> _state.update { it.copy(busy = false, message = res.toLibraryMessage(R.string.staff_error_save)) } + } + } + } + + fun setDefault(t: Staff090LibraryPatronType) = action { repo.setDefaultType(t.id).let { r -> if (r is ApiResult.Success) ApiResult.Success(Unit) else r as ApiResult.Failure } } + + fun move(t: Staff090LibraryPatronType, up: Boolean) { + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val res = repo.moveType(t.id, up) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, data = res.data) + is ApiResult.Failure -> it.copy(busy = false, message = res.toLibraryMessage(R.string.staff_error_save)) + } + } + } + } + + private fun action(call: suspend () -> ApiResult) { + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val res = call() + when (res) { + is ApiResult.Success -> { + _state.update { it.copy(busy = false, message = StaffMessage(R.string.staff090_library_saved)) } + load() + } + is ApiResult.Failure -> _state.update { it.copy(busy = false, message = res.toLibraryMessage(R.string.staff_error_save)) } + } + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** Settings → Circulation: patron types (list, sheet, default, order) and the door to the rules matrix. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PatronTypesScreen(onNavigateUp: () -> Unit, onOpenMatrix: () -> Unit) { + val vm: PatronTypesViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + LeaveWhen(!areas.s090.circulationRules, onNavigateUp) + LibraryExit(state.exit, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + val data = state.data + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff090_circulation_rules), onNavigateUp = onNavigateUp) }, + floatingActionButton = { if (data?.canConfigure == true) StaffFab(Icons.Outlined.Add, stringResource(R.string.staff090_library_type_new), vm::startCreate) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize().padding(padding)) { + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = false, + emptyTitle = "", emptyIcon = Icons.Outlined.Badge, onRetry = vm::refresh, + ) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 96.dp)) { + if (data != null && !data.moduleEnabled) { + item(key = "off") { + InfoCard(Modifier.padding(top = Spacing.sm)) { + Text(stringResource(R.string.staff090_library_rules_module_off), style = MaterialTheme.typography.bodyMedium) + } + } + } + item(key = "matrix") { + StaffActionRow(Icons.Outlined.GridOn, stringResource(R.string.staff090_library_matrix), stringResource(R.string.staff090_library_matrix_subtitle), onOpenMatrix) + } + item(key = "types-title") { FormSectionTitle(stringResource(R.string.staff090_library_types)) } + val types = data?.types.orEmpty() + items(types, key = { it.id }) { t -> + TypeRow( + t, first = t == types.first(), last = t == types.last(), busy = state.busy, canConfigure = data?.canConfigure == true, + onOpen = { vm.startEdit(t) }, onDefault = { vm.setDefault(t) }, onMove = { up -> vm.move(t, up) }, onDelete = { vm.askDelete(t) }, + ) + } + } + } + } + } + + if (data != null) { + state.form?.let { form -> TypeSheet(form, state, data, vm) } + } + state.confirmDelete?.let { t -> + ConfirmDialog( + title = stringResource(R.string.staff090_library_type_delete), + body = stringResource(R.string.staff090_library_type_delete_body, t.name, t.userCount), + confirmLabel = stringResource(R.string.staff090_library_delete), + onConfirm = vm::delete, onDismiss = vm::dismissDelete, + ) + } +} + +@Composable +private fun TypeRow( + t: Staff090LibraryPatronType, + first: Boolean, + last: Boolean, + busy: Boolean, + canConfigure: Boolean, + onOpen: () -> Unit, + onDefault: () -> Unit, + onMove: (Boolean) -> Unit, + onDelete: () -> Unit, +) { + var menu by remember { mutableStateOf(false) } + Surface( + onClick = onOpen, enabled = canConfigure, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("type-${t.id}"), + ) { + Row(Modifier.padding(start = Spacing.lg, top = Spacing.sm, bottom = Spacing.sm, end = Spacing.xs), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text(t.name, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text( + listOf(t.code, stringResource(R.string.staff090_library_type_users, t.userCount)).filter { it.isNotBlank() }.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (t.isDefault || !t.active) { + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + if (t.isDefault) AvailabilityChip(AvailabilityStatus.ReservedReady, stringResource(R.string.staff090_library_type_default)) + if (!t.active) AvailabilityChip(AvailabilityStatus.Unavailable, stringResource(R.string.staff090_library_type_inactive)) + } + } + } + if (canConfigure) { + IconButton(onClick = { menu = true }, enabled = !busy, modifier = Modifier.testTag("type-menu-${t.id}")) { + Icon(Icons.Outlined.MoreVert, contentDescription = stringResource(R.string.staff_more_actions)) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem(text = { Text(stringResource(R.string.staff090_library_type_edit)) }, onClick = { menu = false; onOpen() }) + if (!t.isDefault) DropdownMenuItem(text = { Text(stringResource(R.string.staff090_library_type_make_default)) }, onClick = { menu = false; onDefault() }) + if (!first) DropdownMenuItem(text = { Text(stringResource(R.string.staff090_library_move_up)) }, onClick = { menu = false; onMove(true) }) + if (!last) DropdownMenuItem(text = { Text(stringResource(R.string.staff090_library_move_down)) }, onClick = { menu = false; onMove(false) }) + DropdownMenuItem(text = { Text(stringResource(R.string.staff090_library_delete)) }, onClick = { menu = false; onDelete() }) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TypeSheet(form: TypeForm, state: PatronTypesUiState, data: Staff090LibraryTypes, vm: PatronTypesViewModel) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val invalid = state.invalid + val errors = state.fieldErrors + val type = state.editing + ModalBottomSheet(onDismissRequest = { if (!state.saving) vm.closeForm() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(if (form.id == null) R.string.staff090_library_type_new else R.string.staff090_library_type_edit), style = MaterialTheme.typography.headlineSmall) + state.formError?.let { InlineErrorBanner(message = it.resolve()) } + PinakesTextField( + value = form.name, onValueChange = { v -> vm.edit { it.copy(name = v) } }, label = stringResource(R.string.staff_field_name), + maxLength = 100, isError = "name" in errors, errorText = errors["name"].orEmpty(), modifier = Modifier.fillMaxWidth().testTag("type-name"), + ) + PinakesTextField( + value = form.code, onValueChange = { v -> vm.edit { it.copy(code = v.lowercase()) } }, label = stringResource(R.string.staff090_library_type_code), + maxLength = 40, isError = "code" in invalid || "code" in errors, + errorText = errors["code"] ?: stringResource(R.string.staff090_library_type_code_hint), + supportingText = stringResource(R.string.staff090_library_type_code_hint), modifier = Modifier.fillMaxWidth(), + ) + PinakesTextField( + value = form.description, onValueChange = { v -> vm.edit { it.copy(description = v) } }, label = stringResource(R.string.staff_field_description), + singleLine = false, maxLength = 2000, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp), + ) + SwitchRow(stringResource(R.string.staff090_library_type_active), form.active, { v -> vm.edit { it.copy(active = v) } }, + subtitle = stringResource(R.string.staff090_library_type_active_hint)) + if (form.id == null || !(state.original?.isDefault ?: false)) { + SwitchRow(stringResource(R.string.staff090_library_type_default), form.isDefault, { v -> vm.edit { it.copy(isDefault = v) } }, + subtitle = stringResource(R.string.staff090_library_type_default_hint)) + } + Text(stringResource(R.string.staff090_library_inherit_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + + FormSectionTitle(stringResource(R.string.staff090_library_loans_section)) + TypeNumberField(TypeNumber.MAX_LOANS, form, type, data, invalid, errors, vm) + TypeNumberField(TypeNumber.LOAN_DURATION, form, type, data, invalid, errors, vm) + TypeNumberField(TypeNumber.MAX_REQUEST_DURATION, form, type, data, invalid, errors, vm) + TypeNumberField(TypeNumber.MAX_RENEWALS, form, type, data, invalid, errors, vm) + TypeNumberField(TypeNumber.RENEWAL_DAYS, form, type, data, invalid, errors, vm) + FormSectionTitle(stringResource(R.string.staff090_library_reservations_section)) + SwitchRow(stringResource(R.string.staff090_library_type_reservations), form.reservationsEnabled, { v -> vm.edit { it.copy(reservationsEnabled = v) } }) + TypeNumberField(TypeNumber.MAX_RESERVATIONS, form, type, data, invalid, errors, vm) + FormSectionTitle(stringResource(R.string.staff090_library_card_section)) + TypeNumberField(TypeNumber.CARD_DURATION, form, type, data, invalid, errors, vm) + FormSectionTitle(stringResource(R.string.staff090_library_loans_fines)) + TypeNumberField(TypeNumber.GRACE_DAYS, form, type, data, invalid, errors, vm) + TypeNumberField(TypeNumber.DAILY_FINE, form, type, data, invalid, errors, vm) + TypeNumberField(TypeNumber.MAX_FINE, form, type, data, invalid, errors, vm) + TypeNumberField(TypeNumber.DEBT_BLOCK, form, type, data, invalid, errors, vm) + + PrimaryButton(stringResource(R.string.action_save), onClick = vm::save, loading = state.saving, enabled = state.canSave, + modifier = Modifier.fillMaxWidth().testTag("type-save")) + Spacer(Modifier.height(Spacing.lg)) + } + } +} + +@Composable +private fun TypeNumberField( + n: TypeNumber, + form: TypeForm, + type: Staff090LibraryPatronType?, + data: Staff090LibraryTypes, + invalid: Set, + errors: Map, + vm: PatronTypesViewModel, +) { + val label = stringResource(typeNumberLabel(n)) + val inherited = TypeForms.inherited(n, type, data.globals) + val shown = if (n.money) money(inherited, data.currency) else inherited?.toString() ?: "—" + val hint = errors[n.key] ?: stringResource(R.string.staff090_library_inherits, shown) + val modifier = Modifier.fillMaxWidth().testTag("type-${n.key}") + if (n.money) { + MoneyField(label, form.text(n), data.currency, { v -> vm.edit { it.copy(numbers = it.numbers + (n to v)) } }, invalid = n.key in invalid || n.key in errors, hint = hint, modifier = modifier) + } else { + NumberField(label, form.text(n), { v -> vm.edit { it.copy(numbers = it.numbers + (n to v)) } }, invalid = n.key in invalid || n.key in errors, hint = hint, modifier = modifier) + } +} + +internal fun typeNumberLabel(n: TypeNumber) = when (n) { + TypeNumber.MAX_LOANS -> R.string.staff090_library_num_max_loans + TypeNumber.LOAN_DURATION -> R.string.staff090_library_num_loan_duration + TypeNumber.MAX_REQUEST_DURATION -> R.string.staff090_library_num_max_request + TypeNumber.MAX_RENEWALS -> R.string.staff090_library_num_max_renewals + TypeNumber.RENEWAL_DAYS -> R.string.staff090_library_num_renewal_days + TypeNumber.MAX_RESERVATIONS -> R.string.staff090_library_num_max_reservations + TypeNumber.CARD_DURATION -> R.string.staff090_library_num_card_duration + TypeNumber.GRACE_DAYS -> R.string.staff090_library_num_grace_days + TypeNumber.DAILY_FINE -> R.string.staff090_library_num_daily_fine + TypeNumber.MAX_FINE -> R.string.staff090_library_num_max_fine + TypeNumber.DEBT_BLOCK -> R.string.staff090_library_num_debt_block +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/RulesMatrixScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/RulesMatrixScreen.kt new file mode 100644 index 0000000..429e848 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/library/RulesMatrixScreen.kt @@ -0,0 +1,322 @@ +package com.pinakes.app.ui.screens.staff.s090.library + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.GridOn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.Staff090LibraryRuleCell +import com.pinakes.app.data.model.Staff090LibraryRules +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.Staff090LibraryRepository +import com.pinakes.app.ui.common.AppLocale +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.AvailabilityChip +import com.pinakes.app.ui.components.AvailabilityStatus +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class RulesMatrixUiState( + val data: Staff090LibraryRules? = null, + val loading: Boolean = true, + val error: StaffMessage? = null, + val exit: StaffMessage? = null, + /** Cells changed on the device and not yet saved: `typeId|media` → form. */ + val edits: Map = emptyMap(), + /** The cell open in the editor and its working copy. */ + val open: Staff090LibraryRuleCell? = null, + val openForm: CellForm? = null, + val saving: Boolean = false, + val formError: StaffMessage? = null, + val fieldErrors: Map = emptyMap(), + val message: StaffMessage? = null, +) { + /** The changed cells only, as the save body. */ + val pending: Int get() = data?.let { RulesForm.body(it.cells, edits, AppLocale.current()).size } ?: 0 + + fun formOf(c: Staff090LibraryRuleCell): CellForm = edits[RulesForm.key(c.typeId, c.media)] ?: CellForm.from(c, AppLocale.current()) +} + +@HiltViewModel +class RulesMatrixViewModel @Inject constructor( + private val repo: Staff090LibraryRepository, +) : ViewModel() { + private val _state = MutableStateFlow(RulesMatrixUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load() } + + fun load() { + _state.update { it.copy(loading = it.data == null, error = null) } + viewModelScope.launch { + val res = repo.rules() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(data = res.data, loading = false) + is ApiResult.Failure -> it.copy(loading = false, exit = res.exitMessage(), error = res.toLibraryMessage(R.string.staff_error_load)) + } + } + } + } + + fun open(c: Staff090LibraryRuleCell) = _state.update { it.copy(open = c, openForm = it.formOf(c)) } + fun editOpen(transform: (CellForm) -> CellForm) = _state.update { s -> s.copy(openForm = s.openForm?.let(transform)) } + fun close() = _state.update { it.copy(open = null, openForm = null) } + + /** Clears every value of the open cell: it inherits everything again. */ + fun inheritAll() = _state.update { s -> s.copy(openForm = CellForm()) } + + /** Keeps the open cell's values among the pending changes (saved together with the others). */ + fun apply() { + val s = _state.value + val cell = s.open ?: return + val form = s.openForm ?: return + val key = RulesForm.key(cell.typeId, cell.media) + val unchanged = RulesForm.cellBody(cell, form, AppLocale.current()) == null + _state.update { it.copy(edits = if (unchanged) it.edits - key else it.edits + (key to form), open = null, openForm = null, fieldErrors = emptyMap()) } + } + + fun discard() = _state.update { it.copy(edits = emptyMap(), formError = null, fieldErrors = emptyMap()) } + + fun save() { + val s = _state.value + val data = s.data ?: return + if (s.saving || s.pending == 0) return + val body = RulesForm.body(data.cells, s.edits, AppLocale.current()) + _state.update { it.copy(saving = true, formError = null, fieldErrors = emptyMap()) } + viewModelScope.launch { + val saved = repo.saveRules(body) + _state.update { + when (val res = saved.result) { + is ApiResult.Success -> it.copy(saving = false, data = res.data, edits = emptyMap(), message = StaffMessage(R.string.staff090_library_saved)) + is ApiResult.Failure -> it.copy(saving = false, fieldErrors = saved.fields, formError = res.toLibraryMessage(R.string.staff_error_save)) + } + } + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** The rules matrix: patron type (and "all types") × material, each cell inheriting what it leaves empty. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RulesMatrixScreen(onNavigateUp: () -> Unit) { + val vm: RulesMatrixViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + LeaveWhen(!areas.s090.circulationRules, onNavigateUp) + LibraryExit(state.exit, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + val data = state.data + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff090_library_matrix), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + bottomBar = { + if (data?.canConfigure == true && state.pending > 0) { + Surface(color = MaterialTheme.colorScheme.background) { + Row( + Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = Spacing.lg, vertical = Spacing.sm), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + SecondaryButton(stringResource(R.string.staff090_library_discard), onClick = vm::discard, enabled = !state.saving, modifier = Modifier.weight(1f)) + PrimaryButton( + stringResource(R.string.staff090_library_save_cells, state.pending), onClick = vm::save, loading = state.saving, + modifier = Modifier.weight(1f).testTag("rules-save"), + ) + } + } + } + }, + ) { padding -> + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = data?.cells.isNullOrEmpty(), + emptyTitle = stringResource(R.string.staff090_library_matrix_empty), emptyIcon = Icons.Outlined.GridOn, onRetry = vm::load, + ) { + val rules = data ?: return@StaffLoadBody + LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + item(key = "intro") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.sm)) { + state.formError?.let { InlineErrorBanner(message = it.resolve()) } + if (!rules.moduleEnabled) { + InfoCard { Text(stringResource(R.string.staff090_library_rules_module_off), style = MaterialTheme.typography.bodyMedium) } + } + Text(stringResource(R.string.staff090_library_matrix_intro), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + rules.rows.forEach { row -> + item(key = "row-${row.typeId}") { + FormSectionTitle(if (row.typeId == 0) stringResource(R.string.staff090_library_all_types) else row.name) + } + val cells = rules.cells.filter { it.typeId == row.typeId } + items(cells, key = { "cell-${it.typeId}-${it.media}" }) { cell -> + CellRow( + cell, rules.media.firstOrNull { it.key == cell.media }?.label ?: cell.media, state.formOf(cell), rules.currency, + pending = RulesForm.key(cell.typeId, cell.media) in state.edits, + onClick = if (rules.canConfigure) ({ vm.open(cell) }) else null, + ) + } + } + } + } + } + + val cell = state.open + val form = state.openForm + if (data != null && cell != null && form != null) CellSheet(cell, form, data, state, vm) +} + +/** What a cell means once its empty values are filled from what it inherits. */ +@Composable +private fun cellSummary(cell: Staff090LibraryRuleCell, form: CellForm, currency: String?): String { + val loanable = form.loanable ?: cell.inherited.loanable + if (!loanable) return stringResource(R.string.staff090_library_cell_not_loanable) + fun value(n: RuleNumber): Int? = form.text(n).let { t -> + if (t.isBlank()) n.inherited(cell) + else if (n.money) (LibraryMoney.parse(t) as? CentsInput.Valid)?.cents else t.trim().toIntOrNull() + } + val parts = mutableListOf( + stringResource(R.string.staff090_library_cell_duration, value(RuleNumber.LOAN_DURATION) ?: 0), + stringResource(R.string.staff090_library_cell_renewals, value(RuleNumber.MAX_RENEWALS) ?: 0), + ) + value(RuleNumber.MAX_LOANS)?.takeIf { it > 0 }?.let { parts.add(0, stringResource(R.string.staff090_library_cell_max, it)) } + value(RuleNumber.DAILY_FINE)?.takeIf { it > 0 }?.let { parts += stringResource(R.string.staff090_library_cell_fine, money(it, currency)) } + return parts.joinToString(" · ") +} + +@Composable +private fun CellRow(cell: Staff090LibraryRuleCell, mediaLabel: String, form: CellForm, currency: String?, pending: Boolean, onClick: (() -> Unit)?) { + val customised = form.loanable != null || form.numbers.values.any { it.isNotBlank() } + Surface( + onClick = { onClick?.invoke() }, enabled = onClick != null, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("cell-${cell.typeId}-${cell.media}"), + ) { + Row(Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(mediaLabel, style = MaterialTheme.typography.bodyLarge) + Text(cellSummary(cell, form, currency), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + when { + pending -> AvailabilityChip(AvailabilityStatus.DueSoon, stringResource(R.string.staff090_library_cell_pending)) + customised -> AvailabilityChip(AvailabilityStatus.ReservedReady, stringResource(R.string.staff090_library_cell_custom)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CellSheet(cell: Staff090LibraryRuleCell, form: CellForm, data: Staff090LibraryRules, state: RulesMatrixUiState, vm: RulesMatrixViewModel) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val invalid = RulesForm.invalid(form, data.limits) + val rowName = data.rows.firstOrNull { it.typeId == cell.typeId }?.let { if (it.typeId == 0) stringResource(R.string.staff090_library_all_types) else it.name }.orEmpty() + val mediaLabel = data.media.firstOrNull { it.key == cell.media }?.label ?: cell.media + ModalBottomSheet(onDismissRequest = vm::close, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(R.string.staff090_library_cell_title, rowName, mediaLabel), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.staff090_library_inherit_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + val inheritLabel = stringResource( + R.string.staff090_library_loanable_inherit, + stringResource(if (cell.inherited.loanable) R.string.staff090_library_yes else R.string.staff090_library_no), + ) + ChoiceField( + stringResource(R.string.staff090_library_cell_loanable), + when (form.loanable) { null -> "inherit"; true -> "yes"; false -> "no" }, + listOf( + LookupOption("inherit", inheritLabel), + LookupOption("yes", stringResource(R.string.staff090_library_yes)), + LookupOption("no", stringResource(R.string.staff090_library_no)), + ), + { v -> vm.editOpen { it.copy(loanable = when (v) { "yes" -> true; "no" -> false; else -> null }) } }, + modifier = Modifier.fillMaxWidth().testTag("cell-loanable"), + ) + RuleNumber.entries.forEach { n -> + val label = stringResource(ruleNumberLabel(n)) + val inherited = n.inherited(cell) + val hint = stringResource(R.string.staff090_library_inherits, if (n.money) money(inherited, data.currency) else inherited?.toString() ?: "—") + val errorKey = state.fieldErrors.keys.firstOrNull { it.endsWith(".${n.key}") } + val modifier = Modifier.fillMaxWidth().testTag("cell-${n.key}") + if (n.money) { + MoneyField(label, form.text(n), data.currency, { v -> vm.editOpen { it.copy(numbers = it.numbers + (n to v)) } }, invalid = n.key in invalid, hint = hint, modifier = modifier) + } else { + NumberField(label, form.text(n), { v -> vm.editOpen { it.copy(numbers = it.numbers + (n to v)) } }, invalid = n.key in invalid || errorKey != null, + hint = errorKey?.let { state.fieldErrors[it] } ?: listOfNotNull(hint, data.limits[n.key]?.let { stringResource(R.string.staff090_library_range, it.min, it.max) }).joinToString(" "), + modifier = modifier) + } + } + SecondaryButton(stringResource(R.string.staff090_library_inherit_all), onClick = vm::inheritAll, modifier = Modifier.fillMaxWidth()) + PrimaryButton(stringResource(R.string.staff090_library_apply), onClick = vm::apply, enabled = invalid.isEmpty(), modifier = Modifier.fillMaxWidth().testTag("cell-apply")) + Spacer(Modifier.height(Spacing.lg)) + } + } +} + +private fun ruleNumberLabel(n: RuleNumber) = when (n) { + RuleNumber.MAX_LOANS -> R.string.staff090_library_num_max_loans + RuleNumber.LOAN_DURATION -> R.string.staff090_library_num_loan_duration + RuleNumber.MAX_RENEWALS -> R.string.staff090_library_num_max_renewals + RuleNumber.RENEWAL_DAYS -> R.string.staff090_library_num_renewal_days + RuleNumber.GRACE_DAYS -> R.string.staff090_library_num_grace_days + RuleNumber.DAILY_FINE -> R.string.staff090_library_num_daily_fine + RuleNumber.MAX_FINE -> R.string.staff090_library_num_max_fine +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/PatronsGraph.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/PatronsGraph.kt new file mode 100644 index 0000000..a60e22b --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/PatronsGraph.kt @@ -0,0 +1,26 @@ +package com.pinakes.app.ui.screens.staff.s090.patrons + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.pinakes.app.ui.screens.staff.s090.Staff090Routes +import com.pinakes.app.ui.screens.staff.s090.Staff090Transitions + +/** The patron area: visitor log, patron CSV import and a patron's ledger. */ +internal fun NavGraphBuilder.patronsGraph(nav: NavHostController, t: Staff090Transitions) { + composable(Staff090Routes.ATTENDANCE, enterTransition = t.enter, popExitTransition = t.exit) { + Staff090AttendanceScreen(onNavigateUp = { nav.popBackStack() }) + } + composable(Staff090Routes.USER_IMPORT, enterTransition = t.enter, popExitTransition = t.exit) { + Staff090UserImportScreen(onNavigateUp = { nav.popBackStack() }) + } + composable( + Staff090Routes.PATRON_LEDGER, + arguments = listOf(navArgument(Staff090Routes.ARG_USER_ID) { type = NavType.IntType }), + enterTransition = t.enter, popExitTransition = t.exit, + ) { + Staff090LedgerScreen(onNavigateUp = { nav.popBackStack() }) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsAttendanceScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsAttendanceScreen.kt new file mode 100644 index 0000000..84bcbc9 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsAttendanceScreen.kt @@ -0,0 +1,588 @@ +package com.pinakes.app.ui.screens.staff.s090.patrons + +import android.os.SystemClock +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.PersonAdd +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.AttendanceOverview +import com.pinakes.app.data.model.AttendanceVisit +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.Staff090PatronsRepository +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.AttendanceRules +import com.pinakes.app.data.staff.ScanDebouncer +import com.pinakes.app.data.staff.SubmissionKeys +import com.pinakes.app.data.staff.VisitOutcome +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.ExportFileDialog +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.scan.BarcodeAnalyzer +import com.pinakes.app.ui.screens.staff.scan.ScannerBox +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +enum class AttendanceTab(val label: Int) { DESK(R.string.staff090_patrons_tab_desk), STATS(R.string.staff090_patrons_tab_stats) } + +/** The last check-in at the desk: the visit, or why there is none. */ +data class VisitResult( + val outcome: VisitOutcome, + val visit: AttendanceVisit? = null, + val code: String? = null, + val message: StaffMessage? = null, + val photo: ByteArray? = null, +) + +private data class CheckInSubmission(val code: String, val branchId: Int?) +private data class GuestSubmission(val name: String, val reason: String, val branchId: Int?) + +data class AttendanceUiState( + val tab: AttendanceTab = AttendanceTab.DESK, + val days: Int = 30, + val overview: AttendanceOverview? = null, + val loading: Boolean = true, + val error: StaffMessage? = null, + val branchId: Int? = null, + val checking: Boolean = false, + val last: VisitResult? = null, + /** Bumped on every result, so the screen plays its tone once. */ + val toneTick: Int = 0, + /** The last [toneTick] whose tone was played: a recomposition or a rotation does not replay it. */ + val tonePlayed: Int = 0, + val manualError: Boolean = false, + val guestSheet: Boolean = false, + val savingGuest: Boolean = false, + val guestError: StaffMessage? = null, + val savingSettings: Boolean = false, + val confirmPurge: Boolean = false, + val export: File? = null, + val message: StaffMessage? = null, + val forbidden: Boolean = false, + val disabled: Boolean = false, +) + +/** + * The visitor log at the entrance: the phone scans the patron card (CODE 128 or QR), the + * server records the visit and answers the kiosk's message; guests are recorded by hand. The + * statistics tab shows the counters of the web page, the CSV export and, for administrators, + * the retention and the purge. + */ +@HiltViewModel +class Staff090AttendanceViewModel @Inject constructor( + private val patrons: Staff090PatronsRepository, + private val files: StaffFilesRepository, +) : ViewModel() { + private val _state = MutableStateFlow(AttendanceUiState()) + val state: StateFlow = _state.asStateFlow() + private val debouncer = ScanDebouncer() + private val checkInKeys = SubmissionKeys() + private val guestKeys = SubmissionKeys() + + init { load() } + + fun load(days: Int = _state.value.days) { + _state.update { it.copy(days = days, loading = it.overview == null, error = null) } + viewModelScope.launch { + val res = patrons.attendance(days) + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + overview = res.data, loading = false, + // A branch that closed meanwhile is dropped from the picker. + branchId = it.branchId?.takeIf { id -> res.data.branches.any { b -> b.id == id } }, + ) + is ApiResult.Failure -> it.copy( + loading = false, forbidden = res.isForbidden(), disabled = res.code == "feature_disabled", + error = if (it.overview == null) res.toPatronsMessage(R.string.staff_error_load) else null, + message = if (it.overview != null) res.toPatronsMessage(R.string.staff_error_load) else null, + ) + } + } + } + } + + fun selectTab(tab: AttendanceTab) { + _state.update { it.copy(tab = tab) } + if (tab == AttendanceTab.STATS) load() + } + + fun selectBranch(id: Int?) = _state.update { it.copy(branchId = id) } + + fun onDecoded(raw: String) { + if (_state.value.checking || _state.value.guestSheet) return + val code = AttendanceRules.normalizeCard(raw).takeIf { it.isNotEmpty() } ?: return + val accepted = debouncer.offer(code, SystemClock.elapsedRealtime()) ?: return + checkIn(accepted) + } + + fun clearManualError() = _state.update { if (it.manualError) it.copy(manualError = false) else it } + + /** A typed (or keyboard-wedge) card code; true when it was taken. */ + fun submitManual(input: String): Boolean { + val code = AttendanceRules.normalizeCard(input) + if (code.isEmpty()) { + _state.update { it.copy(manualError = true) } + return false + } + if (_state.value.checking) return false + checkIn(code) + return true + } + + private fun checkIn(code: String) { + val branch = _state.value.branchId + val key = checkInKeys.keyFor(CheckInSubmission(code, branch)) + debouncer.pause() + _state.update { it.copy(checking = true) } + viewModelScope.launch { + val res = patrons.checkIn(code, branch, key) + val result = when (res) { + is ApiResult.Success -> { + checkInKeys.reset() + VisitResult(AttendanceRules.outcome(res.data), visit = res.data, code = code) + } + is ApiResult.Failure -> { + // Nothing recorded: a new scan of the same card is a new attempt only + // after a definite refusal; a lost answer keeps its key for the replay. + if (res.fromEnvelope) checkInKeys.reset() + if (res.isForbidden()) _state.update { it.copy(forbidden = true) } + VisitResult(AttendanceRules.outcome(res), code = code, message = res.toPatronsMessage(R.string.staff_error_save)) + } + } + _state.update { it.copy(checking = false, last = result, toneTick = it.toneTick + 1) } + debouncer.resume(SystemClock.elapsedRealtime()) + if (res is ApiResult.Success) loadPhoto(res.data) + } + } + + private fun loadPhoto(visit: AttendanceVisit) { + val member = visit.member ?: return + if (!member.hasPhoto) return + viewModelScope.launch { + val res = patrons.photo(member.id) + if (res is ApiResult.Success) _state.update { s -> if (s.last?.visit?.id == visit.id) s.copy(last = s.last.copy(photo = res.data)) else s } + } + } + + fun openGuest() { + debouncer.pause() + _state.update { it.copy(guestSheet = true, guestError = null) } + } + + fun closeGuest() { + if (_state.value.savingGuest) return + _state.update { it.copy(guestSheet = false) } + debouncer.resume(SystemClock.elapsedRealtime()) + } + + fun saveGuest(name: String, reason: String) { + if (_state.value.savingGuest) return + val branch = _state.value.branchId + val key = guestKeys.keyFor(GuestSubmission(name.trim(), reason.trim(), branch)) + _state.update { it.copy(savingGuest = true, guestError = null) } + viewModelScope.launch { + when (val res = patrons.guest(name, reason, branch, key)) { + is ApiResult.Success -> { + guestKeys.reset() + _state.update { + it.copy(savingGuest = false, guestSheet = false, last = VisitResult(VisitOutcome.GUEST, visit = res.data), toneTick = it.toneTick + 1) + } + debouncer.resume(SystemClock.elapsedRealtime()) + } + is ApiResult.Failure -> { + if (res.code == SubmissionKeys.CONFLICT) guestKeys.reset() + _state.update { it.copy(savingGuest = false, forbidden = res.isForbidden(), guestError = res.toPatronsMessage(R.string.staff_error_save)) } + } + } + } + } + + fun export() { + val days = _state.value.days + viewModelScope.launch { + val res = files.download("staff/attendance/export.csv?days=$days", "presenze_${days}g.csv") + _state.update { + when (res) { + is ApiResult.Success -> it.copy(export = res.data) + is ApiResult.Failure -> it.copy(forbidden = res.isForbidden(), message = res.toPatronsMessage(R.string.staff_download_failed)) + } + } + } + } + + fun closeExport() = _state.update { it.copy(export = null) } + + fun saveRetention(days: Int) { + if (_state.value.savingSettings) return + _state.update { it.copy(savingSettings = true) } + viewModelScope.launch { + val res = patrons.saveRetention(days) + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + savingSettings = false, overview = it.overview?.copy(retention = res.data.retention), + message = res.data.message?.takeIf { m -> m.isNotBlank() }?.let { m -> StaffMessage(text = m) } ?: StaffMessage(R.string.staff090_patrons_retention_saved), + ) + is ApiResult.Failure -> it.copy(savingSettings = false, forbidden = res.isForbidden(), message = res.toPatronsMessage(R.string.staff_error_save)) + } + } + } + } + + fun askPurge() = _state.update { it.copy(confirmPurge = true) } + fun dismissPurge() = _state.update { it.copy(confirmPurge = false) } + + fun purge() { + if (_state.value.savingSettings) return + _state.update { it.copy(confirmPurge = false, savingSettings = true) } + viewModelScope.launch { + val res = patrons.purgeAttendance() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(savingSettings = false, message = StaffMessage(R.string.staff090_patrons_purged, args = listOf(res.data.deleted))) + is ApiResult.Failure -> it.copy(savingSettings = false, forbidden = res.isForbidden(), message = res.toPatronsMessage(R.string.staff_error_save)) + } + } + if (res is ApiResult.Success) load() + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } + + fun tonePlayed(tick: Int) = _state.update { it.copy(tonePlayed = maxOf(it.tonePlayed, tick)) } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Staff090AttendanceScreen(onNavigateUp: () -> Unit) { + val vm: Staff090AttendanceViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + val haptics = LocalHapticFeedback.current + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.s090.attendance || state.disabled, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + LaunchedEffect(state.toneTick) { + val last = state.last + if (AttendanceRules.tonePending(state.toneTick, state.tonePlayed) && last != null) { + val ok = AttendanceRules.successTone(last.outcome) + DeskTone.play(ok) + haptics.performHapticFeedback(if (ok) HapticFeedbackType.TextHandleMove else HapticFeedbackType.LongPress) + vm.tonePlayed(state.toneTick) + } + } + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff090_attendance), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + PrimaryTabRow(selectedTabIndex = state.tab.ordinal) { + AttendanceTab.entries.forEach { tab -> + Tab(selected = tab == state.tab, onClick = { vm.selectTab(tab) }, text = { Text(stringResource(tab.label)) }, modifier = Modifier.testTag("tab-${tab.name.lowercase()}")) + } + } + if (state.tab == AttendanceTab.DESK) DeskTab(state, vm) else StatsTab(state, vm) + } + } + if (state.guestSheet) GuestSheet(state, vm) + if (state.confirmPurge) ConfirmDialog( + title = stringResource(R.string.staff090_patrons_purge), + body = stringResource(R.string.staff090_patrons_purge_body, state.overview?.retention?.days ?: 0), + confirmLabel = stringResource(R.string.staff090_patrons_purge), + onConfirm = vm::purge, onDismiss = vm::dismissPurge, + ) + state.export?.let { ExportFileDialog(it, "text/csv", stringResource(R.string.staff090_attendance), onDismiss = vm::closeExport) } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun DeskTab(state: AttendanceUiState, vm: Staff090AttendanceViewModel) { + Column( + Modifier.fillMaxSize().imePadding().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg, vertical = Spacing.sm), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + ScannerBox( + formats = BarcodeAnalyzer.PATRON_CARD_FORMATS, onCode = vm::onDecoded, looking = state.checking, + permissionText = stringResource(R.string.staff090_patrons_scan_permission), + ) + Text(stringResource(R.string.staff090_patrons_scan_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + var manual by rememberSaveable { mutableStateOf("") } + val submit = { if (vm.submitManual(manual)) manual = "" } + PinakesTextField( + value = manual, + onValueChange = { manual = it.take(64); vm.clearManualError() }, + label = stringResource(R.string.staff090_patrons_card_code), + isError = state.manualError, errorText = stringResource(R.string.staff090_patrons_card_code_required), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { submit() }), + trailingIcon = { + IconButton(onClick = submit, enabled = !state.checking) { + Icon(Icons.Outlined.Search, contentDescription = stringResource(R.string.staff090_patrons_check_in)) + } + }, + modifier = Modifier.fillMaxWidth().testTag("card-code-input"), + ) + val branches = state.overview?.branches.orEmpty() + if (branches.isNotEmpty()) { + Text(stringResource(R.string.staff090_patrons_branch), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + val chip = @Composable { selected: Boolean, label: String, onClick: () -> Unit -> + FilterChip( + selected = selected, onClick = onClick, label = { Text(label) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + chip(state.branchId == null, stringResource(R.string.staff090_patrons_branch_none)) { vm.selectBranch(null) } + branches.forEach { b -> chip(state.branchId == b.id, b.name) { vm.selectBranch(b.id) } } + } + } + state.last?.let { VisitCard(it) } + SecondaryButton( + stringResource(R.string.staff090_patrons_guest), onClick = vm::openGuest, leadingIcon = Icons.Outlined.PersonAdd, + modifier = Modifier.fillMaxWidth().testTag("guest-open"), + ) + state.overview?.let { o -> + Text( + stringResource(R.string.staff090_patrons_today_counts, o.today.members, o.today.guests), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(Spacing.xl)) + } +} + +/** The last check-in: green for a welcome, amber for a card to look at, red for an unknown card. */ +@Composable +private fun VisitCard(result: VisitResult) { + val colors = MaterialTheme.colorScheme + val (container, content) = when (result.outcome) { + VisitOutcome.WELCOME, VisitOutcome.GUEST -> colors.primaryContainer to colors.onPrimaryContainer + VisitOutcome.WARNING -> colors.tertiaryContainer to colors.onTertiaryContainer + VisitOutcome.UNKNOWN_CARD, VisitOutcome.ERROR -> colors.errorContainer to colors.onErrorContainer + } + Surface(shape = MaterialTheme.shapes.large, color = container, modifier = Modifier.fillMaxWidth().testTag("visit-${result.outcome.name.lowercase()}")) { + Row(Modifier.padding(Spacing.lg), horizontalArrangement = Arrangement.spacedBy(Spacing.lg), verticalAlignment = Alignment.CenterVertically) { + val member = result.visit?.member + if (member != null) PatronPhoto(result.photo, stringResource(R.string.staff090_patrons_photo), size = 72.dp) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + val visit = result.visit + val title = when { + member != null -> member.shortName + visit?.type == "guest" -> visit.guest?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.staff090_patrons_guest_anonymous) + result.outcome == VisitOutcome.UNKNOWN_CARD -> stringResource(R.string.staff090_patrons_err_card_not_found) + else -> result.message?.resolve().orEmpty() + } + Text(title, style = MaterialTheme.typography.titleLarge, color = content) + // The server's kiosk message, in the operator's language. + val detail = visit?.message?.takeIf { it.isNotBlank() } ?: result.code?.let { stringResource(R.string.staff090_patrons_card_line, it) } + detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = content) } + if (member != null && member.cardExpired) { + Text(stringResource(R.string.staff090_patrons_card_expired, DateFormat.date(member.cardExpiresAt)), style = MaterialTheme.typography.bodySmall, color = content) + } + if (member != null && !member.cardActive) { + Text(stringResource(R.string.staff090_patrons_card_not_active), style = MaterialTheme.typography.bodySmall, color = content) + } + visit?.branch?.name?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = content) } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GuestSheet(state: AttendanceUiState, vm: Staff090AttendanceViewModel) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var name by rememberSaveable { mutableStateOf("") } + var reason by rememberSaveable { mutableStateOf("") } + ModalBottomSheet(onDismissRequest = vm::closeGuest, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(stringResource(R.string.staff090_patrons_guest), style = MaterialTheme.typography.headlineSmall) + PinakesTextField(name, { name = it }, stringResource(R.string.staff090_patrons_guest_name), maxLength = 150, modifier = Modifier.fillMaxWidth().testTag("guest-name")) + PinakesTextField(reason, { reason = it }, stringResource(R.string.staff090_patrons_guest_reason), maxLength = 255, modifier = Modifier.fillMaxWidth().testTag("guest-reason")) + state.guestError?.let { InlineErrorBanner(message = it.resolve()) } + PrimaryButton( + stringResource(R.string.staff090_patrons_guest_save), onClick = { vm.saveGuest(name, reason) }, loading = state.savingGuest, + modifier = Modifier.fillMaxWidth().testTag("guest-save"), + ) + Spacer(Modifier.height(Spacing.lg)) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun StatsTab(state: AttendanceUiState, vm: Staff090AttendanceViewModel) { + val overview = state.overview + when { + state.loading -> LoadingState() + overview == null -> ErrorState(message = state.error?.resolve() ?: stringResource(R.string.staff_error_load), onRetry = { vm.load() }) + else -> Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg, vertical = Spacing.sm), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + overview.periods.forEach { days -> + FilterChip( + selected = days == state.days, onClick = { vm.load(days) }, + label = { Text(stringResource(R.string.staff090_patrons_period_days, days)) }, + modifier = Modifier.testTag("period-$days"), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + } + InfoCard(Modifier.testTag("attendance-totals")) { + InfoLine(stringResource(R.string.staff090_patrons_today), stringResource(R.string.staff090_patrons_counts, overview.today.total, overview.today.members, overview.today.guests)) + InfoLine(stringResource(R.string.staff090_patrons_period_total), stringResource(R.string.staff090_patrons_counts, overview.totals.total, overview.totals.members, overview.totals.guests)) + } + val days = overview.daily.filter { it.total > 0 }.reversed() + FormSectionTitle(stringResource(R.string.staff090_patrons_by_day)) + if (days.isEmpty()) Text(stringResource(R.string.staff090_patrons_no_visits), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + val dayBars = AttendanceRules.barFractions(days.map { it.total }) + days.forEachIndexed { i, d -> BarLine(DateFormat.date(d.date), d.total, dayBars[i]) } + val hours = overview.hourly.filter { it.total > 0 } + if (hours.isNotEmpty()) { + FormSectionTitle(stringResource(R.string.staff090_patrons_by_hour)) + val hourBars = AttendanceRules.barFractions(hours.map { it.total }) + hours.forEachIndexed { i, h -> BarLine("%02d:00".format(java.util.Locale.ROOT, h.hour), h.total, hourBars[i]) } + } + if (overview.recent.isNotEmpty()) { + FormSectionTitle(stringResource(R.string.staff090_patrons_recent)) + InfoCard { + overview.recent.forEach { r -> + InfoLine( + DateFormat.dateTime(r.createdAt), + r.label.ifBlank { stringResource(if (r.type == "guest") R.string.staff090_patrons_guest_anonymous else R.string.staff090_patrons_member) }, + ) + } + } + } + SecondaryButton( + stringResource(R.string.staff090_patrons_export_csv), onClick = vm::export, leadingIcon = Icons.Outlined.Download, + modifier = Modifier.fillMaxWidth().testTag("attendance-export"), + ) + if (overview.canConfigure) RetentionCard(overview, state.savingSettings, vm) + Spacer(Modifier.height(Spacing.xxl)) + } + } +} + +@Composable +private fun BarLine(label: String, value: Int, fraction: Float) { + Row(Modifier.fillMaxWidth().padding(vertical = Spacing.xxs), verticalAlignment = Alignment.CenterVertically) { + Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(96.dp)) + LinearProgressIndicator(progress = { fraction }, modifier = Modifier.weight(1f).heightIn(min = 6.dp)) + Text(value.toString(), style = MaterialTheme.typography.bodySmall, modifier = Modifier.width(48.dp).padding(start = Spacing.sm)) + } +} + +@Composable +private fun RetentionCard(overview: AttendanceOverview, saving: Boolean, vm: Staff090AttendanceViewModel) { + val retention = overview.retention + var text by rememberSaveable(retention.days) { mutableStateOf(retention.days.toString()) } + val parsed = AttendanceRules.parseRetention(text, retention.maxDays) + FormSectionTitle(stringResource(R.string.staff090_patrons_retention)) + InfoCard(Modifier.testTag("attendance-retention")) { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(stringResource(R.string.staff090_patrons_retention_hint, retention.maxDays), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + PinakesTextField( + value = text, onValueChange = { text = it.filter(Char::isDigit).take(4) }, label = stringResource(R.string.staff090_patrons_retention_days), + isError = parsed == null, errorText = stringResource(R.string.staff090_patrons_retention_invalid, retention.maxDays), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth().testTag("retention-days"), + ) + PrimaryButton(stringResource(R.string.action_save), onClick = { parsed?.let(vm::saveRetention) }, loading = saving, enabled = parsed != null, modifier = Modifier.fillMaxWidth()) + SecondaryButton( + stringResource(R.string.staff090_patrons_purge), onClick = vm::askPurge, enabled = !saving && retention.days > 0, + leadingIcon = Icons.Outlined.DeleteSweep, modifier = Modifier.fillMaxWidth().testTag("attendance-purge"), + ) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsCommon.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsCommon.kt new file mode 100644 index 0000000..5fb387a --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsCommon.kt @@ -0,0 +1,67 @@ +package com.pinakes.app.ui.screens.staff.s090.patrons + +import android.graphics.BitmapFactory +import android.media.AudioManager +import android.media.ToneGenerator +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.staff.Staff090PatronsErrors +import com.pinakes.app.ui.common.AppLocale +import com.pinakes.app.ui.screens.movements.MoneyFormat +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.toStaffMessage + +/** The message for a failed patron-tools call: our wording for the 0.9.0 codes first. */ +internal fun ApiResult.Failure.toPatronsMessage(@StringRes fallback: Int): StaffMessage = + Staff090PatronsErrors.messageRes(this)?.let { StaffMessage(it) } ?: toStaffMessage(fallback) + +/** Cents in the server's currency, as the reader movements screen writes them. */ +@Composable +internal fun money(cents: Int, currency: String?): String = MoneyFormat.format(cents, currency, AppLocale.current()) + +/** + * A patron photo from its bytes (fetched with the bearer token, kept in memory only), or the + * person placeholder while there is none or it cannot be decoded. + */ +@Composable +internal fun PatronPhoto(bytes: ByteArray?, description: String?, size: Dp = 88.dp, modifier: Modifier = Modifier) { + val bitmap = remember(bytes) { bytes?.let { runCatching { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }.getOrNull() } } + val shape = RoundedCornerShape(12.dp) + if (bitmap != null) { + Image(bitmap, contentDescription = description, contentScale = ContentScale.Crop, modifier = modifier.size(size).clip(shape)) + } else { + Box(modifier.size(size).clip(shape).background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Person, contentDescription = description, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(size / 2)) + } + } +} + +/** The desk's short check-in tones: one beep for a welcome, two low ones for a problem. */ +internal object DeskTone { + fun play(success: Boolean) { + runCatching { + val tone = ToneGenerator(AudioManager.STREAM_NOTIFICATION, 70) + tone.startTone(if (success) ToneGenerator.TONE_PROP_ACK else ToneGenerator.TONE_PROP_NACK, if (success) 150 else 300) + // Released after the tone has played; a ToneGenerator holds a native audio track. + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ tone.release() }, 500) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsImportScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsImportScreen.kt new file mode 100644 index 0000000..42a76f1 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsImportScreen.kt @@ -0,0 +1,623 @@ +package com.pinakes.app.ui.screens.staff.s090.patrons + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.UploadFile +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.UserImportJob +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.Staff090PatronsRepository +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.ChunkStep +import com.pinakes.app.data.staff.UserImportRules +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.ExportFileDialog +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.PickerRow +import com.pinakes.app.ui.screens.staff.ProgressLine +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** A picked CSV, read into memory (at most 5 MB). */ +class PickedCsv(val name: String, val mime: String?, val bytes: ByteArray) + +data class UserImportUiState( + val file: PickedCsv? = null, + val delimiter: String = "", + val job: UserImportJob? = null, + /** The mapping being edited: one field key per header ("" = ignored). */ + val mapping: List = emptyList(), + val sendInvites: Boolean = false, + /** Unfinished jobs of this operator (started here, on another device, or lost after a restart). */ + val openJobs: List = emptyList(), + /** The open job being reopened or cancelled from the list. */ + val openBusy: String? = null, + val uploading: Boolean = false, + val starting: Boolean = false, + val running: Boolean = false, + val error: StaffMessage? = null, + /** The job ended elsewhere (410 gone) or here (cancel): never resumed again. */ + val ended: Boolean = false, + val export: File? = null, + val message: StaffMessage? = null, + val forbidden: Boolean = false, +) + +/** + * The patron CSV import: upload → preview and suggested mapping → start → chunks of 10 rows, + * always from the server's `processed` (out_of_order resumes there, job_busy waits, gone + * stops) → counters and the error report. The job id is kept in the saved state, so the run + * resumes after process death where the server is. + */ +@HiltViewModel +class Staff090UserImportViewModel @Inject constructor( + private val patrons: Staff090PatronsRepository, + private val files: StaffFilesRepository, + @ApplicationContext private val context: Context, + private val savedState: SavedStateHandle, +) : ViewModel() { + private val _state = MutableStateFlow(UserImportUiState()) + val state: StateFlow = _state.asStateFlow() + private var loop: Job? = null + + init { + if (savedState.get(KEY_JOB) == null) loadOpenJobs() + savedState.get(KEY_JOB)?.let { id -> + viewModelScope.launch { + when (val res = patrons.userImport(id)) { + is ApiResult.Success -> { + setJob(res.data) + if (res.data.started && !UserImportRules.isFinished(res.data)) resume() + } + is ApiResult.Failure -> { + savedState.remove(KEY_JOB) + loadOpenJobs() + } + } + } + } + } + + /** The open jobs listed under the upload form. */ + fun loadOpenJobs() { + viewModelScope.launch { + when (val res = patrons.userImports()) { + is ApiResult.Success -> _state.update { it.copy(openJobs = UserImportRules.openJobs(res.data.items)) } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden()) } + } + } + } + + /** Reopen a listed job as the server has it now: the mapping when not started, else the chunk loop. */ + fun reopen(job: UserImportJob) { + if (_state.value.openBusy != null) return + _state.update { it.copy(openBusy = job.id, error = null) } + viewModelScope.launch { + when (val res = patrons.userImport(job.id)) { + is ApiResult.Success -> { + _state.update { it.copy(openBusy = null, ended = false, sendInvites = res.data.sendInvites ?: false) } + setJob(res.data) + if (res.data.started && !UserImportRules.isFinished(res.data)) resume() + } + is ApiResult.Failure -> { + _state.update { it.copy(openBusy = null, forbidden = res.isForbidden(), error = res.toPatronsMessage(R.string.staff_error_load)) } + loadOpenJobs() + } + } + } + } + + /** Cancel a listed job without opening it (the accounts already created stay). */ + fun cancelOpen(job: UserImportJob) { + if (_state.value.openBusy != null) return + _state.update { it.copy(openBusy = job.id) } + viewModelScope.launch { + val res = patrons.cancelUserImport(job.id) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(openBusy = null, message = StaffMessage(R.string.staff090_patrons_import_cancelled)) + is ApiResult.Failure -> it.copy(openBusy = null, forbidden = res.isForbidden(), message = res.toPatronsMessage(R.string.staff_error_save)) + } + } + loadOpenJobs() + } + } + + private fun setJob(job: UserImportJob) { + savedState[KEY_JOB] = job.id + _state.update { it.copy(job = job, mapping = if (it.job?.id == job.id && !job.started) it.mapping.ifEmpty { UserImportRules.aligned(job) } else UserImportRules.aligned(job)) } + } + + fun setDelimiter(value: String) = _state.update { it.copy(delimiter = value) } + + fun onPicked(uri: Uri?) { + uri ?: return + viewModelScope.launch { + val read = withContext(Dispatchers.IO) { runCatching { read(uri) }.getOrNull() } + _state.update { + when { + read == null -> it.copy(error = StaffMessage(R.string.staff_import_read_failed), file = null) + read.bytes.isEmpty() && read.name.isEmpty() -> it.copy(error = StaffMessage(R.string.staff090_patrons_import_too_big), file = null) + !UserImportRules.acceptsFileName(read.name) -> it.copy(error = StaffMessage(R.string.staff090_patrons_import_wrong_ext), file = null) + else -> it.copy(file = read, error = null) + } + } + } + } + + /** The document's bytes; an empty name and body say "too big" (the server takes 5 MB). */ + private fun read(uri: Uri): PickedCsv? { + val resolver = context.contentResolver + var name = uri.lastPathSegment?.substringAfterLast('/') ?: "patrons.csv" + var size = -1L + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> + if (c.moveToFirst()) { + c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let { i -> c.getString(i)?.let { name = it } } + c.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }?.let { i -> if (!c.isNull(i)) size = c.getLong(i) } + } + } + if (size > UserImportRules.MAX_BYTES) return PickedCsv("", null, ByteArray(0)) + val out = java.io.ByteArrayOutputStream() + resolver.openInputStream(uri)?.use { input -> + val buffer = ByteArray(64 * 1024) + var total = 0L + while (true) { + val n = input.read(buffer) + if (n < 0) break + total += n + if (total > UserImportRules.MAX_BYTES) return PickedCsv("", null, ByteArray(0)) + out.write(buffer, 0, n) + } + } ?: return null + return PickedCsv(name, resolver.getType(uri), out.toByteArray()) + } + + fun upload() { + val s = _state.value + val file = s.file ?: return + if (s.uploading) return + _state.update { it.copy(uploading = true, error = null) } + viewModelScope.launch { + val res = patrons.createUserImport(file.name, file.bytes, file.mime, s.delimiter) + when (res) { + is ApiResult.Success -> { + _state.update { it.copy(uploading = false, file = null, ended = false, sendInvites = false) } + setJob(res.data) + } + is ApiResult.Failure -> _state.update { it.copy(uploading = false, forbidden = res.isForbidden(), error = res.toPatronsMessage(R.string.staff_import_failed)) } + } + } + } + + fun assign(column: Int, key: String) = _state.update { it.copy(mapping = UserImportRules.assign(it.mapping, column, key)) } + fun setSendInvites(value: Boolean) = _state.update { it.copy(sendInvites = value) } + + fun start() { + val s = _state.value + val job = s.job ?: return + if (s.starting || !UserImportRules.canStart(job, s.mapping)) return + _state.update { it.copy(starting = true, error = null) } + viewModelScope.launch { + val res = patrons.startUserImport(job.id, s.mapping, s.sendInvites) + when (res) { + is ApiResult.Success -> { + _state.update { it.copy(starting = false) } + setJob(res.data) + resume() + } + is ApiResult.Failure -> { + _state.update { it.copy(starting = false, forbidden = res.isForbidden(), error = res.toPatronsMessage(R.string.staff_import_failed)) } + // Started already (another device, or a lost answer): drive it from where it is. + if (res.code == "already_started") refresh(thenResume = true) + } + } + } + } + + private suspend fun refreshNow(): UserImportJob? { + val id = _state.value.job?.id ?: return null + return (patrons.userImport(id) as? ApiResult.Success)?.data?.also { setJob(it) } + } + + private fun refresh(thenResume: Boolean) { + viewModelScope.launch { + val job = refreshNow() + if (thenResume && job != null && job.started && !UserImportRules.isFinished(job)) resume() + } + } + + fun resume() { + val job = _state.value.job ?: return + if (loop?.isActive == true || !job.started || UserImportRules.isFinished(job) || _state.value.ended) return + _state.update { it.copy(running = true, error = null) } + loop = viewModelScope.launch { + var step: ChunkStep = UserImportRules.next(job) + var busy = 0 + while (isActive) { + val start = when (val s = step) { + is ChunkStep.Next -> s.start + is ChunkStep.Wait -> { delay(3_000); s.start } + ChunkStep.Done -> break + is ChunkStep.Stop -> { + _state.update { it.copy(forbidden = s.failure.isForbidden(), error = s.failure.toPatronsMessage(R.string.staff_import_failed)) } + // Ended elsewhere, cancelled meanwhile, or its file is gone: read the final state. + if (s.failure.code in setOf("gone", "job_cancelled", "file_missing") || s.failure.httpStatus == 410) { + _state.update { it.copy(ended = true) } + refreshNow() + } + break + } + } + when (val res = patrons.userImportChunk(job.id, start)) { + is ApiResult.Success -> { + busy = 0 + setJob(res.data) + step = UserImportRules.next(res.data) + } + is ApiResult.Failure -> { + if (res.code == "job_busy") busy++ + step = UserImportRules.afterFailure(res, start, busy) + res.meta?.processed?.let { p -> _state.update { s -> s.copy(job = s.job?.copy(processed = p)) } } + } + } + } + _state.update { it.copy(running = false) } + } + } + + fun pause() { + loop?.cancel() + _state.update { it.copy(running = false) } + } + + fun cancel() { + val job = _state.value.job ?: return + pause() + viewModelScope.launch { + when (val res = patrons.cancelUserImport(job.id)) { + is ApiResult.Success -> { + _state.update { it.copy(ended = true, message = StaffMessage(R.string.staff090_patrons_import_cancelled)) } + refreshNow() + } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden(), error = res.toPatronsMessage(R.string.staff_error_save)) } + } + } + } + + /** Back to the file picker (the finished job stays in the server's history). */ + fun newImport() { + if (_state.value.running) return + savedState.remove(KEY_JOB) + _state.update { UserImportUiState(delimiter = it.delimiter) } + loadOpenJobs() + } + + fun downloadExample() = download("staff/user-imports/example.csv", "esempio_import_utenti.csv") + + fun downloadErrors() { + val job = _state.value.job ?: return + download("staff/user-imports/${job.id}/errors.csv", "errori_import_utenti.csv") + } + + private fun download(path: String, name: String) { + viewModelScope.launch { + val res = files.download(path, name) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(export = res.data) + is ApiResult.Failure -> it.copy(forbidden = res.isForbidden(), message = res.toPatronsMessage(R.string.staff_download_failed)) + } + } + } + } + + fun closeExport() = _state.update { it.copy(export = null) } + fun consumeMessage() = _state.update { it.copy(message = null) } + + private companion object { + const val KEY_JOB = "userImportJob" + } +} + +internal fun delimiterLabel(value: String): Int = when (value) { + ";" -> R.string.staff090_patrons_delimiter_semicolon + "," -> R.string.staff090_patrons_delimiter_comma + "tab" -> R.string.staff090_patrons_delimiter_tab + else -> R.string.staff090_patrons_delimiter_auto +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun Staff090UserImportScreen(onNavigateUp: () -> Unit) { + val vm: Staff090UserImportViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.s090.userImport, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument(), vm::onPicked) + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff090_user_import), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + val job = state.job + LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + if (job == null) { + item(key = "form") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.sm)) { + Text(stringResource(R.string.staff090_patrons_import_intro), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + state.error?.let { InlineErrorBanner(message = it.resolve()) } + PickerRow( + label = stringResource(R.string.staff_import_file), value = state.file?.name, placeholder = stringResource(R.string.staff_import_choose_file), + onClick = { picker.launch(MIME_TYPES) }, + ) + Text(stringResource(R.string.staff090_patrons_delimiter), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + UserImportRules.DELIMITERS.forEach { d -> + FilterChip( + selected = state.delimiter == d, onClick = { vm.setDelimiter(d) }, label = { Text(stringResource(delimiterLabel(d))) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + } + PrimaryButton( + stringResource(R.string.staff090_patrons_import_upload), onClick = vm::upload, loading = state.uploading, enabled = state.file != null, + leadingIcon = Icons.Outlined.UploadFile, modifier = Modifier.fillMaxWidth().testTag("user-import-upload"), + ) + SecondaryButton( + stringResource(R.string.staff_import_example), onClick = vm::downloadExample, leadingIcon = Icons.Outlined.Download, + modifier = Modifier.fillMaxWidth().testTag("user-import-example"), + ) + } + } + if (state.openJobs.isNotEmpty()) { + item(key = "open-title") { FormSectionTitle(stringResource(R.string.staff_import_open_jobs)) } + itemsIndexed(state.openJobs, key = { _, j -> "open-${j.id}" }) { _, j -> OpenJobRow(j, state.openBusy, vm) } + } + } else { + item(key = "job") { JobHeader(job, state, vm) } + if (!job.started && !UserImportRules.isFinished(job)) { + item(key = "preview-title") { FormSectionTitle(stringResource(R.string.staff090_patrons_import_preview)) } + item(key = "preview") { PreviewTable(job) } + item(key = "mapping-title") { FormSectionTitle(stringResource(R.string.staff090_patrons_import_mapping)) } + itemsIndexed(job.headers, key = { i, _ -> "col-$i" }) { i, header -> MappingRow(i, header, job, state.mapping, vm) } + item(key = "start") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.sm)) { + val missing = UserImportRules.missingRequired(job.fields, state.mapping) + if (missing.isNotEmpty()) { + Text( + stringResource(R.string.staff090_patrons_import_missing, missing.joinToString(", ") { it.label }), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, + ) + } + SwitchRow( + stringResource(R.string.staff090_patrons_import_invites), state.sendInvites, vm::setSendInvites, + subtitle = stringResource(R.string.staff090_patrons_import_invites_hint), + ) + PrimaryButton( + stringResource(R.string.staff090_patrons_import_start), onClick = vm::start, loading = state.starting, + enabled = UserImportRules.canStart(job, state.mapping), modifier = Modifier.fillMaxWidth().testTag("user-import-start"), + ) + SecondaryButton(stringResource(R.string.staff_import_cancel), onClick = vm::cancel, modifier = Modifier.fillMaxWidth()) + } + } + } + if (job.errors.isNotEmpty()) { + item(key = "errors-title") { FormSectionTitle(stringResource(R.string.staff_import_errors)) } + itemsIndexed(job.errors, key = { i, _ -> "err-$i" }) { _, e -> + Text( + stringResource(R.string.staff090_patrons_import_error_line, e.line, listOf(e.title, e.message).filter { it.isNotBlank() }.joinToString(": ")), + style = MaterialTheme.typography.bodySmall, + color = if (e.type == "warning") MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, + modifier = Modifier.padding(vertical = Spacing.xxs), + ) + } + } + } + } + } + state.export?.let { ExportFileDialog(it, "text/csv", stringResource(R.string.staff090_user_import), onDismiss = vm::closeExport) } +} + +/** An unfinished import: where it stands, Resume and Cancel. */ +@Composable +internal fun OpenJobRow(job: UserImportJob, busy: String?, vm: Staff090UserImportViewModel) = + OpenJobRow(job, busy, onResume = { vm.reopen(job) }, onCancel = { vm.cancelOpen(job) }) + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun OpenJobRow(job: UserImportJob, busy: String?, onResume: () -> Unit, onCancel: () -> Unit) { + InfoCard(Modifier.padding(vertical = Spacing.xs).testTag("open-job-${job.id}")) { + Text(job.fileName, style = MaterialTheme.typography.titleSmall) + Text( + if (job.started) stringResource(R.string.staff090_patrons_import_open_started, stringResource(R.string.staff_import_progress, job.processed, job.totalRows)) + else stringResource(R.string.staff090_patrons_import_open_not_started, job.totalRows), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow(Modifier.padding(top = Spacing.sm), horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + PrimaryButton( + stringResource(R.string.staff_import_resume), onClick = onResume, loading = busy == job.id, enabled = busy == null, + modifier = Modifier.testTag("open-resume-${job.id}"), + ) + SecondaryButton(stringResource(R.string.staff_import_cancel), onClick = onCancel, enabled = busy == null, modifier = Modifier.testTag("open-cancel-${job.id}")) + } + } +} + +private val MIME_TYPES = arrayOf("text/csv", "text/comma-separated-values", "text/plain", "text/tab-separated-values", "application/vnd.ms-excel") + +@Composable +private fun JobHeader(job: UserImportJob, state: UserImportUiState, vm: Staff090UserImportViewModel) { + val finished = UserImportRules.isFinished(job) || state.ended + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.sm)) { + Text(job.fileName, style = MaterialTheme.typography.titleMedium) + Text( + stringResource( + when { + job.status == "cancelled" -> R.string.staff_import_status_cancelled + job.status == "failed" -> R.string.staff_import_status_failed + state.ended && !UserImportRules.isFinished(job) -> R.string.staff_import_status_gone + finished -> R.string.staff_import_status_done + !job.started -> R.string.staff090_patrons_import_ready + state.running -> R.string.staff_import_status_running + else -> R.string.staff_import_status_paused + }, + ), + style = MaterialTheme.typography.bodyLarge, modifier = Modifier.testTag("user-import-status"), + ) + job.notice?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + state.error?.let { InlineErrorBanner(message = it.resolve()) } + if (job.started) { + ProgressLine(job.processed, job.totalRows, stringResource(R.string.staff_import_progress, job.processed, job.totalRows)) + InfoCard(Modifier.testTag("user-import-counters")) { + InfoLine(stringResource(R.string.staff090_patrons_import_created), job.created.toString()) + InfoLine(stringResource(R.string.staff090_patrons_import_duplicates), job.duplicates.toString()) + InfoLine(stringResource(R.string.staff090_patrons_import_failed), job.failed.toString()) + if (job.sendInvites == true) { + InfoLine(stringResource(R.string.staff090_patrons_import_invited), job.invited.toString()) + InfoLine(stringResource(R.string.staff090_patrons_import_invite_failed), job.inviteFailed.toString()) + } + } + job.logError?.takeIf { it.isNotBlank() }?.let { InlineErrorBanner(message = it) } + if (!finished) { + if (state.running) SecondaryButton(stringResource(R.string.staff_import_pause), onClick = vm::pause, modifier = Modifier.fillMaxWidth()) + else PrimaryButton(stringResource(R.string.staff_import_resume), onClick = vm::resume, modifier = Modifier.fillMaxWidth()) + SecondaryButton(stringResource(R.string.staff_import_cancel), onClick = vm::cancel, modifier = Modifier.fillMaxWidth()) + } + } else { + InfoLine(stringResource(R.string.staff090_patrons_import_rows), job.totalRows.toString()) + } + // The server keeps a report for every finished job; offer it only when it has rows. + if (job.reportAvailable && job.errors.isNotEmpty()) { + SecondaryButton( + stringResource(R.string.staff_import_errors_csv), onClick = vm::downloadErrors, leadingIcon = Icons.Outlined.Download, + modifier = Modifier.fillMaxWidth().testTag("user-import-errors"), + ) + } + if (finished && !state.running) { + PrimaryButton(stringResource(R.string.staff090_patrons_import_new), onClick = vm::newImport, modifier = Modifier.fillMaxWidth().testTag("user-import-new")) + } + } +} + +/** The first rows as the server read them, scrolling sideways on a narrow screen. */ +@Composable +private fun PreviewTable(job: UserImportJob) { + InfoCard(Modifier.testTag("user-import-preview")) { + Column(Modifier.horizontalScroll(rememberScrollState())) { + val rows = listOf(job.headers) + job.preview + rows.forEachIndexed { r, row -> + Row(Modifier.padding(vertical = Spacing.xxs)) { + job.headers.indices.forEach { c -> + Text( + row.getOrNull(c).orEmpty(), style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, + fontWeight = if (r == 0) FontWeight.SemiBold else FontWeight.Normal, modifier = Modifier.width(132.dp).padding(end = Spacing.sm), + ) + } + } + } + } + } +} + +/** One CSV column and the field it fills; required fields carry an asterisk. */ +@Composable +private fun MappingRow(index: Int, header: String, job: UserImportJob, mapping: List, vm: Staff090UserImportViewModel) { + var open by remember { mutableStateOf(false) } + val current = mapping.getOrNull(index).orEmpty() + val label = { key: String -> job.fields.firstOrNull { it.key == key }?.let { f -> if (f.required) "${f.label} *" else f.label } } + val ignore = stringResource(R.string.staff090_patrons_import_ignore) + Box(Modifier.padding(vertical = Spacing.xs).testTag("mapping-$index")) { + PickerRow(label = header.ifBlank { stringResource(R.string.staff090_patrons_import_column, index + 1) }, value = label(current) ?: ignore, placeholder = ignore, onClick = { open = true }) + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + DropdownMenuItem(text = { Text(ignore) }, onClick = { vm.assign(index, ""); open = false }) + job.fields.forEach { f -> + DropdownMenuItem( + text = { Text(if (f.required) "${f.label} *" else f.label) }, + onClick = { vm.assign(index, f.key); open = false }, + modifier = Modifier.testTag("field-$index-${f.key}"), + ) + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsLedgerScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsLedgerScreen.kt new file mode 100644 index 0000000..748ccc2 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsLedgerScreen.kt @@ -0,0 +1,426 @@ +package com.pinakes.app.ui.screens.staff.s090.patrons + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Payments +import androidx.compose.material.icons.automirrored.outlined.ReceiptLong +import androidx.compose.material.icons.outlined.RemoveCircleOutline +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.StaffLedger +import com.pinakes.app.data.model.StaffLedgerMovement +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.LedgerWrite +import com.pinakes.app.data.repository.Staff090PatronsRepository +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.LedgerAction +import com.pinakes.app.data.staff.LedgerRules +import com.pinakes.app.data.staff.MoneyInput +import com.pinakes.app.data.staff.SubmissionKeys +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.EmptyState +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.components.SectionHeader +import com.pinakes.app.ui.screens.movements.AccruedRow +import com.pinakes.app.ui.screens.movements.BalanceCard +import com.pinakes.app.ui.screens.movements.MoneyFormat +import com.pinakes.app.ui.screens.movements.MovementRow +import com.pinakes.app.ui.screens.staff.ExportFileDialog +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.s090.Staff090Routes +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** The open ledger form: what the operator typed. */ +data class LedgerForm( + val action: LedgerAction, + val amount: String = "", + val method: String? = null, + /** The payment note, the charge description or the waiver reason. */ + val text: String = "", +) + +/** One submission, compared by value for its Idempotency-Key. */ +private data class LedgerSubmission(val userId: Int, val action: LedgerAction, val cents: Int, val method: String?, val text: String) + +data class StaffLedgerUiState( + val ledger: StaffLedger? = null, + val items: List = emptyList(), + val nextCursor: String? = null, + val loading: Boolean = true, + val refreshing: Boolean = false, + val loadingMore: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + /** The fines module went off (404 feature_disabled): the screen leaves. */ + val disabled: Boolean = false, + val form: LedgerForm? = null, + val formError: StaffMessage? = null, + val saving: Boolean = false, + val receiptBusy: Int? = null, + val receipt: File? = null, + val message: StaffMessage? = null, +) + +/** A patron's ledger at the desk: balance, accruing fines, movements, payments, charges, waivers. */ +@HiltViewModel +class Staff090LedgerViewModel @Inject constructor( + private val patrons: Staff090PatronsRepository, + private val files: StaffFilesRepository, + savedState: SavedStateHandle, +) : ViewModel() { + private val userId: Int = savedState.get(Staff090Routes.ARG_USER_ID) ?: 0 + private val _state = MutableStateFlow(StaffLedgerUiState()) + val state: StateFlow = _state.asStateFlow() + private val keys = SubmissionKeys() + + init { load() } + + fun load() { + _state.update { it.copy(loading = it.ledger == null && !it.refreshing, error = null) } + viewModelScope.launch { + val res = patrons.ledger(userId) + _state.update { + when (res) { + is ApiResult.Success -> { + val ledger = res.data.items.first() + it.copy(ledger = ledger, items = ledger.items, nextCursor = res.data.nextCursor, loading = false, refreshing = false) + } + is ApiResult.Failure -> it.copy( + loading = false, refreshing = false, forbidden = res.isForbidden(), + disabled = res.code == "feature_disabled", + error = if (it.ledger == null) res.toPatronsMessage(R.string.staff_error_load) else null, + message = if (it.ledger != null) res.toPatronsMessage(R.string.staff_error_load) else null, + ) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + load() + } + + fun loadMore() { + val s = _state.value + val cursor = s.nextCursor ?: return + if (s.loadingMore) return + _state.update { it.copy(loadingMore = true) } + viewModelScope.launch { + val res = patrons.ledger(userId, cursor) + _state.update { + when (res) { + is ApiResult.Success -> { + val page = res.data.items.first().items + // The cursor pins the first page's newest row; never show a row twice. + val seen = it.items.map { m -> m.id }.toSet() + it.copy(items = it.items + page.filter { m -> m.id !in seen }, nextCursor = res.data.nextCursor, loadingMore = false) + } + is ApiResult.Failure -> it.copy(loadingMore = false, forbidden = res.isForbidden(), message = res.toPatronsMessage(R.string.staff_error_load)) + } + } + } + } + + fun open(action: LedgerAction) { + val ledger = _state.value.ledger ?: return + val amount = if (action == LedgerAction.CHARGE) "" else MoneyInput.text(LedgerRules.maxCents(action, ledger)) + _state.update { it.copy(form = LedgerForm(action, amount = amount, method = ledger.paymentMethods.firstOrNull()?.code), formError = null) } + } + + fun edit(form: LedgerForm) = _state.update { it.copy(form = form, formError = null) } + fun close() { if (!_state.value.saving) _state.update { it.copy(form = null) } } + + fun submit() { + val s = _state.value + val form = s.form ?: return + val ledger = s.ledger ?: return + if (s.saving) return + val parsed = MoneyInput.parse(form.amount, LedgerRules.maxCents(form.action, ledger)) + if (!LedgerRules.canSubmit(form.action, parsed, form.method, form.text)) return + val cents = (parsed as MoneyInput.Parsed.Cents).value + val write = when (form.action) { + LedgerAction.PAYMENT -> LedgerWrite.Payment(cents, form.method.orEmpty(), form.text) + LedgerAction.CHARGE -> LedgerWrite.Charge(cents, form.text) + LedgerAction.WAIVER -> LedgerWrite.Waiver(cents, form.text) + } + val key = keys.keyFor(LedgerSubmission(userId, form.action, cents, form.method.takeIf { form.action == LedgerAction.PAYMENT }, form.text.trim())) + _state.update { it.copy(saving = true, formError = null) } + viewModelScope.launch { + var res = patrons.recordLedger(userId, write, key) + // The core asks for a retry when two desks wrote at once: the same key, once more. + if (res is ApiResult.Failure && res.code == "concurrent_retry") res = patrons.recordLedger(userId, write, key) + val outcome = res + when (outcome) { + is ApiResult.Success -> { + keys.reset() + _state.update { + it.copy( + saving = false, form = null, + message = StaffMessage( + when (form.action) { + LedgerAction.PAYMENT -> R.string.staff090_patrons_payment_recorded + LedgerAction.CHARGE -> R.string.staff090_patrons_charge_recorded + LedgerAction.WAIVER -> R.string.staff090_patrons_waiver_recorded + }, + ), + ) + } + load() + } + is ApiResult.Failure -> { + if (outcome.code == SubmissionKeys.CONFLICT) keys.reset() + _state.update { it.copy(saving = false, forbidden = outcome.isForbidden(), formError = outcome.toPatronsMessage(R.string.staff_error_save)) } + // More than the patron owes: the balance changed meanwhile, show the real one. + if (outcome.code == "amount_exceeds_balance") load() + } + } + } + } + + fun receipt(movement: StaffLedgerMovement) { + if (_state.value.receiptBusy != null) return + _state.update { it.copy(receiptBusy = movement.id) } + viewModelScope.launch { + val res = files.download("staff/users/$userId/ledger/movements/${movement.id}/receipt.pdf", "ricevuta_${movement.id}.pdf") + _state.update { + when (res) { + is ApiResult.Success -> it.copy(receiptBusy = null, receipt = res.data) + is ApiResult.Failure -> it.copy(receiptBusy = null, forbidden = res.isForbidden(), message = res.toPatronsMessage(R.string.staff_download_failed)) + } + } + } + } + + fun closeReceipt() = _state.update { it.copy(receipt = null) } + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +internal fun ledgerActionLabel(action: LedgerAction): Int = when (action) { + LedgerAction.PAYMENT -> R.string.staff090_patrons_record_payment + LedgerAction.CHARGE -> R.string.staff090_patrons_add_charge + LedgerAction.WAIVER -> R.string.staff090_patrons_waive +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun Staff090LedgerScreen(onNavigateUp: () -> Unit) { + val vm: Staff090LedgerViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.s090.ledger || state.disabled, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff090_ledger), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize().padding(padding)) { + val ledger = state.ledger + when { + state.loading -> LoadingState() + ledger == null -> ErrorState(message = state.error?.resolve() ?: stringResource(R.string.staff_error_load), onRetry = vm::refresh) + else -> { + val money = { cents: Int -> MoneyFormat.format(cents, ledger.currency, com.pinakes.app.ui.common.AppLocale.current()) } + LazyColumn( + Modifier.fillMaxSize().testTag("ledger-list"), + contentPadding = PaddingValues(top = Spacing.sm, bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + item(key = "balance") { BalanceCard(ledger.toPayload(), money, staff = true) } + val actions = LedgerRules.actions(ledger) + if (actions.isNotEmpty()) item(key = "actions") { + FlowRow( + Modifier.padding(horizontal = Spacing.lg), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + actions.forEachIndexed { index, action -> + val label = stringResource(ledgerActionLabel(action)) + val icon = when (action) { + LedgerAction.PAYMENT -> Icons.Outlined.Payments + LedgerAction.CHARGE -> Icons.Outlined.Add + LedgerAction.WAIVER -> Icons.Outlined.RemoveCircleOutline + } + val tag = Modifier.testTag("ledger-${action.name.lowercase()}") + if (index == 0) PrimaryButton(label, onClick = { vm.open(action) }, leadingIcon = icon, modifier = tag) + else SecondaryButton(label, onClick = { vm.open(action) }, leadingIcon = icon, modifier = tag) + } + } + } + if (ledger.accruedCents != 0 || ledger.accrued.isNotEmpty()) { + item(key = "accrued-title") { SectionHeader(title = stringResource(R.string.movements_accrued_title, money(ledger.accruedCents))) } + itemsIndexed(ledger.accrued, key = { i, a -> MoneyFormat.rowKey("accrued", i, a.loanId) }) { _, a -> AccruedRow(a, money) } + } + item(key = "items-title") { SectionHeader(title = stringResource(R.string.movements_history_title)) } + if (state.items.isEmpty()) { + item(key = "empty") { + EmptyState( + title = stringResource(R.string.movements_empty_title), subtitle = stringResource(R.string.movements_empty_subtitle), + icon = Icons.Outlined.AccountBalanceWallet, + ) + } + } + itemsIndexed(state.items, key = { i, m -> MoneyFormat.rowKey("movement", i, m.id) }) { index, m -> + if (index >= state.items.size - 5 && state.nextCursor != null) androidx.compose.runtime.LaunchedEffect(state.items.size) { vm.loadMore() } + Column { + MovementRow(m.toMovement(), money) + val operator = m.operator?.name?.takeIf { it.isNotBlank() } + if (operator != null || m.receiptAvailable) { + Row(Modifier.fillMaxWidth().padding(horizontal = Spacing.xl), verticalAlignment = Alignment.CenterVertically) { + Text( + operator?.let { stringResource(R.string.staff090_patrons_recorded_by, it) }.orEmpty(), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (m.receiptAvailable) { + PinakesTextButton( + stringResource(if (state.receiptBusy == m.id) R.string.staff090_patrons_receipt_loading else R.string.staff090_patrons_receipt), + onClick = { vm.receipt(m) }, enabled = state.receiptBusy == null, + modifier = Modifier.testTag("receipt-${m.id}"), + ) + } + } + } + } + } + if (state.loadingMore) item(key = "more") { LoadingState(modifier = Modifier.heightIn(max = 80.dp)) } + } + } + } + } + } + val ledger = state.ledger + val form = state.form + if (form != null && ledger != null) LedgerFormSheet(form, ledger, state, vm) + state.receipt?.let { ExportFileDialog(it, "application/pdf", stringResource(R.string.staff090_patrons_receipt_ready), onDismiss = vm::closeReceipt) } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun LedgerFormSheet(form: LedgerForm, ledger: StaffLedger, state: StaffLedgerUiState, vm: Staff090LedgerViewModel) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val max = LedgerRules.maxCents(form.action, ledger) + val parsed = MoneyInput.parse(form.amount, max) + val locale = com.pinakes.app.ui.common.AppLocale.current() + ModalBottomSheet(onDismissRequest = vm::close, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(ledgerActionLabel(form.action)), style = MaterialTheme.typography.headlineSmall) + Text( + stringResource(R.string.staff090_patrons_balance_line, MoneyFormat.format(ledger.balanceCents, ledger.currency, locale)), + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + PinakesTextField( + value = form.amount, onValueChange = { vm.edit(form.copy(amount = it)) }, + label = stringResource(R.string.staff090_patrons_amount, ledger.currency.orEmpty()), + isError = parsed is MoneyInput.Parsed.Invalid || parsed is MoneyInput.Parsed.TooLarge, + errorText = if (parsed is MoneyInput.Parsed.TooLarge) stringResource(R.string.staff090_patrons_amount_too_large, MoneyFormat.format(max, ledger.currency, locale)) + else stringResource(R.string.staff090_patrons_amount_invalid), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), maxLength = 13, + modifier = Modifier.fillMaxWidth().testTag("ledger-amount"), + ) + when (form.action) { + LedgerAction.PAYMENT -> { + Text(stringResource(R.string.staff090_patrons_method), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + ledger.paymentMethods.forEach { m -> + FilterChip( + selected = form.method == m.code, onClick = { vm.edit(form.copy(method = m.code)) }, + label = { Text(m.label.ifBlank { m.code }) }, modifier = Modifier.testTag("method-${m.code}"), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + } + PinakesTextField( + value = form.text, onValueChange = { vm.edit(form.copy(text = it)) }, label = stringResource(R.string.staff090_patrons_note_optional), + singleLine = false, maxLength = 255, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).testTag("ledger-text"), + ) + } + LedgerAction.CHARGE, LedgerAction.WAIVER -> PinakesTextField( + value = form.text, onValueChange = { vm.edit(form.copy(text = it)) }, + label = stringResource(if (form.action == LedgerAction.CHARGE) R.string.staff090_patrons_description else R.string.staff090_patrons_waive_reason), + singleLine = false, maxLength = 255, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).testTag("ledger-text"), + ) + } + state.formError?.let { InlineErrorBanner(message = it.resolve()) } + PrimaryButton( + stringResource(ledgerActionLabel(form.action)), onClick = vm::submit, loading = state.saving, + enabled = LedgerRules.canSubmit(form.action, parsed, form.method, form.text), leadingIcon = Icons.AutoMirrored.Outlined.ReceiptLong, + modifier = Modifier.fillMaxWidth().testTag("ledger-submit"), + ) + Spacer(Modifier.height(Spacing.lg)) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsReturnFine.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsReturnFine.kt new file mode 100644 index 0000000..e127261 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsReturnFine.kt @@ -0,0 +1,75 @@ +package com.pinakes.app.ui.screens.staff.s090.patrons + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.pinakes.app.R +import com.pinakes.app.data.model.StaffLoanLateFine +import com.pinakes.app.data.staff.LateFineRules +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.theme.Spacing + +/** + * The late fine block of the return form (Mobile API 1.10.0): the amount a return today + * charges and the waiver, whose reason is mandatory. Inert (nothing drawn, nothing sent) when + * the loan accrues no fine or the server predates the field. + */ +class ReturnFineState internal constructor( + val fine: StaffLoanLateFine?, + private val waiveState: MutableState, + private val reasonState: MutableState, +) { + var waive by waiveState + var reason by reasonState + val shows: Boolean get() = LateFineRules.shows(fine) + val canConfirm: Boolean get() = !shows || LateFineRules.canConfirm(waive, reason) + + /** The reason to send with the return, or null when the fine is charged. */ + fun waiveReason(): String? = LateFineRules.waiveReason(fine, waive, reason) + + @Composable + fun Content() { + val fine = fine ?: return + if (!shows) return + Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth().testTag("return-fine")) { + Column(Modifier.padding(Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text( + stringResource(R.string.staff090_patrons_return_fine, money(fine.accruingCents, fine.currency), fine.accruingDays), + style = MaterialTheme.typography.titleSmall, + color = if (waive) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, + ) + SwitchRow(stringResource(R.string.staff090_patrons_return_waive), waive, { waive = it }) + if (waive) { + PinakesTextField( + value = reason, onValueChange = { reason = it }, label = stringResource(R.string.staff090_patrons_waive_reason), + singleLine = false, maxLength = 255, isError = reason.isBlank(), errorText = stringResource(R.string.staff090_patrons_reason_required), + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).testTag("return-waive-reason"), + ) + } + } + } + } +} + +@Composable +fun rememberReturnFineState(fine: StaffLoanLateFine?): ReturnFineState { + val waive = rememberSaveable { mutableStateOf(false) } + val reason = rememberSaveable { mutableStateOf("") } + return ReturnFineState(fine, waive, reason) +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsSection.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsSection.kt new file mode 100644 index 0000000..ee12b67 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/patrons/Staff090PatronsSection.kt @@ -0,0 +1,481 @@ +package com.pinakes.app.ui.screens.staff.s090.patrons + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material.icons.outlined.CameraAlt +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.Print +import androidx.compose.material.icons.outlined.RadioButtonUnchecked +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.PatronTypeList +import com.pinakes.app.data.model.StaffUserDetail +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.Staff090PatronsRepository +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.CoverImageEncoder +import com.pinakes.app.data.staff.PatronTools +import com.pinakes.app.data.staff.Staff090Areas +import com.pinakes.app.data.staff.SubmissionKeys +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.ExportFileDialog +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.StaffActionRow +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** What the patron tools are doing; one at a time. */ +enum class PatronBusy { RENEW, TYPE, PHOTO, PRINT } + +data class PatronSectionUiState( + val userId: Int = 0, + val photo: ByteArray? = null, + /** The `photo_url` (versioned) the bytes belong to: a new photo has a new one. */ + val photoUrl: String? = null, + val busy: PatronBusy? = null, + val types: PatronTypeList? = null, + val typesLoading: Boolean = false, + val typeSheet: Boolean = false, + val selectedType: Int? = null, + val recompute: Boolean = false, + val confirmRenew: Boolean = false, + val confirmRemovePhoto: Boolean = false, + val export: File? = null, + val message: StaffMessage? = null, + val error: StaffMessage? = null, + /** Bumped after a write: the record screen reloads the user. */ + val changed: Int = 0, + val forbidden: Boolean = false, +) + +/** + * The 0.9.0 tools on a patron record: photo, patron type, card renewal and printout, and the + * way into the ledger. Every write answers the reloaded user, and the record screen reloads. + */ +@HiltViewModel +class Staff090PatronSectionViewModel @Inject constructor( + private val patrons: Staff090PatronsRepository, + private val files: StaffFilesRepository, + @ApplicationContext private val context: Context, +) : ViewModel() { + private val _state = MutableStateFlow(PatronSectionUiState()) + val state: StateFlow = _state.asStateFlow() + private val renewKeys = SubmissionKeys() + + /** The user on screen: loads its photo when it has one (or a new one). */ + fun bind(user: StaffUserDetail, photos: Boolean) { + val url = user.photoUrl?.takeIf { photos && user.hasPhoto } + _state.update { if (it.userId != user.id) PatronSectionUiState(userId = user.id) else it } + if (url == null) { + _state.update { it.copy(photo = null, photoUrl = null) } + return + } + if (url == _state.value.photoUrl) return + _state.update { it.copy(photoUrl = url) } + viewModelScope.launch { + when (val res = patrons.photo(user.id)) { + is ApiResult.Success -> _state.update { if (it.photoUrl == url) it.copy(photo = res.data) else it } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden()) } + } + } + } + + // ---- Patron type ---- + + fun openTypes(current: Int?) { + _state.update { it.copy(typeSheet = true, selectedType = current, recompute = false, error = null) } + if (_state.value.types != null) return + _state.update { it.copy(typesLoading = true) } + viewModelScope.launch { + val res = patrons.patronTypes() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(types = res.data, typesLoading = false) + is ApiResult.Failure -> it.copy(typesLoading = false, forbidden = res.isForbidden(), error = res.toPatronsMessage(R.string.staff_error_load)) + } + } + } + } + + fun selectType(id: Int) = _state.update { it.copy(selectedType = id) } + fun setRecompute(value: Boolean) = _state.update { it.copy(recompute = value) } + fun closeTypes() = _state.update { it.copy(typeSheet = false) } + + fun saveType() { + val s = _state.value + if (s.busy != null) return + // The default type is sent as "none of its own", as the web form's empty choice. + val chosen = s.selectedType?.takeIf { it != s.types?.defaultId } + _state.update { it.copy(busy = PatronBusy.TYPE, error = null) } + viewModelScope.launch { + val res = patrons.setPatronType(s.userId, chosen, s.recompute) + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + busy = null, typeSheet = false, changed = it.changed + 1, + message = StaffMessage(if (res.data.cardExpiryRecomputed) R.string.staff090_patrons_type_saved_expiry else R.string.staff090_patrons_type_saved), + ) + is ApiResult.Failure -> it.copy(busy = null, forbidden = res.isForbidden(), error = res.toPatronsMessage(R.string.staff_error_save)) + } + } + } + } + + // ---- Card ---- + + fun askRenew() = _state.update { it.copy(confirmRenew = true) } + fun dismissRenew() = _state.update { it.copy(confirmRenew = false) } + + fun renew() { + val s = _state.value + if (s.busy != null) return + // One key per renewal: a retry after a lost answer never adds the duration twice. + val key = renewKeys.keyFor(s.userId to s.changed) + _state.update { it.copy(confirmRenew = false, busy = PatronBusy.RENEW, error = null) } + viewModelScope.launch { + val res = patrons.renewCard(s.userId, key) + _state.update { + when (res) { + is ApiResult.Success -> { + renewKeys.reset() + it.copy( + busy = null, changed = it.changed + 1, + message = StaffMessage(R.string.staff090_patrons_card_renewed, args = listOf(DateFormat.date(res.data.cardExpiresAt))), + ) + } + is ApiResult.Failure -> { + if (res.code == SubmissionKeys.CONFLICT) renewKeys.reset() + it.copy(busy = null, forbidden = res.isForbidden(), error = res.toPatronsMessage(R.string.staff_error_save)) + } + } + } + } + } + + fun printCard() { + val s = _state.value + if (s.busy != null) return + _state.update { it.copy(busy = PatronBusy.PRINT, error = null) } + viewModelScope.launch { + val res = files.download("staff/users/${s.userId}/card.pdf", "tessera_${s.userId}.pdf") + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = null, export = res.data) + is ApiResult.Failure -> it.copy(busy = null, forbidden = res.isForbidden(), error = res.toPatronsMessage(R.string.staff_download_failed)) + } + } + } + } + + fun closeExport() = _state.update { it.copy(export = null) } + + // ---- Photo ---- + + /** A photo taken or picked: re-encoded (EXIF rotation, metadata dropped), then uploaded. */ + fun uploadPhoto(uri: Uri, scratch: File?) { + val s = _state.value + if (s.busy != null) return + _state.update { it.copy(busy = PatronBusy.PHOTO, error = null) } + viewModelScope.launch { + val bytes = withContext(Dispatchers.IO) { + CoverImageEncoder.encode(context, uri).also { scratch?.delete() } + } + if (bytes == null) { + _state.update { it.copy(busy = null, error = StaffMessage(R.string.staff090_patrons_photo_unreadable)) } + return@launch + } + val res = patrons.uploadPhoto(s.userId, bytes) + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + busy = null, photo = bytes, photoUrl = res.data.photoUrl, changed = it.changed + 1, + message = StaffMessage(R.string.staff090_patrons_photo_saved), + ) + is ApiResult.Failure -> it.copy(busy = null, forbidden = res.isForbidden(), error = res.toPatronsMessage(R.string.staff_error_save)) + } + } + } + } + + fun askRemovePhoto() = _state.update { it.copy(confirmRemovePhoto = true) } + fun dismissRemovePhoto() = _state.update { it.copy(confirmRemovePhoto = false) } + + fun removePhoto() { + val s = _state.value + if (s.busy != null) return + _state.update { it.copy(confirmRemovePhoto = false, busy = PatronBusy.PHOTO, error = null) } + viewModelScope.launch { + val res = patrons.deletePhoto(s.userId) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = null, photo = null, photoUrl = null, changed = it.changed + 1, message = StaffMessage(R.string.staff090_patrons_photo_removed)) + is ApiResult.Failure -> it.copy(busy = null, forbidden = res.isForbidden(), error = res.toPatronsMessage(R.string.staff_error_save)) + } + } + } + } + + fun photoUnavailable() = _state.update { it.copy(error = StaffMessage(R.string.staff_camera_unavailable)) } + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** A fresh scratch file for the camera app (cache/covers is shared through the FileProvider). */ +internal fun newPatronPhotoFile(context: Context): File { + val dir = File(context.cacheDir, "covers").apply { mkdirs() } + return File(dir, "patron_${System.currentTimeMillis()}.jpg") +} + +/** + * The patron section of the staff user screen. Shown only when the server offers at least one + * of its tools ([PatronTools]); [onChanged] reloads the record after a write. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun Staff090PatronSection( + user: StaffUserDetail, + areas: Staff090Areas, + onChanged: () -> Unit, + onOpenLedger: (Int) -> Unit, + onForbidden: () -> Unit, +) { + val tools = PatronTools.of(areas, user) + if (!tools.section) return + val vm: Staff090PatronSectionViewModel = hiltViewModel(key = "patron-section-${user.id}") + val state by vm.state.collectAsStateWithLifecycle() + val context = LocalContext.current + LaunchedEffect(user.id, user.photoUrl, user.hasPhoto, tools.photo) { vm.bind(user, tools.photo) } + LaunchedEffect(state.changed) { if (state.changed > 0) onChanged() } + LaunchedEffect(state.forbidden) { if (state.forbidden) onForbidden() } + val message = state.message?.resolve() + LaunchedEffect(state.message) { + if (!message.isNullOrBlank()) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + vm.consumeMessage() + } + } + + var scratchPath by rememberSaveable { mutableStateOf(null) } + val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> + val file = scratchPath?.let(::File) + if (ok && file != null) vm.uploadPhoto(Uri.fromFile(file), file) else file?.delete() + scratchPath = null + } + fun launchCamera() { + val file = newPatronPhotoFile(context) + scratchPath = file.absolutePath + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + runCatching { takePicture.launch(uri) }.onFailure { vm.photoUnavailable() } + } + val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) launchCamera() else Toast.makeText(context, context.getString(R.string.staff_scan_permission_rationale), Toast.LENGTH_LONG).show() + } + val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> if (uri != null) vm.uploadPhoto(uri, null) } + + Column(Modifier.testTag("patron-section")) { + FormSectionTitle(stringResource(R.string.staff090_patrons_section)) + InfoCard { + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.lg), verticalAlignment = Alignment.Top) { + if (tools.photo) { + if (state.busy == PatronBusy.PHOTO) LoadingState(modifier = Modifier.heightIn(max = 88.dp).padding(end = Spacing.sm)) + else PatronPhoto(state.photo, stringResource(R.string.staff090_patrons_photo), modifier = Modifier.testTag("patron-photo")) + } + Column(Modifier.weight(1f)) { + user.patronType?.let { type -> + InfoLine( + stringResource(R.string.staff090_patrons_type), + if (type.assigned) type.name else stringResource(R.string.staff090_patrons_type_default, type.name), + ) + } + if (user.patronType == null) { + Text(stringResource(R.string.staff090_patrons_tools_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + state.error?.let { InlineErrorBanner(message = it.resolve(), modifier = Modifier.padding(top = Spacing.sm)) } + Spacer(Modifier.height(Spacing.sm)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + if (tools.renewCard) SecondaryButton( + stringResource(R.string.staff090_patrons_renew_card), onClick = vm::askRenew, enabled = state.busy == null, + leadingIcon = Icons.Outlined.Refresh, modifier = Modifier.testTag("patron-renew"), + ) + if (tools.printCard) SecondaryButton( + stringResource(R.string.staff090_patrons_print_card), onClick = vm::printCard, enabled = state.busy == null, + leadingIcon = Icons.Outlined.Print, modifier = Modifier.testTag("patron-print"), + ) + if (tools.patronTypeEdit) SecondaryButton( + stringResource(R.string.staff090_patrons_change_type), onClick = { vm.openTypes(user.patronType?.id) }, enabled = state.busy == null, + modifier = Modifier.testTag("patron-type"), + ) + if (tools.photoEdit) { + SecondaryButton(stringResource(if (state.photo != null) R.string.staff090_patrons_photo_replace else R.string.staff090_patrons_photo_take), + leadingIcon = Icons.Outlined.CameraAlt, enabled = state.busy == null, onClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) launchCamera() + else permission.launch(Manifest.permission.CAMERA) + }) + SecondaryButton(stringResource(R.string.staff090_patrons_photo_pick), leadingIcon = Icons.Outlined.Image, enabled = state.busy == null, onClick = { + pickImage.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }) + if (user.hasPhoto || state.photo != null) SecondaryButton( + stringResource(R.string.staff090_patrons_photo_remove), onClick = vm::askRemovePhoto, enabled = state.busy == null, + leadingIcon = Icons.Outlined.Delete, + ) + } + } + } + val ledger = user.ledger + if (tools.ledger && ledger != null) { + StaffActionRow( + Icons.Outlined.AccountBalanceWallet, + stringResource(R.string.staff090_ledger), + listOfNotNull( + stringResource(R.string.staff090_patrons_balance_line, money(ledger.balanceCents, ledger.currency)), + stringResource(R.string.staff090_patrons_debt_blocked).takeIf { ledger.debtBlocked }, + ).joinToString(" · "), + ) { onOpenLedger(user.id) } + } + } + + if (state.confirmRenew) ConfirmDialog( + title = stringResource(R.string.staff090_patrons_renew_card), + body = stringResource(R.string.staff090_patrons_renew_body), + confirmLabel = stringResource(R.string.staff090_patrons_renew_card), + onConfirm = vm::renew, onDismiss = vm::dismissRenew, + ) + if (state.confirmRemovePhoto) ConfirmDialog( + title = stringResource(R.string.staff090_patrons_photo_remove), + body = stringResource(R.string.staff090_patrons_photo_remove_body), + confirmLabel = stringResource(R.string.staff090_patrons_photo_remove), + onConfirm = vm::removePhoto, onDismiss = vm::dismissRemovePhoto, + ) + if (state.typeSheet) PatronTypeSheet(state, vm) + state.export?.let { ExportFileDialog(it, "application/pdf", stringResource(R.string.staff090_patrons_card_ready), onDismiss = vm::closeExport) } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PatronTypeSheet(state: PatronSectionUiState, vm: Staff090PatronSectionViewModel) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = { if (state.busy == null) vm.closeTypes() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(stringResource(R.string.staff090_patrons_change_type), style = MaterialTheme.typography.headlineSmall) + val types = state.types + when { + state.typesLoading -> LoadingState(modifier = Modifier.heightIn(max = 120.dp)) + types == null -> state.error?.let { InlineErrorBanner(message = it.resolve()) } + else -> { + // Inactive types stay listed only when the account already has one. + val choices = types.types.filter { it.active || it.id == state.selectedType } + LazyColumn(Modifier.fillMaxWidth().heightIn(max = 360.dp)) { + items(choices, key = { it.id }) { type -> + val selected = type.id == state.selectedType + Surface( + onClick = { vm.selectType(type.id) }, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("type-${type.id}"), + ) { + Row(Modifier.padding(Spacing.md), verticalAlignment = Alignment.CenterVertically) { + Icon( + if (selected) Icons.Outlined.CheckCircle else Icons.Outlined.RadioButtonUnchecked, + contentDescription = stringResource(if (selected) R.string.staff_selected else R.string.staff_not_selected), + tint = MaterialTheme.colorScheme.primary, + ) + Column(Modifier.weight(1f).padding(start = Spacing.md)) { + Text( + if (type.isDefault) stringResource(R.string.staff090_patrons_type_default, type.name) else type.name, + style = MaterialTheme.typography.bodyLarge, + ) + val details = listOfNotNull( + type.description?.takeIf { it.isNotBlank() }, + type.cardDurationDays?.let { stringResource(R.string.staff090_patrons_type_card_days, it) }, + ).joinToString(" · ") + if (details.isNotBlank()) Text(details, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } + SwitchRow( + stringResource(R.string.staff090_patrons_recompute_expiry), state.recompute, vm::setRecompute, + subtitle = stringResource(R.string.staff090_patrons_recompute_expiry_hint), + ) + state.error?.let { InlineErrorBanner(message = it.resolve()) } + PrimaryButton( + stringResource(R.string.action_save), onClick = vm::saveType, loading = state.busy == PatronBusy.TYPE, + enabled = types.canAssign && state.selectedType != null, modifier = Modifier.fillMaxWidth().testTag("type-save"), + ) + } + } + Spacer(Modifier.height(Spacing.lg)) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StatisticsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StatisticsScreen.kt new file mode 100644 index 0000000..13b18dc --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StatisticsScreen.kt @@ -0,0 +1,409 @@ +package com.pinakes.app.ui.screens.staff.s090.stocktake + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.Staff090Statistics +import com.pinakes.app.data.model.Staff090StatisticsFinesReport +import com.pinakes.app.data.model.Staff090StatisticsMoney +import com.pinakes.app.data.model.Staff090StatisticsSection +import com.pinakes.app.data.model.Staff090StatisticsVisitors +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.Staff090StatisticsRepository +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.ui.common.AppLocale +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.BookCover +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.movements.MoneyFormat +import com.pinakes.app.ui.screens.staff.DatePickerField +import com.pinakes.app.ui.screens.staff.ExportFileDialog +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class StatisticsUiState( + val data: Staff090Statistics? = null, + val loading: Boolean = true, + val refreshing: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + /** The fines report period (YYYY-MM-DD); null until the server's default arrives. */ + val finesFrom: String? = null, + val finesTo: String? = null, + val export: java.io.File? = null, + val message: StaffMessage? = null, +) + +/** The statistics page; the fines period is the server's default until the user picks dates. */ +@HiltViewModel +class StatisticsViewModel @Inject constructor( + private val repo: Staff090StatisticsRepository, + private val files: StaffFilesRepository, +) : ViewModel() { + private val _state = MutableStateFlow(StatisticsUiState()) + val state: StateFlow = _state.asStateFlow() + private var generation = 0 + + init { + reload() + } + + fun reload() { + val gen = ++generation + _state.update { it.copy(loading = it.data == null && !it.refreshing, error = null) } + viewModelScope.launch { + val s = _state.value + val res = repo.statistics(s.finesFrom, s.finesTo) + if (gen != generation) return@launch + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + data = res.data, loading = false, refreshing = false, + // The period the server used (its default, or the range swapped when reversed). + finesFrom = res.data.finesReport?.from ?: it.finesFrom, + finesTo = res.data.finesReport?.to ?: it.finesTo, + ) + is ApiResult.Failure -> it.copy( + loading = false, refreshing = false, forbidden = res.isForbidden(), + error = if (it.data == null) res.toStaffMessage(R.string.staff_error_load) else null, + message = if (it.data != null) res.toStaffMessage(R.string.staff_error_load) else null, + ) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + reload() + } + + fun setFinesFrom(value: String?) { + _state.update { it.copy(finesFrom = value) } + reload() + } + + fun setFinesTo(value: String?) { + _state.update { it.copy(finesTo = value) } + reload() + } + + fun downloadFines() { + val s = _state.value + viewModelScope.launch { + val name = "multe_${s.finesFrom.orEmpty()}_${s.finesTo.orEmpty()}.csv" + val res = files.download(Staff090StatisticsRepository.finesCsvPath(s.finesFrom, s.finesTo), name) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(export = res.data) + is ApiResult.Failure -> it.copy(forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_download_failed)) + } + } + } + } + + fun closeExport() = _state.update { it.copy(export = null) } + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** One label + bar + value line of a simple horizontal bar chart. */ +private data class Bar(val key: String, val label: String, val value: Int) + +/** + * The web statistics page on one scrolling screen: overview, loans by month / state / material + * as plain bars, the most borrowed books (a tap opens the staff book) and the most active + * readers, then each 0.9.0 section the server sent (patron types, fines, stocktake), visitors + * and the fines report with its period and CSV. What the server leaves out is not shown. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun Staff090StatisticsScreen(onNavigateUp: () -> Unit, onOpenBook: (Int) -> Unit) { + val vm: StatisticsViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.s090.statistics, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff090_statistics), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + val data = state.data + when { + state.loading && data == null -> LoadingState(modifier = Modifier.padding(padding)) + data == null -> ErrorState( + message = state.error?.resolve().orEmpty().ifBlank { stringResource(R.string.staff_error_load) }, + onRetry = vm::reload, modifier = Modifier.padding(padding), + ) + else -> PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize().padding(padding)) { + StatisticsBody(data, state.finesFrom, state.finesTo, onOpenBook, vm::setFinesFrom, vm::setFinesTo, vm::downloadFines) + } + } + } + + state.export?.let { file -> + ExportFileDialog(file = file, mime = "text/csv", title = stringResource(R.string.staff090_statistics_fines_report), onDismiss = vm::closeExport) + } +} + +/** The scrolling content, apart from the ViewModel so it renders from a captured body in tests. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun StatisticsBody( + data: Staff090Statistics, + finesFrom: String?, + finesTo: String?, + onOpenBook: (Int) -> Unit, + onFinesFrom: (String?) -> Unit, + onFinesTo: (String?) -> Unit, + onDownloadFines: () -> Unit, +) { + val locale = AppLocale.current() + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + item(key = "overview") { + Column { + FormSectionTitle(stringResource(R.string.staff090_statistics_overview)) + val o = data.overview + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Tile(R.string.staff090_statistics_books_available, o.booksAvailable, "stat-books-available") + Tile(R.string.staff090_statistics_books_on_loan, o.booksOnLoan, "stat-books-on-loan") + Tile(R.string.staff090_statistics_active_loans, o.activeLoans, "stat-active") + Tile(R.string.staff090_statistics_overdue_loans, o.overdueLoans, "stat-overdue") + Tile(R.string.staff090_statistics_completed_loans, o.completedLoans, "stat-completed") + Tile(R.string.staff090_statistics_borrowers, o.borrowers, "stat-borrowers") + } + } + } + item(key = "by-month") { + BarChart( + stringResource(R.string.staff090_statistics_by_month), + data.loansByMonth.map { Bar("m-${it.month}", Staff090StatisticsBars.monthLabel(it.month, locale), it.total) }, + ) + } + item(key = "by-status") { BarChart(stringResource(R.string.staff090_statistics_by_status), data.loansByStatus.map { Bar("s-${it.status}", it.label.ifBlank { it.status }, it.total) }) } + item(key = "by-media") { BarChart(stringResource(R.string.staff090_statistics_by_media), data.loansByMediaType.map { Bar("t-${it.mediaType}", it.label.ifBlank { it.mediaType }, it.total) }) } + if (data.topBooks.isNotEmpty()) { + item(key = "top-books-title") { FormSectionTitle(stringResource(R.string.staff090_statistics_top_books)) } + items(data.topBooks, key = { "book-${it.id}" }) { book -> + Surface( + onClick = { onOpenBook(book.id) }, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("top-book-${book.id}"), + ) { + Row(Modifier.padding(Spacing.md), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.md)) { + BookCover(book.title, book.coverUrl, Modifier.width(44.dp).aspectRatio(2f / 3f), compact = true) + Column(Modifier.weight(1f)) { + Text(book.title, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text( + stringResource(R.string.staff090_statistics_loan_counts, book.totalLoans, book.activeLoans, book.overdueLoans), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + if (data.topReaders.isNotEmpty()) { + item(key = "top-readers-title") { FormSectionTitle(stringResource(R.string.staff090_statistics_top_readers)) } + items(data.topReaders, key = { "reader-${it.id}" }) { reader -> + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs), + ) { + Column(Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm)) { + Text(reader.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + reader.email?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text( + stringResource(R.string.staff090_statistics_loan_counts, reader.totalLoans, reader.activeLoans, reader.overdueLoans), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + items(data.sections, key = { "section-${it.id}" }) { section -> Section(section) } + data.visitors?.let { visitors -> item(key = "visitors") { Visitors(visitors) } } + data.finesReport?.let { report -> + item(key = "fines") { + FinesReport(report, finesFrom, finesTo, locale, onFinesFrom, onFinesTo, onDownloadFines) + } + } + } +} + +@Composable +private fun Tile(label: Int, value: Int, tag: String) { + Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.widthIn(min = 150.dp).semantics(mergeDescendants = true) {}.testTag(tag)) { + Column(Modifier.padding(horizontal = Spacing.md, vertical = Spacing.sm)) { + Text(value.toString(), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface) + Text(stringResource(label), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +/** A titled list of horizontal bars, each scaled to the largest; nothing when there is nothing to show. */ +@Composable +private fun BarChart(title: String, bars: List) { + if (bars.isEmpty()) return + val max = bars.maxOf { it.value } + Column { + FormSectionTitle(title) + InfoCard { + bars.forEach { bar -> + Column(Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("bar-${bar.key}")) { + Row(Modifier.fillMaxWidth()) { + Text(bar.label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(bar.value.toString(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + } + Spacer(Modifier.height(Spacing.xxs)) + LinearProgressIndicator( + progress = { Staff090StatisticsBars.fraction(bar.value, max) }, + modifier = Modifier.fillMaxWidth().height(8.dp), + trackColor = MaterialTheme.colorScheme.surfaceVariant, + drawStopIndicator = {}, + ) + } + } + } + } +} + +@Composable +private fun Section(section: Staff090StatisticsSection) { + Column(Modifier.testTag("section-${section.id}")) { + FormSectionTitle(section.title) + InfoCard { + section.rows.forEach { InfoLine(it.label, it.display) } + section.note?.takeIf { it.isNotBlank() }?.let { + Spacer(Modifier.height(Spacing.xs)) + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun Visitors(visitors: Staff090StatisticsVisitors) { + Column(Modifier.testTag("visitors")) { + FormSectionTitle(stringResource(R.string.staff090_statistics_visitors, visitors.days)) + InfoCard { + InfoLine(stringResource(R.string.staff090_statistics_visitors_members), visitors.members.toString()) + InfoLine(stringResource(R.string.staff090_statistics_visitors_guests), visitors.guests.toString()) + InfoLine(stringResource(R.string.staff090_statistics_visitors_total), visitors.total.toString()) + } + val days = visitors.daily.filter { it.total > 0 } + BarChart(stringResource(R.string.staff090_statistics_visitors_daily), days.map { Bar("v-${it.date}", stocktakeDate(it.date), it.total) }) + } +} + +@Composable +private fun FinesReport( + report: Staff090StatisticsFinesReport, + from: String?, + to: String?, + locale: java.util.Locale, + onFrom: (String?) -> Unit, + onTo: (String?) -> Unit, + onDownload: () -> Unit, +) { + val money = { cents: Int -> MoneyFormat.format(cents, report.currency, locale) } + Column(Modifier.testTag("fines-report"), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + FormSectionTitle(stringResource(R.string.staff090_statistics_fines_report)) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Column(Modifier.weight(1f)) { DatePickerField(stringResource(R.string.staff090_statistics_from), from, onFrom) } + Column(Modifier.weight(1f)) { DatePickerField(stringResource(R.string.staff090_statistics_to), to, onTo) } + } + if (report.months.isEmpty()) { + Text(stringResource(R.string.staff090_statistics_fines_empty), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + report.months.forEach { month -> + InfoCard(Modifier.testTag("fines-${month.month}")) { + Text(Staff090StatisticsBars.monthLabel(month.month, locale), style = MaterialTheme.typography.titleSmall) + MoneyLines(month.money, money) + } + } + InfoCard(Modifier.testTag("fines-totals")) { + Text(stringResource(R.string.staff090_statistics_fines_totals), style = MaterialTheme.typography.titleSmall) + MoneyLines(report.totals, money) + } + SecondaryButton( + stringResource(R.string.staff090_statistics_fines_csv), onClick = onDownload, leadingIcon = Icons.Outlined.Download, + modifier = Modifier.fillMaxWidth().testTag("fines-csv"), + ) + } +} + +@Composable +private fun MoneyLines(m: Staff090StatisticsMoney, money: (Int) -> String) { + InfoLine(stringResource(R.string.staff090_statistics_charged), money(m.chargedCents)) + InfoLine(stringResource(R.string.staff090_statistics_collected), money(m.collectedCents)) + InfoLine(stringResource(R.string.staff090_statistics_waived), money(m.waivedCents)) + InfoLine(stringResource(R.string.staff090_statistics_reversed), money(m.reversedCents)) +} + +/** A visitor-log day (YYYY-MM-DD) in the app's date style. */ +private fun stocktakeDate(raw: String): String = com.pinakes.app.ui.common.DateFormat.date(raw) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeLogic.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeLogic.kt new file mode 100644 index 0000000..dd8b6e9 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeLogic.kt @@ -0,0 +1,337 @@ +package com.pinakes.app.ui.screens.staff.s090.stocktake + +import android.media.AudioManager +import androidx.annotation.StringRes +import com.pinakes.app.R +import com.pinakes.app.data.model.Staff090StocktakeColor +import com.pinakes.app.data.model.Staff090StocktakeCreateRequest +import com.pinakes.app.data.model.Staff090StocktakeLimits +import com.pinakes.app.data.model.Staff090StocktakeOptions +import com.pinakes.app.data.model.Staff090StocktakeScan +import com.pinakes.app.data.model.Staff090StocktakeSession +import com.pinakes.app.data.model.Staff090StocktakeShelf +import com.pinakes.app.data.model.Staff090StocktakeSummary +import com.pinakes.app.data.model.Staff090StocktakeTone +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.staff.ScanDebouncer +import com.pinakes.app.data.staff.Staff090StocktakeErrors +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.toStaffMessage +import java.time.OffsetDateTime +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.PI +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin + +// The pure decisions of the stocktake and statistics screens, kept apart so they are unit-tested +// without a device: which camera reads become scans, which tone and colour an outcome gets, the +// create form, the close choices, the counters and the statistics bars. + +/** + * Which camera decodes become scans. A stocktake scans shelf after shelf, so the scanner never + * stops: a label held under the camera is sent once, but reading it again after moving away + * (it left the view for [awayMs], or another code was read meanwhile) is a deliberate second + * read and is sent too; the server records it as a repeat, as the web scan page does. + * + * [ScanDebouncer] still asks for [requiredReads] identical decodes, so a misread while the + * camera focuses is never sent; its own window is off ([ScanDebouncer.suppressMs] = 0) because + * the window here slides: every decode of the held label pushes it forward. Clock-injected. + */ +class Staff090StocktakeScanGate( + private val awayMs: Long = AWAY_MS, + requiredReads: Int = 2, +) { + private val debouncer = ScanDebouncer(requiredReads = requiredReads, suppressMs = 0) + private var held: String? = null + private var heldSeenAt = Long.MIN_VALUE + + /** Feed one decode (already normalised); returns the code when it should be sent. */ + fun offer(code: String, nowMs: Long): String? { + if (code.isEmpty() || debouncer.paused) return null + if (code == held && nowMs - heldSeenAt < awayMs) { + // Still under the camera: no new scan, and the window moves with it. + heldSeenAt = nowMs + return null + } + val accepted = debouncer.offer(code, nowMs) ?: return null + held = accepted + heldSeenAt = nowMs + return accepted + } + + /** A code sent by hand (typed or a keyboard-wedge reader): the camera must not send it again at once. */ + fun markSent(code: String, nowMs: Long) { + held = code + heldSeenAt = nowMs + } + + /** Stop sending (a dialog or a sheet covers the scanner). */ + fun pause() = debouncer.pause() + + /** Send again; the label still under the camera keeps its window. */ + fun resume() = debouncer.resume() + + val paused: Boolean get() = debouncer.paused + + companion object { + /** A label out of view this long has been moved away. */ + const val AWAY_MS = 1_500L + } +} + +/** + * The scan beep: the server's frequency and duration (the web page's), clamped to what a phone + * speaker plays, and silent unless the ringer is in normal mode. Pure: the samples are made + * here, the player only writes them to an AudioTrack. + */ +object Staff090StocktakeSound { + const val SAMPLE_RATE = 22_050 + private const val MIN_HZ = 150 + private const val MAX_HZ = 4_000 + private const val MIN_MS = 40 + private const val MAX_MS = 800 + private const val FADE_MS = 8 + + data class Spec(val frequencyHz: Int, val durationMs: Int) + + /** The tone of a scan answer: its own `tone`, else the outcome's from the options. */ + fun toneFor(scan: Staff090StocktakeScan, answered: Staff090StocktakeTone?, options: Staff090StocktakeOptions?): Staff090StocktakeTone? = + answered?.takeIf { it.frequencyHz > 0 && it.durationMs > 0 } + ?: options?.outcomes?.firstOrNull { it.code == scan.outcome }?.tone?.takeIf { it.frequencyHz > 0 && it.durationMs > 0 } + + /** What to play for [tone] with the phone's [ringerMode]; null = stay silent. */ + fun spec(tone: Staff090StocktakeTone?, ringerMode: Int): Spec? { + if (tone == null || tone.frequencyHz <= 0 || tone.durationMs <= 0) return null + if (ringerMode != AudioManager.RINGER_MODE_NORMAL) return null + return Spec(tone.frequencyHz.coerceIn(MIN_HZ, MAX_HZ), tone.durationMs.coerceIn(MIN_MS, MAX_MS)) + } + + /** 16-bit mono PCM of [spec], with a short fade at both ends so the speaker does not click. */ + fun samples(spec: Spec, sampleRate: Int = SAMPLE_RATE, volume: Double = 0.6): ShortArray { + val count = (sampleRate.toLong() * spec.durationMs / 1000).toInt() + val fade = min(count / 2, sampleRate * FADE_MS / 1000).coerceAtLeast(1) + return ShortArray(count) { i -> + val envelope = when { + i < fade -> i.toDouble() / fade + i >= count - fade -> (count - 1 - i).toDouble() / fade + else -> 1.0 + } + (sin(2 * PI * spec.frequencyHz * i / sampleRate) * envelope * volume * Short.MAX_VALUE).roundToInt().toShort() + } + } +} + +/** The server's badge colours ("#166534") as ARGB, for Compose `Color(Long)`. Pure. */ +object Staff090StocktakeColors { + + /** `#rrggbb` (or `#rgb`) → 0xFFrrggbb; null for anything else. */ + fun argb(hex: String?): Long? { + val raw = hex?.trim()?.removePrefix("#") ?: return null + val full = when (raw.length) { + 3 -> raw.map { "$it$it" }.joinToString("") + 6 -> raw + else -> return null + } + return full.toLongOrNull(16)?.let { 0xFF000000L or it } + } + + /** The colour of [outcome]: the one the row carries, else the options', else null (neutral). */ + fun forOutcome(outcome: String, own: Staff090StocktakeColor?, options: Staff090StocktakeOptions?): Staff090StocktakeColor? = + own?.takeIf { argb(it.background) != null && argb(it.foreground) != null } + ?: options?.outcomes?.firstOrNull { it.code == outcome }?.color?.takeIf { argb(it.background) != null && argb(it.foreground) != null } +} + +/** One counter tile of the session screen. */ +data class Staff090StocktakeCounter(val key: String, @param:StringRes val label: Int, val value: Int, val outcome: String? = null) + +object Staff090StocktakeCounters { + /** Every counter the web shows, in its order; `lost` only once a close marked copies lost. */ + fun of(s: Staff090StocktakeSummary): List = listOfNotNull( + Staff090StocktakeCounter("expected", R.string.staff090_stocktake_count_expected, s.expected), + Staff090StocktakeCounter("found", R.string.staff090_stocktake_count_found, s.found, "found"), + Staff090StocktakeCounter("misplaced", R.string.staff090_stocktake_count_misplaced, s.misplaced, "misplaced"), + Staff090StocktakeCounter("on_loan", R.string.staff090_stocktake_count_on_loan, s.onLoan, "on_loan"), + Staff090StocktakeCounter("missing", R.string.staff090_stocktake_count_missing, s.missing, "missing"), + if (s.lost > 0) Staff090StocktakeCounter("lost", R.string.staff090_stocktake_count_lost, s.lost) else null, + Staff090StocktakeCounter("unknown", R.string.staff090_stocktake_count_unknown, s.unknown, "unknown"), + Staff090StocktakeCounter("out_of_scope", R.string.staff090_stocktake_count_out_of_scope, s.outOfScope, "out_of_scope"), + Staff090StocktakeCounter("duplicates", R.string.staff090_stocktake_count_duplicates, s.duplicates, "duplicate"), + Staff090StocktakeCounter("mine", R.string.staff090_stocktake_count_mine, s.mine), + ) +} + +/** The create sheet's fields, as typed. */ +data class Staff090StocktakeForm( + val name: String = "", + val note: String = "", + val branchId: Int? = null, + val shelfId: Int? = null, + val levelId: Int? = null, + val genreId: Int? = null, + val mediaType: String? = null, + val collocationFrom: String = "", + val collocationTo: String = "", +) { + fun nameValid(limits: Staff090StocktakeLimits): Boolean = name.trim().let { it.isNotEmpty() && it.length <= limits.maxNameLength } + + fun collocationValid(limits: Staff090StocktakeLimits): Boolean = + collocationFrom.trim().length <= limits.maxCollocationLength && collocationTo.trim().length <= limits.maxCollocationLength + + fun valid(limits: Staff090StocktakeLimits): Boolean = nameValid(limits) && collocationValid(limits) + + /** A new shelf drops a level of another shelf. */ + fun withShelf(id: Int?): Staff090StocktakeForm = copy(shelfId = id, levelId = levelId.takeIf { id != null && id == shelfId }) + + /** The levels the level picker offers: only the chosen shelf's. */ + fun levels(shelves: List) = shelves.firstOrNull { it.id == shelfId }?.levels.orEmpty() + + /** The request: trimmed texts, blank ones and unset pickers left out. */ + fun request(): Staff090StocktakeCreateRequest = Staff090StocktakeCreateRequest( + name = name.trim(), + note = note.trim().ifEmpty { null }, + branchId = branchId, + shelfId = shelfId, + levelId = levelId.takeIf { shelfId != null }, + genreId = genreId, + mediaType = mediaType?.takeIf { it.isNotBlank() }, + collocationFrom = collocationFrom.trim().ifEmpty { null }, + collocationTo = collocationTo.trim().ifEmpty { null }, + ) +} + +/** What the close dialog offers and says. */ +object Staff090StocktakeClose { + const val NONE = "none" + const val LOST = "lost" + + /** + * The missing actions to offer: `none` while the session is open, `lost` only when the + * server says this user may mark them (the session's `can_mark_lost`, which also covers + * finishing a mark-lost close cut short). Never decided here beyond hiding. + */ + fun actions(session: Staff090StocktakeSession): List = buildList { + if (session.canClose) add(NONE) + if (session.canMarkLost) add(LOST) + } + + /** A closed session whose mark-lost close did not reach every copy: it can be finished. */ + fun canResume(session: Staff090StocktakeSession): Boolean = !session.isOpen && session.canMarkLost && session.pendingLost > 0 + + /** The copies the confirmation names: the missing ones, or those still to mark when resuming. */ + fun affected(session: Staff090StocktakeSession): Int = if (canResume(session)) session.pendingLost else session.summary.missing + + @StringRes + fun confirmText(session: Staff090StocktakeSession, action: String): Int = when { + canResume(session) -> R.string.staff090_stocktake_close_confirm_resume + action == LOST -> R.string.staff090_stocktake_close_confirm_lost + else -> R.string.staff090_stocktake_close_confirm_none + } +} + +/** Whether a picked code list is one the server takes (extension and size), before uploading it. */ +object Staff090StocktakeUploadRules { + fun acceptsName(name: String, limits: Limits): Boolean = + name.substringAfterLast('.', "").lowercase(Locale.ROOT) in limits.extensions + + fun acceptsSize(size: Long, limits: Limits): Boolean = size in 1..limits.maxBytes + + /** The two limits as a small value, so the check is testable without the whole options payload. */ + data class Limits(val extensions: List, val maxBytes: Long) { + companion object { + fun of(limits: Staff090StocktakeLimits) = Limits(limits.uploadExtensions.map { it.lowercase(Locale.ROOT) }, limits.maxUploadBytes) + } + } +} + +/** The live session updated by a scan answer or a poll. Pure. */ +object Staff090StocktakeMerge { + /** Newest first, without the same scan twice (a poll may already carry it). */ + fun prepend(list: List, scan: Staff090StocktakeScan, max: Int = 50): List = + (listOf(scan) + list.filter { it.id != scan.id }).take(max) + + /** + * The session after a status change seen by a scan answer or a poll: no more scanning once not + * open. Closed and cancelled are final: an answer still saying "open" was read before the + * session ended (a poll in flight during the close) and never reopens it. + */ + fun withStatus(session: Staff090StocktakeSession, status: String): Staff090StocktakeSession = + if (status == session.status || status.isBlank() || (session.status != "open" && status == "open")) session + else session.copy(status = status, canScan = status == "open" && session.canScan, canUpload = status == "open" && session.canUpload, + canClose = status == "open" && session.canClose, canCancel = status == "open" && session.canCancel) + + /** + * Whether a reload [answer] may replace the [current] session. It may not when the status + * changed while the reload was in flight (it was [asked]: this device closed or cancelled the + * session meanwhile), nor when it would turn a closed or cancelled session back to open. + */ + fun acceptReload(current: Staff090StocktakeSession?, asked: String?, answer: Staff090StocktakeSession): Boolean = when { + current == null -> true + current.status != asked -> false + else -> !(current.status != "open" && answer.status == "open") + } + + /** Polling runs only while the session takes scans. */ + fun shouldPoll(session: Staff090StocktakeSession?): Boolean = session?.isOpen == true + + /** + * A first page read again by the poll, applied to the outcome list. Until the operator loads a + * further page it replaces the list. Afterwards the scans newer than the list go on top and the + * pages already read stay, with their cursor: replacing them dropped every row past the first + * page a few seconds after "Show more", and offered "Show more" again on a list at its end. + */ + fun refreshedFiltered( + current: List, + currentCursor: String?, + paged: Boolean, + firstPage: List, + firstCursor: String?, + ): Pair, String?> { + // Page one shares no scan with the list: more than a page arrived since the last refresh + // (the screen sat in the background while others scanned). Putting it on top would hide + // the scans in between, so the list starts again from page one and its cursor. + val overlaps = firstPage.any { new -> current.any { it.id == new.id } } + return if (!paged || !overlaps) firstPage to firstCursor + else (firstPage + current).distinctBy(Staff090StocktakeScan::id) to currentCursor + } +} + +/** The statistics bars and labels. Pure. */ +object Staff090StatisticsBars { + /** Width share of [value] against the largest bar; 0 for an empty chart, a sliver for a non-zero value. */ + fun fraction(value: Int, max: Int): Float = when { + max <= 0 || value <= 0 -> 0f + else -> (value.toFloat() / max).coerceIn(0.02f, 1f) + } + + /** "2026-09" → "Sep 2026" in [locale]; the raw text when it is not a month. */ + fun monthLabel(month: String, locale: Locale): String = + runCatching { YearMonth.parse(month).format(DateTimeFormatter.ofPattern("MMM yyyy", locale)) }.getOrDefault(month) +} + +/** A stocktake timestamp ("2026-11-01T20:15:26+00:00") shown in the app's date-time style. */ +internal fun stocktakeDateTime(raw: String?): String { + if (raw.isNullOrBlank()) return "" + val instant = runCatching { OffsetDateTime.parse(raw).toInstant().toString() }.getOrNull() + return DateFormat.dateTime(instant ?: raw) +} + +/** The message for a failed stocktake call: ours for a stocktake code, the server's for `not_permitted`, else the staff wording. */ +internal fun ApiResult.Failure.toStocktakeMessage(@StringRes fallback: Int): StaffMessage { + Staff090StocktakeErrors.messageRes(code)?.takeIf { fromEnvelope }?.let { return StaffMessage(it) } + if (Staff090StocktakeErrors.isNotPermitted(this)) { + return message.takeIf { it.isNotBlank() }?.let { StaffMessage(text = it) } ?: StaffMessage(R.string.staff090_stocktake_not_permitted) + } + return toStaffMessage(fallback) +} + +/** + * A typed or read label as the server stores it (StocktakeService::normalizeCode): control + * characters removed, trimmed. Inner spaces stay, unlike [com.pinakes.app.data.staff.CopyCodeCodec]: + * the stocktake compares the code as read. A code over the limit is sent as is and the server + * explains the refusal. + */ +fun normalizeStocktakeCode(raw: String): String = raw.filterNot { it.isISOControl() }.trim() diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeReportScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeReportScreen.kt new file mode 100644 index 0000000..683d5df --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeReportScreen.kt @@ -0,0 +1,260 @@ +package com.pinakes.app.ui.screens.staff.s090.stocktake + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Assessment +import androidx.compose.material.icons.outlined.PictureAsPdf +import androidx.compose.material.icons.outlined.TableChart +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.Staff090StocktakeOptions +import com.pinakes.app.data.model.Staff090StocktakeReportRow +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.Staff090StocktakeRepository +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.screens.staff.ExportFileDialog +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class StocktakeReportUiState( + /** Report category (an outcome code); null = every row. */ + val category: String? = null, + val items: List = emptyList(), + val nextCursor: String? = null, + val total: Int? = null, + val loading: Boolean = true, + val refreshing: Boolean = false, + val loadingMore: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + val options: Staff090StocktakeOptions? = null, + val downloading: Boolean = false, + val export: java.io.File? = null, + val exportMime: String = "text/csv", + val message: StaffMessage? = null, +) + +/** The report of a session by category, as the web final report lists it, with the CSV and PDF downloads. */ +@HiltViewModel +class StocktakeReportViewModel @Inject constructor( + private val repo: Staff090StocktakeRepository, + private val files: StaffFilesRepository, + savedState: SavedStateHandle, +) : ViewModel() { + val sessionId: Int = savedState.get(STOCKTAKE_ARG_SESSION) ?: 0 + private val _state = MutableStateFlow(StocktakeReportUiState()) + val state: StateFlow = _state.asStateFlow() + private var generation = 0 + + init { + viewModelScope.launch { (repo.options() as? ApiResult.Success)?.data?.let { o -> _state.update { it.copy(options = o) } } } + reload() + } + + fun reload() { + val gen = ++generation + _state.update { it.copy(loading = it.items.isEmpty() && !it.refreshing, error = null) } + viewModelScope.launch { + val res = repo.report(sessionId, _state.value.category) + if (gen != generation) return@launch + _state.update { + when (res) { + is ApiResult.Success -> it.copy(items = res.data.items, nextCursor = res.data.nextCursor, total = res.data.totalCount, loading = false, refreshing = false) + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toStocktakeMessage(R.string.staff_error_load)) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + reload() + } + + fun loadMore() { + val s = _state.value + val cursor = s.nextCursor ?: return + if (s.loadingMore || s.loading) return + val gen = generation + _state.update { it.copy(loadingMore = true) } + viewModelScope.launch { + val res = repo.report(sessionId, s.category, cursor) + if (gen != generation) { + _state.update { it.copy(loadingMore = false) } + return@launch + } + _state.update { + when (res) { + // Report rows have no id of their own: pages are appended as they come. + is ApiResult.Success -> it.copy(items = it.items + res.data.items, nextCursor = res.data.nextCursor, loadingMore = false) + is ApiResult.Failure -> it.copy(loadingMore = false, forbidden = res.isForbidden(), message = res.toStocktakeMessage(R.string.staff_error_load)) + } + } + } + } + + fun selectCategory(category: String?) { + if (category == _state.value.category) return + _state.update { it.copy(category = category, items = emptyList(), nextCursor = null, total = null) } + reload() + } + + fun downloadCsv() = download(Staff090StocktakeRepository.reportCsvPath(sessionId), "revisione-$sessionId.csv", "text/csv") + + /** A report with too many rows is refused as PDF (422 `report_too_large`): the CSV holds any size. */ + fun downloadPdf() = download(Staff090StocktakeRepository.reportPdfPath(sessionId), "revisione-$sessionId.pdf", "application/pdf") + + private fun download(path: String, name: String, mime: String) { + if (_state.value.downloading) return + _state.update { it.copy(downloading = true) } + viewModelScope.launch { + val res = files.download(path, name) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(downloading = false, export = res.data, exportMime = mime) + is ApiResult.Failure -> it.copy(downloading = false, forbidden = res.isForbidden(), message = res.toStocktakeMessage(R.string.staff_download_failed)) + } + } + } + } + + fun closeExport() = _state.update { it.copy(export = null) } + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Staff090StocktakeReportScreen(onNavigateUp: () -> Unit) { + val vm: StocktakeReportViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.s090.stocktake, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + val categories = listOf>(null to stringResource(R.string.staff090_stocktake_filter_all)) + + state.options?.outcomes.orEmpty().map { it.code to it.label } + + Scaffold( + topBar = { + PinakesTopBar( + title = stringResource(R.string.staff090_stocktake_report), + onNavigateUp = onNavigateUp, + actions = { + IconButton(onClick = vm::downloadCsv, enabled = !state.downloading, modifier = Modifier.testTag("report-csv")) { + Icon(Icons.Outlined.TableChart, contentDescription = stringResource(R.string.staff090_stocktake_download_csv)) + } + IconButton(onClick = vm::downloadPdf, enabled = !state.downloading, modifier = Modifier.testTag("report-pdf")) { + Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.staff090_stocktake_download_pdf)) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + PrimaryScrollableTabRow(selectedTabIndex = categories.indexOfFirst { it.first == state.category }.coerceAtLeast(0), edgePadding = Spacing.lg) { + categories.forEach { (code, label) -> + Tab(selected = code == state.category, onClick = { vm.selectCategory(code) }, text = { Text(label) }) + } + } + state.total?.let { + Text(stringResource(R.string.staff090_stocktake_report_rows, it), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm)) + } + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize()) { + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = state.items.isEmpty(), + emptyTitle = stringResource(R.string.staff090_stocktake_report_empty), emptyIcon = Icons.Outlined.Assessment, onRetry = vm::refresh, + ) { + val listState = rememberLazyListState() + val nearEnd by remember { derivedStateOf { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let { it >= state.items.size - 5 } == true } } + LaunchedEffect(nearEnd, state.nextCursor) { if (nearEnd) vm.loadMore() } + LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + itemsIndexed(state.items, key = { index, row -> "$index-${row.category}-${row.code}" }) { _, row -> ReportRow(row, state.options) } + } + } + } + } + } + + state.export?.let { file -> + ExportFileDialog(file = file, mime = state.exportMime, title = stringResource(R.string.staff090_stocktake_report), onDismiss = vm::closeExport) + } +} + +@Composable +private fun ReportRow(row: Staff090StocktakeReportRow, options: Staff090StocktakeOptions?) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs), + ) { + Column(Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + StocktakeBadge(row.categoryLabel.ifBlank { row.category }, Staff090StocktakeColors.forOutcome(row.category, row.color, options)) + Text(row.code, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + } + row.title?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) } + val meta = listOfNotNull( + row.collocation?.takeIf { it.isNotBlank() }, + row.expectedShelf?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.staff090_stocktake_belongs_on, it) }, + row.copyStatusLabel?.takeIf { it.isNotBlank() }, + row.operatorName?.takeIf { it.isNotBlank() }, + stocktakeDateTime(row.scannedAt).takeIf { it.isNotBlank() }, + ).joinToString(" · ") + if (meta.isNotBlank()) Text(meta, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeSessionScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeSessionScreen.kt new file mode 100644 index 0000000..b5f6c9e --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeSessionScreen.kt @@ -0,0 +1,882 @@ +package com.pinakes.app.ui.screens.staff.s090.stocktake + +import android.content.Context +import android.net.Uri +import android.os.SystemClock +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Assessment +import androidx.compose.material.icons.automirrored.outlined.FactCheck +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material.icons.outlined.UploadFile +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.Staff090StocktakeOptions +import com.pinakes.app.data.model.Staff090StocktakeScan +import com.pinakes.app.data.model.Staff090StocktakeScanRequest +import com.pinakes.app.data.model.Staff090StocktakeSession +import com.pinakes.app.data.model.Staff090StocktakeTone +import com.pinakes.app.data.model.Staff090StocktakeUpload +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.data.repository.Staff090StocktakeRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.Staff090StocktakeErrors +import com.pinakes.app.data.staff.SubmissionKeys +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.PickerRow +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.scan.BarcodeAnalyzer +import com.pinakes.app.ui.screens.staff.scan.ScannerBox +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** One scan answer to play and feel: [id] grows with every answer, so the same tone twice plays twice. */ +data class StocktakeFeedback(val id: Int, val tone: Staff090StocktakeTone?) + +data class StocktakeSessionUiState( + val session: Staff090StocktakeSession? = null, + val options: Staff090StocktakeOptions? = null, + val loading: Boolean = true, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + /** The module went off on the server (404 `feature_disabled`): the screen leaves. */ + val leave: Boolean = false, + /** The shelf the operator stands at: every scan carries it, so a copy of another shelf reads as misplaced. */ + val shelfId: Int? = null, + val shelfPicker: Boolean = false, + val pickerQuery: String = "", + val mine: Boolean = false, + /** Recent scans of one outcome only (null = the live list of the latest scans). */ + val outcomeFilter: String? = null, + val filtered: List = emptyList(), + val filteredCursor: String? = null, + /** The operator loaded pages past the first: a refresh adds the newer scans on top and keeps them. */ + val filteredPaged: Boolean = false, + val filteredLoading: Boolean = false, + val lastResult: Staff090StocktakeScan? = null, + /** Codes read and not answered yet. */ + val pending: Int = 0, + val feedback: StocktakeFeedback? = null, + val manualError: Boolean = false, + val uploading: Boolean = false, + val uploadResult: Staff090StocktakeUpload? = null, + val closeDialog: Boolean = false, + val closeAction: String = Staff090StocktakeClose.NONE, + val closing: Boolean = false, + val cancelDialog: Boolean = false, + val message: StaffMessage? = null, +) { + val recent: List get() = if (outcomeFilter == null) session?.recentScans.orEmpty() else filtered + val scanning: Boolean get() = session?.canScan == true +} + +/** + * One stocktake session on the phone: a continuous scanner (camera, keyboard-wedge reader or + * typed code) sending every code with the current shelf, the answer at once in the outcome's + * colour with its tone, live counters polled every few seconds so several operators see each + * other's progress, the latest scans, a code list upload, close and cancel. + * + * Offline the screen leaves (the area is hidden offline): codes are never queued for later. + */ +@HiltViewModel +class StocktakeSessionViewModel @Inject constructor( + private val repo: Staff090StocktakeRepository, + @ApplicationContext private val context: Context, + savedState: SavedStateHandle, +) : ViewModel() { + val sessionId: Int = savedState.get(STOCKTAKE_ARG_SESSION) ?: 0 + private val _state = MutableStateFlow(StocktakeSessionUiState()) + val state: StateFlow = _state.asStateFlow() + private val gate = Staff090StocktakeScanGate() + private val keys = SubmissionKeys() + private val queue = Channel(Channel.UNLIMITED) + private var polling: Job? = null + private var feedbackId = 0 + private var shelfChosen = false + + init { + viewModelScope.launch { for (request in queue) send(request) } + viewModelScope.launch { + (repo.options() as? ApiResult.Success)?.data?.let { options -> _state.update { it.copy(options = options) } } + } + } + + fun reload() { + _state.update { it.copy(loading = it.session == null, error = null) } + viewModelScope.launch { + val asked = _state.value.session?.status + val res = repo.session(sessionId, _state.value.mine) + _state.update { + when (res) { + // Same rule as the poll: an answer read before this device closed or cancelled + // the session never brings the open session back. + is ApiResult.Success -> if (!Staff090StocktakeMerge.acceptReload(it.session, asked, res.data)) it.copy(loading = false) else it.copy( + session = res.data, loading = false, + // The session's own shelf is where the operator starts, until they pick another. + shelfId = if (shelfChosen) it.shelfId else res.data.filter.shelfId, + ) + is ApiResult.Failure -> it.copy( + loading = false, forbidden = res.isForbidden(), leave = Staff090StocktakeErrors.moduleOff(res), + error = if (it.session == null) res.toStocktakeMessage(R.string.staff_error_load) else null, + message = if (it.session != null) res.toStocktakeMessage(R.string.staff_error_load) else null, + ) + } + } + } + } + + // ---------- Visibility: poll and scan only while the screen is on top ---------- + + fun onVisible() { + reload() + gate.resume() + polling?.cancel() + polling = viewModelScope.launch { + while (isActive) { + delay(POLL_MS) + if (Staff090StocktakeMerge.shouldPoll(_state.value.session)) poll() + } + } + } + + fun onHidden() { + polling?.cancel() + polling = null + gate.pause() + } + + private suspend fun poll() { + val asked = _state.value.session?.status + val res = repo.state(sessionId, _state.value.mine) + // The session changed while the poll was in flight (this device closed or cancelled it): + // the answer describes the old state, so it is dropped. + if (_state.value.session?.status != asked) return + if (res is ApiResult.Failure) { + // A missed poll is retried by the next one; only a refusal or the module going away ends the screen. + if (res.isForbidden() || Staff090StocktakeErrors.moduleOff(res)) _state.update { it.copy(forbidden = res.isForbidden(), leave = Staff090StocktakeErrors.moduleOff(res)) } + return + } + val data = (res as ApiResult.Success).data + val before = _state.value.session ?: return + _state.update { s -> + val session = s.session ?: return@update s + s.copy(session = Staff090StocktakeMerge.withStatus(session.copy(summary = data.summary, recentScans = data.recentScans), data.status)) + } + // Another operator closed or cancelled it: read the whole session (who, when, what next). + if (data.status != before.status) reload() + if (_state.value.outcomeFilter != null) refreshFiltered() + } + + // ---------- Scanning ---------- + + /** A camera decode: sent when the gate says it is a new read (see [Staff090StocktakeScanGate]). */ + fun onDecoded(raw: String) { + if (!_state.value.scanning || _state.value.shelfPicker) return + val code = normalizeStocktakeCode(raw).takeIf { it.isNotEmpty() } ?: return + val accepted = gate.offer(code, SystemClock.elapsedRealtime()) ?: return + enqueue(accepted) + } + + fun clearManualError() = _state.update { if (it.manualError) it.copy(manualError = false) else it } + + /** The typed (or keyboard-wedge scanned) code; the text stays in the screen. True when it was taken. */ + fun submitManual(input: String): Boolean { + val code = normalizeStocktakeCode(input) + if (code.isEmpty()) { + _state.update { it.copy(manualError = true) } + return false + } + if (!_state.value.scanning) return false + gate.markSent(code, SystemClock.elapsedRealtime()) + enqueue(code) + return true + } + + private fun enqueue(code: String) { + _state.update { it.copy(pending = it.pending + 1) } + queue.trySend(Staff090StocktakeScanRequest(code, _state.value.shelfId)) + } + + /** + * Send one code. One Idempotency-Key per read: the automatic retry after a dropped + * connection reuses it (the server answers the first scan, never a second one), a new + * read of the same label gets a new key and becomes the web's "already scanned". + */ + private suspend fun send(request: Staff090StocktakeScanRequest) { + if (!_state.value.scanning) { + _state.update { it.copy(pending = (it.pending - 1).coerceAtLeast(0)) } + return + } + val key = keys.keyFor(request) + var res = repo.scan(sessionId, request, key) + if (res is ApiResult.Failure && (res.code == ErrorCodes.NETWORK || res.code == "concurrent_retry")) res = repo.scan(sessionId, request, key) + if (res is ApiResult.Success || (res as? ApiResult.Failure)?.code == SubmissionKeys.CONFLICT) keys.reset() + when (val result = res) { + is ApiResult.Success -> { + val answer = result.data + val tone = Staff090StocktakeSound.toneFor(answer.scan, answer.tone, _state.value.options) + _state.update { s -> + // The operator's own scan belongs to "only mine" as well: it always goes on top. + val session = s.session?.let { current -> + Staff090StocktakeMerge.withStatus( + current.copy(summary = answer.summary, recentScans = Staff090StocktakeMerge.prepend(current.recentScans, answer.scan)), + answer.sessionStatus, + ) + } + s.copy( + session = session, lastResult = answer.scan, pending = (s.pending - 1).coerceAtLeast(0), + feedback = StocktakeFeedback(++feedbackId, tone), + filtered = if (s.outcomeFilter == answer.scan.outcome) Staff090StocktakeMerge.prepend(s.filtered, answer.scan, Int.MAX_VALUE) else s.filtered, + ) + } + } + is ApiResult.Failure -> { + val offline = result.code == ErrorCodes.NETWORK + // Never keep codes for later: what was read while the connection was gone is dropped, and said so. + if (offline) drainQueue() + _state.update { + it.copy( + pending = if (offline) 0 else (it.pending - 1).coerceAtLeast(0), + forbidden = result.isForbidden(), leave = Staff090StocktakeErrors.moduleOff(result), + message = if (offline) StaffMessage(R.string.staff090_stocktake_not_recorded, args = listOf(request.code)) else result.toStocktakeMessage(R.string.staff_error_save), + ) + } + if (Staff090StocktakeErrors.sessionEnded(result)) { + drainQueue() + reload() + } + } + } + } + + private fun drainQueue() { + while (queue.tryReceive().isSuccess) Unit + _state.update { it.copy(pending = 0) } + } + + // ---------- Shelf, filters ---------- + + fun openShelfPicker() { + gate.pause() + _state.update { it.copy(shelfPicker = true, pickerQuery = "") } + } + + fun closeShelfPicker() { + gate.resume() + _state.update { it.copy(shelfPicker = false, pickerQuery = "") } + } + + fun setPickerQuery(value: String) = _state.update { it.copy(pickerQuery = value) } + + fun pickShelf(item: PickItem) { + shelfChosen = true + _state.update { it.copy(shelfId = item.id) } + closeShelfPicker() + } + + fun clearShelf() { + shelfChosen = true + _state.update { it.copy(shelfId = null) } + } + + fun setMine(value: Boolean) { + _state.update { it.copy(mine = value, filtered = emptyList(), filteredCursor = null, filteredPaged = false) } + viewModelScope.launch { poll() } + if (_state.value.outcomeFilter != null) refreshFiltered() + } + + fun setOutcomeFilter(code: String?) { + _state.update { it.copy(outcomeFilter = code, filtered = emptyList(), filteredCursor = null, filteredPaged = false) } + if (code != null) refreshFiltered() + } + + private fun refreshFiltered() { + val s = _state.value + val outcome = s.outcomeFilter ?: return + _state.update { it.copy(filteredLoading = it.filtered.isEmpty()) } + viewModelScope.launch { + val res = repo.scans(sessionId, outcome, s.mine) + if (_state.value.outcomeFilter != outcome) return@launch + _state.update { + when (res) { + is ApiResult.Success -> { + // The poll reads only the first page: a list the operator paged through keeps its pages. + val (items, cursor) = Staff090StocktakeMerge.refreshedFiltered(it.filtered, it.filteredCursor, it.filteredPaged, res.data.items, res.data.nextCursor) + it.copy(filtered = items, filteredCursor = cursor, filteredLoading = false) + } + is ApiResult.Failure -> it.copy(filteredLoading = false, forbidden = res.isForbidden(), message = res.toStocktakeMessage(R.string.staff_error_load)) + } + } + } + } + + fun loadMoreFiltered() { + val s = _state.value + val outcome = s.outcomeFilter ?: return + val cursor = s.filteredCursor ?: return + if (s.filteredLoading) return + _state.update { it.copy(filteredLoading = true) } + viewModelScope.launch { + val res = repo.scans(sessionId, outcome, s.mine, cursor) + if (_state.value.outcomeFilter != outcome) return@launch + _state.update { + when (res) { + is ApiResult.Success -> it.copy(filtered = (it.filtered + res.data.items).distinctBy(Staff090StocktakeScan::id), filteredCursor = res.data.nextCursor, filteredPaged = true, filteredLoading = false) + is ApiResult.Failure -> it.copy(filteredLoading = false, forbidden = res.isForbidden(), message = res.toStocktakeMessage(R.string.staff_error_load)) + } + } + } + } + + // ---------- Upload ---------- + + /** A picked code list: refused here when the server would refuse it anyway (extension, size). */ + fun onPicked(uri: Uri?) { + uri ?: return + val limits = Staff090StocktakeUploadRules.Limits.of(_state.value.options?.limits ?: com.pinakes.app.data.model.Staff090StocktakeLimits()) + viewModelScope.launch { + val read = withContext(Dispatchers.IO) { runCatching { read(uri, limits.maxBytes) }.getOrNull() } + when { + read == null -> _state.update { it.copy(message = StaffMessage(R.string.staff_import_read_failed)) } + !Staff090StocktakeUploadRules.acceptsName(read.name, limits) || !Staff090StocktakeUploadRules.acceptsSize(read.bytes.size.toLong(), limits) -> + _state.update { it.copy(message = StaffMessage(R.string.staff090_stocktake_upload_wrong_file)) } + else -> upload(read) + } + } + } + + private class Picked(val name: String, val mime: String?, val bytes: ByteArray) + + private fun read(uri: Uri, maxBytes: Long): Picked? { + val resolver = context.contentResolver + var name = uri.lastPathSegment?.substringAfterLast('/') ?: "codes.txt" + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> + if (c.moveToFirst()) c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let { i -> c.getString(i)?.let { name = it } } + } + val out = java.io.ByteArrayOutputStream() + resolver.openInputStream(uri)?.use { input -> + val buffer = ByteArray(64 * 1024) + var total = 0L + while (true) { + val n = input.read(buffer) + if (n < 0) break + total += n + // One byte over the limit is enough to refuse it: stop reading there. + if (total > maxBytes) return Picked(name, null, ByteArray((maxBytes + 1).toInt())) + out.write(buffer, 0, n) + } + } ?: return null + return Picked(name, resolver.getType(uri), out.toByteArray()) + } + + private fun upload(file: Picked) { + if (_state.value.uploading) return + _state.update { it.copy(uploading = true) } + viewModelScope.launch { + val res = repo.upload(sessionId, file.name, file.bytes, file.mime, _state.value.shelfId) + _state.update { s -> + when (res) { + is ApiResult.Success -> s.copy( + uploading = false, uploadResult = res.data, + session = s.session?.copy(summary = res.data.summary), + ) + is ApiResult.Failure -> s.copy(uploading = false, forbidden = res.isForbidden(), message = res.toStocktakeMessage(R.string.staff_error_save)) + } + } + if (res is ApiResult.Success) poll() + if (res is ApiResult.Failure && Staff090StocktakeErrors.sessionEnded(res)) reload() + } + } + + fun closeUploadResult() = _state.update { it.copy(uploadResult = null) } + + // ---------- Close / cancel ---------- + + fun openClose() { + val session = _state.value.session ?: return + val actions = Staff090StocktakeClose.actions(session) + if (actions.isEmpty()) return + gate.pause() + _state.update { it.copy(closeDialog = true, closeAction = if (Staff090StocktakeClose.canResume(session)) Staff090StocktakeClose.LOST else actions.first()) } + } + + fun setCloseAction(value: String) = _state.update { it.copy(closeAction = value) } + + fun dismissClose() { + gate.resume() + _state.update { it.copy(closeDialog = false) } + } + + /** `lost` refused to this account (403 `not_permitted`): the server's message, the role stays. */ + fun close() { + val action = _state.value.closeAction + if (_state.value.closing) return + _state.update { it.copy(closing = true) } + viewModelScope.launch { + val res = repo.close(sessionId, action) + _state.update { s -> + when (res) { + is ApiResult.Success -> s.copy( + closing = false, closeDialog = false, session = res.data, + message = res.data.closeResult?.message?.takeIf { it.isNotBlank() }?.let { StaffMessage(text = it) } ?: StaffMessage(R.string.staff090_stocktake_closed), + ) + is ApiResult.Failure -> s.copy(closing = false, closeDialog = false, forbidden = res.isForbidden(), message = res.toStocktakeMessage(R.string.staff_error_save)) + } + } + gate.resume() + if (res is ApiResult.Failure && Staff090StocktakeErrors.sessionEnded(res)) reload() + } + } + + fun openCancel() { + gate.pause() + _state.update { it.copy(cancelDialog = true) } + } + + fun dismissCancel() { + gate.resume() + _state.update { it.copy(cancelDialog = false) } + } + + fun cancel() { + _state.update { it.copy(cancelDialog = false, closing = true) } + viewModelScope.launch { + val res = repo.cancel(sessionId) + _state.update { s -> + when (res) { + is ApiResult.Success -> s.copy( + closing = false, session = res.data, + message = res.data.message?.takeIf { it.isNotBlank() }?.let { StaffMessage(text = it) } ?: StaffMessage(R.string.staff090_stocktake_cancelled), + ) + is ApiResult.Failure -> s.copy(closing = false, forbidden = res.isForbidden(), message = res.toStocktakeMessage(R.string.staff_error_save)) + } + } + gate.resume() + if (res is ApiResult.Failure && Staff090StocktakeErrors.sessionEnded(res)) reload() + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } + + /** The tone and haptic of [id] were played: a recomposition or a rotation does not play them again. */ + fun feedbackPlayed(id: Int) = _state.update { if (it.feedback?.id == id) it.copy(feedback = null) else it } + + companion object { + /** The web scan page polls every few seconds; so does the app while the screen is visible. */ + const val POLL_MS = 4_000L + } +} + +private val UPLOAD_MIME_TYPES = arrayOf("text/plain", "text/csv", "text/comma-separated-values", "application/vnd.ms-excel") + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun Staff090StocktakeSessionScreen(onNavigateUp: () -> Unit, onOpenReport: (Int) -> Unit, onOpenBook: (Int) -> Unit) { + val vm: StocktakeSessionViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + val context = LocalContext.current + val haptics = LocalHapticFeedback.current + val player = remember { Staff090StocktakeTonePlayer(context.applicationContext) } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.s090.stocktake || state.leave, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + LaunchedEffect(state.feedback?.id) { + val feedback = state.feedback ?: return@LaunchedEffect + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + player.play(feedback.tone) + vm.feedbackPlayed(feedback.id) + } + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> vm.onVisible() + Lifecycle.Event.ON_PAUSE -> vm.onHidden() + else -> Unit + } + } + owner.lifecycle.addObserver(observer) + onDispose { + owner.lifecycle.removeObserver(observer) + vm.onHidden() + player.stop() + } + } + val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument(), vm::onPicked) + val session = state.session + val open = { book: Int -> vm.onHidden(); onOpenBook(book) } + + Scaffold( + topBar = { + PinakesTopBar( + title = session?.name ?: stringResource(R.string.staff090_stocktake), + onNavigateUp = onNavigateUp, + actions = { + if (session != null) { + IconButton(onClick = { onOpenReport(session.id) }) { + Icon(Icons.Outlined.Assessment, contentDescription = stringResource(R.string.staff090_stocktake_report)) + } + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + when { + state.loading && session == null -> LoadingState(modifier = Modifier.padding(padding)) + session == null -> ErrorState( + message = state.error?.resolve().orEmpty().ifBlank { stringResource(R.string.staff_error_load) }, + onRetry = vm::reload, modifier = Modifier.padding(padding), + ) + else -> LazyColumn( + Modifier.fillMaxSize().padding(padding).imePadding(), + contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, top = Spacing.sm, bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + if (session.canScan) { + item(key = "scanner") { + ScannerBox( + formats = BarcodeAnalyzer.COPY_FORMATS, onCode = vm::onDecoded, looking = false, + permissionText = stringResource(R.string.staff090_stocktake_camera_permission), + ) + } + item(key = "shelf") { + val shelf = state.options?.shelves?.firstOrNull { it.id == state.shelfId } + PickerRow( + label = stringResource(R.string.staff090_stocktake_current_shelf), + value = shelf?.label?.ifBlank { shelf.name } ?: state.shelfId?.let { "#$it" }, + placeholder = stringResource(R.string.staff090_stocktake_no_shelf), onClick = vm::openShelfPicker, onClear = vm::clearShelf, + ) + } + item(key = "last") { LastResultCard(state.lastResult, state.pending, state.options, onOpenBook = open) } + item(key = "manual") { + var manual by rememberSaveable { mutableStateOf("") } + val submit = { if (vm.submitManual(manual)) manual = "" } + PinakesTextField( + value = manual, + onValueChange = { manual = it.take(state.options?.limits?.maxCodeLength ?: 100); vm.clearManualError() }, + label = stringResource(R.string.staff090_stocktake_manual_code), + isError = state.manualError, errorText = stringResource(R.string.staff_copy_code_required), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { submit() }), + trailingIcon = { + IconButton(onClick = submit) { Icon(Icons.AutoMirrored.Outlined.Send, contentDescription = stringResource(R.string.staff090_stocktake_send_code)) } + }, + modifier = Modifier.fillMaxWidth().testTag("stocktake-code-input"), + ) + } + if (session.canUpload) { + item(key = "upload") { + SecondaryButton( + stringResource(R.string.staff090_stocktake_upload), onClick = { picker.launch(UPLOAD_MIME_TYPES) }, + enabled = !state.uploading, leadingIcon = Icons.Outlined.UploadFile, modifier = Modifier.fillMaxWidth().testTag("stocktake-upload"), + ) + } + } + } else { + item(key = "status") { StatusBanner(session) } + } + item(key = "counters") { + Column { + FormSectionTitle(stringResource(R.string.staff090_stocktake_progress)) + StocktakeCounters(session.summary, state.options) + } + } + item(key = "actions") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.sm)) { + SecondaryButton(stringResource(R.string.staff090_stocktake_report), onClick = { onOpenReport(session.id) }, + leadingIcon = Icons.Outlined.Assessment, modifier = Modifier.fillMaxWidth()) + if (session.canClose) { + PrimaryButton(stringResource(R.string.staff090_stocktake_close), onClick = vm::openClose, loading = state.closing, + leadingIcon = Icons.AutoMirrored.Outlined.FactCheck, modifier = Modifier.fillMaxWidth().testTag("stocktake-close")) + } else if (Staff090StocktakeClose.canResume(session)) { + PrimaryButton(stringResource(R.string.staff090_stocktake_close_resume), onClick = vm::openClose, loading = state.closing, + modifier = Modifier.fillMaxWidth().testTag("stocktake-resume")) + } + if (session.canCancel) { + PinakesTextButton(stringResource(R.string.staff090_stocktake_cancel), onClick = vm::openCancel, enabled = !state.closing, + modifier = Modifier.align(Alignment.CenterHorizontally).testTag("stocktake-cancel")) + } + } + } + item(key = "recent-title") { + Column { + FormSectionTitle(stringResource(R.string.staff090_stocktake_recent)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + val chipColors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + FilterChip(selected = state.outcomeFilter == null, onClick = { vm.setOutcomeFilter(null) }, + label = { Text(stringResource(R.string.staff090_stocktake_filter_latest)) }, colors = chipColors) + state.options?.outcomes?.filter { it.scanResult }?.forEach { outcome -> + FilterChip(selected = state.outcomeFilter == outcome.code, onClick = { vm.setOutcomeFilter(outcome.code) }, + label = { Text(outcome.label) }, colors = chipColors, modifier = Modifier.testTag("filter-${outcome.code}")) + } + } + SwitchRow(stringResource(R.string.staff090_stocktake_only_mine), state.mine, vm::setMine) + } + } + if (state.recent.isEmpty() && !state.filteredLoading) { + item(key = "recent-empty") { + Text(stringResource(R.string.staff090_stocktake_no_scans), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + items(state.recent, key = { "scan-${it.id}" }) { scan -> StocktakeScanRow(scan, state.options, onOpenBook = open) } + if (state.outcomeFilter != null && state.filteredCursor != null) { + item(key = "recent-more") { + PinakesTextButton(stringResource(R.string.staff090_stocktake_more), onClick = vm::loadMoreFiltered, enabled = !state.filteredLoading, + modifier = Modifier.fillMaxWidth()) + } + } + item(key = "info") { SessionInfo(session) } + } + } + } + + if (state.shelfPicker) { + LocalPickerSheet( + title = stringResource(R.string.staff090_stocktake_current_shelf), placeholder = stringResource(R.string.staff090_stocktake_search_shelf), + items = state.options?.shelves.orEmpty().map { PickItem(it.id, it.label.ifBlank { it.name }) }, + query = state.pickerQuery, onQuery = vm::setPickerQuery, onPick = vm::pickShelf, onDismiss = vm::closeShelfPicker, + ) + } + if (state.closeDialog && session != null) CloseDialog(session, state.closeAction, state.closing, vm::setCloseAction, vm::close, vm::dismissClose) + if (state.cancelDialog) { + ConfirmDialog( + title = stringResource(R.string.staff090_stocktake_cancel_title), + body = stringResource(R.string.staff090_stocktake_cancel_body), + confirmLabel = stringResource(R.string.staff090_stocktake_cancel), + dismissLabel = stringResource(R.string.staff090_stocktake_keep), + onConfirm = vm::cancel, onDismiss = vm::dismissCancel, + ) + } + state.uploadResult?.let { result -> + AlertDialog( + onDismissRequest = vm::closeUploadResult, + shape = MaterialTheme.shapes.large, + title = { Text(stringResource(R.string.staff090_stocktake_upload_done), style = MaterialTheme.typography.titleMedium) }, + text = { + Column { + if (result.message.isNotBlank()) Text(result.message, style = MaterialTheme.typography.bodyMedium) + result.outcomes.filter { it.count > 0 }.forEach { InfoLine(it.label, it.count.toString()) } + } + }, + confirmButton = { PrimaryButton(stringResource(R.string.action_confirm), onClick = vm::closeUploadResult) }, + ) + } +} + +/** The answer to the latest code, in its outcome's colour: what it is and what to do with it. */ +@Composable +internal fun LastResultCard(scan: Staff090StocktakeScan?, pending: Int, options: Staff090StocktakeOptions?, onOpenBook: (Int) -> Unit) { + if (scan == null) { + Text( + if (pending > 0) stringResource(R.string.staff090_stocktake_sending, pending) else stringResource(R.string.staff090_stocktake_scan_hint), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + val (bg, fg) = Staff090StocktakeColors.forOutcome(scan.outcome, scan.color, options).pair() + Surface( + onClick = { scan.bookId?.takeIf { it > 0 }?.let(onOpenBook) }, + enabled = (scan.bookId ?: 0) > 0, + shape = MaterialTheme.shapes.large, color = bg, contentColor = fg, + modifier = Modifier.fillMaxWidth().testTag("stocktake-last-result"), + ) { + Column(Modifier.padding(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Text(scan.outcomeLabel.ifBlank { scan.outcome }, style = MaterialTheme.typography.titleLarge, color = fg) + Text(scan.code, style = MaterialTheme.typography.labelLarge, color = fg) + scan.title?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodyLarge, color = fg, maxLines = 2, overflow = TextOverflow.Ellipsis) } + scan.expectedShelf?.takeIf { it.isNotBlank() }?.let { Text(stringResource(R.string.staff090_stocktake_belongs_on, it), style = MaterialTheme.typography.bodyMedium, color = fg) } + if (scan.offShelf) scan.copyStatusLabel?.let { Text(stringResource(R.string.staff090_stocktake_recorded_as, it), style = MaterialTheme.typography.bodyMedium, color = fg) } + if (pending > 0) Text(stringResource(R.string.staff090_stocktake_sending, pending), style = MaterialTheme.typography.bodySmall, color = fg) + } + } +} + +/** A session that takes no scans: why (closed, cancelled) and what the close did. */ +@Composable +private fun StatusBanner(session: Staff090StocktakeSession) { + InfoCard { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + StocktakeBadge(session.statusLabel.ifBlank { session.status }, session.statusColor) + Text(stringResource(R.string.staff090_stocktake_no_more_scans), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + } + session.closeResult?.message?.takeIf { it.isNotBlank() }?.let { + Spacer(Modifier.height(Spacing.sm)) + Text(it, style = MaterialTheme.typography.bodyMedium) + } + if (session.pendingLost > 0) { + Spacer(Modifier.height(Spacing.sm)) + InlineErrorBanner(message = stringResource(R.string.staff090_stocktake_pending_lost, session.pendingLost)) + } + } +} + +@Composable +private fun SessionInfo(session: Staff090StocktakeSession) { + Column { + FormSectionTitle(stringResource(R.string.staff090_stocktake_details)) + InfoCard { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(session.name, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) + StocktakeBadge(session.statusLabel.ifBlank { session.status }, session.statusColor) + } + InfoLine(stringResource(R.string.staff090_stocktake_field_branch), session.branch?.name) + InfoLine(stringResource(R.string.staff090_stocktake_scope), session.filterLabels.joinToString("\n").ifBlank { stringResource(R.string.staff090_stocktake_whole_library) }) + InfoLine(stringResource(R.string.staff090_stocktake_field_note), session.note) + InfoLine(stringResource(R.string.staff090_stocktake_opened_label), listOfNotNull(stocktakeDateTime(session.openedAt).ifBlank { null }, session.openedBy?.name?.ifBlank { null }).joinToString(" · ")) + InfoLine(stringResource(R.string.staff090_stocktake_closed_label), listOfNotNull(stocktakeDateTime(session.closedAt).ifBlank { null }, session.closedBy?.name?.ifBlank { null }).joinToString(" · ")) + } + if (session.history.isNotEmpty()) { + FormSectionTitle(stringResource(R.string.staff090_stocktake_history)) + InfoCard { + session.history.forEach { event -> + InfoLine(stocktakeDateTime(event.at), listOfNotNull(event.label.ifBlank { event.event }, event.operatorName).joinToString(" · ")) + } + } + } + } +} + +/** The missing copies' fate, stated with their count; `lost` appears only when the server allows it. */ +@Composable +internal fun CloseDialog( + session: Staff090StocktakeSession, + action: String, + closing: Boolean, + onAction: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val actions = Staff090StocktakeClose.actions(session) + val resume = Staff090StocktakeClose.canResume(session) + AlertDialog( + onDismissRequest = { if (!closing) onDismiss() }, + shape = MaterialTheme.shapes.large, + title = { Text(stringResource(if (resume) R.string.staff090_stocktake_close_resume else R.string.staff090_stocktake_close), style = MaterialTheme.typography.titleMedium) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + if (!resume && actions.size > 1) { + actions.forEach { code -> + val label = when (code) { + Staff090StocktakeClose.LOST -> stringResource(R.string.staff090_stocktake_action_lost) + else -> stringResource(R.string.staff090_stocktake_action_none) + } + Row( + Modifier.fillMaxWidth().selectable(selected = action == code, role = Role.RadioButton, onClick = { onAction(code) }).testTag("close-$code"), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = action == code, onClick = null) + Spacer(Modifier.padding(start = Spacing.sm)) + Text(label, style = MaterialTheme.typography.bodyLarge) + } + } + } + Text( + stringResource(Staff090StocktakeClose.confirmText(session, action), Staff090StocktakeClose.affected(session)), + style = MaterialTheme.typography.bodyMedium, modifier = Modifier.testTag("close-confirm-text"), + ) + } + }, + confirmButton = { + PrimaryButton(stringResource(if (resume) R.string.staff090_stocktake_close_resume else R.string.staff090_stocktake_close), onClick = onConfirm, + loading = closing, modifier = Modifier.testTag("close-confirm")) + }, + dismissButton = { PinakesTextButton(stringResource(R.string.action_cancel), onClick = onDismiss, enabled = !closing) }, + ) +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeSessionsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeSessionsScreen.kt new file mode 100644 index 0000000..4d7a8c7 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeSessionsScreen.kt @@ -0,0 +1,418 @@ +package com.pinakes.app.ui.screens.staff.s090.stocktake + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.automirrored.outlined.FactCheck +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.Staff090StocktakeOptions +import com.pinakes.app.data.model.Staff090StocktakeSession +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.data.repository.Staff090StocktakeRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.SubmissionKeys +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.LookupField +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.PickerRow +import com.pinakes.app.ui.screens.staff.StaffFab +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** The list's status filter; [wire] null = every session. */ +enum class StocktakeStatusFilter(val wire: String?, val label: Int) { + OPEN("open", R.string.staff090_stocktake_filter_open), + CLOSED("closed", R.string.staff090_stocktake_filter_closed), + CANCELLED("cancelled", R.string.staff090_stocktake_filter_cancelled), + ALL(null, R.string.staff090_stocktake_filter_all), +} + +/** Which picker of the create sheet is open (the sheet steps aside meanwhile). */ +enum class StocktakePicker { SHELF, GENRE } + +data class StocktakeSessionsUiState( + val filter: StocktakeStatusFilter = StocktakeStatusFilter.OPEN, + val items: List = emptyList(), + val nextCursor: String? = null, + val loading: Boolean = true, + val refreshing: Boolean = false, + val loadingMore: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + val options: Staff090StocktakeOptions? = null, + /** The create sheet's form; null = closed. */ + val creating: Staff090StocktakeForm? = null, + val picker: StocktakePicker? = null, + val pickerQuery: String = "", + val saving: Boolean = false, + val formError: StaffMessage? = null, + val createdId: Int? = null, + val message: StaffMessage? = null, +) + +/** Stocktake sessions by status, with the create sheet built from `/options`. */ +@HiltViewModel +class StocktakeSessionsViewModel @Inject constructor(private val repo: Staff090StocktakeRepository) : ViewModel() { + private val _state = MutableStateFlow(StocktakeSessionsUiState()) + val state: StateFlow = _state.asStateFlow() + private val keys = SubmissionKeys() + private var generation = 0 + + fun reload() { + val gen = ++generation + _state.update { it.copy(loading = it.items.isEmpty() && !it.refreshing, error = null) } + viewModelScope.launch { + val res = repo.sessions(_state.value.filter.wire) + if (gen != generation) return@launch + _state.update { + when (res) { + is ApiResult.Success -> it.copy(items = res.data.items, nextCursor = res.data.nextCursor, loading = false, refreshing = false) + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toStocktakeMessage(R.string.staff_error_load)) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + reload() + } + + fun loadMore() { + val s = _state.value + val cursor = s.nextCursor ?: return + if (s.loadingMore || s.loading) return + val gen = generation + _state.update { it.copy(loadingMore = true) } + viewModelScope.launch { + val res = repo.sessions(s.filter.wire, cursor) + if (gen != generation) { + _state.update { it.copy(loadingMore = false) } + return@launch + } + _state.update { + when (res) { + is ApiResult.Success -> it.copy(items = (it.items + res.data.items).distinctBy(Staff090StocktakeSession::id), nextCursor = res.data.nextCursor, loadingMore = false) + is ApiResult.Failure -> it.copy(loadingMore = false, forbidden = res.isForbidden(), message = res.toStocktakeMessage(R.string.staff_error_load)) + } + } + } + } + + fun selectFilter(filter: StocktakeStatusFilter) { + if (filter == _state.value.filter) return + _state.update { it.copy(filter = filter, items = emptyList(), nextCursor = null) } + reload() + } + + /** Open the create sheet; the options load with it (and stay for the next one). */ + fun startCreate() { + keys.reset() + _state.update { it.copy(creating = Staff090StocktakeForm(), formError = null) } + if (_state.value.options != null) return + viewModelScope.launch { + when (val res = repo.options()) { + is ApiResult.Success -> _state.update { it.copy(options = res.data) } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden(), formError = res.toStocktakeMessage(R.string.staff_error_load)) } + } + } + } + + fun edit(change: (Staff090StocktakeForm) -> Staff090StocktakeForm) = _state.update { s -> s.copy(creating = s.creating?.let(change), formError = null) } + + fun closeCreate() = _state.update { it.copy(creating = null, picker = null, saving = false, formError = null) } + + fun openPicker(picker: StocktakePicker) = _state.update { it.copy(picker = picker, pickerQuery = "") } + fun closePicker() = _state.update { it.copy(picker = null, pickerQuery = "") } + fun setPickerQuery(value: String) = _state.update { it.copy(pickerQuery = value) } + + fun pick(item: PickItem) { + when (_state.value.picker) { + StocktakePicker.SHELF -> edit { it.withShelf(item.id) } + StocktakePicker.GENRE -> edit { it.copy(genreId = item.id) } + null -> Unit + } + closePicker() + } + + /** Open the session under one submission key: a retry after a dropped connection replays it, never a second session. */ + fun create() { + val s = _state.value + val form = s.creating ?: return + val limits = s.options?.limits ?: return + if (!form.valid(limits) || s.saving) return + val request = form.request() + _state.update { it.copy(saving = true, formError = null) } + viewModelScope.launch { + val key = keys.keyFor(request) + var res = repo.create(request, key) + if (res is ApiResult.Failure && (res.code == ErrorCodes.NETWORK || res.code == "concurrent_retry")) res = repo.create(request, key) + if (res is ApiResult.Success || (res as? ApiResult.Failure)?.code == SubmissionKeys.CONFLICT) keys.reset() + val result = res + _state.update { + when (result) { + is ApiResult.Success -> it.copy(saving = false, creating = null, createdId = result.data.id) + is ApiResult.Failure -> it.copy(saving = false, forbidden = result.isForbidden(), formError = result.toStocktakeMessage(R.string.staff_error_save)) + } + } + } + } + + fun consumeCreated() = _state.update { it.copy(createdId = null) } + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +private val FILTERS = StocktakeStatusFilter.entries + +/** Stocktake sessions (open first, then the history) with their counters; the FAB opens a new one. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Staff090StocktakeSessionsScreen(onNavigateUp: () -> Unit, onOpenSession: (Int) -> Unit) { + val vm: StocktakeSessionsViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.s090.stocktake, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + LaunchedEffect(state.createdId) { state.createdId?.let { vm.consumeCreated(); onOpenSession(it) } } + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) vm.reload() } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff090_stocktake), onNavigateUp = onNavigateUp) }, + floatingActionButton = { StaffFab(Icons.Outlined.Add, stringResource(R.string.staff090_stocktake_new), vm::startCreate) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + PrimaryScrollableTabRow(selectedTabIndex = FILTERS.indexOf(state.filter).coerceAtLeast(0), edgePadding = Spacing.lg) { + FILTERS.forEach { filter -> + Tab(selected = filter == state.filter, onClick = { vm.selectFilter(filter) }, text = { Text(stringResource(filter.label)) }) + } + } + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize()) { + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = state.items.isEmpty(), + emptyTitle = stringResource(R.string.staff090_stocktake_empty), emptyIcon = Icons.AutoMirrored.Outlined.FactCheck, onRetry = vm::refresh, + emptySubtitle = stringResource(R.string.staff090_stocktake_empty_hint), + ) { + val listState = rememberLazyListState() + val nearEnd by remember { derivedStateOf { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let { it >= state.items.size - 5 } == true } } + LaunchedEffect(nearEnd, state.nextCursor) { if (nearEnd) vm.loadMore() } + LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 96.dp)) { + items(state.items, key = { it.id }) { session -> SessionRow(session) { onOpenSession(session.id) } } + } + } + } + } + } + + state.creating?.let { form -> + val options = state.options + when (state.picker) { + StocktakePicker.SHELF -> LocalPickerSheet( + title = stringResource(R.string.staff090_stocktake_field_shelf), placeholder = stringResource(R.string.staff090_stocktake_search_shelf), + items = options?.shelves.orEmpty().map { PickItem(it.id, it.label.ifBlank { it.name }) }, + query = state.pickerQuery, onQuery = vm::setPickerQuery, onPick = vm::pick, onDismiss = vm::closePicker, + ) + StocktakePicker.GENRE -> LocalPickerSheet( + title = stringResource(R.string.staff090_stocktake_field_genre), placeholder = stringResource(R.string.staff090_stocktake_search_genre), + items = options?.genres.orEmpty().map { PickItem(it.id, it.label) }, + query = state.pickerQuery, onQuery = vm::setPickerQuery, onPick = vm::pick, onDismiss = vm::closePicker, + ) + null -> CreateSheet( + form = form, options = options, saving = state.saving, error = state.formError, + onEdit = vm::edit, onPicker = vm::openPicker, onSave = vm::create, onDismiss = vm::closeCreate, + ) + } + } +} + +@Composable +private fun SessionRow(session: Staff090StocktakeSession, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("session-${session.id}"), + ) { + Column(Modifier.padding(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(session.name, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + StocktakeBadge(session.statusLabel.ifBlank { session.status }, session.statusColor) + } + if (session.filterLabels.isNotEmpty()) { + Text(session.filterLabels.joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + val s = session.summary + Text( + stringResource(R.string.staff090_stocktake_row_counts, s.expected, s.found, s.missing, s.scans), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, + ) + val opened = listOfNotNull(stocktakeDateTime(session.openedAt).takeIf { it.isNotBlank() }, session.openedBy?.name?.takeIf { it.isNotBlank() }) + if (opened.isNotEmpty()) { + Text(stringResource(R.string.staff090_stocktake_opened, opened.joinToString(" · ")), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CreateSheet( + form: Staff090StocktakeForm, + options: Staff090StocktakeOptions?, + saving: Boolean, + error: StaffMessage?, + onEdit: ((Staff090StocktakeForm) -> Staff090StocktakeForm) -> Unit, + onPicker: (StocktakePicker) -> Unit, + onSave: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = { if (!saving) onDismiss() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(R.string.staff090_stocktake_new), style = MaterialTheme.typography.headlineSmall) + error?.let { InlineErrorBanner(message = it.resolve()) } + if (options == null) { + if (error == null) com.pinakes.app.ui.components.LoadingState(modifier = Modifier.heightIn(max = 120.dp)) + Spacer(Modifier.height(Spacing.lg)) + return@Column + } + val limits = options.limits + PinakesTextField( + value = form.name, onValueChange = { v -> onEdit { it.copy(name = v) } }, label = stringResource(R.string.staff090_stocktake_field_name), + maxLength = limits.maxNameLength, isError = form.name.isNotEmpty() && !form.nameValid(limits), + errorText = stringResource(R.string.staff090_stocktake_name_required), modifier = Modifier.fillMaxWidth().testTag("stocktake-name"), + ) + PinakesTextField( + value = form.note, onValueChange = { v -> onEdit { it.copy(note = v) } }, label = stringResource(R.string.staff090_stocktake_field_note), + singleLine = false, maxLength = 5000, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp), + ) + FormSectionTitle(stringResource(R.string.staff090_stocktake_scope)) + Text(stringResource(R.string.staff090_stocktake_scope_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (options.branches.isNotEmpty()) { + val branches = options.branches.map { LookupOption(it.id.toString(), it.name) } + LookupField( + label = stringResource(R.string.staff090_stocktake_field_branch), value = form.branchId?.toString().orEmpty(), + displayValue = branches.firstOrNull { it.value == form.branchId?.toString() }?.label.orEmpty(), options = branches, + editable = false, onValueChange = { v -> onEdit { it.copy(branchId = v.toIntOrNull()) } }, modifier = Modifier.fillMaxWidth(), + ) + } + val shelf = options.shelves.firstOrNull { it.id == form.shelfId } + PickerRow( + label = stringResource(R.string.staff090_stocktake_field_shelf), value = shelf?.label?.ifBlank { shelf.name }, + placeholder = stringResource(R.string.staff090_stocktake_any_shelf), onClick = { onPicker(StocktakePicker.SHELF) }, + onClear = { onEdit { it.withShelf(null) } }, + ) + val levels = form.levels(options.shelves).map { LookupOption(it.id.toString(), stringResource(R.string.staff090_stocktake_level, it.level)) } + if (levels.isNotEmpty()) { + LookupField( + label = stringResource(R.string.staff090_stocktake_field_level), value = form.levelId?.toString().orEmpty(), + displayValue = levels.firstOrNull { it.value == form.levelId?.toString() }?.label.orEmpty(), options = levels, + editable = false, onValueChange = { v -> onEdit { it.copy(levelId = v.toIntOrNull()) } }, modifier = Modifier.fillMaxWidth(), + ) + } + PickerRow( + label = stringResource(R.string.staff090_stocktake_field_genre), value = options.genres.firstOrNull { it.id == form.genreId }?.label, + placeholder = stringResource(R.string.staff090_stocktake_any_genre), onClick = { onPicker(StocktakePicker.GENRE) }, + onClear = { onEdit { it.copy(genreId = null) } }, + ) + val media = options.mediaTypes.map { LookupOption(it.code, it.label) } + LookupField( + label = stringResource(R.string.staff090_stocktake_field_media), value = form.mediaType.orEmpty(), + displayValue = media.firstOrNull { it.value == form.mediaType }?.label.orEmpty(), options = media, + editable = false, onValueChange = { v -> onEdit { it.copy(mediaType = v.ifBlank { null }) } }, modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + PinakesTextField( + value = form.collocationFrom, onValueChange = { v -> onEdit { it.copy(collocationFrom = v) } }, + label = stringResource(R.string.staff090_stocktake_field_collocation_from), maxLength = limits.maxCollocationLength, modifier = Modifier.weight(1f), + ) + PinakesTextField( + value = form.collocationTo, onValueChange = { v -> onEdit { it.copy(collocationTo = v) } }, + label = stringResource(R.string.staff090_stocktake_field_collocation_to), maxLength = limits.maxCollocationLength, modifier = Modifier.weight(1f), + ) + } + PrimaryButton( + stringResource(R.string.staff090_stocktake_open_session), onClick = onSave, loading = saving, enabled = form.valid(limits), + modifier = Modifier.fillMaxWidth().testTag("stocktake-create"), + ) + Spacer(Modifier.height(Spacing.lg)) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeTonePlayer.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeTonePlayer.kt new file mode 100644 index 0000000..c09cb6d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeTonePlayer.kt @@ -0,0 +1,59 @@ +package com.pinakes.app.ui.screens.staff.s090.stocktake + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.os.Handler +import android.os.Looper +import com.pinakes.app.data.model.Staff090StocktakeTone + +/** + * Plays the scan beep the web scan page plays (the server's frequency and duration) through a + * static AudioTrack on the sonification stream. Silent unless the ringer is in normal mode, so + * a phone on silent or vibrate stays quiet (the haptic tick still says the scan was taken). + * Any audio failure is swallowed: a missing beep must never stop a stocktake. + */ +class Staff090StocktakeTonePlayer(context: Context) { + private val audio = context.getSystemService(AudioManager::class.java) + private val main = Handler(Looper.getMainLooper()) + private var current: AudioTrack? = null + + fun play(tone: Staff090StocktakeTone?) { + val spec = Staff090StocktakeSound.spec(tone, audio?.ringerMode ?: AudioManager.RINGER_MODE_SILENT) ?: return + val samples = Staff090StocktakeSound.samples(spec) + runCatching { + stop() + val track = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(), + ) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(Staff090StocktakeSound.SAMPLE_RATE) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build(), + ) + .setTransferMode(AudioTrack.MODE_STATIC) + .setBufferSizeInBytes(samples.size * 2) + .build() + track.write(samples, 0, samples.size) + track.play() + current = track + main.postDelayed({ if (current === track) stop() else runCatching { track.release() } }, spec.durationMs + 150L) + } + } + + /** Stop and release the beep in progress (screen leaving, next scan). */ + fun stop() { + val track = current ?: return + current = null + runCatching { track.stop() } + runCatching { track.release() } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeUi.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeUi.kt new file mode 100644 index 0000000..59355de --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/Staff090StocktakeUi.kt @@ -0,0 +1,139 @@ +package com.pinakes.app.ui.screens.staff.s090.stocktake + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pinakes.app.R +import com.pinakes.app.data.model.Staff090StocktakeColor +import com.pinakes.app.data.model.Staff090StocktakeOptions +import com.pinakes.app.data.model.Staff090StocktakeScan +import com.pinakes.app.data.model.Staff090StocktakeSummary +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.SearchPickerSheet +import com.pinakes.app.ui.screens.staff.SearchState +import com.pinakes.app.ui.theme.Spacing + +// Building blocks shared by the stocktake screens: the outcome badge in the web's colours, the +// counter tiles, a scan row and a picker over a local list. Same shapes as the staff screens. + +/** The server's colour pair; the theme's neutral pair when the server sent none. */ +@Composable +internal fun Staff090StocktakeColor?.pair(): Pair { + val bg = Staff090StocktakeColors.argb(this?.background) + val fg = Staff090StocktakeColors.argb(this?.foreground) + return if (bg != null && fg != null) Color(bg) to Color(fg) + else MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant +} + +/** A fully round badge: the outcome (or status) label on its own colour, as on the web page. */ +@Composable +internal fun StocktakeBadge(label: String, color: Staff090StocktakeColor?, modifier: Modifier = Modifier) { + val (bg, fg) = color.pair() + Surface(shape = RoundedCornerShape(50), color = bg, contentColor = fg, modifier = modifier) { + Text(label, style = MaterialTheme.typography.labelMedium, color = fg, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.xxs)) + } +} + +/** The live counters as tiles; an outcome counter carries its colour as a dot. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun StocktakeCounters(summary: Staff090StocktakeSummary, options: Staff090StocktakeOptions?) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + modifier = Modifier.fillMaxWidth().testTag("stocktake-counters"), + ) { + Staff090StocktakeCounters.of(summary).forEach { counter -> + Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.widthIn(min = 96.dp)) { + Column(Modifier.semantics(mergeDescendants = true) {}.padding(horizontal = Spacing.md, vertical = Spacing.sm).testTag("count-${counter.key}")) { + Text(counter.value.toString(), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface) + Row(verticalAlignment = Alignment.CenterVertically) { + counter.outcome?.let { code -> + val (_, fg) = Staff090StocktakeColors.forOutcome(code, null, options).pair() + Box(Modifier.size(8.dp).background(fg, CircleShape)) + Spacer(Modifier.width(Spacing.xs)) + } + Text(stringResource(counter.label), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } +} + +/** One scan in a list: badge, code, title and the operator. */ +@Composable +internal fun StocktakeScanRow(scan: Staff090StocktakeScan, options: Staff090StocktakeOptions?, onOpenBook: ((Int) -> Unit)?) { + val bookId = scan.bookId?.takeIf { it > 0 } + Surface( + onClick = { if (bookId != null) onOpenBook?.invoke(bookId) }, + enabled = bookId != null && onOpenBook != null, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("scan-${scan.id}"), + ) { + Column(Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + StocktakeBadge(scan.outcomeLabel.ifBlank { scan.outcome }, Staff090StocktakeColors.forOutcome(scan.outcome, scan.color, options)) + Text(scan.code, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + } + scan.title?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + val meta = listOfNotNull( + scan.expectedShelf?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.staff090_stocktake_belongs_on, it) }, + scan.operator?.name?.takeIf { it.isNotBlank() }, + stocktakeDateTime(scan.scannedAt).takeIf { it.isNotBlank() }, + ).joinToString(" · ") + if (meta.isNotBlank()) Text(meta, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +/** [SearchPickerSheet] over a list already in memory (shelves, genres): filtered as the user types. */ +@Composable +internal fun LocalPickerSheet( + title: String, + placeholder: String, + items: List, + query: String, + onQuery: (String) -> Unit, + onPick: (PickItem) -> Unit, + onDismiss: () -> Unit, +) { + val results = remember(items, query) { + val q = query.trim() + if (q.isEmpty()) items else items.filter { it.title.contains(q, ignoreCase = true) || it.subtitle?.contains(q, ignoreCase = true) == true } + } + SearchPickerSheet( + title = title, placeholder = placeholder, + state = SearchState(query = query, results = results, searched = true), + onQuery = onQuery, onPick = onPick, onDismiss = onDismiss, + ) +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/StocktakeGraph.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/StocktakeGraph.kt new file mode 100644 index 0000000..870fe57 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/s090/stocktake/StocktakeGraph.kt @@ -0,0 +1,38 @@ +package com.pinakes.app.ui.screens.staff.s090.stocktake + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.s090.Staff090Routes +import com.pinakes.app.ui.screens.staff.s090.Staff090Transitions + +/** The session id argument of the stocktake session and report routes. */ +internal const val STOCKTAKE_ARG_SESSION = "sessionId" +private const val STOCKTAKE_SESSION = "staff/s090/stocktake/{$STOCKTAKE_ARG_SESSION}" +private const val STOCKTAKE_REPORT = "staff/s090/stocktake/{$STOCKTAKE_ARG_SESSION}/report" +private fun stocktakeSession(id: Int) = "staff/s090/stocktake/$id" +private fun stocktakeReport(id: Int) = "staff/s090/stocktake/$id/report" + +/** Stocktake (sessions → session scanner → report) and statistics. A tapped book opens the staff book editor. */ +internal fun NavGraphBuilder.stocktakeGraph(nav: NavHostController, t: Staff090Transitions) { + val sessionArg = listOf(navArgument(STOCKTAKE_ARG_SESSION) { type = NavType.IntType }) + composable(Staff090Routes.STOCKTAKE, enterTransition = t.enter, popExitTransition = t.exit) { + Staff090StocktakeSessionsScreen(onNavigateUp = { nav.popBackStack() }, onOpenSession = { nav.navigate(stocktakeSession(it)) }) + } + composable(STOCKTAKE_SESSION, arguments = sessionArg, enterTransition = t.enter, popExitTransition = t.exit) { + Staff090StocktakeSessionScreen( + onNavigateUp = { nav.popBackStack() }, + onOpenReport = { nav.navigate(stocktakeReport(it)) }, + onOpenBook = { nav.navigate(Routes.staffBookEdit(it)) }, + ) + } + composable(STOCKTAKE_REPORT, arguments = sessionArg, enterTransition = t.enter, popExitTransition = t.exit) { + Staff090StocktakeReportScreen(onNavigateUp = { nav.popBackStack() }) + } + composable(Staff090Routes.STATISTICS, enterTransition = t.enter, popExitTransition = t.exit) { + Staff090StatisticsScreen(onNavigateUp = { nav.popBackStack() }, onOpenBook = { nav.navigate(Routes.staffBookEdit(it)) }) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/BarcodeAnalyzer.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/BarcodeAnalyzer.kt index ca65e4a..802ba8d 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/BarcodeAnalyzer.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/BarcodeAnalyzer.kt @@ -38,6 +38,9 @@ class BarcodeAnalyzer( /** Copy inventory labels (CODE 128, CODE 39) plus EAN-13 for libraries that label with it. */ val COPY_FORMATS: List = listOf(BarcodeFormat.CODE_128, BarcodeFormat.CODE_39, BarcodeFormat.EAN_13) + /** Library cards (visitor log): the card prints its code as CODE 128 or as a QR code. */ + val PATRON_CARD_FORMATS: List = listOf(BarcodeFormat.CODE_128, BarcodeFormat.QR_CODE, BarcodeFormat.CODE_39) + /** * The decoded text, with the EAN 2/5-digit add-on appended when [appendEanAddOn] asks for * it. A periodical issue is identified by the 13-digit masthead code PLUS its add-on (the diff --git a/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt b/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt index 8177a47..8f8539a 100644 --- a/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt +++ b/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt @@ -49,7 +49,7 @@ class CatalogDaoTest { genre: String = "", availableOnly: Boolean = false, sort: String = "title_asc", limit: Int = 50, offset: Int = 0, ) = dao.search( - SearchText.queryPattern(q), SearchText.escapeLike(author), SearchText.escapeLike(publisher), + SearchText.queryPattern(q), SearchText.filterPattern(author), SearchText.filterPattern(publisher), language, genre, availableOnly, sort, limit, offset, ).map { it.id } @@ -212,4 +212,18 @@ class CatalogDaoTest { dao.upsertAll(listOf(book(1, author = "A_B"), book(2, author = "AxB"))) assertEquals(listOf(1), search(author = "A_B")) } + + @Test + fun quotedQueriesAndFiltersFindTheirWordsOffline() = runBlocking { + // Pinakes #501: quotes ask the server for the exact spelling; offline they are dropped. + dao.upsertAll( + listOf( + book(1, title = "Il barone rampante", author = "Italo Calvino", publisher = "Einaudi"), + book(2, title = "Altro", author = "Thomas Mann", publisher = "Fischer"), + ), + ) + assertEquals(listOf(1), search(q = "\"barone rampante\"")) + assertEquals(listOf(1), search(author = "\"Italo Calvino\"")) + assertEquals(listOf(1), search(publisher = "\u201CEinaudi\u201D")) + } } diff --git a/app/src/test/java/com/pinakes/app/LatestReleaseContractTest.kt b/app/src/test/java/com/pinakes/app/LatestReleaseContractTest.kt index 4299c42..f5b934b 100644 --- a/app/src/test/java/com/pinakes/app/LatestReleaseContractTest.kt +++ b/app/src/test/java/com/pinakes/app/LatestReleaseContractTest.kt @@ -33,6 +33,14 @@ class LatestReleaseContractTest { assertEquals(CirculationRequestKind.LoanPending, pending.kind) assertEquals(CirculationRequestKind.LoanReadyForPickup, ready.kind) assertEquals(CirculationRequestKind.Unknown, CirculationRequestResult(type = "future").kind) + // Pinakes 0.9.0: approved with the library closed today, the loan starts on the next open + // day; auto_approved is true but the book is not waiting at the desk yet. + val scheduled = json.decodeFromString>( + """{"data":{"type":"loan","book_id":7,"loan_id":9,"auto_approved":true,"status":"prenotato","start_date":"2026-11-02"},"meta":{},"error":null}""", + ).data + assertEquals(CirculationRequestKind.LoanScheduled, scheduled?.kind) + assertEquals("2026-11-02", scheduled?.startDate) + assertEquals(null, ready.startDate) assertEquals( CirculationRequestKind.Unknown, CirculationRequestResult(type = "loan", status = "future_status").kind, diff --git a/app/src/test/java/com/pinakes/app/LoanWindowTest.kt b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt index 83e2f01..d1ecc1d 100644 --- a/app/src/test/java/com/pinakes/app/LoanWindowTest.kt +++ b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt @@ -32,6 +32,20 @@ class LoanWindowTest { assertTrue(window!!.capped) } + @Test fun aReturnCutToTheMaximumOnAClosedDayMovesBackNeverPastTheMaximum() { + // max 14 < loan duration 30: the return is start + 14, a Sunday the library is closed. + val cal = AvailabilityCalendar(totalCopies = 1, maxLoanDurationDays = 14, closedDates = listOf("2030-03-17", "2030-03-18")) + val window = loanWindowFrom("2030-03-04", cal, 30)!! + assertEquals("2030-03-16", window.endDate) + assertEquals(12, window.maxDays) + assertTrue(window.requiresEndDate) + assertFalse(window.capped) + // The full duration on a closed day still moves forward, as the server's due date does. + val full = loanWindowFrom("2030-03-04", AvailabilityCalendar(totalCopies = 1, maxLoanDurationDays = 90, closedDates = listOf("2030-03-18")), 14)!! + assertEquals("2030-03-19", full.endDate) + assertFalse(full.requiresEndDate) + } + @Test fun noConflictMeansStartPlusTheDuration() { val window = loanWindowFrom("2030-03-04", calendar("2030-05-01"), 14) assertEquals("2030-03-18", window?.endDate) diff --git a/app/src/test/java/com/pinakes/app/server090/CardBarcodeTest.kt b/app/src/test/java/com/pinakes/app/server090/CardBarcodeTest.kt new file mode 100644 index 0000000..5884408 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/CardBarcodeTest.kt @@ -0,0 +1,42 @@ +package com.pinakes.app.server090 + +import com.google.zxing.BarcodeFormat +import com.google.zxing.DecodeHintType +import com.google.zxing.common.BitArray +import com.google.zxing.oned.Code128Reader +import com.pinakes.app.ui.common.CardBarcode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** The library card code drawn as the printed card's Code128 (Pinakes 0.9.0) reads back as the code. */ +class CardBarcodeTest { + private fun decode(modules: BooleanArray): String { + val row = BitArray(modules.size + 2 * CardBarcode.QUIET_ZONE) + modules.forEachIndexed { i, black -> if (black) row.set(i + CardBarcode.QUIET_ZONE) } + val hints = mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.CODE_128)) + return Code128Reader().decodeRow(0, row, hints).text + } + + @Test fun aCardCodeReadsBackAsItself() { + for (code in listOf("TES000123", "000123", "LIB-2026/07", "a b")) { + val bars = CardBarcode.modules(code) + assertNotNull(bars) + assertEquals(code, decode(bars!!)) + } + } + + @Test fun surroundingSpacesAreNotEncoded() { + assertEquals("TES1", decode(CardBarcode.modules(" TES1 ")!!)) + } + + @Test fun whatCode128CannotHoldIsNotDrawn() { + // As on the printed card: only printable ASCII; the text line still shows the code. + assertNull(CardBarcode.modules("Tessera-è")) + assertNull(CardBarcode.modules("TES\u0007")) + assertNull(CardBarcode.modules("")) + assertNull(CardBarcode.modules(null)) + assertNull(CardBarcode.modules("X".repeat(81))) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/CirculationRulesTest.kt b/app/src/test/java/com/pinakes/app/server090/CirculationRulesTest.kt new file mode 100644 index 0000000..b16d372 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/CirculationRulesTest.kt @@ -0,0 +1,85 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.R +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.ui.screens.detail.CirculationRules +import com.pinakes.app.ui.screens.detail.LoanAction +import com.pinakes.app.ui.screens.detail.ViewerRules +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Patron-type rules on the book page and the 0.9.0 refusal codes of POST /reservations. */ +class CirculationRulesTest { + + private fun refusal(code: String, message: String = "Server text", reason: String? = null, status: Int = 409): ApiResult.Failure { + val meta = reason?.let { """{"reason":"$it"}""" } ?: "{}" + return parseErrorBody("""{"data":null,"meta":$meta,"error":{"code":"$code","message":"$message"}}""", status) + } + + @Test fun unknownRulesKeepTheUsualButton() { + assertEquals(LoanAction.Request, CirculationRules.loanAction(available = true, rules = null)) + assertEquals(LoanAction.Reserve, CirculationRules.loanAction(available = false, rules = null)) + } + + @Test fun consultationOnlyWinsWhateverTheCopies() { + val rules = ViewerRules(loanable = false) + assertEquals(LoanAction.ConsultationOnly, CirculationRules.loanAction(true, rules)) + assertEquals(LoanAction.ConsultationOnly, CirculationRules.loanAction(false, rules)) + assertFalse(CirculationRules.canRequest(LoanAction.ConsultationOnly)) + } + + @Test fun noReservationsOnlyMattersWhenNoCopyIsFree() { + val rules = ViewerRules(reservationsEnabled = false) + assertEquals(LoanAction.Request, CirculationRules.loanAction(true, rules)) + assertEquals(LoanAction.ReservationsDisabled, CirculationRules.loanAction(false, rules)) + assertFalse(CirculationRules.canRequest(LoanAction.ReservationsDisabled)) + assertTrue(CirculationRules.canRequest(LoanAction.Reserve)) + } + + @Test fun rulesAreReadOffTheAvailabilityPayload() { + assertEquals(ViewerRules(false, true), ViewerRules.of(AvailabilityCalendar(loanable = false))) + assertEquals(ViewerRules(true, true), ViewerRules.of(AvailabilityCalendar())) + } + + @Test fun aRefusalTeachesTheRuleSoTheButtonChangesAtOnce() { + assertEquals(ViewerRules(loanable = false), CirculationRules.rulesAfterRefusal(null, "not_loanable")) + assertEquals(ViewerRules(reservationsEnabled = false), CirculationRules.rulesAfterRefusal(ViewerRules(), "reservations_disabled")) + assertNull(CirculationRules.rulesAfterRefusal(null, "not_available")) + } + + @Test fun theNewConflictCodesHaveTheirOwnWording() { + assertEquals(R.string.request_err_not_loanable, CirculationRules.requestErrorRes(refusal("not_loanable"))) + assertEquals(R.string.request_err_reservations_disabled, CirculationRules.requestErrorRes(refusal("reservations_disabled"))) + assertEquals(R.string.request_err_reservation_limit, CirculationRules.requestErrorRes(refusal("reservation_limit_reached"))) + assertEquals(R.string.request_err_media_limit, CirculationRules.requestErrorRes(refusal("media_limit_reached"))) + assertEquals(R.string.request_err_max_loans, CirculationRules.requestErrorRes(refusal("max_loans_reached"))) + } + + @Test fun notEligibleNamesTheReasonWhenTheServerGivesIt() { + assertEquals(R.string.request_err_debt_block, CirculationRules.requestErrorRes(refusal("not_eligible", reason = "debt_block"))) + assertEquals(R.string.request_err_card_expired, CirculationRules.requestErrorRes(refusal("not_eligible", reason = "card_expired"))) + assertEquals(R.string.request_err_suspended, CirculationRules.requestErrorRes(refusal("not_eligible", reason = "user_suspended"))) + } + + @Test fun notEligibleWithoutAReasonKeepsTheServerSentence() { + // The patron route answers 403 not_eligible with the cause in the message only. + assertNull(CirculationRules.requestErrorRes(refusal("not_eligible", "Prestiti bloccati per debito.", status = 403))) + assertNull(CirculationRules.requestErrorRes(refusal("not_eligible", reason = "a_reason_this_app_does_not_know"))) + } + + @Test fun unknownCodesAndNotAvailableFallBackToTheServerMessage() { + assertNull(CirculationRules.requestErrorRes(refusal("some_future_code"))) + assertNull(CirculationRules.requestErrorRes(refusal("not_available", "Durata massima del prestito: 4 giorni"))) + assertNull(CirculationRules.requestErrorRes(refusal("duplicate"))) + } + + @Test fun aBare409FromAProxyStillReadsAsADateClash() { + val failure = parseErrorBody("conflict", 409) + assertEquals(R.string.snackbar_request_conflict, CirculationRules.requestErrorRes(failure)) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/ClosedDatesCalendarTest.kt b/app/src/test/java/com/pinakes/app/server090/ClosedDatesCalendarTest.kt new file mode 100644 index 0000000..0136128 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/ClosedDatesCalendarTest.kt @@ -0,0 +1,139 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.AvailabilityDay +import com.pinakes.app.ui.screens.detail.CalendarModel +import com.pinakes.app.ui.screens.detail.DayKind +import com.pinakes.app.ui.screens.detail.firstRequestableDay +import com.pinakes.app.ui.screens.detail.isClosed +import com.pinakes.app.ui.screens.detail.loanWindowFrom +import com.pinakes.app.ui.screens.detail.openDayFrom +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * Closures calendar (Mobile API 1.9.0 `closed_dates`): a closed day is never a start, gets + * its own colour, and a proposed return date always lands on an open day. + */ +class ClosedDatesCalendarTest { + private val today = LocalDate.parse("2030-03-01") + + private fun freeDays(from: String, count: Int) = (0 until count).map { + AvailabilityDay(date = LocalDate.parse(from).plusDays(it.toLong()).toString(), available = 1, state = "free") + } + + private fun calendar(closed: List, maxDuration: Int? = 90, full: List = emptyList()) = AvailabilityCalendar( + totalCopies = 1, + maxLoanDurationDays = maxDuration, + closedDates = closed, + days = freeDays("2030-03-01", 90).map { d -> if (d.date in full) d.copy(available = 0, state = "full") else d }, + ) + + @Test fun aClosedFutureDayHasItsOwnKindAndCannotBePicked() { + val model = CalendarModel(calendar(listOf("2030-03-05"))) + val day = LocalDate.parse("2030-03-05") + assertEquals(DayKind.Closed, model.kind(day, today)) + assertFalse(model.isSelectable(day, today)) + assertTrue(model.isSelectable(LocalDate.parse("2030-03-06"), today)) + } + + @Test fun aClosedDayInThePastIsJustPast() { + val model = CalendarModel(calendar(listOf("2030-02-27"))) + assertEquals(DayKind.PastOrUnknown, model.kind(LocalDate.parse("2030-02-27"), today)) + } + + @Test fun closedWinsOverFullyBookedForTheColour() { + val model = CalendarModel(calendar(listOf("2030-03-05"), full = listOf("2030-03-05"))) + assertEquals(DayKind.Closed, model.kind(LocalDate.parse("2030-03-05"), today)) + } + + @Test fun theAvailabilityPayloadReadsTheNewFieldsAndDefaultsOnOlderServers() { + val json = Json { ignoreUnknownKeys = true } + val new = json.decodeFromString( + """{"total_copies":1,"closed_dates":["2030-03-05","2030-03-12"],"loanable":false,"reservations_enabled":false}""", + ) + assertEquals(listOf("2030-03-05", "2030-03-12"), new.closedDates) + assertFalse(new.loanable) + assertFalse(new.reservationsEnabled) + val old = json.decodeFromString("""{"total_copies":1}""") + assertTrue(old.closedDates.isEmpty()) + assertTrue(old.loanable) + assertTrue(old.reservationsEnabled) + } + + @Test fun openDayFromWalksOverARunOfClosedDays() { + val cal = calendar(listOf("2030-03-09", "2030-03-10")) + assertEquals(LocalDate.parse("2030-03-11"), cal.openDayFrom(LocalDate.parse("2030-03-09"), forward = true)) + assertEquals(LocalDate.parse("2030-03-08"), cal.openDayFrom(LocalDate.parse("2030-03-10"), forward = false)) + assertNull(cal.openDayFrom(LocalDate.parse("2030-03-10"), forward = false, notBefore = LocalDate.parse("2030-03-09"))) + assertTrue(cal.isClosed("2030-03-09")) + } + + @Test fun thePreselectedStartSkipsClosedAndBookedDays() { + val cal = calendar(listOf("2030-03-02", "2030-03-03"), full = listOf("2030-03-04")) + assertEquals("2030-03-05", cal.firstRequestableDay("2030-03-02")) + // Nothing closed: the server's earliest day is kept as it is. + assertEquals("2030-03-02", calendar(emptyList()).firstRequestableDay("2030-03-02")) + assertNull(cal.firstRequestableDay(null)) + } + + @Test fun aDefaultReturnOnAClosedDayMovesToTheNextOpenDay() { + // 2030-03-04 + 14 = 2030-03-18 (closed), 2030-03-19 closed too → 2030-03-20. + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-18", "2030-03-19")), 14) + assertEquals("2030-03-20", window?.endDate) + assertEquals(16, window?.maxDays) + assertFalse(window!!.requiresEndDate) + assertFalse(window.capped) + } + + @Test fun anOlderServerWithoutTheMaximumKeepsTheDefaultReturn() { + // closed_dates only comes with Mobile API 1.9.0, which always sends the maximum. + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-18"), maxDuration = null), 14) + assertEquals("2030-03-18", window?.endDate) + assertFalse(window!!.requiresEndDate) + } + + @Test fun aReturnMovedOverAClosureNeverLandsOnABookedDay() { + // Default return 2030-03-18 is closed and 2030-03-19 is fully booked: the loan stops on + // the last open day before the booking, and the app sends it as end_date. + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-18"), full = listOf("2030-03-19")), 14) + assertEquals("2030-03-17", window?.endDate) + assertEquals("2030-03-19", window?.firstBlocked) + assertTrue(window!!.capped) + assertTrue(window.requiresEndDate) + assertEquals(13, window.maxDays) + } + + @Test fun aClosedAndBookedDefaultReturnCapsTheLoanBeforeIt() { + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-18", "2030-03-17"), full = listOf("2030-03-18")), 14) + assertEquals("2030-03-16", window?.endDate) + assertEquals("2030-03-18", window?.firstBlocked) + assertTrue(window!!.requiresEndDate) + } + + @Test fun aShortenedReturnOnAClosedDayMovesBackNotPastTheBookedDay() { + // Booked from 2030-03-10, so the return would be 2030-03-09, which is closed: 2030-03-08. + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-09"), full = listOf("2030-03-10")), 14) + assertEquals("2030-03-08", window?.endDate) + assertEquals("2030-03-10", window?.firstBlocked) + assertEquals(4, window?.maxDays) + assertTrue(window!!.requiresEndDate) + assertTrue(window.possible) + } + + @Test fun noOpenDayBeforeTheBookedOneMeansNoLoanFromThatStart() { + val window = loanWindowFrom("2030-03-04", calendar(listOf("2030-03-05", "2030-03-06"), full = listOf("2030-03-07")), 14) + assertFalse(window!!.possible) + } + + @Test fun withoutClosuresTheWindowIsUnchanged() { + val window = loanWindowFrom("2030-03-04", calendar(emptyList()), 14) + assertEquals("2030-03-18", window?.endDate) + assertEquals(14, window?.maxDays) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/ConsultationOnlyButtonTest.kt b/app/src/test/java/com/pinakes/app/server090/ConsultationOnlyButtonTest.kt new file mode 100644 index 0000000..08f2493 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/ConsultationOnlyButtonTest.kt @@ -0,0 +1,90 @@ +package com.pinakes.app.server090 + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.model.Availability +import com.pinakes.app.data.model.BookDetail +import com.pinakes.app.ui.screens.detail.DetailContent +import com.pinakes.app.ui.screens.detail.ViewerRules +import com.pinakes.app.ui.theme.PinakesTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * `loanable=false` / `reservations_enabled=false` (Pinakes 0.9.0): the loan button stays on the + * page, disabled, with the reason under it; unknown rules keep the usual, tappable button. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ConsultationOnlyButtonTest { + // Same RuleChain as DetailAvailabilityAlignmentTest: registers the host activity so the test + // also runs in the release unit tests (ui-test-manifest is a debug dependency). + private val compose = createComposeRule() + @get:Rule val rules: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app.packageManager).addActivityIfNotPresent(ComponentName(app.packageName, ComponentActivity::class.java.name)) + } + }).around(compose) + + private var taps = 0 + + private fun show(available: Boolean, rules: ViewerRules?) { + compose.setContent { + PinakesTheme { + DetailContent( + BookDetail( + title = "Reference book", + availability = Availability( + copiesTotal = 1, copiesAvailable = if (available) 1 else 0, loanableNow = available, + state = if (available) "available" else "on_loan", + ), + ), + wishlisted = false, reserveBusy = false, + canBorrow = true, showWishlist = false, showReviews = false, + onReserve = { taps++ }, onToggleWishlist = {}, onShowMessage = {}, + rules = rules, + ) + } + } + } + + @Test fun consultationOnlyDisablesTheButtonAndSaysWhy() { + show(available = true, rules = ViewerRules(loanable = false)) + compose.onNodeWithTag("detail-loan").assertIsNotEnabled() + compose.onNodeWithTag("detail-loan-reason", useUnmergedTree = true).assertExists() + compose.onNodeWithTag("detail-loan").performClick() + assertEquals(0, taps) + } + + @Test fun aClosedWaitlistDisablesReserveWhenNoCopyIsFree() { + show(available = false, rules = ViewerRules(reservationsEnabled = false)) + compose.onNodeWithTag("detail-loan").assertIsNotEnabled() + compose.onNodeWithTag("detail-loan-reason", useUnmergedTree = true).assertExists() + } + + @Test fun unknownRulesKeepTheUsualButton() { + show(available = true, rules = null) + compose.onNodeWithTag("detail-loan").assertIsEnabled() + compose.onNodeWithTag("detail-loan-reason", useUnmergedTree = true).assertDoesNotExist() + compose.onNodeWithTag("detail-loan").performClick() + assertEquals(1, taps) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt b/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt new file mode 100644 index 0000000..80f869b --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt @@ -0,0 +1,109 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.HealthFeatures +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.store.InstanceFeatures +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** `/health.features.{fines,closures_calendar,circulation_rules}` (Pinakes 0.9.0) → app flags. */ +class FeatureFlags090Test { + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + + private fun health(features: String, catalogueMode: Boolean = false) = json.decodeFromString( + """{"name":"Lib","app_access_enabled":true,"catalogue_mode":$catalogueMode,"features":{"catalog":true,"loans":true,"reservations":true,$features}}""", + ) + + @Test fun anOlderServerWithoutTheKeysLeavesAllThreeOff() { + val f = InstanceFeatures.from(health(""""wishlist":true""")) + assertFalse(f.finesAvailable) + assertFalse(f.closuresCalendarAvailable) + assertFalse(f.circulationRulesAvailable) + assertFalse(f.showMovements) + } + + @Test fun aServerAdvertisingTheFlagsTurnsThemOn() { + val f = InstanceFeatures.from(health(""""fines":true,"closures_calendar":true,"circulation_rules":true""")) + assertTrue(f.finesAvailable) + assertTrue(f.closuresCalendarAvailable) + assertTrue(f.circulationRulesAvailable) + assertTrue(f.showMovements) + } + + @Test fun eachFlagIsReadOnItsOwn() { + val f = InstanceFeatures.from(health(""""fines":false,"closures_calendar":true,"circulation_rules":false""")) + assertFalse(f.finesAvailable) + assertTrue(f.closuresCalendarAvailable) + assertFalse(f.circulationRulesAvailable) + } + + @Test fun catalogueModeWinsOverAnyAdvertisedFlag() { + val f = InstanceFeatures.from(health(""""fines":true,"closures_calendar":true,"circulation_rules":true""", catalogueMode = true)) + assertFalse(f.finesAvailable) + assertFalse(f.closuresCalendarAvailable) + assertFalse(f.circulationRulesAvailable) + assertFalse(f.showMovements) + } + + @Test fun appAccessOffHidesTheThreeAreasLikeEveryOtherArea() { + val payload = json.decodeFromString( + """{"app_access_enabled":false,"features":{"loans":true,"fines":true,"closures_calendar":true,"circulation_rules":true}}""", + ) + val f = InstanceFeatures.from(payload) + assertFalse(f.finesAvailable) + assertFalse(f.closuresCalendarAvailable) + assertFalse(f.circulationRulesAvailable) + assertFalse(f.showMovements) + } + + @Test fun consultationOnlyIsReadOnItsOwnSwitch() { + // Pinakes 0.9.0: consultation-only copies have their own switch, so a title can be + // consultation only with the patron-type rules off. + val f = InstanceFeatures.from(health(""""circulation_rules":false,"consultation_only":true""")) + assertFalse(f.circulationRulesAvailable) + assertTrue(f.consultationOnlyAvailable) + assertTrue(f.viewerRulesAvailable) + } + + @Test fun theBookPageAsksForTheReaderRulesWithEitherSwitch() { + assertTrue(InstanceFeatures.from(health(""""circulation_rules":true""")).viewerRulesAvailable) + assertFalse(InstanceFeatures.from(health(""""circulation_rules":false,"consultation_only":false""")).viewerRulesAvailable) + // A server from before the split omits consultation_only: circulation_rules alone decides. + assertFalse(InstanceFeatures.from(health(""""wishlist":true""")).viewerRulesAvailable) + } + + @Test fun consultationOnlyFollowsCatalogueModeAndAppAccess() { + assertFalse(InstanceFeatures.from(health(""""consultation_only":true""", catalogueMode = true)).consultationOnlyAvailable) + val noApp = json.decodeFromString("""{"app_access_enabled":false,"features":{"loans":true,"consultation_only":true}}""") + assertFalse(InstanceFeatures.from(noApp).viewerRulesAvailable) + } + + @Test fun theMovementsPageNeedsLoansToo() { + val f = InstanceFeatures(finesAvailable = true, loans = false) + assertFalse(f.showMovements) + assertTrue(InstanceFeatures(finesAvailable = true, loans = true).showMovements) + } + + @Test fun theDefaultsBeforeAnyHealthKeepTheNewAreasHidden() { + val f = InstanceFeatures.AllEnabled + assertTrue(f.loans) + assertFalse(f.finesAvailable) + assertFalse(f.showMovements) + } + + @Test fun theMappingKeepsThePluginProbesAndTheExistingFlags() { + val payload = HealthPayload( + appAccessEnabled = true, + features = HealthFeatures(loans = true, staffCirculation = true, calendarFeed = true, fines = true), + ) + val f = InstanceFeatures.from(payload, bookClub = true, periodicals = false) + assertTrue(f.bookClubAvailable) + assertFalse(f.periodicalsAvailable) + assertTrue(f.staffCirculationAvailable) + assertTrue(f.calendarFeedAvailable) + assertEquals(true, f.finesAvailable) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt b/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt new file mode 100644 index 0000000..c972a31 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/MovementsTest.kt @@ -0,0 +1,130 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.R +import com.pinakes.app.data.model.MovementsPayload +import com.pinakes.app.data.model.UserProfile +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.screens.movements.BalanceKind +import com.pinakes.app.ui.screens.movements.MoneyFormat +import com.pinakes.app.ui.screens.movements.MovementsUiState +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Locale + +/** `GET /me/movements` (Mobile API 1.9.0): parsing, the page state and money formatting. */ +class MovementsTest { + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + + private val serverBody = """{ + "currency":"EUR","balance_cents":450,"balance":"4.50","accrued_cents":120, + "accrued":[{"loan_id":9,"title":"Il nome della rosa","due_at":"2030-03-01","days_late":6,"amount_cents":120}], + "debt_block_threshold_cents":300,"debt_blocked":true, + "items":[{"id":2,"type":"pagamento","type_label":"Pagamento","amount_cents":-200,"amount":"-2.00","description":"Contanti","payment_method":"contanti","loan_id":null,"book_title":null,"created_at":"2030-03-05 10:00:00"}, + {"id":1,"type":"multa_ritardo","type_label":"Multa per ritardo","amount_cents":650,"amount":"6.50","description":"Ritardo","loan_id":7,"book_title":"Emma","created_at":"2030-03-04 09:00:00"}] + }""" + + @Test fun theServerPayloadIsReadInFull() { + val p = json.decodeFromString(serverBody) + assertEquals("EUR", p.currency) + assertEquals(450, p.balanceCents) + assertTrue(p.debtBlocked) + assertEquals(300, p.debtBlockThresholdCents) + assertEquals(2, p.items.size) + assertEquals(-200, p.items[0].amountCents) + assertNull(p.items[0].loanId) + assertEquals(7, p.items[1].loanId) + assertEquals(6, p.accrued.single().daysLate) + } + + @Test fun theAccruedTotalFallsBackToTheRowsOnlyWhenAbsent() { + val rowsOnly = json.decodeFromString("""{"accrued":[{"loan_id":1,"amount_cents":120},{"loan_id":2,"amount_cents":30}]}""") + assertEquals(150, rowsOnly.accruedTotalCents) + val explicit = json.decodeFromString("""{"accrued_cents":0,"accrued":[{"loan_id":1,"amount_cents":120}]}""") + assertEquals(0, explicit.accruedTotalCents) + assertEquals(120, json.decodeFromString(serverBody).accruedTotalCents) + } + + @Test fun theDecimalBalanceIsTheFallbackForAMissingBalanceCents() { + assertEquals(450, json.decodeFromString("""{"balance":"4.50"}""").balanceTotalCents) + assertEquals(-200, json.decodeFromString("""{"balance":"-2,00"}""").balanceTotalCents) + assertEquals(0, json.decodeFromString("""{"balance":"n/a"}""").balanceTotalCents) + // balance_cents wins when both are there. + assertEquals(300, json.decodeFromString("""{"balance_cents":300,"balance":"9.99"}""").balanceTotalCents) + } + + @Test fun thePlannedNameMovementsIsReadToo() { + val p = json.decodeFromString("""{"balance_cents":0,"movements":[{"id":5,"type":"condono","amount_cents":-100}]}""") + assertEquals(5, p.items.single().id) + } + + @Test fun aBarePayloadMissingEveryFieldStillParses() { + val p = json.decodeFromString("{}") + assertEquals(0, p.balanceTotalCents) + assertFalse(p.debtBlocked) + assertNull(p.debtBlockThresholdCents) + assertTrue(p.items.isEmpty()) + } + + @Test fun featureDisabledClosesThePageWithoutAnError() { + val s = MovementsUiState.after(MovementsUiState(), ApiResult.Failure("feature_disabled", "Funzione non disponibile", 404, fromEnvelope = true)) + assertTrue(s.disabled) + assertTrue(s.content is UiState.Loading) + } + + @Test fun aServerWithoutTheRouteIsTreatedAsDisabled() { + assertTrue(MovementsUiState.after(MovementsUiState(), ApiResult.Failure("not_found", "Not found.", 404)).disabled) + } + + @Test fun aServerErrorIsShownWithItsFallback() { + val s = MovementsUiState.after(MovementsUiState(), ApiResult.Failure("internal_error", "", 500)) + assertFalse(s.disabled) + assertEquals(R.string.movements_error_load, (s.content as UiState.Error).messageRes) + } + + @Test fun aFailedRefreshKeepsThePageOnScreenAndSaysSo() { + val loaded = MovementsUiState(content = UiState.Success(MovementsPayload(balanceCents = 100)), refreshing = true) + val s = MovementsUiState.after(loaded, ApiResult.Failure("network", "", 0)) + assertEquals(100, (s.content as UiState.Success).data.balanceCents) + assertFalse(s.refreshing) + assertTrue(s.refreshFailed) + // The next successful refresh clears it. + assertFalse(MovementsUiState.after(s, ApiResult.Success(MovementsPayload())).refreshFailed) + } + + @Test fun rowsWithoutIdsGetDistinctKeys() { + val p = json.decodeFromString("""{"items":[{"type":"pagamento"},{"type":"condono"},{"id":4,"type":"storno"}],"accrued":[{},{}]}""") + val keys = p.items.mapIndexed { i, m -> MoneyFormat.rowKey("movement", i, m.id) } + + p.accrued.mapIndexed { i, a -> MoneyFormat.rowKey("accrued", i, a.loanId) } + assertEquals(keys.size, keys.toSet().size) + assertEquals("movement-4", keys[2]) + } + + @Test fun theBalanceSignPicksTheWording() { + assertEquals(BalanceKind.Owed, MoneyFormat.balanceKind(1)) + assertEquals(BalanceKind.Settled, MoneyFormat.balanceKind(0)) + assertEquals(BalanceKind.Credit, MoneyFormat.balanceKind(-50)) + } + + @Test fun amountsAreFormattedInTheLibraryCurrency() { + val it = MoneyFormat.format(450, "EUR", Locale.ITALY) + assertTrue(it, it.contains("4,50") && it.contains("€")) + val us = MoneyFormat.format(123456, "usd", Locale.US) + assertEquals("$1,234.56", us) + } + + @Test fun anUnknownCurrencyIsAppendedNotLost() { + assertEquals("4.50 €", MoneyFormat.format(450, "€", Locale.US)) + assertEquals("4.50", MoneyFormat.format(450, null, Locale.US)) + } + + @Test fun theProfileReadsThePatronTypeWhenPresent() { + val withType = json.decodeFromString("""{"id":1,"patron_type":{"code":"student","name":"Studente"}}""") + assertEquals("Studente", withType.patronType?.name) + assertNull(json.decodeFromString("""{"id":1}""").patronType) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090CatalogueFormsTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090CatalogueFormsTest.kt new file mode 100644 index 0000000..8cdecdd --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090CatalogueFormsTest.kt @@ -0,0 +1,230 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.R +import com.pinakes.app.data.model.IdName +import com.pinakes.app.data.model.RdaEffective +import com.pinakes.app.data.model.StaffBook +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.model.Supplier +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.repository.Staff090CataloguePaths +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.ui.common.serverMessage +import com.pinakes.app.data.staff.BookCataloguing +import com.pinakes.app.data.staff.CataloguingModules +import com.pinakes.app.data.staff.CopyCatalogingRules +import com.pinakes.app.data.staff.CopyImportSubmission +import com.pinakes.app.data.staff.MarcImportRules +import com.pinakes.app.data.staff.Staff090CatalogueErrors +import com.pinakes.app.data.staff.StaffBookForm +import com.pinakes.app.data.staff.StaffBookWrite +import com.pinakes.app.data.staff.SubmissionKeys +import com.pinakes.app.data.staff.SupplierControls +import com.pinakes.app.data.staff.SupplierField +import com.pinakes.app.data.staff.SupplierForm +import com.pinakes.app.data.staff.SupplierProblem +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The pure rules of the cataloguing screens: forms, request bodies, limits, error wording. */ +class Staff090CatalogueFormsTest { + + private val stored = Supplier( + id = 21, name = "Libreria Cat090 Prova", contactPerson = "Mario Rossi", email = "ordini@example.org", phone = "+39 02 1234", + website = "https://www.example.org", address = "Via Roma 1, Milano", vatNumber = "IT01234567890", notes = "Consegna il martedì", + canEdit = true, canDelete = true, + ) + + // ---------- suppliers ---------- + + @Test fun aSupplierNeedsANameAndAPlausibleEmail() { + assertEquals(mapOf(SupplierField.NAME to SupplierProblem.REQUIRED), SupplierForm(name = " ").validate()) + assertEquals(SupplierProblem.INVALID, SupplierForm(name = "A", email = "x@").validate()[SupplierField.EMAIL]) + assertEquals(SupplierProblem.INVALID, SupplierForm(name = "A", website = "not a url").validate()[SupplierField.WEBSITE]) + // A bare domain is fine: the server adds https://. + assertTrue(SupplierForm(name = "A", email = "a@b.it", website = "www.example.org").valid) + } + + @Test fun typedTextNeverExceedsTheColumn() { + val form = SupplierForm().with(SupplierField.VAT_NUMBER, "X".repeat(30)) + assertEquals(20, form.vatNumber.length) + assertEquals(255, SupplierField.NAME.maxLength) + } + + @Test fun createSendsTheNameAndTheFilledFieldsTrimmed() { + val body = SupplierForm(name = " Libreria ", email = "a@b.it ", notes = "").createJson() + assertEquals(buildJsonObject { put("name", JsonPrimitive("Libreria")); put("email", JsonPrimitive("a@b.it")) }, body) + } + + @Test fun updateSendsOnlyChangesAndNullForACleared() { + val form = SupplierForm.from(stored).copy(phone = "", notes = "Aggiornato") + assertEquals(buildJsonObject { put("phone", JsonNull); put("notes", JsonPrimitive("Aggiornato")) }, form.patchJson(stored)) + assertTrue(SupplierForm.from(stored).patchJson(stored).isEmpty()) + } + + @Test fun serverFieldKeysMapToFormFields() { + assertEquals(SupplierField.VAT_NUMBER, SupplierForm.fieldOf("vat_number")) + assertEquals(SupplierField.CONTACT_PERSON, SupplierForm.fieldOf("contact_person")) + assertNull(SupplierForm.fieldOf("idempotency_key")) + } + + @Test fun supplierControlsFollowTheServer() { + assertEquals(SupplierControls(create = true, edit = true, delete = true), SupplierControls.of(true, stored)) + assertEquals(SupplierControls(create = false, edit = false, delete = false), SupplierControls.of(false, stored.copy(canEdit = false, canDelete = false))) + assertFalse(SupplierControls.of(false, null).edit) + } + + @Test fun aRetriedCreateKeepsItsIdempotencyKey() { + var n = 0 + val keys = SubmissionKeys { "k${++n}" } + val form = SupplierForm(name = "Libreria") + assertEquals("k1", keys.keyFor(form)) + assertEquals("k1", keys.keyFor(form.copy())) + assertEquals("k2", keys.keyFor(form.copy(phone = "1"))) + } + + // ---------- book form ---------- + + private val lookupsOn = StaffLookups( + suppliers = listOf(IdName(21, "Libreria"), IdName(22, "Donatore")), + rdaContentTypes = listOf(JsonPrimitive("txt")), rdaMediaTypes = listOf(JsonPrimitive("n")), rdaCarrierTypes = listOf(JsonPrimitive("nc")), + ) + + private val book = StaffBook( + id = 9, title = "Libro", supplierId = 21, supplier = IdName(21, "Libreria"), + rdaContentType = "spw", rdaMediaType = null, rdaCarrierType = "cd", rdaEffective = RdaEffective("spw", "n", "cd"), + ) + + @Test fun modulesComeFromTheLookups() { + assertEquals(CataloguingModules(suppliers = true, rda = true), CataloguingModules.from(lookupsOn)) + assertEquals(CataloguingModules.Off, CataloguingModules.from(StaffLookups())) + assertEquals(CataloguingModules(suppliers = true, rda = false), CataloguingModules.from(StaffLookups(suppliers = emptyList()))) + } + + @Test fun theFormReadsTheBooksValues() { + assertEquals(BookCataloguing(supplierId = 21, rdaContentType = "spw", rdaMediaType = null, rdaCarrierType = "cd"), StaffBookForm.from(book).cataloguing) + } + + @Test fun createSendsSupplierAndOverridesOnlyWithTheModulesOn() { + val form = StaffBookForm(title = "Libro", cataloguing = BookCataloguing(supplierId = 21, rdaContentType = "spw")) + val on = StaffBookWrite.create(form, CataloguingModules.from(lookupsOn)) + assertEquals(JsonPrimitive(21), on["supplier_id"]) + assertEquals(JsonPrimitive("spw"), on["rda_content_type"]) + assertFalse("derived is never sent on create", on.containsKey("rda_media_type")) + val off = StaffBookWrite.create(form) + assertFalse(off.containsKey("supplier_id")) + assertFalse(off.containsKey("rda_content_type")) + } + + @Test fun updateSendsChangesAndNullToClear() { + val modules = CataloguingModules.from(lookupsOn) + val form = StaffBookForm.from(book).let { it.copy(cataloguing = it.cataloguing.copy(supplierId = 22, rdaContentType = null)) } + val patch = StaffBookWrite.update(book, form, modules) + assertEquals(JsonPrimitive(22), patch["supplier_id"]) + assertEquals(JsonNull, patch["rda_content_type"]) + assertFalse(patch.containsKey("rda_carrier_type")) + assertFalse(patch.containsKey("title")) + // Unchanged: an empty patch, so the screen does not call the server at all. + assertTrue(StaffBookWrite.update(book, StaffBookForm.from(book), modules).isEmpty()) + // Modules off: the keys stay out even when the values differ. + assertTrue(StaffBookWrite.update(book, form, CataloguingModules.Off).isEmpty()) + val cleared = StaffBookWrite.update(book, StaffBookForm.from(book).let { it.copy(cataloguing = it.cataloguing.copy(supplierId = null)) }, modules) + assertEquals(JsonNull, cleared["supplier_id"]) + } + + // ---------- MARC ---------- + + @Test fun marcFileNamesAndEncoding() { + assertTrue(MarcImportRules.acceptsFileName("export.MRC", emptyList())) + assertTrue(MarcImportRules.acceptsFileName("records.xml", listOf("mrc", "xml"))) + assertFalse(MarcImportRules.acceptsFileName("books.csv", listOf("mrc", "xml"))) + assertFalse(MarcImportRules.acceptsFileName("noextension", emptyList())) + assertTrue(MarcImportRules.encodingApplies("a.mrc")) + assertTrue(MarcImportRules.encodingApplies(null)) + assertFalse(MarcImportRules.encodingApplies("a.marcxml")) + } + + @Test fun downloadPaths() { + assertEquals("staff/books/9/catalogue-card.pdf", Staff090CataloguePaths.catalogueCard(9)) + assertEquals("staff/books/catalogue-cards.pdf?ids=3,1", Staff090CataloguePaths.catalogueCards(listOf(3, 0, 1, 3))) + assertNull(Staff090CataloguePaths.catalogueCards(listOf(0, -1))) + assertEquals(200, Staff090CataloguePaths.catalogueCards((1..250).toList())!!.substringAfter("ids=").split(',').size) + assertEquals("staff/marc/export?format=marcxml", Staff090CataloguePaths.marcExport("marcxml", null)) + assertEquals("staff/marc/export?format=marc21&ids=5,6", Staff090CataloguePaths.marcExport("pdf", listOf(5, 6, 5))) + assertNull(Staff090CataloguePaths.marcExport("marc21", emptyList())) + assertNull(Staff090CataloguePaths.marcExport("marc21", (1..1001).toList())) + assertEquals("application/marcxml+xml", Staff090CataloguePaths.exportMime("marcxml")) + assertEquals("application/marc", Staff090CataloguePaths.exportMime("unimarc")) + } + + // ---------- copy cataloguing ---------- + + @Test fun searchNeedsACatalogueATermAndAValidMax() { + assertTrue(CopyCatalogingRules.canSearch(setOf("local"), "Eco", 10)) + assertFalse(CopyCatalogingRules.canSearch(emptySet(), "Eco", 10)) + assertFalse(CopyCatalogingRules.canSearch(setOf("local"), " ", 10)) + assertFalse(CopyCatalogingRules.canSearch(setOf("local"), "Eco", 26)) + assertFalse(CopyCatalogingRules.canSearch(setOf("local"), "x".repeat(201), 10)) + } + + @Test fun selectionStopsAtTheImportLimit() { + val two = CopyCatalogingRules.toggle(CopyCatalogingRules.toggle(emptySet(), "a", limit = 2), "b", limit = 2) + assertEquals(setOf("a", "b"), two) + assertEquals(two, CopyCatalogingRules.toggle(two, "c", limit = 2)) + assertEquals(setOf("b"), CopyCatalogingRules.toggle(two, "a", limit = 2)) + } + + @Test fun aScannedBarcodeBecomesASearch() { + assertEquals("isbn" to "9788806207823", CopyCatalogingRules.fromScan("9788806207823")) + // ISSN 0317-8471 printed as an EAN-13 (977 + 7 digits + 00 + check). + assertEquals("issn" to "0317-8471", CopyCatalogingRules.fromScan("9770317847001")) + assertEquals("any" to "4006381333931", CopyCatalogingRules.fromScan("4006381333931")) + assertNull(CopyCatalogingRules.fromScan("9788806207824")) // bad check digit + assertNull(CopyCatalogingRules.fromScan("12345")) + assertEquals("2434-561X", CopyCatalogingRules.issnOf("2434561")) + } + + @Test fun theSameSelectionIsTheSameSubmission() { + val keys = SubmissionKeys() + val first = keys.keyFor(CopyImportSubmission("s", listOf("a", "b"), "skip", false)) + assertEquals(first, keys.keyFor(CopyImportSubmission("s", listOf("a", "b"), "skip", false))) + assertNotEquals(first, keys.keyFor(CopyImportSubmission("s", listOf("a", "b"), "overwrite", false))) + } + + // ---------- errors ---------- + + @Test fun cataloguingCodesHaveOurWording() { + fun f(code: String, status: Int) = parseErrorBody("""{"data":null,"meta":{},"error":{"code":"$code","message":"Server text"}}""", status) + assertEquals(R.string.staff090_catalogue_err_schema_missing, Staff090CatalogueErrors.messageRes(f("schema_missing", 409))) + assertEquals(R.string.staff090_catalogue_err_copy_unavailable, Staff090CatalogueErrors.messageRes(f("copy_cataloging_unavailable", 404))) + assertEquals(R.string.staff090_catalogue_err_job_cancelled, Staff090CatalogueErrors.messageRes(f("job_cancelled", 409))) + // 410: an expired search for copy cataloguing, the import's own wording elsewhere. + assertEquals(R.string.staff090_catalogue_err_search_gone, Staff090CatalogueErrors.messageRes(f("gone", 410), copyCataloguing = true)) + assertNull(Staff090CatalogueErrors.messageRes(f("gone", 410))) + // A validation error keeps the server's text, which names the field. + assertNull(Staff090CatalogueErrors.messageRes(f("validation", 422))) + assertNull(Staff090CatalogueErrors.messageRes(ApiResult.Failure("network", "x"))) + } + + @Test fun notPermittedShowsTheServerTextAndKeepsTheRole() { + val f = parseErrorBody("""{"data":null,"meta":{},"error":{"code":"not_permitted","message":"Permesso mancante."}}""", 403) + assertNull(Staff090CatalogueErrors.messageRes(f)) + assertFalse(f.isForbidden()) + assertEquals("Permesso mancante.", f.serverMessage()) + assertTrue(parseErrorBody("""{"data":null,"meta":{},"error":{"code":"forbidden","message":"x"}}""", 403).isForbidden()) + } + + @Test fun unavailabilityReason() { + assertEquals(R.string.staff090_catalogue_copy_reason_plugin, Staff090CatalogueErrors.copyCatalogingReasonRes("plugin_inactive")) + assertEquals(R.string.staff090_catalogue_err_copy_unavailable, Staff090CatalogueErrors.copyCatalogingReasonRes(null)) + assertEquals(R.string.staff090_catalogue_err_copy_unavailable, Staff090CatalogueErrors.copyCatalogingReasonRes("something_new")) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090CatalogueModelsTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090CatalogueModelsTest.kt new file mode 100644 index 0000000..9657e2f --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090CatalogueModelsTest.kt @@ -0,0 +1,267 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.CopyCatalogingImport +import com.pinakes.app.data.model.CopyCatalogingSearch +import com.pinakes.app.data.model.CopyCatalogingServers +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.Envelope090 +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.model.MarcCancelResult +import com.pinakes.app.data.model.MarcImportJob +import com.pinakes.app.data.model.MarcOptions +import com.pinakes.app.data.model.StaffBook +import com.pinakes.app.data.model.StaffBookRow +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.model.Supplier +import com.pinakes.app.data.model.SupplierDeleteResult +import com.pinakes.app.data.model.fieldErrors +import com.pinakes.app.data.model.metaBoolean +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.repository.Staff090CatalogueRepository +import com.pinakes.app.data.staff.ChunkStep +import com.pinakes.app.data.staff.ImportProgress +import com.pinakes.app.data.staff.MarcImportRules +import com.pinakes.app.data.staff.StaffAreas +import com.pinakes.app.data.store.InstanceFeatures +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The cataloguing payloads of Mobile API 1.10.0 decode with the app's Json settings. Every + * sample under `resources/server090/catalogue` is a body the slot server (Pinakes 0.9.0-rc.1, + * branch feat/mobile-staff-090) answered to curl on 2026-11-01. + */ +class Staff090CatalogueModelsTest { + // Same configuration as NetworkModule.json. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + private fun sample(name: String): String = + requireNotNull(javaClass.getResourceAsStream("/server090/catalogue/$name.json")) { name }.bufferedReader().use { it.readText() } + + private inline fun data(name: String): T = requireNotNull(json.decodeFromString>(sample(name)).data) + + // ---------- suppliers ---------- + + @Test fun supplierListCarriesCanCreateInItsMeta() { + val envelope = json.decodeFromString>>(sample("suppliers")) + val s = envelope.data!!.single() + assertEquals(21, s.id) + assertEquals("Libreria Cat090 Prova", s.name) + assertEquals("https://www.example.org", s.website) // the server adds the scheme + assertEquals("IT01234567890", s.vatNumber) + assertEquals(0, s.bookCount) + assertTrue(s.canEdit && s.canDelete) + assertEquals(true, envelope.meta.metaBoolean("can_create")) + } + + @Test fun createdUpdatedAndReplayedSuppliers() { + val created = data("supplier_created") + assertEquals("Mario Rossi", created.contactPerson) + assertEquals("+39 02 1234", created.phone) + val updated = data("supplier_updated") + assertNull(updated.phone) // null cleared it + assertEquals("Aggiornato", updated.notes) + val replay = json.decodeFromString>(sample("supplier_replayed")) + assertEquals(true, replay.meta?.replayed) + assertEquals(created.id, replay.data?.id) + } + + @Test fun supplierValidationErrorsAreReadFieldByField() { + val body = sample("supplier_422") + val failure = parseErrorBody(body, 422) + assertEquals("validation", failure.code) + assertEquals("vat_number", failure.meta?.field) + val fields = Staff090CatalogueRepository.metaOf(json, body).fieldErrors() + assertEquals(setOf("vat_number", "name", "email", "website"), fields.keys) + assertTrue(fields.getValue("name").isNotBlank()) + } + + @Test fun supplierDeleteSaysHowManyBooksLostTheLink() { + val d = data("supplier_deleted") + assertEquals(21, d.id) + assertTrue(d.deleted) + assertEquals(2, d.booksUnlinked) + } + + // ---------- book fields ---------- + + @Test fun lookupsCarryTheSupplierAndRdaListsWhileTheModulesAreOn() { + val l = json.decodeFromString>(sample("lookups_full")).data!! + assertEquals("Libreria Cat090 Prova", l.suppliers!!.single { it.id == 21 }.name) + assertEquals("spw", StaffLookups.options(l.rdaContentTypes!!)[1].value) + assertTrue(StaffLookups.options(l.rdaMediaTypes!!).any { it.value == "s" }) + assertTrue(StaffLookups.options(l.rdaCarrierTypes!!).any { it.value == "nc" }) + // A server with the modules off sends none of the keys. + val off = json.decodeFromString("""{"languages":[]}""") + assertNull(off.suppliers) + assertNull(off.rdaContentTypes) + } + + @Test fun staffBookReadsSupplierRdaOverridesAndTheEffectiveTriple() { + val b = json.decodeFromString>(sample("book_created")).data!! + assertEquals(21, b.supplierId) + assertEquals("Libreria Cat090 Prova", b.supplier?.name) + assertEquals("spw", b.rdaContentType) + assertNull(b.rdaMediaType) + assertEquals("cd", b.rdaCarrierType) + assertEquals("n", b.rdaEffective?.mediaType) // derived from tipo_media = libro + assertEquals("spw", b.rdaEffective?.contentType) + val detail = json.decodeFromString>(sample("book_detail")).data!! + assertEquals(b.id, detail.id) + assertEquals(21, detail.supplierId) + } + + @Test fun bookListFilteredBySupplier() { + val e = json.decodeFromString>>(sample("books_by_supplier")) + assertEquals(listOf(338681), e.data!!.map { it.id }) + assertEquals(false, e.meta?.hasMore) + } + + // ---------- MARC ---------- + + @Test fun marcOptions() { + val o = data("marc_options") + assertEquals(listOf("auto", "marc21", "unimarc", "danmarc2"), o.flavours.map { it.value }) + assertEquals(listOf("skip", "fill", "overwrite"), o.duplicateModes.map { it.value }) + assertTrue(o.duplicateModes.all { it.description.isNotBlank() }) + assertEquals(listOf("marc21", "marcxml", "unimarc"), o.exportFormats.map { it.value }) + assertEquals(20L * 1024 * 1024, o.maxFileBytes) + assertEquals(1000, o.maxExportIds) + assertEquals(21, o.suppliers!!.single().id) + assertTrue(o.copyCataloging.available) + assertNull(o.copyCataloging.reason) + val off = data("marc_options_copy_unavailable").copyCataloging + assertFalse(off.available) + assertEquals("plugin_inactive", off.reason) + // Suppliers module off: null, not an empty list. + assertNull(json.decodeFromString("""{"suppliers":null}""").suppliers) + } + + @Test fun uploadedJobShowsItsPreviewAndIsNotFinished() { + val job = data("marc_job_ready") + assertEquals("marc", job.kind) + assertEquals("ready", job.status) + assertEquals(1, job.totalRows) + assertEquals(21, job.supplierId) + assertEquals("iso2709", job.container) + val row = job.preview.single() + assertEquals("Libro Cat090 Prova", row.title) + assertEquals("marc21", row.flavour) + assertNull(row.existingId) + assertNull(row.error) + // The CSV loop drives it unchanged. + assertFalse(ImportProgress.isFinished(job.asImportJob())) + assertEquals(ChunkStep.Next(0), ImportProgress.next(job.asImportJob())) + assertEquals(0, ImportProgress.percent(job.asImportJob())) + } + + @Test fun completedJobEndsTheLoop() { + val job = data("marc_job_completed") + assertTrue(job.complete) + assertEquals(1, job.imported) + assertEquals(0, job.skipped) + assertEquals("fcba5bd46c2dbafb057abe13d8615218e30c", job.importId) + assertTrue(ImportProgress.isFinished(job.asImportJob())) + assertEquals(ChunkStep.Done, ImportProgress.next(job.asImportJob())) + assertEquals(100, ImportProgress.percent(job.asImportJob())) + assertTrue(job.errorLines.isEmpty()) + } + + @Test fun outOfOrderResumesFromTheServersPositionAndGoneStops() { + val outOfOrder = parseErrorBody(sample("marc_chunk_409"), 409) + assertEquals("out_of_order", outOfOrder.code) + assertEquals(ChunkStep.Next(0), ImportProgress.afterFailure(outOfOrder, start = 1, busyAttempts = 0)) + val gone = parseErrorBody(sample("marc_chunk_410"), 410) + assertEquals("gone", gone.code) + assertTrue(ImportProgress.afterFailure(gone, start = 1, busyAttempts = 0) is ChunkStep.Stop) + val cancelled = parseErrorBody("""{"data":null,"meta":{"processed":4},"error":{"code":"job_cancelled","message":"x"}}""", 409) + assertTrue(ImportProgress.afterFailure(cancelled, start = 4, busyAttempts = 0) is ChunkStep.Stop) + } + + @Test fun uploadValidationAndCancel() { + val body = sample("marc_upload_422") + assertEquals("flavour", parseErrorBody(body, 422).meta?.field) + assertEquals(setOf("flavour", "duplicates"), Staff090CatalogueRepository.metaOf(json, body).fieldErrors().keys) + val c = data("marc_cancelled") + assertTrue(c.cancelled) + assertEquals("ce1b2d36-6abc-4aa9-91fc-8fea6d95e644", c.id) + } + + @Test fun jobListOffersOnlyTheOpenJobs() { + val e = json.decodeFromString>>(sample("marc_jobs")) + val jobs = e.data!! + assertEquals(listOf("ready", "cancelled"), jobs.map { it.status }) // newest first, every status + assertEquals(false, e.meta?.hasMore) + val open = MarcImportRules.openJobs(jobs, shownId = null) + assertEquals(listOf("3232bb79-91dd-427d-afae-ce574c49af04"), open.map { it.id }) + assertEquals("altro-dispositivo.mrc", open.single().fileName) + assertEquals("fill", open.single().duplicates) + // The job already on screen is not offered twice. + assertTrue(MarcImportRules.openJobs(jobs, shownId = "3232bb79-91dd-427d-afae-ce574c49af04").isEmpty()) + val page = json.decodeFromString>>(sample("marc_jobs_page1")) + assertEquals("eyJvIjoxfQ", page.meta?.nextCursor) + assertEquals(true, page.meta?.hasMore) + } + + // ---------- copy cataloguing ---------- + + @Test fun copyCatalogingServers() { + val s = data("cc_servers") + assertTrue(s.servers.any { it.key == "local" && it.kind == "local" }) + assertTrue(s.servers.any { it.key == "preset:loc" && it.kind == "preset" }) + assertEquals(listOf("title", "author", "isbn", "issn", "any"), s.indexes.map { it.value }) + assertEquals(25, s.maxResultsPerServer) + assertEquals(100, s.maxImportIds) + } + + @Test fun copyCatalogingSearch() { + val s = data("cc_search") + assertEquals("53248631-5faf-463d-8da2-09b7f326a9f3", s.searchId) + assertEquals(2, s.results.size) + val first = s.results.first() + assertTrue(Regex("^[a-f0-9]{16}$").matches(first.id)) + assertEquals("Libro Cat090 Prova", first.title) + assertTrue(first.detail.any { it.tag == "245" }) + val report = s.servers.single() + assertEquals("local", report.key) + assertEquals(2, report.count) + assertNull(report.error) + } + + @Test fun copyCatalogingImportAndReplay() { + val r = data("cc_import") + assertEquals(1, r.imported) + val item = r.items.single() + assertEquals("created", item.action) + assertEquals(338683, item.bookId) + val replay = json.decodeFromString>(sample("cc_import_replayed")) + assertEquals(true, replay.meta?.replayed) + assertEquals(338683, replay.data!!.items.single().bookId) + val failed = data("cc_import_failed") + assertEquals(1, failed.failed) + assertEquals("failed", failed.items.single().action) + assertNull(failed.items.single().bookId) + } + + // ---------- health ---------- + + @Test fun healthTurnsTheCatalogueEntriesOn() { + val health = json.decodeFromString>(sample("health")).data!! + val areas = StaffAreas.of("admin", InstanceFeatures.from(health), online = true).s090 + assertTrue(areas.suppliers && areas.marc && areas.catalogueCards && areas.rdaTypes) + assertTrue(areas.anyCatalogue) + assertFalse(StaffAreas.of("admin", InstanceFeatures.from(health), online = false).s090.marc) + } + + @Test fun rawMetaToleratesOtherBodies() { + assertNull(Staff090CatalogueRepository.metaOf(json, "proxy error")) + assertTrue(Staff090CatalogueRepository.metaOf(json, """{"meta":{}}""") is JsonObject) + assertTrue(Staff090CatalogueRepository.metaOf(json, """{"meta":{}}""").fieldErrors().isEmpty()) + assertNull(null.metaBoolean("can_create")) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090CatalogueUiTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090CatalogueUiTest.kt new file mode 100644 index 0000000..8e35f1e --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090CatalogueUiTest.kt @@ -0,0 +1,139 @@ +package com.pinakes.app.server090 + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.R +import com.pinakes.app.data.model.CopyCatalogingResult +import com.pinakes.app.data.model.IdName +import com.pinakes.app.data.model.RdaEffective +import com.pinakes.app.data.model.StaffBook +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.model.Supplier +import com.pinakes.app.data.staff.BookCataloguing +import com.pinakes.app.ui.screens.staff.s090.catalogue.ResultRow +import com.pinakes.app.ui.screens.staff.s090.catalogue.Staff090CatalogueBookFields +import com.pinakes.app.ui.screens.staff.s090.catalogue.SupplierRow +import com.pinakes.app.ui.theme.PinakesTheme +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The cataloguing pieces drawn alone: the book form's supplier/RDA block, a supplier row, a copy cataloguing result. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class Staff090CatalogueUiTest { + private val compose = createComposeRule() + @get:Rule val rules: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app.packageManager).addActivityIfNotPresent(ComponentName(app.packageName, ComponentActivity::class.java.name)) + } + }).around(compose) + + private val context get() = ApplicationProvider.getApplicationContext() + private fun text(id: Int, vararg args: Any) = context.getString(id, *args) + private fun list(raw: String): List = Json.decodeFromString(raw) + + private val lookupsOn = StaffLookups( + suppliers = listOf(IdName(21, "Libreria Cat090 Prova"), IdName(22, "Donatore")), + rdaContentTypes = list("""[{"value":"txt","label":"Testo"},{"value":"spw","label":"Parola parlata"}]"""), + rdaMediaTypes = list("""[{"value":"n","label":"Senza mediazione"},{"value":"s","label":"Audio"}]"""), + rdaCarrierTypes = list("""[{"value":"nc","label":"Volume"},{"value":"cd","label":"Disco per computer"}]"""), + ) + + private val book = StaffBook( + id = 9, title = "Libro", supplierId = 21, supplier = IdName(21, "Libreria Cat090 Prova"), + rdaContentType = "spw", rdaCarrierType = "cd", rdaEffective = RdaEffective("spw", "n", "cd"), + ) + + @Test fun moduleOffShowsNoField() { + compose.setContent { PinakesTheme { Column { Staff090CatalogueBookFields(StaffLookups(), book, BookCataloguing.from(book)) {} } } } + assertEquals(0, compose.onAllNodes(androidx.compose.ui.test.hasTestTag("book-supplier")).fetchSemanticsNodes().size) + assertEquals(0, compose.onAllNodes(androidx.compose.ui.test.hasTestTag("book-rda-content")).fetchSemanticsNodes().size) + } + + @Test fun supplierAndRdaShowStoredOverridesAndTheDerivedValue() { + compose.setContent { PinakesTheme { Column { Staff090CatalogueBookFields(lookupsOn, book, BookCataloguing.from(book)) {} } } } + compose.onNodeWithText("Libreria Cat090 Prova").assertIsDisplayed() + compose.onNodeWithText("Parola parlata").assertIsDisplayed() + // Media type has no override: it shows what the server derives from the media type. + compose.onNodeWithText(text(R.string.staff090_catalogue_rda_derived_value, "Senza mediazione")).assertIsDisplayed() + compose.onNodeWithText("Disco per computer").assertIsDisplayed() + } + + @Test fun pickingAnotherSupplierAndGoingBackToDerived() { + var value by mutableStateOf(BookCataloguing.from(book)) + compose.setContent { PinakesTheme { Column { Staff090CatalogueBookFields(lookupsOn, book, value) { value = it } } } } + compose.onNodeWithTag("book-supplier").performClick() + compose.onNodeWithText("Donatore").performClick() + compose.runOnIdle { assertEquals(22, value.supplierId) } + compose.onNodeWithTag("book-rda-content").performClick() + // The stored override had no derived label: the generic one is offered. + compose.onNodeWithText(text(R.string.staff090_catalogue_rda_derived)).performClick() + compose.runOnIdle { assertEquals(null, value.rdaContentType) } + } + + @Test fun theHubShowsNoCataloguingHeaderWithoutARowUnderIt() { + var areas by mutableStateOf(com.pinakes.app.data.staff.Staff090Areas(catalogueCards = true)) + compose.setContent { PinakesTheme { Column { com.pinakes.app.ui.screens.staff.s090.Staff090HubSections(areas) {} } } } + val header = text(R.string.staff090_section_cataloguing).uppercase() + assertTrue(compose.onAllNodes(androidx.compose.ui.test.hasText(header)).fetchSemanticsNodes().isEmpty()) + areas = com.pinakes.app.data.staff.Staff090Areas(catalogueCards = true, suppliers = true) + compose.onNodeWithText(header).assertIsDisplayed() + compose.onNodeWithText(text(R.string.staff090_suppliers)).assertIsDisplayed() + } + + @Test fun supplierRowMenuOnlyWithTheServersPermission() { + val supplier = Supplier(id = 21, name = "Libreria Cat090 Prova", email = "ordini@example.org", bookCount = 2, canEdit = true, canDelete = true) + var deleted = 0 + var current by mutableStateOf(supplier) + compose.setContent { PinakesTheme { SupplierRow(current, deleting = false, onOpen = {}, onDelete = { deleted++ }) } } + compose.onNodeWithText(text(R.string.staff090_catalogue_supplier_books, 2), substring = true).assertIsDisplayed() + compose.onNodeWithContentDescription(text(R.string.cd_more_options)).performClick() + compose.onNodeWithText(text(R.string.staff090_catalogue_supplier_delete)).performClick() + compose.runOnIdle { assertEquals(1, deleted) } + current = supplier.copy(canEdit = false, canDelete = false) + compose.waitForIdle() + assertTrue(compose.onAllNodesWithContentDescription(text(R.string.cd_more_options)).fetchSemanticsNodes().isEmpty()) + } + + @Test fun resultRowSaysWhereItCameFromAndToggles() { + var toggles = 0 + val result = CopyCatalogingResult( + id = "6eb1848e432e386c", server = "Questa installazione (Pinakes)", title = "Libro Cat090 Prova", + authors = "Rossi, Mario", year = "2024", existingId = 338681, + ) + compose.setContent { PinakesTheme { ResultRow(result, selected = false) { toggles++ } } } + compose.onNodeWithText("Questa installazione (Pinakes)", substring = true).assertIsDisplayed() + compose.onNodeWithText("Rossi, Mario · 2024").assertIsDisplayed() + compose.onNodeWithText(text(R.string.staff090_catalogue_in_catalogue)).assertIsDisplayed() + compose.onNodeWithContentDescription(text(R.string.staff090_catalogue_copy_not_selected)).assertIsDisplayed() + compose.onNodeWithTag("copy-result-6eb1848e432e386c").performClick() + compose.runOnIdle { assertEquals(1, toggles) } + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090FlagsTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090FlagsTest.kt new file mode 100644 index 0000000..8f1207f --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090FlagsTest.kt @@ -0,0 +1,99 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.staff.Staff090Areas +import com.pinakes.app.data.staff.StaffAreas +import com.pinakes.app.data.store.InstanceFeatures +import com.pinakes.app.data.store.Staff090Flags +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Mobile API 1.10.0: the 0.9.0 module flags and `staff_` flags → [Staff090Flags] → [Staff090Areas]. */ +class Staff090FlagsTest { + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + + private val allOn = listOf( + "stocktake", "marc", "visitor_log", "catalogue_cards", "suppliers", "rda_types", "patron_cards", + "user_import", "patron_photos", "automatic_card_expiry", "automatic_fines", "debt_block", + "loss_penalties", "attendance_retention", "circulation_rules", "fines", "staff_catalog", + "staff_patrons", "staff_ledger", "staff_stocktake", "staff_library_settings", "staff_circulation_rules", + "staff_closures_calendar", "staff_suppliers", "staff_catalogue_cards", "staff_marc", + "staff_attendance", "staff_statistics", "staff_user_import", + ).joinToString(",") { "\"$it\":true" } + + private fun health(features: String, catalogueMode: Boolean = false, appAccess: Boolean = true) = + json.decodeFromString( + """{"name":"Lib","app_access_enabled":$appAccess,"catalogue_mode":$catalogueMode,"features":{"catalog":true,"loans":true,$features}}""", + ) + + @Test fun anOlderServerLeavesEveryToolHidden() { + val f = InstanceFeatures.from(health(""""staff_catalog":true""")) + assertEquals(Staff090Flags.None, f.staff090) + assertEquals(Staff090Areas.None, StaffAreas.of("admin", f, online = true).s090.copy(admin = false)) + } + + @Test fun aServerAdvertisingEverythingShowsEveryTool() { + val areas = StaffAreas.of("admin", InstanceFeatures.from(health(allOn)), online = true).s090 + assertTrue(areas.admin) + assertTrue(areas.patrons && areas.patronTypes && areas.patronCards && areas.patronPhotos && areas.ledger) + assertTrue(areas.stocktake && areas.librarySettings && areas.circulationRules && areas.closuresCalendar) + assertTrue(areas.suppliers && areas.rdaTypes && areas.catalogueCards && areas.marc) + assertTrue(areas.attendance && areas.statistics && areas.userImport) + assertTrue(areas.anyPatronDesk && areas.anyCatalogue && areas.anyLibrary) + } + + @Test fun catalogueModeHidesOnlyTheCirculationTools() { + val f = InstanceFeatures.from(health(allOn, catalogueMode = true)) + val areas = StaffAreas.of("staff", f, online = true).s090 + assertFalse(areas.ledger) + assertFalse(areas.attendance) + assertFalse(f.staff090.automaticFines) + assertTrue(areas.stocktake) + assertTrue(areas.marc) + assertTrue(areas.userImport) + assertFalse(areas.admin) + // Patron types, rules and the calendar are an administrator's tools. + assertFalse(areas.circulationRules) + assertFalse(areas.closuresCalendar) + assertTrue(areas.librarySettings) + // An administrator keeps them in catalogue mode too: configuring stays reachable. + val adminAreas = StaffAreas.of("admin", f, online = true).s090 + assertTrue(adminAreas.circulationRules && adminAreas.closuresCalendar) + } + + @Test fun appAccessOffHidesEverything() { + val f = InstanceFeatures.from(health(allOn, appAccess = false)) + assertEquals(Staff090Flags.None, f.staff090) + } + + @Test fun offlineHidesEveryToolAndAReaderNeverSeesThem() { + val f = InstanceFeatures.from(health(allOn)) + assertEquals(Staff090Areas.None, StaffAreas.of("admin", f, online = false).s090) + assertEquals(Staff090Areas.None, StaffAreas.of("standard", f, online = true).s090) + } + + @Test fun patronToolsFollowTheirOwnModules() { + val f = InstanceFeatures.from(health(""""staff_catalog":true,"staff_patrons":true,"patron_cards":true""")) + val areas = StaffAreas.of("staff", f, online = true).s090 + assertTrue(areas.patrons) + assertTrue(areas.patronCards) + assertFalse(areas.patronPhotos) + assertFalse(areas.patronTypes) + } + + @Test fun theFlagsSurviveTheirJsonRoundTrip() { + val flags = InstanceFeatures.from(health(allOn)).staff090 + val decoded = json.decodeFromString(Staff090Flags.serializer(), json.encodeToString(Staff090Flags.serializer(), flags)) + assertEquals(flags, decoded) + } + + @Test fun catalogueCardsAloneOpenNoCataloguingSection() { + // Cards live on the book screen: without MARC or suppliers the hub has no row for them. + assertFalse(Staff090Areas(catalogueCards = true).anyCatalogue) + assertTrue(Staff090Areas(catalogueCards = true, suppliers = true).anyCatalogue) + assertTrue(Staff090Areas(marc = true).anyCatalogue) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090LibraryErrorsTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090LibraryErrorsTest.kt new file mode 100644 index 0000000..d53e1aa --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090LibraryErrorsTest.kt @@ -0,0 +1,85 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.R +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.repository.Staff090LibraryRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.Staff090LibraryErrors +import com.pinakes.app.ui.screens.staff.s090.library.exitMessage +import com.pinakes.app.ui.screens.staff.s090.library.toLibraryMessage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Error bodies of the library settings areas, as the server sent them (captured samples). */ +class Staff090LibraryErrorsTest { + + private fun raw(name: String) = javaClass.getResourceAsStream("/server090/library/$name.json")!!.bufferedReader().readText() + private fun failure(name: String, status: Int): ApiResult.Failure = parseErrorBody(raw(name), status) + + @Test fun ourCodesHaveOurWording() { + assertEquals(R.string.staff090_library_err_always_closed, Staff090LibraryErrors.messageRes("calendar_always_closed")) + assertEquals(R.string.staff090_library_err_preset_closure, Staff090LibraryErrors.messageRes("preset_closure")) + assertEquals(R.string.staff090_library_err_schema_not_ready, Staff090LibraryErrors.messageRes("schema_not_ready")) + assertEquals(R.string.staff090_library_err_ics_too_large, Staff090LibraryErrors.messageRes("file_too_large")) + assertNull(Staff090LibraryErrors.messageRes("validation")) + assertEquals(R.string.staff090_library_err_preset_closure, failure("s090_library_closure_preset_refused", 409).toLibraryMessage(R.string.staff_error_save).res) + } + + @Test fun theDefaultTypeRefusalShowsTheServerMessage() { + val f = failure("s090_library_type_delete_default", 409) + assertEquals("default_type", f.code) + val message = f.toLibraryMessage(R.string.staff_error_save) + assertNull(message.res) + assertTrue(message.text!!.startsWith("La tipologia predefinita")) + assertNull(f.exitMessage()) + } + + @Test fun aScreenTheAccountMayNotUseClosesWithTheServerMessage() { + val forbidden = failure("s090_library_types_staff_refused", 403) + assertTrue(Staff090LibraryErrors.closesScreen(forbidden)) + assertEquals("Operazione riservata agli amministratori", forbidden.exitMessage()?.text) + val disabled = failure("s090_library_types_staff_disabled", 404) + assertEquals("feature_disabled", disabled.code) + assertTrue(Staff090LibraryErrors.closesScreen(disabled)) + assertEquals("Funzione non disponibile su questa istanza.", disabled.exitMessage()?.text) + // A 404 of a single record or a validation error leaves the screen open. + assertFalse(Staff090LibraryErrors.closesScreen(failure("s090_library_type_missing", 404))) + assertFalse(Staff090LibraryErrors.closesScreen(failure("s090_library_loans_invalid", 422))) + // An HTML 403 page (no envelope) is not taken as the server's verdict. + assertFalse(Staff090LibraryErrors.closesScreen(parseErrorBody("Forbidden", 403))) + } + + /** `not_permitted` refuses one action and keeps the role; only the role gate's `forbidden` drops it. */ + @Test fun aRefusedActionIsNotTheRoleGate() { + val refused = failure("s090_library_types_staff_refused", 403) + assertEquals(Staff090LibraryErrors.NOT_PERMITTED, refused.code) + assertFalse(refused.isForbidden()) + assertTrue(Staff090LibraryErrors.isNotPermitted(refused)) + val gate = failure("s090_library_role_gate_forbidden", 403) + assertTrue(gate.isForbidden()) + assertFalse(Staff090LibraryErrors.closesScreen(gate)) // the role is dropped instead: the areas close + } + + @Test fun aRefusedSaveShowsTheServerMessageAndStays() { + val f = failure("s090_library_loans_staff_refused", 403) + assertEquals("Operazione riservata agli amministratori", f.toLibraryMessage(R.string.staff_error_save).text) + assertEquals(mapOf("daily_fine_cents" to "Operazione riservata agli amministratori"), Staff090LibraryRepository.fieldErrors(raw("s090_library_loans_staff_refused"))) + // An empty server text still reads as a refusal (parseErrorBody's wording for a 403), never as blank. + assertTrue(parseErrorBody("""{"data":null,"meta":{},"error":{"code":"not_permitted","message":""}}""", 403) + .toLibraryMessage(R.string.staff_error_save).text!!.isNotBlank()) + } + + @Test fun fieldMessagesComeFromMetaFields() { + assertEquals(mapOf("loan_duration_days" to "Valore non valido."), Staff090LibraryRepository.fieldErrors(raw("s090_library_loans_invalid"))) + assertEquals(mapOf("cells.0.max_loans" to "Valore non valido."), Staff090LibraryRepository.fieldErrors(raw("s090_library_rules_invalid"))) + assertEquals("Indica il nome della festività.", Staff090LibraryRepository.fieldErrors(raw("s090_library_custom_invalid"))["name"]) + assertEquals(setOf("ics_file"), Staff090LibraryRepository.fieldErrors(raw("s090_library_ics_invalid")).keys) + assertTrue(Staff090LibraryRepository.fieldErrors(raw("s090_library_types_staff_refused")).isEmpty()) + assertTrue(Staff090LibraryRepository.fieldErrors(null).isEmpty()) + assertTrue(Staff090LibraryRepository.fieldErrors("").isEmpty()) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090LibraryFormsTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090LibraryFormsTest.kt new file mode 100644 index 0000000..c3b19fe --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090LibraryFormsTest.kt @@ -0,0 +1,334 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.Staff090LibraryCalendar +import com.pinakes.app.data.model.Staff090LibraryCardSettings +import com.pinakes.app.data.model.Staff090LibraryClosure +import com.pinakes.app.data.model.Staff090LibraryCustomHoliday +import com.pinakes.app.data.model.Staff090LibraryDay +import com.pinakes.app.data.model.Staff090LibraryLoans +import com.pinakes.app.data.model.Staff090LibraryModules +import com.pinakes.app.data.model.Staff090LibraryRange +import com.pinakes.app.data.model.Staff090LibraryRules +import com.pinakes.app.data.model.Staff090LibraryTypes +import com.pinakes.app.ui.screens.staff.s090.library.CalendarGrid +import com.pinakes.app.ui.screens.staff.s090.library.CardsForm +import com.pinakes.app.ui.screens.staff.s090.library.CellForm +import com.pinakes.app.ui.screens.staff.s090.library.CentsInput +import com.pinakes.app.ui.screens.staff.s090.library.ClosureForm +import com.pinakes.app.ui.screens.staff.s090.library.CustomField +import com.pinakes.app.ui.screens.staff.s090.library.CustomForm +import com.pinakes.app.ui.screens.staff.s090.library.CustomForms +import com.pinakes.app.ui.screens.staff.s090.library.IntInput +import com.pinakes.app.ui.screens.staff.s090.library.LibraryMoney +import com.pinakes.app.ui.screens.staff.s090.library.LibrarySettingsUiState +import com.pinakes.app.ui.screens.staff.s090.library.LoansForm +import com.pinakes.app.ui.screens.staff.s090.library.ModulesForm +import com.pinakes.app.ui.screens.staff.s090.library.RuleNumber +import com.pinakes.app.ui.screens.staff.s090.library.RulesForm +import com.pinakes.app.ui.screens.staff.s090.library.SettingsTab +import com.pinakes.app.ui.screens.staff.s090.library.TypeForm +import com.pinakes.app.ui.screens.staff.s090.library.TypeForms +import com.pinakes.app.ui.screens.staff.s090.library.TypeNumber +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Locale + +/** The pure side of the library settings screens: amounts, partial bodies, validation, grid. */ +class Staff090LibraryFormsTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + private val it = Locale.ITALY + + private fun data(name: String, serializer: KSerializer): T = + json.decodeFromString(Envelope.serializer(serializer), javaClass.getResourceAsStream("/server090/library/$name.json")!!.bufferedReader().readText()).data!! + + // ---------- money ---------- + + @Test fun amountsParseToExactCents() { + assertEquals(CentsInput.Valid(150), LibraryMoney.parse("1.50")) + assertEquals(CentsInput.Valid(150), LibraryMoney.parse("1,5")) + assertEquals(CentsInput.Valid(200), LibraryMoney.parse(" 2 ")) + assertEquals(CentsInput.Valid(29), LibraryMoney.parse("0,29")) // 0.29 * 100 is 28.999… as a double + assertEquals(CentsInput.Valid(999999), LibraryMoney.parse("9999.99", max = 999999)) + assertEquals(CentsInput.Invalid, LibraryMoney.parse("10000", max = 999999)) + assertEquals(CentsInput.Empty, LibraryMoney.parse("")) + listOf("1.234", "-1", "1.000,50", "abc", "1,", ",5").forEach { assertEquals(it, CentsInput.Invalid, LibraryMoney.parse(it)) } + } + + @Test fun amountsStartFromTheStoredCentsInTheLocaleSeparator() { + assertEquals("1,50", LibraryMoney.input(150, it)) + assertEquals("1.50", LibraryMoney.input(150, Locale.US)) + assertEquals("0,05", LibraryMoney.input(5, it)) + assertEquals("", LibraryMoney.input(null, it)) + // What the field shows parses back to the same cents. + listOf(0, 1, 99, 150, 999999).forEach { c -> assertEquals(CentsInput.Valid(c), LibraryMoney.parse(LibraryMoney.input(c, it))) } + } + + @Test fun wholeNumbersRespectTheServerRange() { + val r = Staff090LibraryRange(1, 30) + assertEquals(IntInput.Valid(3), IntInput.parse("3", r)) + assertEquals(IntInput.Invalid, IntInput.parse("0", r)) + assertEquals(IntInput.Invalid, IntInput.parse("31", r)) + assertEquals(IntInput.Empty, IntInput.parse(" ", r)) + assertEquals(IntInput.Invalid, IntInput.parse("-2", null)) + assertEquals(IntInput.Valid(-2), IntInput.parse("-2", Staff090LibraryRange(-200, 200), allowNegative = true)) + } + + // ---------- modules ---------- + + @Test fun modulesSendOnlyTheChangedSwitches() { + val m = data("s090_library_modules_admin", Staff090LibraryModules.serializer()) + val original = m.modules.associate { x -> x.key to x.stored } + assertNull(ModulesForm.body(original, original, 365, "365")) + val body = ModulesForm.body(original, original + ("suppliers" to true), 365, "365")!! + assertEquals("""{"features":{"suppliers":true}}""", body.toString()) + assertEquals("""{"attendance_retention_days":400}""", ModulesForm.body(original, original, 365, "400").toString()) + } + + @Test fun moduleNotesNameTheParentAndCatalogueMode() { + val m = data("s090_library_modules_admin", Staff090LibraryModules.serializer()) + val fines = m.modules.single { x -> x.key == "automatic_fines" } + val notes = ModulesForm.notes(fines, m.modules, catalogueMode = true) + assertEquals(ModulesForm.Note.NeedsParent("Registro movimenti", parentOn = false), notes[0]) + assertTrue(ModulesForm.Note.CatalogueMode in notes) + // inherits_from equal to the parent is said once. + assertEquals(2, notes.size) + val photos = ModulesForm.notes(m.modules.single { x -> x.key == "patron_photos" }, m.modules, catalogueMode = false) + assertEquals(listOf(ModulesForm.Note.Follows("Stampa delle tessere")), photos) + } + + @Test fun theSaveActionIsHiddenWithoutPermission() { + val staffModules = data("s090_library_modules_staff", Staff090LibraryModules.serializer()) + val staffLoans = data("s090_library_loans_staff", Staff090LibraryLoans.serializer()) + val s = LibrarySettingsUiState(modules = staffModules, loans = staffLoans, loading = false) + assertFalse(s.copy(tab = SettingsTab.MODULES).canSave) + assertTrue(s.copy(tab = SettingsTab.LOANS).canSave) // Staff saves the non-money loan fields + assertEquals(listOf(SettingsTab.MODULES, SettingsTab.LOANS), s.tabs) // no cards: 404 for Staff with the module off + } + + // ---------- loans ---------- + + @Test fun loansSendTheChangedEditableFieldsTyped() { + val l = data("s090_library_loans_admin", Staff090LibraryLoans.serializer()) + val original = LoansForm.initial(l, it) + assertEquals("30", original["loan_duration_days"]) + assertEquals("false", original["auto_approve_requests"]) + assertEquals("", original["daily_fine_cents"]) + assertEquals("Europe/Rome", original["app_timezone"]) + assertNull(LoansForm.body(l, original, original)) + val edited = original + mapOf("recall_interval_days" to "8", "daily_fine_cents" to "1,50", "auto_approve_requests" to "true") + assertEquals("""{"auto_approve_requests":true,"recall_interval_days":8,"daily_fine_cents":150}""", LoansForm.body(l, original, edited).toString()) + // Clearing an amount sends null (no amount). + val saved = data("s090_library_loans_saved", Staff090LibraryLoans.serializer()) + val withFine = LoansForm.initial(saved, it) + assertEquals("1,50", withFine["daily_fine_cents"]) + assertEquals("""{"daily_fine_cents":null}""", LoansForm.body(saved, withFine, withFine + ("daily_fine_cents" to "")).toString()) + } + + @Test fun staffNeverSendsAnAdministratorOnlyField() { + val l = data("s090_library_loans_staff", Staff090LibraryLoans.serializer()) + val original = LoansForm.initial(l, it) + val edited = original + mapOf("daily_fine_cents" to "1", "max_renewals" to "4") + assertEquals("""{"max_renewals":4}""", LoansForm.body(l, original, edited).toString()) + assertFalse(LoansForm.editable(l, "fine_grace_mode")) + } + + @Test fun loanValidationUsesTheServerLimits() { + val l = data("s090_library_loans_admin", Staff090LibraryLoans.serializer()) + val values = LoansForm.initial(l, it) + assertTrue(LoansForm.invalid(l, values).isEmpty()) + val bad = LoansForm.invalid(l, values + mapOf("pickup_expiry_days" to "31", "loan_duration_days" to "", "daily_fine_cents" to "abc", "fine_grace_mode" to "x")) + assertEquals(setOf("pickup_expiry_days", "loan_duration_days", "daily_fine_cents", "fine_grace_mode"), bad) + assertTrue(LoansForm.invalid(l, values + ("daily_fine_cents" to "10000,00")).contains("daily_fine_cents")) + } + + // ---------- cards ---------- + + @Test fun cardsSendTheChangedSettings() { + val o = Staff090LibraryCardSettings(layout = "classic", codeType = "code128", format = "single", title = "", backText = "", showPhoto = true, showLogo = true, showExpiry = true) + assertNull(CardsForm.body(o, o)) + assertNull(CardsForm.body(o, o.copy(title = " "))) + assertEquals("""{"code_type":"qr","title":"Tessera","show_logo":false}""", CardsForm.body(o, o.copy(codeType = "qr", title = " Tessera ", showLogo = false)).toString()) + } + + // ---------- patron types ---------- + + @Test fun aNewTypeSendsWhatWasFilledAndLeavesTheRestToInherit() { + val form = TypeForm(name = " Ricercatori ", code = "ricercatori", numbers = mapOf(TypeNumber.MAX_LOANS to "5", TypeNumber.DAILY_FINE to "0,50", TypeNumber.GRACE_DAYS to "")) + assertEquals( + """{"name":"Ricercatori","code":"ricercatori","reservations_enabled":true,"active":true,"max_loans":5,"daily_fine_cents":50}""", + TypeForms.createBody(form).toString(), + ) + } + + @Test fun anEditSendsOnlyTheChangesAndNullToInheritAgain() { + val t = data("s090_library_type_created", com.pinakes.app.data.model.Staff090LibraryPatronType.serializer()) + val original = TypeForm.from(t, it) + assertEquals("5", original.text(TypeNumber.MAX_LOANS)) + assertEquals("0,50", original.text(TypeNumber.DAILY_FINE)) + assertNull(TypeForms.updateBody(original, original)) + val edited = original.copy(numbers = original.numbers + (TypeNumber.MAX_LOANS to "") + (TypeNumber.LOAN_DURATION to "60")) + assertEquals("""{"max_loans":null,"loan_duration_days":60}""", TypeForms.updateBody(original, edited).toString()) + assertEquals("""{"is_default":true}""", TypeForms.updateBody(original, original.copy(isDefault = true)).toString()) + } + + @Test fun typeValidationAndInheritedHints() { + val types = data("s090_library_types_two", Staff090LibraryTypes.serializer()) + assertEquals(setOf("name", "code", "max_loans", "daily_fine_cents"), + TypeForms.invalid(TypeForm(name = "", code = "Bad Code", numbers = mapOf(TypeNumber.MAX_LOANS to "1001", TypeNumber.DAILY_FINE to "1.234")), types.limits)) + assertTrue(TypeForms.invalid(TypeForm(name = "Ok", code = ""), types.limits).isEmpty()) + // An empty renewal period inherits the effective one (the loan duration), not the empty global. + val standard = types.types.single { x -> x.isDefault } + assertEquals(30, TypeForms.inherited(TypeNumber.RENEWAL_DAYS, standard, types.globals)) + assertNull(TypeForms.inherited(TypeNumber.RENEWAL_DAYS, null, types.globals)) + assertEquals(30, TypeForms.inherited(TypeNumber.LOAN_DURATION, null, types.globals)) + } + + @Test fun aTypesOwnValueIsNeverShownAsWhatClearingTheFieldGives() { + val types = data("s090_library_types_two", Staff090LibraryTypes.serializer()) + val standard = types.types.single { x -> x.isDefault } + // The live run: card validity 400 on the type, so `effective` says 400 too. + val own = standard.copy( + cardDurationDays = 400, loanDurationDays = 60, renewalDays = 10, + effective = (standard.effective ?: types.globals).copy(cardDurationDays = 400, loanDurationDays = 60, renewalDays = 10), + ) + assertEquals(types.globals.cardDurationDays, TypeForms.inherited(TypeNumber.CARD_DURATION, own, types.globals)) + assertEquals(30, TypeForms.inherited(TypeNumber.LOAN_DURATION, own, types.globals)) + // An empty global renewal period follows the loan duration this type gets. + assertEquals(60, TypeForms.inherited(TypeNumber.RENEWAL_DAYS, own, types.globals)) + } + + // ---------- rules matrix ---------- + + @Test fun onlyChangedCellsAndOnlyTheirChangedValuesAreSent() { + val r = data("s090_library_rules_saved", Staff090LibraryRules.serializer()) + val dvd = r.cells.single { c -> c.typeId == 0 && c.media == "dvd" } + val book = r.cells.single { c -> c.typeId == 1 && c.media == "libro" } + val dvdForm = CellForm.from(dvd, it) + assertEquals(false, dvdForm.loanable) + assertEquals("7", dvdForm.text(RuleNumber.LOAN_DURATION)) + assertNull(RulesForm.cellBody(dvd, dvdForm, it)) + val edits = mapOf( + RulesForm.key(0, "dvd") to dvdForm.copy(loanable = null, numbers = dvdForm.numbers + (RuleNumber.LOAN_DURATION to "")), + RulesForm.key(1, "libro") to CellForm.from(book, it), // opened, not changed + RulesForm.key(0, "altro") to CellForm(numbers = mapOf(RuleNumber.MAX_FINE to "2")), + ) + assertEquals( + """[{"type_id":0,"media":"dvd","loanable":null,"loan_duration_days":null},{"type_id":0,"media":"altro","max_fine_per_loan_cents":200}]""", + RulesForm.body(r.cells, edits, it).toString(), + ) + assertTrue(RulesForm.customised(dvd)) + assertFalse(RulesForm.customised(r.cells.single { c -> c.typeId == 0 && c.media == "libro" })) + assertEquals(setOf("max_loans", "daily_fine_cents"), RulesForm.invalid(CellForm(numbers = mapOf(RuleNumber.MAX_LOANS to "99999", RuleNumber.DAILY_FINE to "x")), r.limits)) + } + + // ---------- calendar ---------- + + @Test fun theMonthGridStartsOnMonday() { + val c = data("s090_library_calendar_full", Staff090LibraryCalendar.serializer()) + val weeks = CalendarGrid.weeks(c.days) + // 1 December 2026 is a Tuesday: one blank Monday first. + assertNull(weeks.first()[0]) + assertEquals("2026-12-01", weeks.first()[1]?.date) + assertTrue(weeks.all { w -> w.size == 7 }) + assertEquals(31, weeks.flatten().count { d -> d != null }) + assertEquals("2026-12-06", weeks.first()[6]?.date) // Sunday closes the row + assertTrue(CalendarGrid.weeks(emptyList()).isEmpty()) + assertEquals("2027-01", CalendarGrid.shift("2026-12", 1)) + assertEquals("2026-11", CalendarGrid.shift("2026-12", -1)) + } + + @Test fun yearlyTogglesExcludePresetHolidaysAndOneWeekdayStaysOpen() { + val c = data("s090_library_calendar_january_preset", Staff090LibraryCalendar.serializer()) + assertTrue(CalendarGrid.yearly(c.closures).none { x -> x.presetRule != null }) + assertTrue(CalendarGrid.weekdaysValid(setOf(0, 6))) + assertFalse(CalendarGrid.weekdaysValid((0..6).toSet())) + } + + @Test fun aClosureFormIsTheWholeClosure() { + val f = ClosureForm(startDate = "2026-12-24", endDate = "2026-12-26", description = " Natale ") + assertTrue(f.valid) + assertEquals("""{"start_date":"2026-12-24","end_date":"2026-12-26","description":"Natale","yearly":false,"branch_id":null}""", f.body().toString()) + assertFalse(f.copy(endDate = "2026-12-23").valid) + assertFalse(ClosureForm().valid) + val single = ClosureForm.from(Staff090LibraryClosure(id = 3, startDate = "2026-08-15", endDate = "2026-08-15", yearly = true)) + assertNull(single.endDate) // a one-day closure edits with an empty last day + } + + @Test fun eachHolidayKindShowsAndSendsItsOwnFields() { + assertEquals(setOf(CustomField.MONTH, CustomField.DAY), CustomForms.fields("fixed")) + assertEquals(setOf(CustomField.OCCURRENCE, CustomField.WEEKDAY, CustomField.MONTH), CustomForms.fields("nth_weekday")) + assertEquals(setOf(CustomField.OFFSET), CustomForms.fields("orthodox_easter")) + assertEquals(setOf(CustomField.CALENDAR, CustomField.MONTH, CustomField.DAY), CustomForms.fields("calendar")) + assertEquals(setOf(CustomField.DATES), CustomForms.fields("dates")) + val nth = CustomForm(name = "Ultimo lunedì", kind = "nth_weekday", month = "11", weekday = 1, occurrence = -1, day = "9") + val body = CustomForms.body(nth).toString() + assertTrue(body, body.contains(""""month":11,"day":null,"weekday":1,"occurrence":-1,"offset_days":null,"calendar":null,"dates":[]""")) + val dates = CustomForm(name = "Ponti", kind = "dates", dates = "2026-12-28\n2027-01-04, ") + assertTrue(CustomForms.body(dates).toString().contains(""""dates":["2026-12-28","2027-01-04"]""")) + } + + @Test fun holidayValidation() { + assertTrue(CustomForms.invalid(CustomForm(name = "Patrono", kind = "fixed", month = "12", day = "7"), 14, 400, 12).isEmpty()) + assertEquals(setOf("name", "day"), CustomForms.invalid(CustomForm(name = " ", kind = "fixed", month = "2", day = "32"), 14, 400, 12)) + assertEquals(setOf("offset_days"), CustomForms.invalid(CustomForm(name = "x", kind = "easter", offset = "201"), 14, 400, 12)) + assertTrue(CustomForms.invalid(CustomForm(name = "x", kind = "easter", offset = "-2"), 14, 400, 12).isEmpty()) + assertEquals(setOf("dates"), CustomForms.invalid(CustomForm(name = "x", kind = "dates", dates = "2026-02-30"), 14, 400, 12)) + assertEquals(setOf("duration_days", "year_until"), CustomForms.invalid(CustomForm(name = "x", duration = "15", yearFrom = "2030", yearUntil = "2029"), 14, 400, 12)) + // The Hebrew calendar has a 13th month; the Gregorian one does not. + assertTrue(CustomForms.invalid(CustomForm(name = "x", kind = "calendar", calendar = "hebrew", month = "13", day = "1"), 14, 400, 13).isEmpty()) + assertEquals(setOf("month"), CustomForms.invalid(CustomForm(name = "x", kind = "fixed", month = "13", day = "1"), 14, 400, 13)) + } + + @Test fun anExistingHolidayReopensWithItsValues() { + val h = data("s090_library_custom_updated", Staff090LibraryCustomHoliday.serializer()) + val f = CustomForm.from(h) + assertEquals("nth_weekday", f.kind) + assertEquals("11", f.month) + assertEquals(-1, f.occurrence) + assertTrue(CustomForms.invalid(f, 14, 400, 12).isEmpty()) + } + + @Test fun aReloadKeepsUnsavedWeekdayTicks() { + // Untouched: another month brings the server's days. + assertEquals(setOf(0), CalendarGrid.reseedWeekdays(draft = setOf(1), before = setOf(1), server = setOf(0))) + // Saturday and Sunday ticked, then next month: the ticks stay (and Save stays offered). + assertEquals(setOf(0, 6), CalendarGrid.reseedWeekdays(draft = setOf(0, 6), before = emptySet(), server = emptySet())) + // First load: nothing to keep. + assertEquals(setOf(0), CalendarGrid.reseedWeekdays(draft = emptySet(), before = null, server = setOf(0))) + } + + @Test fun aReloadKeepsUnsavedYearlySwitches() { + val xmas = Staff090LibraryClosure(id = 1, yearly = true, active = true) + val may = Staff090LibraryClosure(id = 2, yearly = true, active = true) + // Christmas switched off and not saved; May Day turned off on the server meanwhile. + val after = CalendarGrid.reseedYearly(mapOf(1 to false, 2 to true), listOf(xmas, may), listOf(xmas, may.copy(active = false))) + assertEquals(mapOf(1 to false, 2 to false), after) + // A closure gone from the server drops its switch; a new one starts from the server. + assertEquals(mapOf(3 to true), CalendarGrid.reseedYearly(mapOf(1 to false), listOf(xmas), listOf(Staff090LibraryClosure(id = 3, yearly = true, active = true)))) + } + + @Test fun aNewHolidayPreviewKeepsTheTicksOfTheSameCountry() { + val listed = listOf("easter", "xmas", "patron") + // Another year of the same country: the operator's choice, for the rules still listed. + assertEquals(setOf("xmas"), CalendarGrid.reseedHolidays(setOf("xmas", "gone"), touched = true, sameCountry = true, listed = listed, serverChecked = setOf("easter"))) + // Untouched, or another country: the server's selection. + assertEquals(setOf("easter"), CalendarGrid.reseedHolidays(setOf("xmas"), touched = false, sameCountry = true, listed = listed, serverChecked = setOf("easter"))) + assertEquals(setOf("easter"), CalendarGrid.reseedHolidays(setOf("xmas"), touched = true, sameCountry = false, listed = listed, serverChecked = setOf("easter"))) + } + + @Test fun aModulesSaveKeepsTheUnsavedLoanEdits() { + val before = mapOf("fine_daily" to "0,50", "max_loans" to "5") + val server = mapOf("fine_daily" to "0,50", "max_loans" to "6") + // The fine was edited and not saved; max loans changed on the server. + assertEquals(mapOf("fine_daily" to "1,00", "max_loans" to "6"), LoansForm.reseed(mapOf("fine_daily" to "1,00", "max_loans" to "5"), before, server)) + // Nothing edited: the server's values. + assertEquals(server, LoansForm.reseed(before, before, server)) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090LibraryModelsTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090LibraryModelsTest.kt new file mode 100644 index 0000000..be80694 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090LibraryModelsTest.kt @@ -0,0 +1,232 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.DeleteResult +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.model.Staff090LibraryCalendar +import com.pinakes.app.data.model.Staff090LibraryCards +import com.pinakes.app.data.model.Staff090LibraryClosure +import com.pinakes.app.data.model.Staff090LibraryCountries +import com.pinakes.app.data.model.Staff090LibraryCustomHoliday +import com.pinakes.app.data.model.Staff090LibraryIcsImported +import com.pinakes.app.data.model.Staff090LibraryLoans +import com.pinakes.app.data.model.Staff090LibraryModules +import com.pinakes.app.data.model.Staff090LibraryPatronType +import com.pinakes.app.data.model.Staff090LibraryPresetApplied +import com.pinakes.app.data.model.Staff090LibraryRules +import com.pinakes.app.data.model.Staff090LibraryTypeDeleted +import com.pinakes.app.data.model.Staff090LibraryTypes +import com.pinakes.app.data.model.Staff090LibraryYearlySaved +import com.pinakes.app.data.store.InstanceFeatures +import com.pinakes.app.data.staff.Staff090Areas +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The library settings areas (Mobile API 1.10.0) decode the bodies the server really sends: + * every sample under `test/resources/server090/library` was captured from a Pinakes 0.9.0 + * instance (feat/mobile-staff-090) with curl, as an administrator and as a staff member. + */ +class Staff090LibraryModelsTest { + + // Same configuration as NetworkModule.json. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + private fun raw(name: String): String = + requireNotNull(javaClass.getResourceAsStream("/server090/library/$name.json")) { "missing sample $name" }.bufferedReader().use { it.readText() } + + private fun data(name: String, serializer: KSerializer): T = + requireNotNull(json.decodeFromString(Envelope.serializer(serializer), raw(name)).data) { "no data in $name" } + + @Test fun healthAdvertisesTheThreeAreasStaffSeesSettingsAndAdminsEverything() { + val health = data("s090_library_health", HealthPayload.serializer()) + assertTrue(health.features.staffLibrarySettings && health.features.staffCirculationRules && health.features.staffClosuresCalendar) + val staff = Staff090Areas.of("staff", InstanceFeatures.from(health), online = true) + assertTrue(staff.librarySettings && staff.anyLibrary) + // Patron types, loan rules and the calendar are configured by administrators only. + assertFalse(staff.circulationRules || staff.closuresCalendar) + val admin = Staff090Areas.of("admin", InstanceFeatures.from(health), online = true) + assertTrue(admin.librarySettings && admin.circulationRules && admin.closuresCalendar) + assertFalse(Staff090Areas.of("admin", InstanceFeatures.from(health), online = false).anyLibrary) + } + + @Test fun modulesAsAdministratorAndAsStaff() { + val admin = data("s090_library_modules_admin", Staff090LibraryModules.serializer()) + assertTrue(admin.canConfigure) + assertEquals(365, admin.attendanceRetentionDays) + assertEquals(3650, admin.attendanceRetentionMaxDays) + val photos = admin.modules.single { it.key == "patron_photos" } + assertEquals("patron_cards", photos.inheritsFrom) + val fines = admin.modules.single { it.key == "automatic_fines" } + assertEquals("fines", fines.parent) + assertEquals("loans", fines.group) + assertTrue(fines.circulation) + assertEquals(setOf("modules", "loans"), admin.modules.map { it.group }.toSet()) + val staff = data("s090_library_modules_staff", Staff090LibraryModules.serializer()) + assertFalse(staff.canConfigure) + assertEquals(admin.modules.map { it.key }, staff.modules.map { it.key }) + val saved = data("s090_library_modules_saved", Staff090LibraryModules.serializer()) + assertTrue(saved.modules.single { it.key == "suppliers" }.stored) + assertEquals(400, saved.attendanceRetentionDays) + } + + @Test fun loansKeepTypedValuesAndTheEditableFieldsPerRole() { + val admin = data("s090_library_loans_admin", Staff090LibraryLoans.serializer()) + assertEquals("EUR", admin.currency) + assertEquals(30, (admin.settings["loan_duration_days"] as JsonPrimitive).intOrNull) + assertEquals("Europe/Rome", (admin.settings["app_timezone"] as JsonPrimitive).content) + assertTrue("daily_fine_cents" in admin.settings) + assertEquals(listOf("threshold", "deduct"), admin.options["fine_grace_mode"]) + assertEquals(999999L, admin.limits["money_max_cents"]?.max) + assertTrue(admin.editableFields.containsAll(admin.adminOnlyFields)) + val staff = data("s090_library_loans_staff", Staff090LibraryLoans.serializer()) + assertFalse(staff.canConfigure) + assertTrue(staff.adminOnlyFields.none { it in staff.editableFields }) + assertTrue("max_renewals" in staff.editableFields) + val saved = data("s090_library_loans_saved", Staff090LibraryLoans.serializer()) + assertEquals(150, (saved.settings["daily_fine_cents"] as JsonPrimitive).intOrNull) + } + + @Test fun cards() { + val c = data("s090_library_cards_admin", Staff090LibraryCards.serializer()) + assertTrue(c.moduleEnabled) + assertEquals("classic", c.settings.layout) + assertEquals("code128", c.settings.codeType) + assertTrue(c.settings.showPhoto) + assertEquals(listOf("classic", "minimal", "portrait"), c.options["layout"]) + assertEquals(60, c.limits.titleMax) + assertEquals("Tessera di prova", data("s090_library_cards_saved", Staff090LibraryCards.serializer()).settings.title) + } + + @Test fun patronTypesWithGlobalsEffectiveAndLimits() { + val t = data("s090_library_types_two", Staff090LibraryTypes.serializer()) + assertEquals(1, t.defaultTypeId) + assertEquals(30, t.globals.loanDurationDays) + assertNull(t.globals.renewalDays) + assertEquals(2, t.types.size) + val standard = t.types.single { it.isDefault } + assertNull(standard.maxLoans) + assertEquals(30, standard.effective?.renewalDays) + val created = data("s090_library_type_created", Staff090LibraryPatronType.serializer()) + assertEquals("ricercatori_app", created.code) + assertEquals(5, created.maxLoans) + assertEquals(50, created.dailyFineCents) + assertEquals(50, created.effective?.dailyFineCents) + val updated = data("s090_library_type_updated", Staff090LibraryPatronType.serializer()) + assertNull(updated.maxLoans) + assertEquals(60, updated.loanDurationDays) + assertEquals(36500L, t.limits["card_duration_days"]?.max) + val moved = data("s090_library_types_moved", Staff090LibraryTypes.serializer()) + assertEquals("Ricercatori app", moved.types.first().name) + assertEquals(0, data("s090_library_type_deleted", Staff090LibraryTypeDeleted.serializer()).usersReassigned) + assertFalse(data("s090_library_types_admin_module_off", Staff090LibraryTypes.serializer()).moduleEnabled) + } + + @Test fun theReplayOfACreateSaysSo() { + val env = json.decodeFromString(Envelope.serializer(Staff090LibraryPatronType.serializer()), raw("s090_library_type_replayed")) + assertEquals(true, env.meta?.replayed) + } + + @Test fun rulesMatrixCellsWithInheritedValues() { + val r = data("s090_library_rules_saved", Staff090LibraryRules.serializer()) + assertEquals(listOf("libro", "disco", "audiolibro", "dvd", "altro"), r.media.map { it.key }) + assertEquals(0, r.rows.first().typeId) + assertEquals(r.rows.size * r.media.size, r.cells.size) + val dvd = r.cells.single { it.typeId == 0 && it.media == "dvd" } + assertTrue(dvd.hasRule) + assertEquals(false, dvd.loanable) + assertEquals(7, dvd.loanDurationDays) + assertTrue(dvd.inherited.loanable) + assertEquals(30, dvd.inherited.loanDurationDays) + val book = r.cells.single { it.typeId == 1 && it.media == "libro" } + assertNull(book.loanable) + assertEquals(25, book.dailyFineCents) + assertTrue(data("s090_library_rules", Staff090LibraryRules.serializer()).cells.none { it.hasRule }) + } + + @Test fun calendarDaysReasonsClosuresAndCustomHolidays() { + val c = data("s090_library_calendar_full", Staff090LibraryCalendar.serializer()) + assertEquals("2026-12", c.month) + assertEquals("2026-11", c.prevMonth) + assertEquals(31, c.days.size) + assertEquals(listOf(0), c.closedWeekdays) + val sunday = c.days.single { it.date == "2026-12-06" } + assertEquals(0, sunday.weekday) + assertEquals("weekly", sunday.reasons.single().type) + assertNull(sunday.reasons.single().numericId) + val christmas = c.days.single { it.date == "2026-12-25" } + assertEquals("closure", christmas.reasons.single().type) + assertEquals(c.closures.single { !it.yearly }.id, christmas.reasons.single().numericId) + val yearly = c.closures.single { it.yearly } + assertFalse(yearly.active) + assertTrue(yearly.editable) + assertEquals(3, c.customHolidays.size) + val dates = c.customHolidays.single { it.kind == "dates" } + assertEquals(listOf("2026-12-28", "2027-01-04"), dates.dates) + val hebrew = c.customHolidays.single { it.kind == "calendar" } + assertEquals("hebrew", hebrew.calendar) + assertEquals(2, hebrew.durationDays) + assertTrue(hebrew.nextDates.isNotEmpty()) + assertEquals(listOf("fixed", "nth_weekday", "easter", "orthodox_easter", "calendar", "dates"), c.customHolidayOptions.kinds) + assertEquals(listOf(1, 2, 3, 4, 5, -1), c.customHolidayOptions.occurrences) + assertEquals(13, c.customHolidayOptions.calendars.single { it.code == "hebrew" }.months.size) + assertEquals(1_048_576, c.limits.icsMaxBytes) + assertTrue(c.branches.isEmpty()) + assertEquals("IT", c.holidayPreset.country) + } + + @Test fun aPresetHolidayClosureIsNotEditableAndHasATextRuleId() { + val c = data("s090_library_calendar_january_preset", Staff090LibraryCalendar.serializer()) + val preset = c.closures.first { it.presetRule != null } + assertFalse(preset.editable) + assertTrue(c.holidayPreset.saved) + val newYear = c.days.single { it.date == "2026-01-01" } + val reason = newYear.reasons.first { it.type == "holiday" } + assertNull(reason.numericId) // a holiday rule id (text), not a closure row id + assertEquals("new_year", (reason.id as JsonPrimitive).content) + } + + @Test fun holidayPreviewAndPresetApplied() { + val it = data("s090_library_countries_it", Staff090LibraryCountries.serializer()) + assertTrue(it.countries.size > 200) + assertEquals(2027, it.year) + assertEquals(1900L, it.years?.min) + assertTrue(it.holidays.any { h -> h.id == "IT:new_year" && h.yearly }) + assertEquals(it.holidays, it.preset.holidays) + val applied = data("s090_library_preset_applied", Staff090LibraryPresetApplied.serializer()) + assertTrue(applied.message.isNotBlank()) + assertTrue(applied.holidayPreset.saved) + } + + @Test fun calendarWritesAnswerTheirResource() { + val created = data("s090_library_closure_created", Staff090LibraryClosure.serializer()) + assertEquals("2026-12-24", created.startDate) + assertEquals("2026-12-26", created.endDate) + assertNull(created.branchId) + assertEquals("Chiusura natalizia (prova app)", data("s090_library_closure_updated", Staff090LibraryClosure.serializer()).description) + assertTrue(data("s090_library_closure_deleted", DeleteResult.serializer()).deleted) + val yearly = data("s090_library_yearly_saved", Staff090LibraryYearlySaved.serializer()) + assertEquals(1, yearly.changed) + assertFalse(yearly.yearlyClosures.single().active) + assertEquals(listOf(0), data("s090_library_weekdays_saved", Staff090LibraryCalendar.serializer()).closedWeekdays) + val custom = data("s090_library_custom_created", Staff090LibraryCustomHoliday.serializer()) + assertEquals("fixed", custom.kind) + assertEquals(12, custom.month) + assertEquals(7, custom.day) + assertFalse(data("s090_library_custom_toggled", Staff090LibraryCustomHoliday.serializer()).active) + val updated = data("s090_library_custom_updated", Staff090LibraryCustomHoliday.serializer()) + assertEquals("nth_weekday", updated.kind) + assertEquals(-1, updated.occurrence) + assertEquals(1, updated.weekday) + val ics = data("s090_library_ics_imported", Staff090LibraryIcsImported.serializer()) + assertEquals(1, ics.added) + assertEquals(1, ics.addedIds.size) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090LibraryUiTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090LibraryUiTest.kt new file mode 100644 index 0000000..acca0a3 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090LibraryUiTest.kt @@ -0,0 +1,128 @@ +package com.pinakes.app.server090 + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.R +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.Staff090LibraryCalendar +import com.pinakes.app.data.model.Staff090LibraryDay +import com.pinakes.app.data.model.Staff090LibraryLoans +import com.pinakes.app.data.model.Staff090LibraryModules +import com.pinakes.app.ui.screens.staff.s090.library.LibrarySettingsUiState +import com.pinakes.app.ui.screens.staff.s090.library.LoansForm +import com.pinakes.app.ui.screens.staff.s090.library.LoansTab +import com.pinakes.app.ui.screens.staff.s090.library.ModulesTab +import com.pinakes.app.ui.screens.staff.s090.library.MonthGrid +import com.pinakes.app.ui.theme.PinakesTheme +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.util.Locale + +/** + * What the library settings screens show for each role, rendered from captured server bodies: + * an administrator gets switches and inputs, Staff the plain values (hidden, never disabled). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class Staff090LibraryUiTest { + // Same RuleChain as ConsultationOnlyButtonTest: registers the host activity. + private val compose = createComposeRule() + @get:Rule val rules: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app.packageManager).addActivityIfNotPresent(ComponentName(app.packageName, ComponentActivity::class.java.name)) + } + }).around(compose) + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + private val context get() = ApplicationProvider.getApplicationContext() + private val switches = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Switch) + + private fun data(name: String, serializer: KSerializer): T = + json.decodeFromString(Envelope.serializer(serializer), javaClass.getResourceAsStream("/server090/library/$name.json")!!.bufferedReader().readText()).data!! + + private fun modules(name: String, onModule: (String, Boolean) -> Unit = { _, _ -> }) { + val m = data(name, Staff090LibraryModules.serializer()) + val state = LibrarySettingsUiState(modules = m, moduleValues = m.modules.associate { it.key to it.stored }, retention = m.attendanceRetentionDays.toString()) + compose.setContent { PinakesTheme { Column(Modifier.verticalScroll(rememberScrollState())) { ModulesTab(m, state, onModule, {}) } } } + } + + @Test fun anAdministratorSwitchesModules() { + var toggled: Pair? = null + modules("s090_library_modules_admin") { key, on -> toggled = key to on } + val count = compose.onAllNodes(switches).fetchSemanticsNodes().size + assertEquals(data("s090_library_modules_admin", Staff090LibraryModules.serializer()).modules.size, count) + compose.onNodeWithText("Fornitori").performScrollTo().performClick() + assertEquals("suppliers" to true, toggled) + compose.onNodeWithTag("retention-days").performScrollTo() + } + + @Test fun staffSeesTheStateWithoutControls() { + modules("s090_library_modules_staff") + assertEquals(0, compose.onAllNodes(switches).fetchSemanticsNodes().size) + compose.onNodeWithText(context.getString(R.string.staff090_library_modules_read_only)).assertExists() + org.junit.Assert.assertTrue(compose.onAllNodesWithText(context.getString(R.string.staff090_library_on)).fetchSemanticsNodes().isNotEmpty()) + assertEquals(0, compose.onAllNodesWithTag("retention-days").fetchSemanticsNodes().size) + } + + @Test fun staffEditsLoanRulesButNotMoney() { + val l = data("s090_library_loans_staff", Staff090LibraryLoans.serializer()) + val values = LoansForm.initial(l, Locale.ITALY) + val state = LibrarySettingsUiState(loans = l, loanOriginal = values, loanValues = values) + compose.setContent { PinakesTheme { Column(Modifier.verticalScroll(rememberScrollState())) { LoansTab(l, state) { _, _ -> } } } } + compose.onNodeWithTag("loan-max_renewals").assertExists() + compose.onNodeWithTag("loan-app_timezone").assertExists() + assertEquals(0, compose.onAllNodesWithTag("loan-daily_fine_cents").fetchSemanticsNodes().size) + assertEquals(0, compose.onAllNodesWithTag("loan-fine_grace_mode").fetchSemanticsNodes().size) + compose.onNodeWithText(context.getString(R.string.staff090_library_fines_admin_only)).performScrollTo().assertExists() + compose.onNodeWithText(context.getString(R.string.staff090_library_loan_daily_fine)).performScrollTo().assertExists() + } + + @Test fun anAdministratorGetsTheMoneyFields() { + val l = data("s090_library_loans_admin", Staff090LibraryLoans.serializer()) + val values = LoansForm.initial(l, Locale.ITALY) + compose.setContent { PinakesTheme { Column(Modifier.verticalScroll(rememberScrollState())) { LoansTab(l, LibrarySettingsUiState(loans = l, loanOriginal = values, loanValues = values)) { _, _ -> } } } } + compose.onNodeWithTag("loan-daily_fine_cents").performScrollTo().assertExists() + compose.onNodeWithTag("loan-fine_grace_mode").performScrollTo().assertExists() + assertEquals(0, compose.onAllNodesWithText(context.getString(R.string.staff090_library_fines_admin_only)).fetchSemanticsNodes().size) + } + + @Test fun theMonthShowsEveryDayAndATapOpensIt() { + val c = data("s090_library_calendar_full", Staff090LibraryCalendar.serializer()) + var opened: Staff090LibraryDay? = null + compose.setContent { PinakesTheme { MonthGrid(c, onPrev = {}, onNext = {}, onDay = { opened = it }) } } + assertEquals(31, compose.onAllNodes(SemanticsMatcher("day cell") { n -> + n.config.getOrElseNullable(SemanticsProperties.TestTag) { null }?.startsWith("calendar-day-") == true + }).fetchSemanticsNodes().size) + compose.onNodeWithTag("calendar-day-2026-12-25").performClick() + assertEquals("closure", opened?.reasons?.single()?.type) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090OpenImportsUiTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090OpenImportsUiTest.kt new file mode 100644 index 0000000..17bdf79 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090OpenImportsUiTest.kt @@ -0,0 +1,69 @@ +package com.pinakes.app.server090 + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.UserImportJob +import com.pinakes.app.ui.screens.staff.s090.patrons.OpenJobRow +import com.pinakes.app.ui.theme.PinakesTheme +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The open patron imports on the import screen, from the list the server answered. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class, qualifiers = "en-w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class Staff090OpenImportsUiTest { + private val compose = createComposeRule() + @get:Rule val rules: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app.packageManager).addActivityIfNotPresent(ComponentName(app.packageName, ComponentActivity::class.java.name)) + } + }).around(compose) + + private val jobs: List = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + .decodeFromString>>(javaClass.getResourceAsStream("/server090/patrons/ui_list.json")!!.bufferedReader().readText()).data!! + + private val taps = mutableListOf() + + private fun show(busy: String? = null) = compose.setContent { + PinakesTheme { + Column { jobs.forEach { j -> OpenJobRow(j, busy, onResume = { taps += "resume ${j.fileName}" }, onCancel = { taps += "cancel ${j.fileName}" }) } } + } + } + + @Test fun eachOpenJobSaysWhereItStandsAndOffersResumeAndCancel() { + show() + compose.onNodeWithText("readers-b.csv").assertExists() + compose.onNodeWithText("Started · Rows: 0 of 2").assertExists() + compose.onNodeWithText("Not started yet · rows: 2").assertExists() + compose.onNodeWithTag("open-resume-${jobs[1].id}").performClick() + compose.onNodeWithTag("open-cancel-${jobs[0].id}").performClick() + assertEquals(listOf("resume readers-a.csv", "cancel readers-b.csv"), taps) + } + + @Test fun whileOneJobIsBusyTheOthersWait() { + show(busy = jobs[0].id) + compose.onNodeWithTag("open-resume-${jobs[1].id}").assertIsNotEnabled() + compose.onNodeWithTag("open-cancel-${jobs[1].id}").assertIsNotEnabled() + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090PatronsParsingTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090PatronsParsingTest.kt new file mode 100644 index 0000000..3e5ea6b --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090PatronsParsingTest.kt @@ -0,0 +1,294 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.AttendanceOverview +import com.pinakes.app.data.model.AttendancePurgeResult +import com.pinakes.app.data.model.AttendanceSettingsResult +import com.pinakes.app.data.model.AttendanceVisit +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.model.LedgerChargeRequest +import com.pinakes.app.data.model.LedgerPaymentRequest +import com.pinakes.app.data.model.LedgerWriteResult +import com.pinakes.app.data.model.MetaEnvelope +import com.pinakes.app.data.model.PatronPhotoState +import com.pinakes.app.data.model.PatronType +import com.pinakes.app.data.model.PatronTypeRequest +import com.pinakes.app.data.model.PatronTypeUpdateMeta +import com.pinakes.app.data.model.PatronTypesMeta +import com.pinakes.app.data.model.ReturnRequest +import com.pinakes.app.data.model.StaffLedger +import com.pinakes.app.data.model.StaffLoan +import com.pinakes.app.data.model.StaffUser +import com.pinakes.app.data.model.StaffUserDetail +import com.pinakes.app.data.model.UserImportCancelResult +import com.pinakes.app.data.model.UserImportJob +import com.pinakes.app.data.model.UserImportStartRequest +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.staff.Staff090Areas +import com.pinakes.app.data.staff.Staff090PatronsErrors +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.R +import com.pinakes.app.ui.screens.staff.s090.patrons.toPatronsMessage +import com.pinakes.app.data.store.InstanceFeatures +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The patron tools' models against bodies captured from a real Pinakes 0.9.0-rc.1 server + * (Mobile API 1.10.0, branch feat/mobile-staff-090): `src/test/resources/server090/patrons`. + * Decoded with the app's own Json settings (NetworkModule.json). + */ +class Staff090PatronsParsingTest { + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + private fun body(name: String): String = + requireNotNull(javaClass.getResourceAsStream("/server090/patrons/$name.json")) { name }.bufferedReader().use { it.readText() } + + private inline fun data(name: String): T = requireNotNull(json.decodeFromString>(body(name)).data) { name } + + private fun failure(name: String, status: Int): ApiResult.Failure = parseErrorBody(body(name), status) + + @Test fun healthAdvertisesThePatronAreas() { + val health = data("health") + val areas = Staff090Areas.of("admin", InstanceFeatures.from(health), online = true) + assertTrue(areas.patrons && areas.patronTypes && areas.patronCards && areas.patronPhotos) + assertTrue(areas.ledger && areas.attendance && areas.userImport) + } + + @Test fun staffUserCarriesTheNewPatronFields() { + val user = data("user_detail") + assertEquals("PCAP090A", user.cardNumber) + assertEquals("standard", user.patronType?.code) + assertFalse(user.patronType!!.assigned) + assertFalse(user.hasPhoto) + assertNull(user.photoUrl) + assertEquals(0, user.ledger?.balanceCents) + assertEquals("EUR", user.ledger?.currency) + assertTrue(user.canEdit) + + val blocked = data("user_detail_blocked") + assertFalse(blocked.eligible) + assertEquals("debt_block", blocked.eligibilityReason) + assertEquals(1100, blocked.ledger?.balanceCents) + assertTrue(blocked.ledger!!.debtBlocked) + assertTrue(blocked.hasPhoto) + assertTrue(blocked.photoUrl!!.contains("/staff/users/113143/photo?v=")) + + val list = data>("users_search") + assertEquals("Standard", list.single().patronType?.name) + } + + @Test fun patronTypesKeepTheirMeta() { + val e = json.decodeFromString, PatronTypesMeta>>(body("patron_types2")) + assertEquals(2, e.data!!.size) + assertTrue(e.data!![0].isDefault) + assertEquals(30, e.data!![1].cardDurationDays) + assertNull(e.data!![0].cardDurationDays) + assertEquals(1, e.meta?.defaultId) + assertTrue(e.meta!!.canAssign) + } + + @Test fun patronTypeChangeAnswersTheUserAndTheExpiryFlag() { + val e = json.decodeFromString>(body("set_type2")) + assertEquals(3, e.data?.patronType?.id) + assertTrue(e.data!!.patronType!!.assigned) + assertEquals("2026-12-01", e.data?.cardExpiresAt) + assertTrue(e.meta!!.cardExpiryRecomputed) + // The default type is sent as 0, never as an omitted key (the server requires it). + assertEquals("""{"patron_type_id":0}""", json.encodeToString(PatronTypeRequest.serializer(), PatronTypeRequest(0))) + assertEquals("""{"patron_type_id":3,"recompute_card_expiry":true}""", json.encodeToString(PatronTypeRequest.serializer(), PatronTypeRequest(3, true))) + } + + @Test fun renewAndPhotoAnswers() { + assertEquals("2027-11-01", data("renew").cardExpiresAt) + assertEquals(true, json.decodeFromString>(body("renew_replay")).meta?.replayed) + val photo = data("photo_upload") + assertTrue(photo.hasPhoto) + assertNotNull(photo.photoUrl) + assertFalse(data("photo_delete").hasPhoto) + val refused = failure("renew_admin", 409) + assertEquals("card_not_renewable", refused.code) + } + + @Test fun ledgerIsReadInFull() { + val ledger = data("ledger_accrued") + assertEquals(500, ledger.balanceCents) + assertEquals(200, ledger.accruedCents) + assertEquals(98228, ledger.accrued.single().loanId) + assertEquals(4, ledger.accrued.single().daysLate) + assertEquals(1000, ledger.debtBlockThresholdCents) + assertTrue(ledger.automaticFines) + assertEquals(listOf("contanti", "pos", "bonifico", "altro"), ledger.paymentMethods.map { it.code }) + assertEquals("Card (POS)", ledger.paymentMethods[1].label) + assertEquals(99_999_999, ledger.maxAmountCents) + assertTrue(ledger.canRecordPayment && ledger.canCharge && ledger.canWaive) + + val page = json.decodeFromString>(body("ledger")) + assertEquals(2, page.data!!.items.size) + assertEquals(true, page.meta?.hasMore) + assertNotNull(page.meta?.nextCursor) + val waiver = page.data!!.items.first() + assertEquals("condono", waiver.type) + assertEquals(-250, waiver.amountCents) + assertEquals("E2E Audit", waiver.operator?.name) + val payment = page.data!!.items[1] + assertTrue(payment.receiptAvailable) + assertEquals("Card (POS)", payment.toMovement().paymentMethod) + + val last = json.decodeFromString>(body("ledger_page2")) + assertEquals(false, last.meta?.hasMore) + assertEquals(12, last.data!!.items.last().id) + // The balance card of the reader screen reads the same payload. + assertEquals(500, ledger.toPayload().balanceTotalCents) + } + + @Test fun ledgerWritesAndTheirRefusals() { + val charge = data("charge") + assertEquals(1250, charge.movement.amountCents) + assertEquals("addebito_manuale", charge.movement.type) + assertEquals(1250, charge.balanceCents) + val payment = data("payment") + assertEquals("pos", payment.movement.paymentMethod) + assertEquals(750, payment.balanceCents) + assertEquals(500, data("waiver").balanceCents) + + val tooMuch = failure("payment_too_much", 409) + assertEquals("amount_exceeds_balance", tooMuch.code) + assertEquals("amount_exceeds_balance", tooMuch.meta?.reason) + assertEquals("idempotency_conflict", failure("charge_conflict", 422).code) + val noKey = failure("charge_nokey", 422) + assertEquals("validation", noKey.code) + assertEquals("idempotency_key", noKey.meta?.field) + assertEquals("reason", failure("waiver_noreason", 422).meta?.field) + + assertEquals( + """{"amount_cents":500,"method":"pos"}""", + json.encodeToString(LedgerPaymentRequest.serializer(), LedgerPaymentRequest(500, "pos", null)), + ) + assertEquals("""{"amount_cents":1250,"description":"x"}""", json.encodeToString(LedgerChargeRequest.serializer(), LedgerChargeRequest(1250, "x"))) + } + + @Test fun loanLateFineAndTheWaiverOnReturn() { + val fine = data("loan_overdue").lateFine!! + assertEquals("EUR", fine.currency) + assertTrue(fine.automaticFines) + assertEquals(4, fine.accruingDays) + assertEquals(200, fine.accruingCents) + assertEquals(0, fine.chargedCents) + val returned = data("return_waived") + assertEquals("restituito", returned.status) + assertEquals(0, returned.lateFine!!.accruingCents) + assertEquals("waive_reason", failure("return_noreason", 422).meta?.field) + + assertEquals( + """{"outcome":"restituito","waive_fine":true,"waive_reason":"First time"}""", + json.encodeToString(ReturnRequest.serializer(), ReturnRequest("restituito", waiveFine = true, waiveReason = "First time")), + ) + // Without a waiver the body is the 1.7.0 one: no new keys. + assertEquals("""{"outcome":"restituito"}""", json.encodeToString(ReturnRequest.serializer(), ReturnRequest("restituito"))) + } + + @Test fun visitorLogOverviewAndVisits() { + val o = data("att_overview_branches") + assertEquals(7, o.days) + assertEquals(listOf(7, 30, 90, 365), o.periods) + assertEquals(3, o.today.total) + assertEquals(7, o.daily.size) + assertEquals(24, o.hourly.size) + assertEquals("Sede Pcap", o.branches.single().name) + assertEquals(365, o.retention.days) + assertEquals(3650, o.retention.maxDays) + assertTrue(o.canConfigure) + assertEquals("guest", o.recent[1].type) + + val ok = data("att_checkin") + assertEquals("member", ok.type) + assertEquals("Marta P.", ok.member?.shortName) + assertTrue(ok.member!!.cardActive) + assertNull(ok.warning) + val warn = data("att_checkin_suspended") + assertEquals("card_not_active", warn.warning) + assertTrue(warn.member!!.cardExpired) + assertEquals("Sede Pcap", warn.branch?.name) + val guest = data("att_guest") + assertEquals("Research", guest.guest?.reason) + assertNull(guest.member) + assertEquals("card_not_found", failure("att_unknown", 404).code) + assertEquals("branch_id", failure("att_bad_branch", 422).meta?.field) + + assertEquals(400, data("att_settings").retention.days) + assertEquals("retention_days", failure("att_settings_bad", 422).meta?.field) + assertEquals(0, data("att_purge").deleted) + } + + @Test fun aRefusedActionIsNotPermittedAndKeepsTheStaffRole() { + // A Staff account on an administrator's record and on the administrator-only settings. + for (name in listOf("np_patron_type", "np_photo_delete", "np_purge", "np_settings")) { + val f = failure(name, 403) + assertEquals(name, "not_permitted", f.code) + assertFalse(name, f.isForbidden()) + assertNull(name, Staff090PatronsErrors.messageRes(f)) + } + assertEquals("Operation reserved for administrators.", failure("np_purge", 403).toPatronsMessage(R.string.staff_error_save).text) + // The same operator reads the log, without the administrator's settings. + assertFalse(data("np_overview").canConfigure) + } + + @Test fun theOpenImportsList() { + val e = json.decodeFromString>>(body("ui_list")) + // Newest first: the started one (readers-b) was uploaded last. + assertEquals(listOf("readers-b.csv", "readers-a.csv"), e.data!!.map { it.fileName }) + assertTrue(e.data!![0].started) + assertFalse(e.data!![1].started) + assertEquals(2, e.data!![0].totalRows) + assertEquals(false, e.meta?.hasMore) + assertEquals(2, com.pinakes.app.data.staff.UserImportRules.openJobs(e.data!!).size) + val page = json.decodeFromString>>(body("ui_list_page")) + assertEquals(1, page.data!!.size) + assertEquals(true, page.meta?.hasMore) + assertNotNull(page.meta?.nextCursor) + // Cancelled jobs stay listed until they expire: none is offered again. + val after = json.decodeFromString>>(body("ui_list_after")).data!! + assertEquals(listOf("cancelled", "cancelled"), after.map { it.status }) + assertTrue(com.pinakes.app.data.staff.UserImportRules.openJobs(after).isEmpty()) + } + + @Test fun patronImportJob() { + val created = data("ui_upload") + assertEquals("ready", created.status) + assertFalse(created.started) + assertEquals(4, created.totalRows) + assertEquals(listOf("Nome", "Cognome", "Email", "Tessera"), created.headers) + assertEquals(4, created.preview.size) + assertEquals(listOf("nome", "email"), created.fields.filter { it.required }.map { it.key }) + assertEquals(listOf("nome", "cognome", "email", "codice_tessera"), created.mapping) + assertNull(created.sendInvites) + + val done = data("ui_chunk") + assertTrue(done.complete) + assertEquals(4, done.processed) + assertEquals(2, done.created) + assertEquals(1, done.duplicates) + assertEquals(1, done.failed) + assertEquals("duplicate", done.errors[1].type) + assertTrue(done.reportAvailable) + assertEquals(false, done.sendInvites) + + assertEquals(0, failure("ui_chunk_ooo", 409).meta?.processed) + assertEquals("not_started", failure("ui_chunk_notstarted", 409).code) + assertEquals("gone", failure("ui_chunk_gone", 410).code) + assertEquals("mapping", failure("ui_start_bad", 422).meta?.field) + assertEquals("file", failure("ui_upload_bad", 422).meta?.field) + assertTrue(data("ui_cancel").cancelled) + assertEquals( + """{"mapping":["nome",""],"send_invites":true}""", + json.encodeToString(UserImportStartRequest.serializer(), UserImportStartRequest(listOf("nome", ""), true)), + ) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090PatronsRulesTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090PatronsRulesTest.kt new file mode 100644 index 0000000..6423071 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090PatronsRulesTest.kt @@ -0,0 +1,208 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.R +import com.pinakes.app.data.model.AttendanceMember +import com.pinakes.app.data.model.AttendanceVisit +import com.pinakes.app.data.model.StaffLedger +import com.pinakes.app.data.model.StaffLedgerSummary +import com.pinakes.app.data.model.StaffLoanLateFine +import com.pinakes.app.data.model.StaffPatronTypeRef +import com.pinakes.app.data.model.StaffUserDetail +import com.pinakes.app.data.model.UserImportField +import com.pinakes.app.data.model.UserImportJob +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.model.Meta +import com.pinakes.app.data.staff.AttendanceRules +import com.pinakes.app.data.staff.ChunkStep +import com.pinakes.app.data.staff.LateFineRules +import com.pinakes.app.data.staff.LedgerAction +import com.pinakes.app.data.staff.LedgerRules +import com.pinakes.app.data.staff.MoneyInput +import com.pinakes.app.data.staff.PatronTools +import com.pinakes.app.data.staff.Staff090Areas +import com.pinakes.app.data.staff.Staff090PatronsErrors +import com.pinakes.app.data.staff.UserImportRules +import com.pinakes.app.data.staff.VisitOutcome +import com.pinakes.app.ui.screens.movements.MoneyFormat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Locale + +/** The pure decisions of the patron tools: money input, permissions, scan outcomes, import. */ +class Staff090PatronsRulesTest { + + // ---- Money ---- + + @Test fun amountsParseExactlyToCents() { + assertEquals(MoneyInput.Parsed.Cents(29), MoneyInput.parse("0.29")) + assertEquals(MoneyInput.Parsed.Cents(29), MoneyInput.parse("0,29")) + assertEquals(MoneyInput.Parsed.Cents(1250), MoneyInput.parse(" 12.5 ")) + assertEquals(MoneyInput.Parsed.Cents(1200), MoneyInput.parse("12")) + assertEquals(MoneyInput.Parsed.Cents(110), MoneyInput.parse("1.10")) + // 4.35 * 100 is 434.99999 as a double: the string path never loses the cent. + assertEquals(MoneyInput.Parsed.Cents(435), MoneyInput.parse("4.35")) + assertEquals(MoneyInput.Parsed.Cents(99_999_999), MoneyInput.parse("999999.99")) + } + + @Test fun badAmountsAreRefused() { + assertEquals(MoneyInput.Parsed.Empty, MoneyInput.parse(" ")) + listOf("0", "0.00", "-1", "1.234", "1,000.00", "abc", "1.", ".5", "1e3").forEach { + assertEquals(it, MoneyInput.Parsed.Invalid, MoneyInput.parse(it)) + } + assertEquals(MoneyInput.Parsed.TooLarge, MoneyInput.parse("5.01", maxCents = 500)) + assertEquals(MoneyInput.Parsed.Cents(500), MoneyInput.parse("5", maxCents = 500)) + } + + @Test fun amountTextAndFormatting() { + assertEquals("12.50", MoneyInput.text(1250)) + assertEquals("0.05", MoneyInput.text(5)) + assertEquals("€12.50", MoneyFormat.format(1250, "EUR", Locale.US)) + assertEquals("12,50 €", MoneyFormat.format(1250, "EUR", Locale.ITALY).replace(' ', ' ')) + } + + // ---- Ledger ---- + + private val ledger = StaffLedger(balanceCents = 750, maxAmountCents = 99_999_999, canRecordPayment = true, canCharge = true, canWaive = true) + + @Test fun ledgerActionsFollowTheServerFlagsAndTheBalance() { + assertEquals(listOf(LedgerAction.PAYMENT, LedgerAction.CHARGE, LedgerAction.WAIVER), LedgerRules.actions(ledger)) + // Nothing owed: only a charge makes sense. + assertEquals(listOf(LedgerAction.CHARGE), LedgerRules.actions(ledger.copy(balanceCents = 0))) + assertEquals(emptyList(), LedgerRules.actions(ledger.copy(canRecordPayment = false, canCharge = false, canWaive = false))) + assertEquals(750, LedgerRules.maxCents(LedgerAction.PAYMENT, ledger)) + assertEquals(750, LedgerRules.maxCents(LedgerAction.WAIVER, ledger)) + assertEquals(99_999_999, LedgerRules.maxCents(LedgerAction.CHARGE, ledger)) + } + + @Test fun ledgerFormsNeedTheirText() { + val ok = MoneyInput.Parsed.Cents(100) + assertTrue(LedgerRules.canSubmit(LedgerAction.PAYMENT, ok, "contanti", "")) + assertFalse(LedgerRules.canSubmit(LedgerAction.PAYMENT, ok, null, "")) + assertFalse(LedgerRules.canSubmit(LedgerAction.CHARGE, ok, null, " ")) + assertTrue(LedgerRules.canSubmit(LedgerAction.WAIVER, ok, null, "Goodwill")) + assertFalse(LedgerRules.canSubmit(LedgerAction.WAIVER, MoneyInput.Parsed.TooLarge, null, "Goodwill")) + } + + // ---- Patron record ---- + + private val allAreas = Staff090Areas(patrons = true, patronTypes = true, patronCards = true, patronPhotos = true, ledger = true) + private val reader = StaffUserDetail( + id = 7, cardNumber = "C-1", role = "standard", canEdit = true, + patronType = StaffPatronTypeRef(1, "standard", "Standard"), ledger = StaffLedgerSummary(0, "EUR"), + ) + + @Test fun patronToolsShowOnlyWhatTheServerOffers() { + val all = PatronTools.of(allAreas, reader) + assertTrue(all.section && all.photoEdit && all.patronTypeEdit && all.renewCard && all.printCard && all.ledger) + + val readOnly = PatronTools.of(allAreas, reader.copy(canEdit = false)) + assertTrue(readOnly.photo && readOnly.patronType) + assertFalse(readOnly.photoEdit || readOnly.patronTypeEdit) + + // Fines off (no ledger summary) and no patron types: those parts disappear. + val bare = PatronTools.of(allAreas, reader.copy(ledger = null, patronType = null)) + assertFalse(bare.ledger || bare.patronType) + + // Staff and administrator accounts have no card to renew (409 card_not_renewable). + assertFalse(PatronTools.of(allAreas, reader.copy(role = "admin")).renewCard) + assertFalse(PatronTools.of(allAreas, reader.copy(cardNumber = null)).printCard) + assertFalse(PatronTools.of(Staff090Areas.None, reader).section) + } + + // ---- Return with a late fine ---- + + @Test fun lateFineShowsOnlyWhenItWouldBeCharged() { + val fine = StaffLoanLateFine("EUR", automaticFines = true, accruingDays = 4, accruingCents = 200) + assertTrue(LateFineRules.shows(fine)) + assertFalse(LateFineRules.shows(fine.copy(accruingCents = 0))) + assertFalse(LateFineRules.shows(fine.copy(automaticFines = false))) + assertFalse(LateFineRules.shows(null)) + assertFalse(LateFineRules.canConfirm(waive = true, reason = " ")) + assertTrue(LateFineRules.canConfirm(waive = false, reason = "")) + assertEquals("First time", LateFineRules.waiveReason(fine, waive = true, reason = " First time ")) + assertNull(LateFineRules.waiveReason(fine, waive = false, reason = "x")) + assertNull(LateFineRules.waiveReason(fine.copy(accruingCents = 0), waive = true, reason = "x")) + } + + // ---- Visitor log ---- + + @Test fun checkInOutcomesAndTones() { + val welcome = AttendanceVisit(type = "member", member = AttendanceMember(shortName = "Marta P.")) + assertEquals(VisitOutcome.WELCOME, AttendanceRules.outcome(welcome)) + assertEquals(VisitOutcome.WARNING, AttendanceRules.outcome(welcome.copy(warning = "card_not_active"))) + assertEquals(VisitOutcome.WARNING, AttendanceRules.outcome(welcome.copy(member = AttendanceMember(cardExpired = true)))) + assertEquals(VisitOutcome.GUEST, AttendanceRules.outcome(AttendanceVisit(type = "guest"))) + assertEquals(VisitOutcome.UNKNOWN_CARD, AttendanceRules.outcome(ApiResult.Failure("card_not_found", "", 404))) + assertEquals(VisitOutcome.ERROR, AttendanceRules.outcome(ApiResult.Failure("network", ""))) + assertTrue(AttendanceRules.successTone(VisitOutcome.WELCOME)) + assertTrue(AttendanceRules.successTone(VisitOutcome.GUEST)) + assertFalse(AttendanceRules.successTone(VisitOutcome.WARNING)) + assertFalse(AttendanceRules.successTone(VisitOutcome.UNKNOWN_CARD)) + assertEquals("PCAP 090A", AttendanceRules.normalizeCard(" PCAP 090A \n")) + } + + @Test fun barsAndRetention() { + assertEquals(listOf(0.5f, 1f, 0f), AttendanceRules.barFractions(listOf(2, 4, 0))) + assertEquals(listOf(0f, 0f), AttendanceRules.barFractions(listOf(0, 0))) + assertEquals(0, AttendanceRules.parseRetention("0", 3650)) + assertEquals(3650, AttendanceRules.parseRetention("3650", 3650)) + assertNull(AttendanceRules.parseRetention("3651", 3650)) + assertNull(AttendanceRules.parseRetention("", 3650)) + } + + // ---- Patron CSV import ---- + + private val fields = listOf(UserImportField("nome", "First Name", true), UserImportField("cognome", "Last Name"), UserImportField("email", "Email", true)) + private val job = UserImportJob(id = "j", headers = listOf("A", "B", "C"), fields = fields, mapping = listOf("nome", "cognome"), totalRows = 25) + + @Test fun mappingEditor() { + assertEquals(listOf("nome", "cognome", ""), UserImportRules.aligned(job)) + // A field moves to the column it is assigned to; "" frees a column. + assertEquals(listOf("", "cognome", "nome"), UserImportRules.assign(listOf("nome", "cognome", ""), 2, "nome")) + assertEquals(listOf("nome", "", ""), UserImportRules.assign(listOf("nome", "cognome", ""), 1, "")) + assertEquals(listOf("email"), UserImportRules.missingRequired(fields, listOf("nome", "cognome", "")).map { it.key }) + assertFalse(UserImportRules.canStart(job, listOf("nome", "cognome", ""))) + assertTrue(UserImportRules.canStart(job, listOf("nome", "cognome", "email"))) + assertFalse(UserImportRules.canStart(job.copy(started = true), listOf("nome", "cognome", "email"))) + } + + @Test fun chunkDriverFollowsTheServer() { + assertEquals(ChunkStep.Next(10), UserImportRules.next(job.copy(started = true, processed = 10))) + assertEquals(ChunkStep.Done, UserImportRules.next(job.copy(status = "completed", complete = true))) + assertEquals(ChunkStep.Done, UserImportRules.next(job.copy(status = "failed"))) + assertEquals(40, UserImportRules.percent(job.copy(processed = 10))) + val ooo = ApiResult.Failure("out_of_order", "", 409, meta = Meta(processed = 20)) + assertEquals(ChunkStep.Next(20), UserImportRules.afterFailure(ooo, 10, 0)) + assertEquals(ChunkStep.Wait(10), UserImportRules.afterFailure(ApiResult.Failure("job_busy", "", 409), 10, 1)) + assertTrue(UserImportRules.afterFailure(ApiResult.Failure("not_started", "", 409), 0, 0) is ChunkStep.Stop) + assertTrue(UserImportRules.afterFailure(ApiResult.Failure("gone", "", 410), 0, 0) is ChunkStep.Stop) + assertTrue(UserImportRules.acceptsFileName("readers.CSV")) + assertTrue(UserImportRules.acceptsFileName("readers.tsv")) + assertFalse(UserImportRules.acceptsFileName("readers.xlsx")) + } + + // ---- Errors ---- + + @Test fun errorCodesMapToOurWording() { + assertEquals(R.string.staff090_patrons_err_card_not_renewable, Staff090PatronsErrors.messageRes(ApiResult.Failure("card_not_renewable", "", 409))) + assertEquals(R.string.staff090_patrons_err_amount_exceeds_balance, Staff090PatronsErrors.messageRes(ApiResult.Failure("amount_exceeds_balance", "", 409))) + assertEquals(R.string.staff090_patrons_err_card_not_found, Staff090PatronsErrors.messageRes(ApiResult.Failure("card_not_found", "", 404))) + assertEquals(R.string.staff090_patrons_err_file_missing, Staff090PatronsErrors.messageRes(ApiResult.Failure("file_missing", "", 409))) + assertEquals(R.string.staff090_patrons_err_db_update_pending, Staff090PatronsErrors.messageRes(ApiResult.Failure("not_available", "", 503))) + // The copy desk's 409 not_available keeps its own wording (StaffErrors). + assertNull(Staff090PatronsErrors.messageRes(ApiResult.Failure("not_available", "", 409))) + // A 422 validation carries the precise server text: never replaced. + assertNull(Staff090PatronsErrors.messageRes(ApiResult.Failure("validation", "Enter the library card code.", 422))) + } + + @Test fun aVisitToneSoundsOnce() { + assertTrue(AttendanceRules.tonePending(tick = 1, played = 0)) + // Played, then the screen recomposes or rotates: nothing new to play. + assertFalse(AttendanceRules.tonePending(tick = 1, played = 1)) + assertFalse(AttendanceRules.tonePending(tick = 0, played = 0)) + assertTrue(AttendanceRules.tonePending(tick = 2, played = 1)) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090ReturnFineUiTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090ReturnFineUiTest.kt new file mode 100644 index 0000000..ab8ee80 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090ReturnFineUiTest.kt @@ -0,0 +1,92 @@ +package com.pinakes.app.server090 + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.model.StaffLoanLateFine +import com.pinakes.app.ui.screens.staff.s090.patrons.rememberReturnFineState +import com.pinakes.app.ui.theme.PinakesTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.util.Locale + +/** + * The late fine block of the return form: the amount a return today charges, the waiver switch + * and its mandatory reason; nothing at all when the loan accrues no fine. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class, qualifiers = "en-w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class Staff090ReturnFineUiTest { + private val compose = createComposeRule() + @get:Rule val rules: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + Locale.setDefault(Locale.US) + val app = ApplicationProvider.getApplicationContext() + shadowOf(app.packageManager).addActivityIfNotPresent(ComponentName(app.packageName, ComponentActivity::class.java.name)) + } + }).around(compose) + + private var sent: Pair? = null + + private fun show(fine: StaffLoanLateFine?) { + compose.setContent { + PinakesTheme { + val state = rememberReturnFineState(fine) + Column { + state.Content() + Button(onClick = { sent = true to state.waiveReason() }, enabled = state.canConfirm, modifier = Modifier.testTag("confirm")) { Text("Return") } + } + } + } + } + + @Test fun theFineIsShownAndAWaiverNeedsItsReason() { + // The body the server answered for a loan 4 days late at 0.50 a day. + show(StaffLoanLateFine("EUR", automaticFines = true, accruingDays = 4, accruingCents = 200, chargedCents = 0)) + compose.onNodeWithTag("return-fine").assertExists() + compose.onNodeWithText("Late fine: €2.00 (days late: 4)").assertExists() + compose.onNodeWithTag("confirm").assertIsEnabled() + compose.onNodeWithText("Waive the late fine").performClick() + compose.onNodeWithTag("confirm").assertIsNotEnabled() + compose.onNodeWithTag("return-waive-reason").performTextInput("First time") + compose.onNodeWithTag("confirm").assertIsEnabled().performClick() + assertEquals(true to "First time", sent) + } + + @Test fun withoutAnAccruingFineNothingIsShownOrSent() { + show(StaffLoanLateFine("EUR", automaticFines = true, accruingDays = 0, accruingCents = 0)) + compose.onNodeWithTag("return-fine").assertDoesNotExist() + compose.onNodeWithTag("confirm").assertIsEnabled().performClick() + assertNull(sent!!.second) + } + + @Test fun anOlderServerWithoutTheFieldKeepsTheOldForm() { + show(null) + compose.onNodeWithTag("return-fine").assertDoesNotExist() + compose.onNodeWithTag("confirm").assertIsEnabled() + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090StocktakeLogicTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090StocktakeLogicTest.kt new file mode 100644 index 0000000..5826453 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090StocktakeLogicTest.kt @@ -0,0 +1,359 @@ +package com.pinakes.app.server090 + +import android.media.AudioManager +import com.pinakes.app.R +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.Staff090Statistics +import com.pinakes.app.data.model.Staff090StocktakeColor +import com.pinakes.app.data.model.Staff090StocktakeLimits +import com.pinakes.app.data.model.Staff090StocktakeOptions +import com.pinakes.app.data.model.Staff090StocktakeScan +import com.pinakes.app.data.model.Staff090StocktakeScanResult +import com.pinakes.app.data.model.Staff090StocktakeSession +import com.pinakes.app.data.model.Staff090StocktakeState +import com.pinakes.app.data.model.Staff090StocktakeTone +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.repository.Staff090StatisticsRepository +import com.pinakes.app.data.repository.Staff090StocktakeRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.Staff090StocktakeErrors +import com.pinakes.app.ui.screens.movements.MoneyFormat +import com.pinakes.app.ui.screens.staff.s090.stocktake.Staff090StatisticsBars +import com.pinakes.app.ui.screens.staff.s090.stocktake.Staff090StocktakeClose +import com.pinakes.app.ui.screens.staff.s090.stocktake.Staff090StocktakeColors +import com.pinakes.app.ui.screens.staff.s090.stocktake.Staff090StocktakeCounters +import com.pinakes.app.ui.screens.staff.s090.stocktake.Staff090StocktakeForm +import com.pinakes.app.ui.screens.staff.s090.stocktake.Staff090StocktakeMerge +import com.pinakes.app.ui.screens.staff.s090.stocktake.Staff090StocktakeScanGate +import com.pinakes.app.ui.screens.staff.s090.stocktake.Staff090StocktakeSound +import com.pinakes.app.ui.screens.staff.s090.stocktake.Staff090StocktakeUploadRules +import com.pinakes.app.ui.screens.staff.s090.stocktake.normalizeStocktakeCode +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Locale +import kotlin.math.abs + +/** The pure decisions of the stocktake and statistics screens, against captured server bodies where they read one. */ +class Staff090StocktakeLogicTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + private fun raw(name: String): String = + requireNotNull(javaClass.getResourceAsStream("/server090/stocktake/$name.json")).bufferedReader().use { it.readText() } + + private fun data(name: String, serializer: KSerializer): T = json.decodeFromString(Envelope.serializer(serializer), raw(name)).data!! + + private val options by lazy { data("s090_stocktake_options_admin", Staff090StocktakeOptions.serializer()) } + + // ---------- Scan gate: debounce and deliberate replay ---------- + + @Test fun aLabelNeedsTwoIdenticalReadsBeforeItIsSent() { + val gate = Staff090StocktakeScanGate() + assertNull(gate.offer("A1", 0)) + assertEquals("A1", gate.offer("A1", 40)) + } + + @Test fun aLabelHeldUnderTheCameraIsSentOnceHoweverLongItStays() { + val gate = Staff090StocktakeScanGate(awayMs = 1_500) + gate.offer("A1", 0); assertEquals("A1", gate.offer("A1", 40)) + // The camera keeps decoding it for ten seconds: the window slides with every decode. + var t = 80L + while (t < 10_000) { + assertNull("re-sent at $t", gate.offer("A1", t)) + t += 200 + } + } + + @Test fun readingTheSameLabelAgainAfterMovingAwayIsSent() { + val gate = Staff090StocktakeScanGate(awayMs = 1_500) + gate.offer("A1", 0); assertEquals("A1", gate.offer("A1", 40)) + // Out of view for two seconds, then back: a deliberate second read (a repeat for the server). + assertNull(gate.offer("A1", 2_100)) + assertEquals("A1", gate.offer("A1", 2_140)) + } + + @Test fun aBriefDropoutWhileHoldingTheLabelIsNotASecondRead() { + val gate = Staff090StocktakeScanGate(awayMs = 1_500) + gate.offer("A1", 0); gate.offer("A1", 40) + assertNull(gate.offer("A1", 900)) + assertNull(gate.offer("A1", 2_000)) // 1.1 s after the last decode: still the same hold + } + + @Test fun anotherLabelInBetweenMakesTheFirstOneNewAgain() { + val gate = Staff090StocktakeScanGate() + gate.offer("A1", 0); assertEquals("A1", gate.offer("A1", 40)) + gate.offer("A2", 100); assertEquals("A2", gate.offer("A2", 140)) + gate.offer("A1", 200) + assertEquals("A1", gate.offer("A1", 240)) + } + + @Test fun aMisreadInBetweenRestartsTheConfirmation() { + val gate = Staff090StocktakeScanGate() + assertNull(gate.offer("A1", 0)) + assertNull(gate.offer("A7", 20)) + assertNull(gate.offer("A1", 40)) + assertEquals("A1", gate.offer("A1", 60)) + } + + @Test fun aTypedCodeIsNotSentAgainByTheCameraStraightAway() { + val gate = Staff090StocktakeScanGate() + gate.markSent("A1", 0) + assertNull(gate.offer("A1", 100)) + assertNull(gate.offer("A1", 200)) + } + + @Test fun pausedSendsNothingAndResumeStartsAfresh() { + val gate = Staff090StocktakeScanGate() + gate.pause() + assertNull(gate.offer("A1", 0)); assertNull(gate.offer("A1", 40)) + assertTrue(gate.paused) + gate.resume() + assertNull(gate.offer("A1", 80)) + assertEquals("A1", gate.offer("A1", 120)) + } + + @Test fun codesAreNormalisedAsTheServerStoresThem() { + assertEquals("AST-A1", normalizeStocktakeCode(" AST-A1\n")) + assertEquals("INV 0042", normalizeStocktakeCode("INV 0042\u0007")) + assertEquals("", normalizeStocktakeCode(" \t ")) + } + + // ---------- Tone ---------- + + @Test fun theScanAnswersToneIsPlayedElseTheOutcomesFromTheOptions() { + val found = data("s090_stocktake_scan_found", Staff090StocktakeScanResult.serializer()) + assertEquals(Staff090StocktakeTone(880, 120), Staff090StocktakeSound.toneFor(found.scan, found.tone, options)) + // An answer without a tone falls back to the options' tone of its outcome. + assertEquals(Staff090StocktakeTone(880, 120), Staff090StocktakeSound.toneFor(found.scan, null, options)) + val unknown = data("s090_stocktake_scan_unknown", Staff090StocktakeScanResult.serializer()) + assertEquals(Staff090StocktakeTone(220, 450), Staff090StocktakeSound.toneFor(unknown.scan, unknown.tone, null)) + // Every scan outcome of the options has a tone; missing (never a scan answer) has none. + options.outcomes.forEach { o -> assertEquals(o.code, o.scanResult, o.tone != null) } + } + + @Test fun theToneIsSilentUnlessTheRingerIsNormal() { + val tone = Staff090StocktakeTone(880, 120) + assertEquals(Staff090StocktakeSound.Spec(880, 120), Staff090StocktakeSound.spec(tone, AudioManager.RINGER_MODE_NORMAL)) + assertNull(Staff090StocktakeSound.spec(tone, AudioManager.RINGER_MODE_SILENT)) + assertNull(Staff090StocktakeSound.spec(tone, AudioManager.RINGER_MODE_VIBRATE)) + assertNull(Staff090StocktakeSound.spec(null, AudioManager.RINGER_MODE_NORMAL)) + assertNull(Staff090StocktakeSound.spec(Staff090StocktakeTone(0, 0), AudioManager.RINGER_MODE_NORMAL)) + } + + @Test fun theToneIsClampedToWhatASpeakerPlays() { + assertEquals(Staff090StocktakeSound.Spec(150, 40), Staff090StocktakeSound.spec(Staff090StocktakeTone(20, 5), AudioManager.RINGER_MODE_NORMAL)) + assertEquals(Staff090StocktakeSound.Spec(4_000, 800), Staff090StocktakeSound.spec(Staff090StocktakeTone(20_000, 9_000), AudioManager.RINGER_MODE_NORMAL)) + } + + @Test fun theSamplesLastTheToneAndFadeAtBothEnds() { + val samples = Staff090StocktakeSound.samples(Staff090StocktakeSound.Spec(880, 120), sampleRate = 22_050) + assertEquals(22_050 * 120 / 1000, samples.size) + assertEquals(0, samples.first().toInt()) + assertTrue(abs(samples.last().toInt()) < 200) + assertTrue(samples.maxOf { it.toInt() } > 10_000) + // 880 Hz: about 106 rising zero crossings in 120 ms. + val crossings = (1 until samples.size).count { samples[it - 1] < 0 && samples[it] >= 0 } + assertTrue("crossings $crossings", crossings in 100..110) + } + + // ---------- Colours ---------- + + @Test fun outcomeColoursComeFromTheServerHex() { + assertEquals(0xFF166534L, Staff090StocktakeColors.argb("#166534")) + assertEquals(0xFFDCFCE7L, Staff090StocktakeColors.argb("dcfce7")) + assertEquals(0xFFAABBCCL, Staff090StocktakeColors.argb("#abc")) + assertNull(Staff090StocktakeColors.argb("green")) + assertNull(Staff090StocktakeColors.argb(null)) + val misplaced = data("s090_stocktake_scan_misplaced", Staff090StocktakeScanResult.serializer()).scan + assertEquals("amber", Staff090StocktakeColors.forOutcome(misplaced.outcome, misplaced.color, null)?.name) + // A row without a usable colour takes the options' one, else none (the neutral pair). + assertEquals("red", Staff090StocktakeColors.forOutcome("unknown", Staff090StocktakeColor("x", "", ""), options)?.name) + assertNull(Staff090StocktakeColors.forOutcome("whatever", null, options)) + } + + // ---------- Counters, merge, polling ---------- + + @Test fun countersListEveryWebCounterAndLostOnlyOnceMarked() { + val open = data("s090_stocktake_state", Staff090StocktakeState.serializer()) + val keys = Staff090StocktakeCounters.of(open.summary).map { it.key } + assertEquals(listOf("expected", "found", "misplaced", "on_loan", "missing", "unknown", "out_of_scope", "duplicates", "mine"), keys) + val lost = data("s090_stocktake_closed_lost", Staff090StocktakeSession.serializer()) + assertTrue("lost" in Staff090StocktakeCounters.of(lost.summary).map { it.key }) + assertEquals(2, Staff090StocktakeCounters.of(lost.summary).single { it.key == "lost" }.value) + } + + @Test fun aScanAnswerGoesOnTopOnceAndAClosedSessionStopsScanning() { + val detail = data("s090_stocktake_session_detail", Staff090StocktakeSession.serializer()) + val newest = detail.recentScans.first() + assertEquals(detail.recentScans.size, Staff090StocktakeMerge.prepend(detail.recentScans, newest).size) + val fresh = newest.copy(id = newest.id + 1000) + assertEquals(fresh, Staff090StocktakeMerge.prepend(detail.recentScans, fresh).first()) + assertEquals(3, Staff090StocktakeMerge.prepend(detail.recentScans, fresh, max = 3).size) + assertTrue(Staff090StocktakeMerge.shouldPoll(detail)) + val closed = Staff090StocktakeMerge.withStatus(detail, "closed") + assertFalse(closed.canScan || closed.canUpload || closed.canClose || closed.canCancel) + assertFalse(Staff090StocktakeMerge.shouldPoll(closed)) + assertEquals(detail, Staff090StocktakeMerge.withStatus(detail, "open")) + // A poll read before the close still says "open": a closed or cancelled session stays so. + assertEquals(closed, Staff090StocktakeMerge.withStatus(closed, "open")) + val cancelled = Staff090StocktakeMerge.withStatus(detail, "cancelled") + assertEquals(cancelled, Staff090StocktakeMerge.withStatus(cancelled, "open")) + assertFalse(Staff090StocktakeMerge.withStatus(closed, "open").canClose) + assertFalse(Staff090StocktakeMerge.shouldPoll(null)) + } + + // ---------- Create form ---------- + + @Test fun theCreateFormSendsOnlyWhatWasChosen() { + val limits = Staff090StocktakeLimits() + val blank = Staff090StocktakeForm() + assertFalse(blank.valid(limits)) + assertFalse(blank.copy(name = " ").valid(limits)) + val form = Staff090StocktakeForm(name = " Shelf A ", note = " ", collocationFrom = " A ", mediaType = "") + assertTrue(form.valid(limits)) + val body = json.encodeToJsonElement(com.pinakes.app.data.model.Staff090StocktakeCreateRequest.serializer(), form.request()).jsonObject + assertEquals(setOf("name", "collocation_from"), body.keys) + assertFalse(form.copy(collocationTo = "x".repeat(51)).valid(limits)) + assertFalse(form.copy(name = "x".repeat(256)).valid(limits)) + } + + @Test fun aLevelBelongsToItsShelf() { + val shelf = options.shelves.first { it.levels.isNotEmpty() } + val other = options.shelves.first { it.id != shelf.id } + val form = Staff090StocktakeForm(name = "x").withShelf(shelf.id).copy(levelId = shelf.levels.first().id) + assertEquals(shelf.levels, form.levels(options.shelves)) + assertEquals(shelf.levels.first().id, form.withShelf(shelf.id).levelId) + assertNull(form.withShelf(other.id).levelId) + assertNull(form.withShelf(null).request().levelId) + assertEquals(shelf.id, form.request().shelfId) + } + + // ---------- Close ---------- + + @Test fun lostIsOfferedOnlyWhenTheServerSaysSo() { + val staff = data("s090_stocktake_session_detail", Staff090StocktakeSession.serializer()) + assertEquals(listOf("none"), Staff090StocktakeClose.actions(staff)) + val admin = data("s090_stocktake_session_detail_mine", Staff090StocktakeSession.serializer()) + assertEquals(listOf("none", "lost"), Staff090StocktakeClose.actions(admin)) + assertEquals(staff.summary.missing, Staff090StocktakeClose.affected(staff)) + assertEquals(R.string.staff090_stocktake_close_confirm_none, Staff090StocktakeClose.confirmText(staff, "none")) + assertEquals(R.string.staff090_stocktake_close_confirm_lost, Staff090StocktakeClose.confirmText(admin, "lost")) + val closed = data("s090_stocktake_closed_none", Staff090StocktakeSession.serializer()) + assertTrue(Staff090StocktakeClose.actions(closed).isEmpty()) + assertFalse(Staff090StocktakeClose.canResume(closed)) + // A mark-lost close cut short: closed, still markable by the server's word, with copies pending. + val cut = data("s090_stocktake_closed_lost", Staff090StocktakeSession.serializer()).copy(canMarkLost = true, pendingLost = 3) + assertTrue(Staff090StocktakeClose.canResume(cut)) + assertEquals(listOf("lost"), Staff090StocktakeClose.actions(cut)) + assertEquals(3, Staff090StocktakeClose.affected(cut)) + assertEquals(R.string.staff090_stocktake_close_confirm_resume, Staff090StocktakeClose.confirmText(cut, "lost")) + } + + // ---------- Upload ---------- + + @Test fun onlyTxtAndCsvUpToTheLimitAreUploaded() { + val limits = Staff090StocktakeUploadRules.Limits.of(options.limits) + assertTrue(Staff090StocktakeUploadRules.acceptsName("codes.TXT", limits)) + assertTrue(Staff090StocktakeUploadRules.acceptsName("shelf a.csv", limits)) + assertFalse(Staff090StocktakeUploadRules.acceptsName("codes.pdf", limits)) + assertFalse(Staff090StocktakeUploadRules.acceptsName("codes", limits)) + assertTrue(Staff090StocktakeUploadRules.acceptsSize(2_097_152, limits)) + assertFalse(Staff090StocktakeUploadRules.acceptsSize(2_097_153, limits)) + assertFalse(Staff090StocktakeUploadRules.acceptsSize(0, limits)) + } + + // ---------- Errors ---------- + + @Test fun errorCodesOfTheArea() { + val refused = parseErrorBody(raw("s090_stocktake_close_lost_refused"), 403) + assertTrue(Staff090StocktakeErrors.isNotPermitted(refused)) + // not_permitted is not the role gate: the stored role stays. + assertFalse(refused.isForbidden()) + assertNull(Staff090StocktakeErrors.messageRes(refused.code)) + val closed = parseErrorBody(raw("s090_stocktake_scan_session_closed"), 409) + assertTrue(Staff090StocktakeErrors.sessionEnded(closed)) + assertEquals(R.string.staff090_stocktake_err_session_closed, Staff090StocktakeErrors.messageRes(closed.code)) + assertTrue(Staff090StocktakeErrors.sessionEnded(parseErrorBody(raw("s090_stocktake_scan_session_cancelled"), 409))) + assertEquals(R.string.staff090_stocktake_err_report_too_large, Staff090StocktakeErrors.messageRes("report_too_large")) + assertFalse(Staff090StocktakeErrors.sessionEnded(ApiResult.Failure("session_closed", "proxy", 409, fromEnvelope = false))) + assertFalse(Staff090StocktakeErrors.isNotPermitted(ApiResult.Failure(ErrorCodes.FORBIDDEN, "", 403, fromEnvelope = true))) + assertTrue(Staff090StocktakeErrors.moduleOff(ApiResult.Failure("feature_disabled", "", 404, fromEnvelope = true))) + } + + // ---------- Statistics ---------- + + @Test fun barsScaleToTheLargestAndMonthsReadAsMonths() { + assertEquals(1f, Staff090StatisticsBars.fraction(15, 15)) + assertEquals(0.5f, Staff090StatisticsBars.fraction(5, 10)) + assertEquals(0.02f, Staff090StatisticsBars.fraction(1, 1000)) + assertEquals(0f, Staff090StatisticsBars.fraction(0, 10)) + assertEquals(0f, Staff090StatisticsBars.fraction(3, 0)) + assertEquals("Sep 2026", Staff090StatisticsBars.monthLabel("2026-09", Locale.ENGLISH)) + assertEquals("not-a-month", Staff090StatisticsBars.monthLabel("not-a-month", Locale.ENGLISH)) + } + + @Test fun finesAmountsAreFormattedInTheServerCurrency() { + val report = data("s090_statistics_full", Staff090Statistics.serializer()).finesReport!! + val it = MoneyFormat.format(report.totals.chargedCents, report.currency, Locale.ITALY) + assertTrue(it, it.contains("12,50") && it.contains("€")) + assertEquals("€5.00", MoneyFormat.format(report.totals.collectedCents, report.currency, Locale.UK)) + } + + @Test fun downloadPaths() { + assertEquals("staff/statistics/fines.csv?fines_from=2026-01-01&fines_to=2026-11-30", Staff090StatisticsRepository.finesCsvPath("2026-01-01", "2026-11-30")) + assertEquals("staff/statistics/fines.csv", Staff090StatisticsRepository.finesCsvPath(null, " ")) + assertEquals("staff/stocktake/sessions/41/report.csv", Staff090StocktakeRepository.reportCsvPath(41)) + assertEquals("staff/stocktake/sessions/41/report.pdf", Staff090StocktakeRepository.reportPdfPath(41)) + } + + @Test fun aPagedOutcomeListKeepsItsPagesWhenThePollReadsThePageOneAgain() { + fun scans(range: IntProgression) = range.map { Staff090StocktakeScan(id = it, code = "C$it") } + val pageOne = scans(60 downTo 11) + // Not paged yet: the poll's first page replaces the list. + assertEquals(pageOne to "next", Staff090StocktakeMerge.refreshedFiltered(scans(55 downTo 6), "old", false, pageOne, "next")) + // Paged to the end (cursor null): the 55 rows stay, no "Show more" comes back. + val all = scans(55 downTo 1) + val (kept, cursor) = Staff090StocktakeMerge.refreshedFiltered(all, null, true, scans(55 downTo 6), "page2") + assertEquals(all, kept) + assertEquals(null, cursor) + // A scan recorded meanwhile goes on top, once. + val (withNew, _) = Staff090StocktakeMerge.refreshedFiltered(all, null, true, scans(56 downTo 7), "page2") + assertEquals(56, withNew.first().id) + assertEquals(1, withNew.last().id) + assertEquals(56, withNew.size) + } + + @Test fun moreThanAPageOfNewScansRestartsFromPageOneInsteadOfLeavingAGap() { + fun scans(range: IntProgression) = range.map { Staff090StocktakeScan(id = it, code = "C$it") } + val all = scans(55 downTo 1) + // 120 scans arrived while the screen was in the background: page one (170..121) shares + // nothing with the list, so rows 120..56 would be hidden under it. + val (list, cursor) = Staff090StocktakeMerge.refreshedFiltered(all, null, true, scans(170 downTo 121), "after-121") + assertEquals(scans(170 downTo 121), list) + assertEquals("after-121", cursor) + // An empty first page (every scan of this outcome gone) empties the list too. + assertEquals(emptyList() to null, Staff090StocktakeMerge.refreshedFiltered(all, null, true, emptyList(), null)) + } + + @Test fun aReloadAnsweredAfterTheCloseNeverReopensTheSession() { + val detail = data("s090_stocktake_session_detail", Staff090StocktakeSession.serializer()) + val closed = Staff090StocktakeMerge.withStatus(detail, "closed") + // The close answered first: the reload left while the session was open. + assertFalse(Staff090StocktakeMerge.acceptReload(closed, asked = "open", answer = detail)) + // Even asked after the close, an "open" answer does not undo it. + assertFalse(Staff090StocktakeMerge.acceptReload(closed, asked = "closed", answer = detail)) + // The normal cases: first load, a fresh open session, another operator's close. + assertTrue(Staff090StocktakeMerge.acceptReload(null, asked = null, answer = detail)) + assertTrue(Staff090StocktakeMerge.acceptReload(detail, asked = "open", answer = detail)) + assertTrue(Staff090StocktakeMerge.acceptReload(detail, asked = "open", answer = closed)) + assertTrue(Staff090StocktakeMerge.acceptReload(closed, asked = "closed", answer = closed)) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090StocktakeModelsTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090StocktakeModelsTest.kt new file mode 100644 index 0000000..c1ec966 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090StocktakeModelsTest.kt @@ -0,0 +1,236 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.model.Staff090Statistics +import com.pinakes.app.data.model.Staff090StocktakeOptions +import com.pinakes.app.data.model.Staff090StocktakeReportRow +import com.pinakes.app.data.model.Staff090StocktakeScan +import com.pinakes.app.data.model.Staff090StocktakeScanResult +import com.pinakes.app.data.model.Staff090StocktakeSession +import com.pinakes.app.data.model.Staff090StocktakeState +import com.pinakes.app.data.model.Staff090StocktakeUpload +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.staff.Staff090Areas +import com.pinakes.app.data.store.InstanceFeatures +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The stocktake and statistics areas (Mobile API 1.10.0) decode the bodies the server really + * sends: every sample under `test/resources/server090/stocktake` was captured with curl from a + * Pinakes 0.9.0 instance (feat/mobile-staff-090) as an administrator and as a staff member, on + * shelves and copies created for the capture and removed afterwards. + */ +class Staff090StocktakeModelsTest { + + // Same configuration as NetworkModule.json. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + private fun raw(name: String): String = + requireNotNull(javaClass.getResourceAsStream("/server090/stocktake/$name.json")) { "missing sample $name" }.bufferedReader().use { it.readText() } + + private fun envelope(name: String, serializer: KSerializer): Envelope = json.decodeFromString(Envelope.serializer(serializer), raw(name)) + + private fun data(name: String, serializer: KSerializer): T = requireNotNull(envelope(name, serializer).data) { "no data in $name" } + + @Test fun healthAdvertisesBothAreasAndTheyHideOffline() { + val health = data("s090_stocktake_health", HealthPayload.serializer()) + assertTrue(health.features.staffStocktake && health.features.staffStatistics && health.features.stocktake) + val areas = Staff090Areas.of("staff", InstanceFeatures.from(health), online = true) + assertTrue(areas.stocktake && areas.statistics) + val offline = Staff090Areas.of("staff", InstanceFeatures.from(health), online = false) + assertFalse(offline.stocktake || offline.statistics) + } + + @Test fun optionsCarryShelvesOutcomesWithColourAndToneAndTheAdminOnlyAction() { + val admin = data("s090_stocktake_options_admin", Staff090StocktakeOptions.serializer()) + assertTrue(admin.canMarkLost) + val shelf = admin.shelves.first() + assertTrue(shelf.label.contains(shelf.code)) + assertEquals(1, shelf.levels.single().level) + val found = admin.outcomes.single { it.code == "found" } + assertEquals("green", found.color?.name) + assertEquals("#166534", found.color?.foreground) + assertEquals(880, found.tone?.frequencyHz) + assertEquals(120, found.tone?.durationMs) + val missing = admin.outcomes.single { it.code == "missing" } + assertFalse(missing.scanResult) + assertNull(missing.tone) + assertEquals(listOf("duplicate", "found", "misplaced", "missing", "on_loan", "out_of_scope", "unknown"), admin.outcomes.map { it.code }.sorted()) + assertEquals(listOf("libro", "disco", "audiolibro", "dvd", "altro"), admin.mediaTypes.map { it.code }) + assertTrue(admin.missingActions.single { it.code == "lost" }.adminOnly) + assertEquals(100, admin.limits.maxCodeLength) + assertEquals(2_097_152L, admin.limits.maxUploadBytes) + assertEquals(listOf("txt", "csv"), admin.limits.uploadExtensions) + assertEquals(listOf("open", "closed", "cancelled"), admin.statuses.map { it.code }) + assertTrue(admin.genres.isNotEmpty()) + val staff = data("s090_stocktake_options_staff", Staff090StocktakeOptions.serializer()) + assertFalse(staff.canMarkLost) + } + + @Test fun aCreatedSessionAndItsIdempotentReplay() { + val created = data("s090_stocktake_session_created", Staff090StocktakeSession.serializer()) + assertTrue(created.isOpen) + assertEquals(4, created.expectedTotal) + assertEquals(created.summary.expected, created.expectedTotal) + assertEquals("From the phone", created.note) + assertNotNull(created.filter.shelfId) + assertNull(created.filter.levelId) + assertTrue(created.filterLabels.isNotEmpty()) + assertTrue(created.canScan && created.canUpload && created.canClose && created.canCancel) + assertFalse(created.canMarkLost) + assertEquals("inventory.opened", created.history.single().event) + assertTrue(created.openedAt!!.endsWith("+00:00")) + val replay = envelope("s090_stocktake_session_replayed", Staff090StocktakeSession.serializer()) + assertEquals(true, replay.meta?.replayed) + assertEquals(created.id, replay.data?.id) + } + + @Test fun everyScanOutcomeComesWithItsColourToneAndCounters() { + val found = data("s090_stocktake_scan_found", Staff090StocktakeScanResult.serializer()) + assertEquals("found", found.scan.outcome) + assertEquals(880, found.tone?.frequencyHz) + assertEquals(1, found.summary.found) + assertEquals(1, found.summary.mine) + assertEquals("open", found.sessionStatus) + assertNotNull(found.scan.copyId) + assertNotNull(found.scan.operator) + val misplaced = data("s090_stocktake_scan_misplaced", Staff090StocktakeScanResult.serializer()) + assertEquals("misplaced", misplaced.scan.outcome) + assertTrue(misplaced.scan.expectedShelf!!.isNotBlank()) + assertEquals("amber", misplaced.scan.color?.name) + val onLoan = data("s090_stocktake_scan_on_loan", Staff090StocktakeScanResult.serializer()) + assertEquals("on_loan", onLoan.scan.outcome) + val unknown = data("s090_stocktake_scan_unknown", Staff090StocktakeScanResult.serializer()) + assertEquals("unknown", unknown.scan.outcome) + assertNull(unknown.scan.copyId) + assertNull(unknown.scan.title) + assertEquals(220, unknown.tone?.frequencyHz) + val outOfScope = data("s090_stocktake_scan_out_of_scope", Staff090StocktakeScanResult.serializer()) + assertEquals("out_of_scope", outOfScope.scan.outcome) + assertFalse(outOfScope.scan.offShelf) + // A repeat is stored without its copy, but the answer still names it. + val duplicate = data("s090_stocktake_scan_duplicate", Staff090StocktakeScanResult.serializer()) + assertEquals("duplicate", duplicate.scan.outcome) + assertNotNull(duplicate.scan.copyId) + assertEquals(1, duplicate.summary.duplicates) + assertEquals(6, duplicate.summary.scans) + } + + @Test fun detailStateAndThePagedScanList() { + val detail = data("s090_stocktake_session_detail", Staff090StocktakeSession.serializer()) + assertEquals(6, detail.recentScans.size) + assertEquals("duplicate", detail.recentScans.first().outcome) + val mine = data("s090_stocktake_session_detail_mine", Staff090StocktakeSession.serializer()) + assertEquals(1, mine.recentScans.size) + assertTrue(mine.canMarkLost) + val state = data("s090_stocktake_state", Staff090StocktakeState.serializer()) + assertEquals(detail.id, state.id) + assertEquals("open", state.status) + assertEquals(detail.summary, state.summary) + val page = envelope("s090_stocktake_scans_page", ListSerializer(Staff090StocktakeScan.serializer())) + assertEquals(4, page.data!!.size) + assertEquals(true, page.meta?.hasMore) + assertNotNull(page.meta?.nextCursor) + assertEquals(6, page.meta?.totalCount) + val foundMine = data("s090_stocktake_scans_found_mine", ListSerializer(Staff090StocktakeScan.serializer())) + assertTrue(foundMine.all { it.outcome == "found" }) + val sessions = envelope("s090_stocktake_sessions_open", ListSerializer(Staff090StocktakeSession.serializer())) + assertTrue(sessions.data!!.all { it.isOpen }) + assertTrue(sessions.data!!.all { it.recentScans.isEmpty() }) + assertEquals(false, sessions.meta?.hasMore) + val all = data("s090_stocktake_sessions_all", ListSerializer(Staff090StocktakeSession.serializer())) + assertTrue(setOf("open", "closed", "cancelled").containsAll(all.map { it.status })) + } + + @Test fun reportRowsAndTheUploadAnswer() { + val report = envelope("s090_stocktake_report", ListSerializer(Staff090StocktakeReportRow.serializer())) + val rows = report.data!! + assertEquals(report.meta?.totalCount, rows.size) + val missing = rows.first { it.category == "missing" } + assertNull(missing.scannedAt) + assertTrue(missing.expectedShelf!!.isNotBlank()) + val onlyMissing = data("s090_stocktake_report_missing", ListSerializer(Staff090StocktakeReportRow.serializer())) + assertTrue(onlyMissing.all { it.category == "missing" }) + val upload = data("s090_stocktake_upload", Staff090StocktakeUpload.serializer()) + assertEquals(2, upload.total) + assertEquals(1, upload.outcomes.single { it.code == "found" }.count) + assertEquals(1, upload.outcomes.single { it.code == "unknown" }.count) + assertTrue(upload.message.isNotBlank()) + } + + @Test fun closeCancelAndTheirRefusals() { + val none = data("s090_stocktake_closed_none", Staff090StocktakeSession.serializer()) + assertEquals("closed", none.status) + assertEquals("none", none.missingAction) + assertFalse(none.canScan || none.canClose || none.canCancel) + assertEquals(0, none.closeResult!!.markedLost) + assertTrue(none.closeResult!!.message.isNotBlank()) + assertNotNull(none.closedBy) + val lost = data("s090_stocktake_closed_lost", Staff090StocktakeSession.serializer()) + assertEquals("lost", lost.missingAction) + assertEquals(2, lost.closeResult!!.markedLost) + assertEquals(2, lost.summary.lost) + assertFalse(lost.closeResult!!.partial) + val cancelled = data("s090_stocktake_cancelled", Staff090StocktakeSession.serializer()) + assertEquals("cancelled", cancelled.status) + assertTrue(cancelled.message!!.isNotBlank()) + + val refused = parseErrorBody(raw("s090_stocktake_close_lost_refused"), 403) + assertEquals("not_permitted", refused.code) + assertTrue(refused.fromEnvelope) + assertTrue(refused.message.contains("amministratore")) + val closed = parseErrorBody(raw("s090_stocktake_scan_session_closed"), 409) + assertEquals("session_closed", closed.code) + assertEquals("session_cancelled", parseErrorBody(raw("s090_stocktake_scan_session_cancelled"), 409).code) + val invalid = parseErrorBody(raw("s090_stocktake_create_invalid"), 422) + assertEquals("validation", invalid.code) + assertEquals("Indica un nome per la revisione.", invalid.message) + assertEquals("validation", parseErrorBody(raw("s090_stocktake_scan_invalid"), 422).code) + assertEquals("validation", parseErrorBody(raw("s090_stocktake_upload_invalid"), 422).code) + } + + @Test fun statisticsWithEveryModuleOn() { + val s = data("s090_statistics_full", Staff090Statistics.serializer()) + assertTrue(s.overview.completedLoans > 0) + assertTrue(s.loansByMonth.all { Regex("""\d{4}-\d{2}""").matches(it.month) }) + assertEquals(listOf("libro", "disco", "audiolibro", "dvd", "altro"), s.loansByMediaType.map { it.mediaType }) + assertTrue(s.topBooks.isNotEmpty() && s.topBooks.all { it.coverUrl!!.startsWith("http") }) + assertTrue(s.topReaders.isNotEmpty()) + val visitors = requireNotNull(s.visitors) + assertEquals(30, visitors.days) + assertEquals(30, visitors.daily.size) + val fines = s.finesReport!! + assertEquals("EUR", fines.currency) + assertEquals("2026-01-01", fines.from) + assertEquals(1250, fines.totals.chargedCents) + assertEquals(500, fines.totals.collectedCents) + assertEquals(1250, fines.months.single().chargedCents) + // Section rows mix numbers and texts already formatted with the currency. + val finesSection = s.sections.single { it.id == "fines" } + assertEquals("12,50 €", finesSection.rows.first().display) + assertEquals("1", finesSection.rows.last().display) + assertTrue(s.sections.single { it.id == "patron-types" }.note!!.isNotBlank()) + } + + @Test fun statisticsLeaveOutTheSectionsOfModulesOff() { + val s = data("s090_statistics_modules_off", Staff090Statistics.serializer()) + assertNull(s.visitors) + assertNull(s.finesReport) + assertFalse(s.sections.any { it.id == "fines" || it.id == "stocktake" }) + val staff = data("s090_statistics_staff", Staff090Statistics.serializer()) + val stocktake = staff.sections.single { it.id == "stocktake" } + assertTrue(stocktake.rows.isNotEmpty()) + val bad = parseErrorBody(raw("s090_statistics_bad_date"), 422) + assertEquals("validation", bad.code) + assertEquals("fines_from", bad.meta?.field) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/Staff090StocktakeUiTest.kt b/app/src/test/java/com/pinakes/app/server090/Staff090StocktakeUiTest.kt new file mode 100644 index 0000000..fb9f376 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/Staff090StocktakeUiTest.kt @@ -0,0 +1,127 @@ +package com.pinakes.app.server090 + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.assertTextContains +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.R +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.Staff090Statistics +import com.pinakes.app.data.model.Staff090StocktakeOptions +import com.pinakes.app.data.model.Staff090StocktakeScanResult +import com.pinakes.app.data.model.Staff090StocktakeSession +import com.pinakes.app.ui.screens.staff.s090.stocktake.CloseDialog +import com.pinakes.app.ui.screens.staff.s090.stocktake.LastResultCard +import com.pinakes.app.ui.screens.staff.s090.stocktake.StatisticsBody +import com.pinakes.app.ui.screens.staff.s090.stocktake.StocktakeCounters +import com.pinakes.app.ui.theme.PinakesTheme +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * What the stocktake and statistics screens show, rendered from bodies captured from a 0.9.0 + * server: the close choices per role, the scan answer in its colour, the counters, and the + * statistics sections that follow their module switches. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class Staff090StocktakeUiTest { + // Same RuleChain as ConsultationOnlyButtonTest: registers the host activity. + private val compose = createComposeRule() + @get:Rule val rules: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app.packageManager).addActivityIfNotPresent(ComponentName(app.packageName, ComponentActivity::class.java.name)) + } + }).around(compose) + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + private val context get() = ApplicationProvider.getApplicationContext() + + private fun data(name: String, serializer: KSerializer): T = + json.decodeFromString(Envelope.serializer(serializer), javaClass.getResourceAsStream("/server090/stocktake/$name.json")!!.bufferedReader().readText()).data!! + + @Test fun staffClosesLeavingTheMissingCopiesAndIsNotOfferedLost() { + val session = data("s090_stocktake_session_detail", Staff090StocktakeSession.serializer()) + compose.setContent { PinakesTheme { CloseDialog(session, "none", false, {}, {}, {}) } } + assertEquals(0, compose.onAllNodesWithTag("close-lost").fetchSemanticsNodes().size) + compose.onNodeWithTag("close-confirm-text").assertTextContains(context.getString(R.string.staff090_stocktake_close_confirm_none, session.summary.missing)) + } + + @Test fun anAdministratorChoosesLostAndTheConfirmationSaysHowMany() { + val session = data("s090_stocktake_session_detail_mine", Staff090StocktakeSession.serializer()) + var picked: String? = null + compose.setContent { PinakesTheme { CloseDialog(session, "lost", false, { picked = it }, {}, {}) } } + compose.onNodeWithTag("close-none").assertIsDisplayed() + compose.onNodeWithTag("close-lost").assertIsDisplayed() + compose.onNodeWithTag("close-confirm-text").assertTextContains(context.getString(R.string.staff090_stocktake_close_confirm_lost, session.summary.missing)) + compose.onNodeWithTag("close-none").performClick() + assertEquals("none", picked) + } + + @Test fun theScanAnswerShowsTheOutcomeAndWhereTheCopyBelongs() { + val options = data("s090_stocktake_options_admin", Staff090StocktakeOptions.serializer()) + val misplaced = data("s090_stocktake_scan_misplaced", Staff090StocktakeScanResult.serializer()).scan + var opened: Int? = null + compose.setContent { PinakesTheme { LastResultCard(misplaced, 0, options) { opened = it } } } + compose.onNodeWithText(misplaced.outcomeLabel).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.staff090_stocktake_belongs_on, misplaced.expectedShelf!!)).assertIsDisplayed() + compose.onNodeWithTag("stocktake-last-result").performClick() + assertEquals(misplaced.bookId, opened) + } + + @Test fun theCountersShowTheLiveSummary() { + val result = data("s090_stocktake_scan_duplicate", Staff090StocktakeScanResult.serializer()) + compose.setContent { PinakesTheme { StocktakeCounters(result.summary, null) } } + compose.onNodeWithTag("count-duplicates").assertTextContains("1", substring = true) + compose.onNodeWithTag("count-expected").assertTextContains(result.summary.expected.toString(), substring = true) + assertEquals(0, compose.onAllNodesWithTag("count-lost").fetchSemanticsNodes().size) + } + + @Test fun everyModuleOnShowsVisitorsFinesAndTheirSections() { + val stats = data("s090_statistics_full", Staff090Statistics.serializer()) + var opened: Int? = null + compose.setContent { PinakesTheme { StatisticsBody(stats, stats.finesReport?.from, stats.finesReport?.to, { opened = it }, {}, {}, {}) } } + compose.onNodeWithTag("stat-completed").assertTextContains(stats.overview.completedLoans.toString(), substring = true) + val list = compose.onNode(hasScrollAction()) + list.performScrollToNode(hasTestTag("top-book-${stats.topBooks.first().id}")) + compose.onNodeWithTag("top-book-${stats.topBooks.first().id}").performClick() + assertEquals(stats.topBooks.first().id, opened) + list.performScrollToNode(hasTestTag("section-fines")) + list.performScrollToNode(hasTestTag("visitors")) + list.performScrollToNode(hasTestTag("fines-report")) + list.performScrollToNode(hasTestTag("fines-csv")) + } + + @Test fun modulesOffLeaveTheirSectionsOut() { + val stats = data("s090_statistics_modules_off", Staff090Statistics.serializer()) + compose.setContent { PinakesTheme { StatisticsBody(stats, null, null, {}, {}, {}, {}) } } + val list = compose.onNode(hasScrollAction()) + list.performScrollToNode(hasTestTag("section-patron-types")) + assertEquals(0, compose.onAllNodesWithTag("fines-report").fetchSemanticsNodes().size) + assertEquals(0, compose.onAllNodesWithTag("visitors").fetchSemanticsNodes().size) + assertEquals(0, compose.onAllNodesWithTag("section-fines").fetchSemanticsNodes().size) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/StaffCopyConsultationTest.kt b/app/src/test/java/com/pinakes/app/server090/StaffCopyConsultationTest.kt new file mode 100644 index 0000000..7218764 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/StaffCopyConsultationTest.kt @@ -0,0 +1,50 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.CopyUpdate +import com.pinakes.app.data.model.StaffCopy +import com.pinakes.app.ui.screens.staff.content.CopyDraft +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Consultation-only copies from the copy editor (Pinakes 0.9.0): `consultation_only` on + * `PUT /staff/copies/{id}`, sent only when it changed and the server applies the module. + */ +class StaffCopyConsultationTest { + // The network client's settings: null fields are left out of the body. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + + private val loaded = CopyDraft(id = 5, code = "INV1", status = "disponibile", originalStatus = "disponibile") + + @Test fun aChangedFlagIsSentWhenTheModuleIsOn() { + assertEquals(true, loaded.copy(consultationOnly = true).consultationChange(editable = true)) + val wasConsultation = loaded.copy(consultationOnly = false, originalConsultationOnly = true) + assertEquals(false, wasConsultation.consultationChange(editable = true)) + } + + @Test fun anUnchangedFlagANewCopyOrTheModuleOffSendsNothing() { + assertNull(loaded.consultationChange(editable = true)) + assertNull(loaded.copy(consultationOnly = true, originalConsultationOnly = true).consultationChange(editable = true)) + assertNull(loaded.copy(consultationOnly = true).consultationChange(editable = false)) + assertNull(CopyDraft(id = 0, consultationOnly = true).consultationChange(editable = true)) + } + + @Test fun theKeyIsInTheBodyOnlyWhenSet() { + assertFalse(json.encodeToString(CopyUpdate(notes = "n")).contains("consultation_only")) + assertTrue(json.encodeToString(CopyUpdate(notes = "n", consultationOnly = true)).contains("\"consultation_only\":true")) + assertTrue(json.encodeToString(CopyUpdate(consultationOnly = false)).contains("\"consultation_only\":false")) + } + + @Test fun theServerFlagIsRead() { + val copy = json.decodeFromString("""{"id":5,"inventory_number":"INV1","status":"disponibile","consultation_only":true}""") + assertTrue(copy.consultationOnly) + // An older server omits it: lendable. + assertFalse(json.decodeFromString("""{"id":6,"status":"disponibile"}""").consultationOnly) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/StaffErrors090Test.kt b/app/src/test/java/com/pinakes/app/server090/StaffErrors090Test.kt new file mode 100644 index 0000000..432c054 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/StaffErrors090Test.kt @@ -0,0 +1,58 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.R +import com.pinakes.app.data.model.StaffCopy +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.staff.StaffErrors +import com.pinakes.app.ui.screens.staff.content.copySummary +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Staff side of the 0.9.0 circulation rules: refusal codes, eligibility reasons, copies. */ +class StaffErrors090Test { + + @Test fun theNewCirculationCodesAreMapped() { + assertEquals(R.string.staff_err_media_limit, StaffErrors.messageRes("media_limit_reached")) + assertEquals(R.string.staff_err_not_loanable, StaffErrors.messageRes("not_loanable")) + assertEquals(R.string.staff_err_reservations_disabled, StaffErrors.messageRes("reservations_disabled")) + assertEquals(R.string.staff_err_reservation_limit, StaffErrors.messageRes("reservation_limit_reached")) + assertEquals(R.string.staff_err_max_loans, StaffErrors.messageRes("max_loans_reached")) + } + + @Test fun notEligibleUsesMetaReason() { + val debt = parseErrorBody("""{"data":null,"meta":{"reason":"debt_block"},"error":{"code":"not_eligible","message":"x"}}""", 409) + assertEquals(R.string.staff_err_debt_block, StaffErrors.messageRes(debt)) + val card = parseErrorBody("""{"data":null,"meta":{"reason":"card_expired"},"error":{"code":"not_eligible","message":"x"}}""", 409) + assertEquals(R.string.staff_err_card_expired, StaffErrors.messageRes(card)) + } + + @Test fun notEligibleWithAnUnknownOrMissingReasonKeepsTheGenericText() { + val none = parseErrorBody("""{"data":null,"meta":{},"error":{"code":"not_eligible","message":"x"}}""", 409) + assertEquals(R.string.staff_err_not_eligible, StaffErrors.messageRes(none)) + val future = parseErrorBody("""{"data":null,"meta":{"reason":"something_new"},"error":{"code":"not_eligible","message":"x"}}""", 409) + assertEquals(R.string.staff_err_not_eligible, StaffErrors.messageRes(future)) + } + + @Test fun anUnknownCodeIsLeftToTheServerMessage() { + assertNull(StaffErrors.messageRes("brand_new_code")) + assertNull(StaffErrors.eligibilityRes(null)) + assertNull(StaffErrors.eligibilityRes("brand_new_reason")) + assertEquals(R.string.staff_err_user_suspended, StaffErrors.eligibilityRes("user_suspended")) + } + + @Test fun aConsultationOnlyCopySaysSoBetweenStateAndNotes() { + val copy = StaffCopy(id = 1, consultationOnly = true, notes = "Reference shelf") + assertEquals("Available · Consultation only · Reference shelf", copySummary(copy, "Available", "Consultation only")) + assertEquals("Available", copySummary(StaffCopy(id = 2), "Available", "Consultation only")) + } + + @Test fun theCopyFlagDefaultsToFalseOnOlderServers() { + val json = Json { ignoreUnknownKeys = true } + assertFalse(json.decodeFromString("""{"id":3,"status":"disponibile"}""").consultationOnly) + assertTrue(json.decodeFromString("""{"id":3,"consultation_only":true}""").consultationOnly) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt b/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt new file mode 100644 index 0000000..02d8fc2 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/StaffLoanClosedDaysTest.kt @@ -0,0 +1,67 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.ui.screens.staff.circulation.LoanCreateUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import com.pinakes.app.ui.screens.staff.PickItem +import org.junit.Test + +/** The staff loan form refuses a start or a return on a day the library is closed. */ +class StaffLoanClosedDaysTest { + private val calendar = AvailabilityCalendar(closedDates = listOf("2030-03-09", "2030-03-10")) + + @Test fun aClosedStartOrEndIsNamed() { + assertEquals("2030-03-09", LoanCreateUiState(calendar = calendar, start = "2030-03-09", end = "2030-03-20").closedDateChosen) + assertEquals("2030-03-10", LoanCreateUiState(calendar = calendar, start = "2030-03-04", end = "2030-03-10").closedDateChosen) + } + + @Test fun aLoanWithoutAStartBeginsTodaySoTodayIsChecked() { + val today = java.time.LocalDate.now().toString() + val closedToday = AvailabilityCalendar(closedDates = listOf(today)) + assertEquals(today, LoanCreateUiState(calendar = closedToday).closedDateChosen) + // A reservation without a start has no implicit day. + assertNull(LoanCreateUiState(calendar = closedToday, reservation = true).closedDateChosen) + } + + @Test fun submitWaitsForTheCalendar() { + val picked = LoanCreateUiState(user = PickItem(1, "Ada"), book = PickItem(2, "Emma")) + assertTrue(picked.canSubmit) + assertFalse(picked.copy(calendarLoading = true).canSubmit) + // A failed fetch keeps the form usable: the server decides. + assertTrue(picked.copy(calendarFailed = true).canSubmit) + } + + @Test fun onlyTheLatestCalendarRequestIsApplied() { + val picked = LoanCreateUiState(user = PickItem(1, "Ada"), book = PickItem(2, "Emma")) + val first = picked.startCalendarRequest() + val second = first.startCalendarRequest() + // The answer to the superseded request (another book was picked meanwhile) is dropped, + // whether it succeeded or failed: the latest one is still loading, submit still waits. + val staleOk = second.withCalendarResult(first.calendarRequest, calendar) + assertTrue(staleOk.calendarLoading) + assertNull(staleOk.calendar) + assertFalse(staleOk.canSubmit) + val staleFail = second.copy(calendar = calendar).withCalendarResult(first.calendarRequest, null) + assertTrue(staleFail.calendarLoading) + assertFalse(staleFail.calendarFailed) + assertEquals(calendar, staleFail.calendar) + // The latest answer ends the loading. + val latest = staleOk.withCalendarResult(second.calendarRequest, calendar) + assertFalse(latest.calendarLoading) + assertEquals(calendar, latest.calendar) + assertTrue(latest.canSubmit) + val latestFail = staleOk.withCalendarResult(second.calendarRequest, null, forbidden = true) + assertTrue(latestFail.calendarFailed) + assertTrue(latestFail.forbidden) + assertFalse(latestFail.calendarLoading) + } + + @Test fun openDaysOrNoCalendarPass() { + assertNull(LoanCreateUiState(calendar = calendar, start = "2030-03-04", end = "2030-03-18").closedDateChosen) + assertNull(LoanCreateUiState(calendar = null, start = "2030-03-09").closedDateChosen) + assertEquals(setOf("2030-03-09", "2030-03-10"), LoanCreateUiState(calendar = calendar).closedDates) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/StaffReservationOverrideTest.kt b/app/src/test/java/com/pinakes/app/server090/StaffReservationOverrideTest.kt new file mode 100644 index 0000000..94b3e96 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/StaffReservationOverrideTest.kt @@ -0,0 +1,41 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.ReservationWrite +import com.pinakes.app.ui.screens.staff.circulation.LoanCreateUiState +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A staff reservation may ignore the patron type's reservation limits (Pinakes 0.9.0, + * `override_limits` on POST /staff/reservations), as the website's "new reservation" form allows. + */ +class StaffReservationOverrideTest { + // The network client's settings: null fields are left out of the body. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; encodeDefaults = false } + + private val form = LoanCreateUiState(reservation = true, start = "2030-03-04", end = "2030-03-20") + + @Test fun theOverrideIsSentOnlyWhenTickedAndTheRulesAreOn() { + assertEquals(true, form.copy(limitsOverridable = true, overrideLimits = true).reservationWrite(1, 2).overrideLimits) + assertNull(form.copy(limitsOverridable = true, overrideLimits = false).reservationWrite(1, 2).overrideLimits) + // Rules off on the server: there are no limits to override, as on the website. + assertNull(form.copy(limitsOverridable = false, overrideLimits = true).reservationWrite(1, 2).overrideLimits) + } + + @Test fun theBodyKeepsTheDatesAndIdsAsBefore() { + val body = form.reservationWrite(7, 9) + assertEquals(ReservationWrite(userId = 7, bookId = 9, requestedFrom = "2030-03-04", requestedTo = "2030-03-20"), body) + } + + @Test fun withoutTheOverrideTheKeyIsNotSent() { + val plain = json.encodeToString(form.reservationWrite(7, 9)) + assertFalse(plain.contains("override_limits")) + val forced = json.encodeToString(form.copy(limitsOverridable = true, overrideLimits = true).reservationWrite(7, 9)) + assertTrue(forced.contains("\"override_limits\":true")) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt b/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt index 8f892a5..d80cad7 100644 --- a/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt +++ b/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt @@ -34,4 +34,23 @@ class SearchTextTest { assertEquals("1-2", SearchText.queryPattern("1-2")) assertEquals("harry potter 7", SearchText.queryPattern("Harry Potter 7")) } + + @Test fun quotedQueriesSearchTheWordsOffline() { + // Pinakes #501: quotes ask the server for the exact spelling. Offline the key folds + // accents, so the quotes go and the words are searched as they would be unquoted. + assertEquals("rolf haffner", SearchText.queryPattern("\"Rolf Häffner\"")) + assertEquals("haffner roma", SearchText.queryPattern("\u201EHäffner\u201C Roma")) + assertEquals("haffner roma", SearchText.queryPattern("\u201CHäffner\u201D Roma")) + assertEquals("haffner", SearchText.queryPattern("\u00ABHäffner\u00BB")) + // An unclosed quote is dropped too. + assertEquals("haffner", SearchText.queryPattern("\"Häffner")) + assertEquals("plain words", SearchText.withoutSearchQuotes("plain words")) + } + + @Test fun anInchMarkAfterADigitStays() { + assertEquals("disco 12\"", SearchText.queryPattern("Disco 12\"")) + assertEquals("schermo 15\u201D", SearchText.queryPattern("Schermo 15\u201D")) + // Inside a quoted part the quote after a digit closes it, as on the server. + assertEquals("catch 22", SearchText.queryPattern("\"Catch 22\"")) + } } diff --git a/app/src/test/resources/server090/catalogue/book_created.json b/app/src/test/resources/server090/catalogue/book_created.json new file mode 100644 index 0000000..f2b866d --- /dev/null +++ b/app/src/test/resources/server090/catalogue/book_created.json @@ -0,0 +1 @@ +{"data":{"id":338681,"title":"Libro Cat090 Prova","subtitle":null,"isbn10":null,"isbn13":null,"ean":null,"issn":null,"year":null,"pub_date":null,"edition":null,"place":null,"pages":null,"language":null,"format":null,"media_type":"libro","description":null,"keywords":null,"notes":null,"series":null,"series_number":null,"dewey":null,"price":null,"acquisition_date":null,"acquisition_type":"acquisto","inventory_base":null,"copies_total":1,"copies_available":1,"cover_url":null,"cover_is_local":false,"genre":null,"subgenre":null,"authors":[],"publishers":[],"location":null,"copies":[{"id":109632,"inventory_number":"LIB-338681-C1","status":"disponibile","notes":"Copia 1 di 1"}],"is_wanted":false,"created_at":"2026-11-01 19:39:24","updated_at":"2026-11-01 19:39:24","supplier_id":21,"supplier":{"id":21,"name":"Libreria Cat090 Prova"},"rda_content_type":"spw","rda_media_type":null,"rda_carrier_type":"cd","rda_effective":{"content_type":"spw","media_type":"n","carrier_type":"cd"}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/book_detail.json b/app/src/test/resources/server090/catalogue/book_detail.json new file mode 100644 index 0000000..f2b866d --- /dev/null +++ b/app/src/test/resources/server090/catalogue/book_detail.json @@ -0,0 +1 @@ +{"data":{"id":338681,"title":"Libro Cat090 Prova","subtitle":null,"isbn10":null,"isbn13":null,"ean":null,"issn":null,"year":null,"pub_date":null,"edition":null,"place":null,"pages":null,"language":null,"format":null,"media_type":"libro","description":null,"keywords":null,"notes":null,"series":null,"series_number":null,"dewey":null,"price":null,"acquisition_date":null,"acquisition_type":"acquisto","inventory_base":null,"copies_total":1,"copies_available":1,"cover_url":null,"cover_is_local":false,"genre":null,"subgenre":null,"authors":[],"publishers":[],"location":null,"copies":[{"id":109632,"inventory_number":"LIB-338681-C1","status":"disponibile","notes":"Copia 1 di 1"}],"is_wanted":false,"created_at":"2026-11-01 19:39:24","updated_at":"2026-11-01 19:39:24","supplier_id":21,"supplier":{"id":21,"name":"Libreria Cat090 Prova"},"rda_content_type":"spw","rda_media_type":null,"rda_carrier_type":"cd","rda_effective":{"content_type":"spw","media_type":"n","carrier_type":"cd"}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/books_by_supplier.json b/app/src/test/resources/server090/catalogue/books_by_supplier.json new file mode 100644 index 0000000..02e24aa --- /dev/null +++ b/app/src/test/resources/server090/catalogue/books_by_supplier.json @@ -0,0 +1 @@ +{"data":[{"id":338681,"title":"Libro Cat090 Prova","subtitle":null,"author":null,"publisher":null,"genre":null,"year":null,"language":null,"media_type":"libro","isbn13":null,"cover_url":"http://localhost:8194/uploads/copertine/placeholder.jpg","copies_total":1,"copies_available":1,"loanable_now":true,"is_wanted":false,"updated_at":"2026-11-01 19:39:24"}],"meta":{"count":1,"limit":5,"next_cursor":null,"has_more":false},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/cc_import.json b/app/src/test/resources/server090/catalogue/cc_import.json new file mode 100644 index 0000000..a04235f --- /dev/null +++ b/app/src/test/resources/server090/catalogue/cc_import.json @@ -0,0 +1 @@ +{"data":{"imported":1,"updated":0,"skipped":0,"failed":0,"covers":0,"import_id":"e4a081b37f4129e58d75ac79688b67ef967a","items":[{"id":"6eb1848e432e386c","action":"created","book_id":338683,"title":"Libro Cat090 Prova","warnings":[],"message":null}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/cc_import_failed.json b/app/src/test/resources/server090/catalogue/cc_import_failed.json new file mode 100644 index 0000000..c55cb88 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/cc_import_failed.json @@ -0,0 +1 @@ +{"data":{"imported":0,"updated":0,"skipped":0,"failed":1,"covers":0,"import_id":"058c562fa00d1242f44457303b8a3b74d0b5","items":[{"id":"0123456789abcdef","action":"failed","book_id":null,"title":"","warnings":[],"message":"Risultato non più disponibile: ripeti la ricerca"}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/cc_import_replayed.json b/app/src/test/resources/server090/catalogue/cc_import_replayed.json new file mode 100644 index 0000000..4f6fe40 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/cc_import_replayed.json @@ -0,0 +1 @@ +{"data":{"imported":1,"updated":0,"skipped":0,"failed":0,"covers":0,"import_id":"e4a081b37f4129e58d75ac79688b67ef967a","items":[{"id":"6eb1848e432e386c","action":"created","book_id":338683,"title":"Libro Cat090 Prova","warnings":[],"message":null}]},"meta":{"replayed":true},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/cc_search.json b/app/src/test/resources/server090/catalogue/cc_search.json new file mode 100644 index 0000000..b7b4354 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/cc_search.json @@ -0,0 +1 @@ +{"data":{"search_id":"53248631-5faf-463d-8da2-09b7f326a9f3","expires_at":"2026-11-02 19:39:52","results":[{"id":"6eb1848e432e386c","server":"Questa installazione (Pinakes)","flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"cover_url":null,"existing_id":null,"detail":[{"tag":"LDR","ind":"","value":"00000nam a2200000 4500"},{"tag":"001","ind":"","value":"338681"},{"tag":"008","ind":"","value":"261101s xx "},{"tag":"245","ind":"00","value":"$a Libro Cat090 Prova"},{"tag":"852","ind":" ","value":"$j LIB-338681-C1 $z Status: Available $z Note: Copia 1 di 1"},{"tag":"866","ind":" ","value":"$a Total copies: 1, Available: 1"}]},{"id":"9de1cf3b2e40e13c","server":"Questa installazione (Pinakes)","flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"cover_url":null,"existing_id":null,"detail":[{"tag":"LDR","ind":"","value":"00000nam a2200000 4500"},{"tag":"001","ind":"","value":"338682"},{"tag":"008","ind":"","value":"261101s xx ita "},{"tag":"041","ind":"0 ","value":"$a ita"},{"tag":"245","ind":"00","value":"$a Libro Cat090 Prova"},{"tag":"852","ind":" ","value":"$j LIB-338682-C1 $z Status: Available $z Note: Copia 1 di 1"},{"tag":"866","ind":" ","value":"$a Total copies: 1, Available: 1"}]}],"servers":[{"key":"local","name":"Questa installazione (Pinakes)","total":2,"count":2,"error":null}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/cc_servers.json b/app/src/test/resources/server090/catalogue/cc_servers.json new file mode 100644 index 0000000..bcdc717 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/cc_servers.json @@ -0,0 +1 @@ +{"data":{"servers":[{"key":"cfg:0","name":"BIBSYS - Norwegian Union Catalogue","kind":"configured","url":"https://bibsys.alma.exlibrisgroup.com/view/sru/47BIBSYS_NETWORK","syntax":"marcxml"},{"key":"cfg:1","name":"LIBRIS - Swedish Union Catalogue","kind":"configured","url":"https://libris.kb.se/sru/libris","syntax":"marcxml"},{"key":"sbn","name":"SBN - OPAC del Servizio Bibliotecario Nazionale","kind":"preset","url":"https://opac.sbn.it","syntax":"sbn"},{"key":"preset:loc","name":"Library of Congress","kind":"preset","url":"http://lx2.loc.gov:210/LCDB","syntax":"marcxml"},{"key":"preset:bnf","name":"BnF - Bibliothèque nationale de France","kind":"preset","url":"https://catalogue.bnf.fr/api/SRU","syntax":"unimarcxchange"},{"key":"preset:dnb","name":"DNB - Deutsche Nationalbibliothek","kind":"preset","url":"https://services.dnb.de/sru/dnb","syntax":"MARC21-xml"},{"key":"preset:k10plus","name":"K10plus (GBV)","kind":"preset","url":"http://sru.k10plus.de/opac-de-627","syntax":"marcxml"},{"key":"local","name":"Questa installazione (Pinakes)","kind":"local","url":"http://localhost:8194/api/sru","syntax":"marcxml"}],"indexes":[{"value":"title","label":"Titolo"},{"value":"author","label":"Autore"},{"value":"isbn","label":"ISBN"},{"value":"issn","label":"ISSN"},{"value":"any","label":"Tutti i campi"}],"duplicate_modes":[{"value":"skip","label":"Salta","description":"Il libro già in catalogo resta com'è"},{"value":"fill","label":"Completa i campi vuoti","description":"Solo i campi vuoti del libro vengono riempiti dal record"},{"value":"overwrite","label":"Sovrascrivi","description":"I campi presenti nel record sostituiscono quelli del libro; gli altri restano"}],"max_results_per_server":25,"max_import_ids":100},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/health.json b/app/src/test/resources/server090/catalogue/health.json new file mode 100644 index 0000000..100a8af --- /dev/null +++ b/app/src/test/resources/server090/catalogue/health.json @@ -0,0 +1 @@ +{"data":{"name":"Pinakes","logo":null,"version":"0.9.0-rc.1","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true,"reviews":true,"archives":true,"desiderata":true,"staff_catalog":true,"catalog_sync":true,"calendar_feed":true,"automatic_fines":false,"debt_block":false,"consultation_only":true,"fines":false,"closures_calendar":true,"circulation_rules":true,"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true,"staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true,"staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true,"staff_attendance":false,"staff_catalogue_cards":true,"staff_circulation_rules":true,"staff_closures_calendar":true,"staff_ledger":false,"staff_library_settings":true,"staff_marc":true,"staff_patrons":true,"staff_statistics":true,"staff_stocktake":false,"staff_suppliers":true,"staff_user_import":true,"stocktake":false,"marc":true,"visitor_log":false,"catalogue_cards":true,"suppliers":true,"rda_types":true,"patron_cards":true,"user_import":true,"patron_photos":true,"automatic_card_expiry":true,"loss_penalties":false,"attendance_retention":false},"catalogue_mode":false,"loan_approval_required":true,"app_access_enabled":true,"registration_enabled":true,"registration":{"require_cognome":true,"require_telefono":true,"require_indirizzo":true,"show_sesso":true,"show_cod_fiscale":true,"custom_fields":[]},"private_mode":false,"vapid_public_key":"BK1dWXzNtPiZOaBfelUsLszTE-E_EeL8a-wYLVs_wUR2XMjR3uYIY89X53Wl0TngenWEx2VzCqtRcfOX31zuEog"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/lookups_full.json b/app/src/test/resources/server090/catalogue/lookups_full.json new file mode 100644 index 0000000..b914c30 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/lookups_full.json @@ -0,0 +1 @@ +{"data":{"languages":[{"code":"italiano","label":"italiano"},{"code":"English","label":"English"},{"code":"ZzLang3fd637ba4a","label":"ZzLang3fd637ba4a"},{"code":"ZzSort3fd637ba4a","label":"ZzSort3fd637ba4a"},{"code":"Deutsch","label":"Deutsch"},{"code":"Français","label":"Français"},{"code":"inglese","label":"inglese"},{"code":"ZzOther3fd637ba4a","label":"ZzOther3fd637ba4a"}],"media_types":[{"value":"libro","label":"Libro"},{"value":"disco","label":"Disco"},{"value":"audiolibro","label":"Audiolibro"},{"value":"dvd","label":"DVD"},{"value":"altro","label":"Altro"}],"formats":[{"value":"cartaceo","label":"Cartaceo"}],"acquisition_types":[{"value":"acquisto","label":"Acquisto"},{"value":"donazione","label":"Donazione"},{"value":"prestito","label":"Prestito"}],"contributor_roles":[{"value":"principale","label":"Autore"},{"value":"co-autore","label":"Co-autore"},{"value":"traduttore","label":"Traduttore"},{"value":"illustratore","label":"Illustratore"},{"value":"curatore","label":"Curatore"},{"value":"colorista","label":"Colorista"}],"copy_states":[{"value":"disponibile","label":"Disponibile","creatable":true,"editable":true},{"value":"prestato","label":"Prestato","creatable":false,"editable":false},{"value":"prenotato","label":"Prenotato","creatable":false,"editable":false},{"value":"manutenzione","label":"Manutenzione","creatable":true,"editable":true},{"value":"in_restauro","label":"In restauro","creatable":true,"editable":true},{"value":"perso","label":"Perso","creatable":true,"editable":true},{"value":"danneggiato","label":"Danneggiato","creatable":true,"editable":true},{"value":"in_trasferimento","label":"In trasferimento","creatable":true,"editable":true}],"series_types":[{"value":"serie","label":"Serie"},{"value":"universo","label":"Universo / macroserie"},{"value":"ciclo","label":"Ciclo"},{"value":"stagione","label":"Stagione"},{"value":"spin_off","label":"Spin-off"},{"value":"arco","label":"Arco narrativo"},{"value":"collezione_editoriale","label":"Collana editoriale"},{"value":"altro","label":"Altro"}],"suppliers":[{"id":21,"name":"Libreria Cat090 Prova"}],"rda_content_types":[{"value":"txt","label":"Testo"},{"value":"spw","label":"Parola parlata"},{"value":"prm","label":"Musica eseguita"},{"value":"ntm","label":"Musica notata"},{"value":"snd","label":"Suoni"},{"value":"sti","label":"Immagine fissa"},{"value":"tdi","label":"Immagine in movimento bidimensionale"},{"value":"cri","label":"Immagine cartografica"},{"value":"cop","label":"Programma per computer"},{"value":"xxx","label":"Altro"}],"rda_media_types":[{"value":"n","label":"Senza mediazione"},{"value":"s","label":"Audio"},{"value":"v","label":"Video"},{"value":"c","label":"Computer"},{"value":"h","label":"Microforma"},{"value":"g","label":"Proiettato"},{"value":"x","label":"Altro"}],"rda_carrier_types":[{"value":"nc","label":"Volume"},{"value":"no","label":"Scheda"},{"value":"nb","label":"Foglio"},{"value":"sd","label":"Disco sonoro"},{"value":"ss","label":"Audiocassetta"},{"value":"vd","label":"Videodisco"},{"value":"cd","label":"Disco per computer"},{"value":"cr","label":"Risorsa online"},{"value":"zu","label":"Non specificato"}],"return_outcomes":[{"value":"restituito","label":"Restituito"},{"value":"manutenzione","label":"Manutenzione"},{"value":"in_restauro","label":"In restauro"},{"value":"perso","label":"Perso"},{"value":"danneggiato","label":"Danneggiato"}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/marc_cancelled.json b/app/src/test/resources/server090/catalogue/marc_cancelled.json new file mode 100644 index 0000000..afc9d69 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/marc_cancelled.json @@ -0,0 +1 @@ +{"data":{"id":"ce1b2d36-6abc-4aa9-91fc-8fea6d95e644","cancelled":true},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/marc_chunk_409.json b/app/src/test/resources/server090/catalogue/marc_chunk_409.json new file mode 100644 index 0000000..edc235d --- /dev/null +++ b/app/src/test/resources/server090/catalogue/marc_chunk_409.json @@ -0,0 +1 @@ +{"data":null,"meta":{"processed":0},"error":{"code":"out_of_order","message":"Blocco fuori sequenza: riprendi dalla riga indicata."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/marc_chunk_410.json b/app/src/test/resources/server090/catalogue/marc_chunk_410.json new file mode 100644 index 0000000..abf61ff --- /dev/null +++ b/app/src/test/resources/server090/catalogue/marc_chunk_410.json @@ -0,0 +1 @@ +{"data":null,"meta":{"status":"completed"},"error":{"code":"gone","message":"Importazione conclusa, annullata o scaduta."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/marc_job_completed.json b/app/src/test/resources/server090/catalogue/marc_job_completed.json new file mode 100644 index 0000000..8cb5160 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/marc_job_completed.json @@ -0,0 +1 @@ +{"data":{"id":"6dd79110-5736-4456-aabb-07e682b77466","kind":"marc","file_name":"prova.mrc","status":"completed","total_rows":1,"processed":1,"chunk_size":10,"enable_scraping":false,"imported":1,"updated":0,"scraped":0,"authors_created":0,"publishers_created":0,"errors":0,"error_details":[],"complete":true,"created_at":"2026-11-01 19:39:44","updated_at":"2026-11-01 19:39:44","expires_at":"2026-11-02 19:39:44","skipped":0,"covers":0,"failed":0,"import_id":"fcba5bd46c2dbafb057abe13d8615218e30c","duplicates":"skip","download_covers":false,"supplier_id":21,"flavour":"auto","encoding":"auto","container":"iso2709","unreadable":0,"truncated":null,"preview":[{"position":1,"flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"has_cover":false,"existing_id":null,"error":null}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/marc_job_ready.json b/app/src/test/resources/server090/catalogue/marc_job_ready.json new file mode 100644 index 0000000..ce67135 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/marc_job_ready.json @@ -0,0 +1 @@ +{"data":{"id":"6dd79110-5736-4456-aabb-07e682b77466","kind":"marc","file_name":"prova.mrc","status":"ready","total_rows":1,"processed":0,"chunk_size":10,"enable_scraping":false,"imported":0,"updated":0,"scraped":0,"authors_created":0,"publishers_created":0,"errors":0,"error_details":[],"complete":false,"created_at":"2026-11-01 19:39:44","updated_at":"2026-11-01 19:39:44","expires_at":"2026-11-02 19:39:44","skipped":0,"covers":0,"failed":0,"import_id":null,"duplicates":"skip","download_covers":false,"supplier_id":21,"flavour":"auto","encoding":"auto","container":"iso2709","unreadable":0,"truncated":null,"preview":[{"position":1,"flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"has_cover":false,"existing_id":null,"error":null}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/marc_jobs.json b/app/src/test/resources/server090/catalogue/marc_jobs.json new file mode 100644 index 0000000..cd356e5 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/marc_jobs.json @@ -0,0 +1 @@ +{"data":[{"id":"3232bb79-91dd-427d-afae-ce574c49af04","kind":"marc","file_name":"altro-dispositivo.mrc","status":"ready","total_rows":1,"processed":0,"chunk_size":10,"enable_scraping":false,"imported":0,"updated":0,"scraped":0,"authors_created":0,"publishers_created":0,"errors":0,"error_details":[],"complete":false,"created_at":"2026-11-01 20:28:13","updated_at":"2026-11-01 20:28:13","expires_at":"2026-11-02 20:28:13","skipped":0,"covers":0,"failed":0,"import_id":null,"duplicates":"fill","download_covers":false,"supplier_id":null,"flavour":"auto","encoding":"auto","container":"iso2709","unreadable":0,"truncated":null,"preview":[{"position":1,"flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"has_cover":false,"existing_id":null,"error":null}]},{"id":"a0769c0f-5d48-4968-aed6-e19722059184","kind":"marc","file_name":"vecchio.mrc","status":"cancelled","total_rows":1,"processed":0,"chunk_size":10,"enable_scraping":false,"imported":0,"updated":0,"scraped":0,"authors_created":0,"publishers_created":0,"errors":0,"error_details":[],"complete":false,"created_at":"2026-11-01 20:28:13","updated_at":"2026-11-01 20:28:13","expires_at":"2026-11-02 20:28:13","skipped":0,"covers":0,"failed":0,"import_id":null,"duplicates":"skip","download_covers":false,"supplier_id":null,"flavour":"auto","encoding":"auto","container":"iso2709","unreadable":0,"truncated":null,"preview":[{"position":1,"flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"has_cover":false,"existing_id":null,"error":null}]}],"meta":{"count":2,"limit":30,"next_cursor":null,"has_more":false},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/marc_jobs_page1.json b/app/src/test/resources/server090/catalogue/marc_jobs_page1.json new file mode 100644 index 0000000..dd99ad6 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/marc_jobs_page1.json @@ -0,0 +1 @@ +{"data":[{"id":"3232bb79-91dd-427d-afae-ce574c49af04","kind":"marc","file_name":"altro-dispositivo.mrc","status":"ready","total_rows":1,"processed":0,"chunk_size":10,"enable_scraping":false,"imported":0,"updated":0,"scraped":0,"authors_created":0,"publishers_created":0,"errors":0,"error_details":[],"complete":false,"created_at":"2026-11-01 20:28:13","updated_at":"2026-11-01 20:28:13","expires_at":"2026-11-02 20:28:13","skipped":0,"covers":0,"failed":0,"import_id":null,"duplicates":"fill","download_covers":false,"supplier_id":null,"flavour":"auto","encoding":"auto","container":"iso2709","unreadable":0,"truncated":null,"preview":[{"position":1,"flavour":"marc21","title":"Libro Cat090 Prova","subtitle":null,"authors":null,"publisher":null,"year":null,"isbn":null,"has_cover":false,"existing_id":null,"error":null}]}],"meta":{"count":1,"limit":1,"next_cursor":"eyJvIjoxfQ","has_more":true},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/marc_options.json b/app/src/test/resources/server090/catalogue/marc_options.json new file mode 100644 index 0000000..70718f8 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/marc_options.json @@ -0,0 +1 @@ +{"data":{"flavours":[{"value":"auto","label":"Riconoscimento automatico"},{"value":"marc21","label":"MARC 21"},{"value":"unimarc","label":"UNIMARC"},{"value":"danmarc2","label":"danMARC2"}],"encodings":[{"value":"auto","label":"Dal leader del record (posizione 9)"},{"value":"utf8","label":"UTF-8"},{"value":"marc8","label":"MARC-8"}],"duplicate_modes":[{"value":"skip","label":"Salta","description":"Il libro già in catalogo resta com'è"},{"value":"fill","label":"Completa i campi vuoti","description":"Solo i campi vuoti del libro vengono riempiti dal record"},{"value":"overwrite","label":"Sovrascrivi","description":"I campi presenti nel record sostituiscono quelli del libro; gli altri restano"}],"export_formats":[{"value":"marc21","label":"MARC 21 (.mrc)"},{"value":"marcxml","label":"MARCXML"},{"value":"unimarc","label":"UNIMARC (.mrc)"}],"extensions":["mrc","marc","iso","dat","xml","marcxml"],"max_file_bytes":20971520,"max_records":20000,"max_export_ids":1000,"suppliers":[{"id":21,"name":"Libreria Cat090 Prova"}],"copy_cataloging":{"available":true,"reason":null}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/marc_options_copy_unavailable.json b/app/src/test/resources/server090/catalogue/marc_options_copy_unavailable.json new file mode 100644 index 0000000..75f46f4 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/marc_options_copy_unavailable.json @@ -0,0 +1 @@ +{"data": {"flavours": [{"value": "auto", "label": "Riconoscimento automatico"}, {"value": "marc21", "label": "MARC 21"}, {"value": "unimarc", "label": "UNIMARC"}, {"value": "danmarc2", "label": "danMARC2"}], "encodings": [{"value": "auto", "label": "Dal leader del record (posizione 9)"}, {"value": "utf8", "label": "UTF-8"}, {"value": "marc8", "label": "MARC-8"}], "duplicate_modes": [{"value": "skip", "label": "Salta", "description": "Il libro già in catalogo resta com'è"}, {"value": "fill", "label": "Completa i campi vuoti", "description": "Solo i campi vuoti del libro vengono riempiti dal record"}, {"value": "overwrite", "label": "Sovrascrivi", "description": "I campi presenti nel record sostituiscono quelli del libro; gli altri restano"}], "export_formats": [{"value": "marc21", "label": "MARC 21 (.mrc)"}, {"value": "marcxml", "label": "MARCXML"}, {"value": "unimarc", "label": "UNIMARC (.mrc)"}], "extensions": ["mrc", "marc", "iso", "dat", "xml", "marcxml"], "max_file_bytes": 20971520, "max_records": 20000, "max_export_ids": 1000, "suppliers": [{"id": 21, "name": "Libreria Cat090 Prova"}], "copy_cataloging": {"available": false, "reason": "plugin_inactive"}}, "meta": {}, "error": null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/marc_upload_422.json b/app/src/test/resources/server090/catalogue/marc_upload_422.json new file mode 100644 index 0000000..2d16702 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/marc_upload_422.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"flavour":"Valore non valido.","duplicates":"Valore non valido."},"field":"flavour"},"error":{"code":"validation","message":"Valore non valido."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/supplier_422.json b/app/src/test/resources/server090/catalogue/supplier_422.json new file mode 100644 index 0000000..2d90914 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/supplier_422.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"vat_number":"Valore troppo lungo (massimo 20 caratteri).","name":"Il nome del fornitore è obbligatorio.","email":"L'email del fornitore non è un indirizzo valido.","website":"Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it."},"field":"vat_number"},"error":{"code":"validation","message":"Valore troppo lungo (massimo 20 caratteri)."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/supplier_created.json b/app/src/test/resources/server090/catalogue/supplier_created.json new file mode 100644 index 0000000..db0791b --- /dev/null +++ b/app/src/test/resources/server090/catalogue/supplier_created.json @@ -0,0 +1 @@ +{"data":{"id":21,"name":"Libreria Cat090 Prova","contact_person":"Mario Rossi","email":"ordini@example.org","phone":"+39 02 1234","website":"https://www.example.org","address":"Via Roma 1, Milano","vat_number":"IT01234567890","notes":"Consegna il martedì","book_count":0,"created_at":"2026-11-01T19:39:15+00:00","updated_at":"2026-11-01T19:39:15+00:00","can_edit":true,"can_delete":true},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/supplier_deleted.json b/app/src/test/resources/server090/catalogue/supplier_deleted.json new file mode 100644 index 0000000..b149e12 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/supplier_deleted.json @@ -0,0 +1 @@ +{"data":{"id":21,"deleted":true,"books_unlinked":2},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/supplier_replayed.json b/app/src/test/resources/server090/catalogue/supplier_replayed.json new file mode 100644 index 0000000..2af5b13 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/supplier_replayed.json @@ -0,0 +1 @@ +{"data":{"id":21,"name":"Libreria Cat090 Prova","contact_person":"Mario Rossi","email":"ordini@example.org","phone":"+39 02 1234","website":"https://www.example.org","address":"Via Roma 1, Milano","vat_number":"IT01234567890","notes":"Consegna il martedì","book_count":0,"created_at":"2026-11-01T19:39:15+00:00","updated_at":"2026-11-01T19:39:15+00:00","can_edit":true,"can_delete":true},"meta":{"replayed":true},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/supplier_updated.json b/app/src/test/resources/server090/catalogue/supplier_updated.json new file mode 100644 index 0000000..4078ea0 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/supplier_updated.json @@ -0,0 +1 @@ +{"data":{"id":21,"name":"Libreria Cat090 Prova","contact_person":"Mario Rossi","email":"ordini@example.org","phone":null,"website":"https://www.example.org","address":"Via Roma 1, Milano","vat_number":"IT01234567890","notes":"Aggiornato","book_count":0,"created_at":"2026-11-01T19:39:15+00:00","updated_at":"2026-11-01T19:39:24+00:00","can_edit":true,"can_delete":true},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/catalogue/suppliers.json b/app/src/test/resources/server090/catalogue/suppliers.json new file mode 100644 index 0000000..0cd82b3 --- /dev/null +++ b/app/src/test/resources/server090/catalogue/suppliers.json @@ -0,0 +1 @@ +{"data":[{"id":21,"name":"Libreria Cat090 Prova","contact_person":"Mario Rossi","email":"ordini@example.org","phone":"+39 02 1234","website":"https://www.example.org","address":"Via Roma 1, Milano","vat_number":"IT01234567890","notes":"Consegna il martedì","book_count":0,"created_at":"2026-11-01T19:39:15+00:00","updated_at":"2026-11-01T19:39:15+00:00","can_edit":true,"can_delete":true}],"meta":{"count":1,"can_create":true},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_calendar.json b/app/src/test/resources/server090/library/s090_library_calendar.json new file mode 100644 index 0000000..da3a4f0 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_calendar.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"today":"2026-11-01","month":"2026-11","prev_month":"2026-10","next_month":"2026-12","branch_id":null,"days":[{"date":"2026-11-01","weekday":0,"closed":false,"reasons":[]},{"date":"2026-11-02","weekday":1,"closed":false,"reasons":[]},{"date":"2026-11-03","weekday":2,"closed":false,"reasons":[]},{"date":"2026-11-04","weekday":3,"closed":false,"reasons":[]},{"date":"2026-11-05","weekday":4,"closed":false,"reasons":[]},{"date":"2026-11-06","weekday":5,"closed":false,"reasons":[]},{"date":"2026-11-07","weekday":6,"closed":false,"reasons":[]},{"date":"2026-11-08","weekday":0,"closed":false,"reasons":[]},{"date":"2026-11-09","weekday":1,"closed":false,"reasons":[]},{"date":"2026-11-10","weekday":2,"closed":false,"reasons":[]},{"date":"2026-11-11","weekday":3,"closed":false,"reasons":[]},{"date":"2026-11-12","weekday":4,"closed":false,"reasons":[]},{"date":"2026-11-13","weekday":5,"closed":false,"reasons":[]},{"date":"2026-11-14","weekday":6,"closed":false,"reasons":[]},{"date":"2026-11-15","weekday":0,"closed":false,"reasons":[]},{"date":"2026-11-16","weekday":1,"closed":false,"reasons":[]},{"date":"2026-11-17","weekday":2,"closed":false,"reasons":[]},{"date":"2026-11-18","weekday":3,"closed":false,"reasons":[]},{"date":"2026-11-19","weekday":4,"closed":false,"reasons":[]},{"date":"2026-11-20","weekday":5,"closed":false,"reasons":[]},{"date":"2026-11-21","weekday":6,"closed":false,"reasons":[]},{"date":"2026-11-22","weekday":0,"closed":false,"reasons":[]},{"date":"2026-11-23","weekday":1,"closed":false,"reasons":[]},{"date":"2026-11-24","weekday":2,"closed":false,"reasons":[]},{"date":"2026-11-25","weekday":3,"closed":false,"reasons":[]},{"date":"2026-11-26","weekday":4,"closed":false,"reasons":[]},{"date":"2026-11-27","weekday":5,"closed":false,"reasons":[]},{"date":"2026-11-28","weekday":6,"closed":false,"reasons":[]},{"date":"2026-11-29","weekday":0,"closed":false,"reasons":[]},{"date":"2026-11-30","weekday":1,"closed":false,"reasons":[]}],"closed_weekdays":[],"closures":[],"custom_holidays_available":true,"custom_holidays":[],"custom_holiday_options":{"kinds":["fixed","nth_weekday","easter","orthodox_easter","calendar","dates"],"weekend_rules":["next_monday","us"],"occurrences":[1,2,3,4,5,-1],"calendars":[{"code":"islamic-umalqura","label":"Islamico (Umm al-Qura)","available":true,"months":["Muharram","Safar","Rabi' al-awwal","Rabi' al-thani","Jumada al-awwal","Jumada al-thani","Rajab","Sha'ban","Ramadan","Shawwal","Dhu al-Qa'dah","Dhu al-Hijjah"]},{"code":"hebrew","label":"Ebraico","available":true,"months":["Tishrei","Cheshvan","Kislev","Tevet","Shevat","Adar I","Adar (Adar II)","Nisan","Iyar","Sivan","Tammuz","Av","Elul"]},{"code":"chinese","label":"Cinese (lunisolare)","available":true,"months":["正月 (1)","二月 (2)","三月 (3)","四月 (4)","五月 (5)","六月 (6)","七月 (7)","八月 (8)","九月 (9)","十月 (10)","冬月 (11)","腊月 (12)"]},{"code":"persian","label":"Persiano (Hijri solare)","available":true,"months":["Farvardin","Ordibehesht","Khordad","Tir","Mordad","Shahrivar","Mehr","Aban","Azar","Dey","Bahman","Esfand"]},{"code":"indian","label":"Indiano (Saka)","available":true,"months":["Chaitra","Vaishakha","Jyeshtha","Ashadha","Shravana","Bhadra","Ashvin","Kartika","Agrahayana","Pausha","Magha","Phalguna"]},{"code":"ethiopic","label":"Etiope","available":true,"months":["Meskerem","Tikimt","Hidar","Tahsas","Tir","Yekatit","Megabit","Miyazya","Ginbot","Sene","Hamle","Nehase","Pagume"]},{"code":"coptic","label":"Copto","available":true,"months":["Thout","Paopi","Hathor","Koiak","Tobi","Meshir","Paremhat","Parmouti","Pashons","Paoni","Epip","Mesori","Pi Kogi Enavot"]}],"max_duration_days":14,"max_dates":400},"holiday_preset":{"country":"IT","country_name":"Italia","region":"","regions":[],"year":2026,"years":{"min":1900,"max":2199},"data_years":null,"saved":true,"schema_ready":true,"missing":[],"unknown_country":"","holidays":[{"id":"IT:new_year","name":"Capodanno","name_en":"Capodanno","date":"2026-01-01","dates":["2026-01-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:epiphany","name":"Epifania","name_en":"Epifania","date":"2026-01-06","dates":["2026-01-06"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:easter","name":"Pasqua","name_en":"Pasqua","date":"2026-04-05","dates":["2026-04-05"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:easter_monday","name":"Lunedì dell'Angelo (Pasquetta)","name_en":"Lunedì dell'Angelo (Pasquetta)","date":"2026-04-06","dates":["2026-04-06"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:liberation","name":"Festa della Liberazione","name_en":"Festa della Liberazione","date":"2026-04-25","dates":["2026-04-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:labour","name":"Festa del Lavoro","name_en":"Festa del Lavoro","date":"2026-05-01","dates":["2026-05-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:republic","name":"Festa della Repubblica","name_en":"Festa della Repubblica","date":"2026-06-02","dates":["2026-06-02"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:ferragosto","name":"Ferragosto","name_en":"Ferragosto","date":"2026-08-15","dates":["2026-08-15"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:san_francesco","name":"San Francesco d'Assisi, patrono d'Italia","name_en":"San Francesco d'Assisi, patrono d'Italia","date":"2026-10-04","dates":["2026-10-04"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:all_saints","name":"Ognissanti","name_en":"Ognissanti","date":"2026-11-01","dates":["2026-11-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:immaculate","name":"Immacolata Concezione","name_en":"Immacolata Concezione","date":"2026-12-08","dates":["2026-12-08"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:christmas","name":"Natale","name_en":"Natale","date":"2026-12-25","dates":["2026-12-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:st_stephen","name":"Santo Stefano","name_en":"Santo Stefano","date":"2026-12-26","dates":["2026-12-26"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false}]},"branches":[],"limits":{"yearly_span_days":363,"once_span_days":3660,"ics_max_bytes":1048576}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_calendar_december.json b/app/src/test/resources/server090/library/s090_library_calendar_december.json new file mode 100644 index 0000000..1bf6207 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_calendar_december.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"today":"2026-11-01","month":"2026-12","prev_month":"2026-11","next_month":"2027-01","branch_id":null,"days":[{"date":"2026-12-01","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-02","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-03","weekday":4,"closed":false,"reasons":[]},{"date":"2026-12-04","weekday":5,"closed":false,"reasons":[]},{"date":"2026-12-05","weekday":6,"closed":false,"reasons":[]},{"date":"2026-12-06","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-12-07","weekday":1,"closed":false,"reasons":[]},{"date":"2026-12-08","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-09","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-10","weekday":4,"closed":false,"reasons":[]},{"date":"2026-12-11","weekday":5,"closed":false,"reasons":[]},{"date":"2026-12-12","weekday":6,"closed":false,"reasons":[]},{"date":"2026-12-13","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-12-14","weekday":1,"closed":false,"reasons":[]},{"date":"2026-12-15","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-16","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-17","weekday":4,"closed":false,"reasons":[]},{"date":"2026-12-18","weekday":5,"closed":false,"reasons":[]},{"date":"2026-12-19","weekday":6,"closed":false,"reasons":[]},{"date":"2026-12-20","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-12-21","weekday":1,"closed":false,"reasons":[]},{"date":"2026-12-22","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-23","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-24","weekday":4,"closed":true,"reasons":[{"type":"closure","id":1134,"name":"Chiusura natalizia (prova app)"}]},{"date":"2026-12-25","weekday":5,"closed":true,"reasons":[{"type":"closure","id":1134,"name":"Chiusura natalizia (prova app)"}]},{"date":"2026-12-26","weekday":6,"closed":true,"reasons":[{"type":"closure","id":1134,"name":"Chiusura natalizia (prova app)"}]},{"date":"2026-12-27","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-12-28","weekday":1,"closed":false,"reasons":[]},{"date":"2026-12-29","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-30","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-31","weekday":4,"closed":false,"reasons":[]}],"closed_weekdays":[0],"closures":[{"id":1135,"start_date":"2026-08-15","end_date":"2026-08-15","description":"Ferragosto (prova app)","yearly":true,"branch_id":null,"branch_name":null,"preset_rule":null,"active":false,"editable":true},{"id":1134,"start_date":"2026-12-24","end_date":"2026-12-26","description":"Chiusura natalizia (prova app)","yearly":false,"branch_id":null,"branch_name":null,"preset_rule":null,"active":true,"editable":true}],"custom_holidays_available":true,"custom_holidays":[],"custom_holiday_options":{"kinds":["fixed","nth_weekday","easter","orthodox_easter","calendar","dates"],"weekend_rules":["next_monday","us"],"occurrences":[1,2,3,4,5,-1],"calendars":[{"code":"islamic-umalqura","label":"Islamico (Umm al-Qura)","available":true,"months":["Muharram","Safar","Rabi' al-awwal","Rabi' al-thani","Jumada al-awwal","Jumada al-thani","Rajab","Sha'ban","Ramadan","Shawwal","Dhu al-Qa'dah","Dhu al-Hijjah"]},{"code":"hebrew","label":"Ebraico","available":true,"months":["Tishrei","Cheshvan","Kislev","Tevet","Shevat","Adar I","Adar (Adar II)","Nisan","Iyar","Sivan","Tammuz","Av","Elul"]},{"code":"chinese","label":"Cinese (lunisolare)","available":true,"months":["正月 (1)","二月 (2)","三月 (3)","四月 (4)","五月 (5)","六月 (6)","七月 (7)","八月 (8)","九月 (9)","十月 (10)","冬月 (11)","腊月 (12)"]},{"code":"persian","label":"Persiano (Hijri solare)","available":true,"months":["Farvardin","Ordibehesht","Khordad","Tir","Mordad","Shahrivar","Mehr","Aban","Azar","Dey","Bahman","Esfand"]},{"code":"indian","label":"Indiano (Saka)","available":true,"months":["Chaitra","Vaishakha","Jyeshtha","Ashadha","Shravana","Bhadra","Ashvin","Kartika","Agrahayana","Pausha","Magha","Phalguna"]},{"code":"ethiopic","label":"Etiope","available":true,"months":["Meskerem","Tikimt","Hidar","Tahsas","Tir","Yekatit","Megabit","Miyazya","Ginbot","Sene","Hamle","Nehase","Pagume"]},{"code":"coptic","label":"Copto","available":true,"months":["Thout","Paopi","Hathor","Koiak","Tobi","Meshir","Paremhat","Parmouti","Pashons","Paoni","Epip","Mesori","Pi Kogi Enavot"]}],"max_duration_days":14,"max_dates":400},"holiday_preset":{"country":"IT","country_name":"Italia","region":"","regions":[],"year":2026,"years":{"min":1900,"max":2199},"data_years":null,"saved":false,"schema_ready":true,"missing":[],"unknown_country":"N","holidays":[{"id":"IT:new_year","name":"Capodanno","name_en":"Capodanno","date":"2026-01-01","dates":["2026-01-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:epiphany","name":"Epifania","name_en":"Epifania","date":"2026-01-06","dates":["2026-01-06"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter","name":"Pasqua","name_en":"Pasqua","date":"2026-04-05","dates":["2026-04-05"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter_monday","name":"Lunedì dell'Angelo (Pasquetta)","name_en":"Lunedì dell'Angelo (Pasquetta)","date":"2026-04-06","dates":["2026-04-06"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:liberation","name":"Festa della Liberazione","name_en":"Festa della Liberazione","date":"2026-04-25","dates":["2026-04-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:labour","name":"Festa del Lavoro","name_en":"Festa del Lavoro","date":"2026-05-01","dates":["2026-05-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:republic","name":"Festa della Repubblica","name_en":"Festa della Repubblica","date":"2026-06-02","dates":["2026-06-02"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:ferragosto","name":"Ferragosto","name_en":"Ferragosto","date":"2026-08-15","dates":["2026-08-15"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:san_francesco","name":"San Francesco d'Assisi, patrono d'Italia","name_en":"San Francesco d'Assisi, patrono d'Italia","date":"2026-10-04","dates":["2026-10-04"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:all_saints","name":"Ognissanti","name_en":"Ognissanti","date":"2026-11-01","dates":["2026-11-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:immaculate","name":"Immacolata Concezione","name_en":"Immacolata Concezione","date":"2026-12-08","dates":["2026-12-08"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:christmas","name":"Natale","name_en":"Natale","date":"2026-12-25","dates":["2026-12-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:st_stephen","name":"Santo Stefano","name_en":"Santo Stefano","date":"2026-12-26","dates":["2026-12-26"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true}]},"branches":[],"limits":{"yearly_span_days":363,"once_span_days":3660,"ics_max_bytes":1048576}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_calendar_full.json b/app/src/test/resources/server090/library/s090_library_calendar_full.json new file mode 100644 index 0000000..47d148d --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_calendar_full.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"today":"2026-11-01","month":"2026-12","prev_month":"2026-11","next_month":"2027-01","branch_id":null,"days":[{"date":"2026-12-01","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-02","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-03","weekday":4,"closed":false,"reasons":[]},{"date":"2026-12-04","weekday":5,"closed":false,"reasons":[]},{"date":"2026-12-05","weekday":6,"closed":false,"reasons":[]},{"date":"2026-12-06","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-12-07","weekday":1,"closed":false,"reasons":[]},{"date":"2026-12-08","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-09","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-10","weekday":4,"closed":false,"reasons":[]},{"date":"2026-12-11","weekday":5,"closed":false,"reasons":[]},{"date":"2026-12-12","weekday":6,"closed":false,"reasons":[]},{"date":"2026-12-13","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-12-14","weekday":1,"closed":false,"reasons":[]},{"date":"2026-12-15","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-16","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-17","weekday":4,"closed":false,"reasons":[]},{"date":"2026-12-18","weekday":5,"closed":false,"reasons":[]},{"date":"2026-12-19","weekday":6,"closed":false,"reasons":[]},{"date":"2026-12-20","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-12-21","weekday":1,"closed":false,"reasons":[]},{"date":"2026-12-22","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-23","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-24","weekday":4,"closed":true,"reasons":[{"type":"closure","id":1134,"name":"Chiusura natalizia (prova app)"}]},{"date":"2026-12-25","weekday":5,"closed":true,"reasons":[{"type":"closure","id":1134,"name":"Chiusura natalizia (prova app)"}]},{"date":"2026-12-26","weekday":6,"closed":true,"reasons":[{"type":"closure","id":1134,"name":"Chiusura natalizia (prova app)"}]},{"date":"2026-12-27","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-12-28","weekday":1,"closed":true,"reasons":[{"type":"custom_holiday","id":52,"name":"Date (prova app)"}]},{"date":"2026-12-29","weekday":2,"closed":false,"reasons":[]},{"date":"2026-12-30","weekday":3,"closed":false,"reasons":[]},{"date":"2026-12-31","weekday":4,"closed":false,"reasons":[]}],"closed_weekdays":[0],"closures":[{"id":1135,"start_date":"2026-08-15","end_date":"2026-08-15","description":"Ferragosto (prova app)","yearly":true,"branch_id":null,"branch_name":null,"preset_rule":null,"active":false,"editable":true},{"id":1134,"start_date":"2026-12-24","end_date":"2026-12-26","description":"Chiusura natalizia (prova app)","yearly":false,"branch_id":null,"branch_name":null,"preset_rule":null,"active":true,"editable":true}],"custom_holidays_available":true,"custom_holidays":[{"id":52,"name":"Date (prova app)","kind":"dates","month":null,"day":null,"weekday":null,"occurrence":null,"offset_days":null,"calendar":null,"dates":["2026-12-28","2027-01-04"],"duration_days":1,"weekend_rule":null,"year_from":null,"year_until":null,"branch_id":null,"branch_name":null,"active":true,"description":"Date specifiche (2)","next_dates":["2026-12-28","2027-01-04"]},{"id":53,"name":"Rosh Hashanah (prova app)","kind":"calendar","month":1,"day":1,"weekday":null,"occurrence":null,"offset_days":null,"calendar":"hebrew","dates":[],"duration_days":2,"weekend_rule":null,"year_from":null,"year_until":null,"branch_id":null,"branch_name":null,"active":true,"description":"Calendario Ebraico: 1 Tishrei · 2 giorni","next_dates":["2027-10-02","2027-10-03","2028-09-21"]},{"id":51,"name":"Santo patrono (prova app)","kind":"nth_weekday","month":11,"day":null,"weekday":1,"occurrence":-1,"offset_days":null,"calendar":null,"dates":[],"duration_days":1,"weekend_rule":null,"year_from":null,"year_until":null,"branch_id":null,"branch_name":null,"active":false,"description":"L’ultimo lunedì di novembre","next_dates":["2026-11-30","2027-11-29","2028-11-27"]}],"custom_holiday_options":{"kinds":["fixed","nth_weekday","easter","orthodox_easter","calendar","dates"],"weekend_rules":["next_monday","us"],"occurrences":[1,2,3,4,5,-1],"calendars":[{"code":"islamic-umalqura","label":"Islamico (Umm al-Qura)","available":true,"months":["Muharram","Safar","Rabi' al-awwal","Rabi' al-thani","Jumada al-awwal","Jumada al-thani","Rajab","Sha'ban","Ramadan","Shawwal","Dhu al-Qa'dah","Dhu al-Hijjah"]},{"code":"hebrew","label":"Ebraico","available":true,"months":["Tishrei","Cheshvan","Kislev","Tevet","Shevat","Adar I","Adar (Adar II)","Nisan","Iyar","Sivan","Tammuz","Av","Elul"]},{"code":"chinese","label":"Cinese (lunisolare)","available":true,"months":["正月 (1)","二月 (2)","三月 (3)","四月 (4)","五月 (5)","六月 (6)","七月 (7)","八月 (8)","九月 (9)","十月 (10)","冬月 (11)","腊月 (12)"]},{"code":"persian","label":"Persiano (Hijri solare)","available":true,"months":["Farvardin","Ordibehesht","Khordad","Tir","Mordad","Shahrivar","Mehr","Aban","Azar","Dey","Bahman","Esfand"]},{"code":"indian","label":"Indiano (Saka)","available":true,"months":["Chaitra","Vaishakha","Jyeshtha","Ashadha","Shravana","Bhadra","Ashvin","Kartika","Agrahayana","Pausha","Magha","Phalguna"]},{"code":"ethiopic","label":"Etiope","available":true,"months":["Meskerem","Tikimt","Hidar","Tahsas","Tir","Yekatit","Megabit","Miyazya","Ginbot","Sene","Hamle","Nehase","Pagume"]},{"code":"coptic","label":"Copto","available":true,"months":["Thout","Paopi","Hathor","Koiak","Tobi","Meshir","Paremhat","Parmouti","Pashons","Paoni","Epip","Mesori","Pi Kogi Enavot"]}],"max_duration_days":14,"max_dates":400},"holiday_preset":{"country":"IT","country_name":"Italia","region":"","regions":[],"year":2026,"years":{"min":1900,"max":2199},"data_years":null,"saved":false,"schema_ready":true,"missing":[],"unknown_country":"N","holidays":[{"id":"IT:new_year","name":"Capodanno","name_en":"Capodanno","date":"2026-01-01","dates":["2026-01-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:epiphany","name":"Epifania","name_en":"Epifania","date":"2026-01-06","dates":["2026-01-06"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter","name":"Pasqua","name_en":"Pasqua","date":"2026-04-05","dates":["2026-04-05"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter_monday","name":"Lunedì dell'Angelo (Pasquetta)","name_en":"Lunedì dell'Angelo (Pasquetta)","date":"2026-04-06","dates":["2026-04-06"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:liberation","name":"Festa della Liberazione","name_en":"Festa della Liberazione","date":"2026-04-25","dates":["2026-04-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:labour","name":"Festa del Lavoro","name_en":"Festa del Lavoro","date":"2026-05-01","dates":["2026-05-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:republic","name":"Festa della Repubblica","name_en":"Festa della Repubblica","date":"2026-06-02","dates":["2026-06-02"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:ferragosto","name":"Ferragosto","name_en":"Ferragosto","date":"2026-08-15","dates":["2026-08-15"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:san_francesco","name":"San Francesco d'Assisi, patrono d'Italia","name_en":"San Francesco d'Assisi, patrono d'Italia","date":"2026-10-04","dates":["2026-10-04"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:all_saints","name":"Ognissanti","name_en":"Ognissanti","date":"2026-11-01","dates":["2026-11-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:immaculate","name":"Immacolata Concezione","name_en":"Immacolata Concezione","date":"2026-12-08","dates":["2026-12-08"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:christmas","name":"Natale","name_en":"Natale","date":"2026-12-25","dates":["2026-12-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:st_stephen","name":"Santo Stefano","name_en":"Santo Stefano","date":"2026-12-26","dates":["2026-12-26"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true}]},"branches":[],"limits":{"yearly_span_days":363,"once_span_days":3660,"ics_max_bytes":1048576}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_calendar_january_preset.json b/app/src/test/resources/server090/library/s090_library_calendar_january_preset.json new file mode 100644 index 0000000..d4253b7 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_calendar_january_preset.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"today":"2026-11-01","month":"2026-01","prev_month":"2025-12","next_month":"2026-02","branch_id":null,"days":[{"date":"2026-01-01","weekday":4,"closed":true,"reasons":[{"type":"holiday","id":"new_year","name":"Capodanno"}]},{"date":"2026-01-02","weekday":5,"closed":false,"reasons":[]},{"date":"2026-01-03","weekday":6,"closed":false,"reasons":[]},{"date":"2026-01-04","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-01-05","weekday":1,"closed":false,"reasons":[]},{"date":"2026-01-06","weekday":2,"closed":true,"reasons":[{"type":"holiday","id":"epiphany","name":"Epifania"}]},{"date":"2026-01-07","weekday":3,"closed":false,"reasons":[]},{"date":"2026-01-08","weekday":4,"closed":false,"reasons":[]},{"date":"2026-01-09","weekday":5,"closed":false,"reasons":[]},{"date":"2026-01-10","weekday":6,"closed":false,"reasons":[]},{"date":"2026-01-11","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-01-12","weekday":1,"closed":false,"reasons":[]},{"date":"2026-01-13","weekday":2,"closed":false,"reasons":[]},{"date":"2026-01-14","weekday":3,"closed":false,"reasons":[]},{"date":"2026-01-15","weekday":4,"closed":false,"reasons":[]},{"date":"2026-01-16","weekday":5,"closed":false,"reasons":[]},{"date":"2026-01-17","weekday":6,"closed":false,"reasons":[]},{"date":"2026-01-18","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-01-19","weekday":1,"closed":false,"reasons":[]},{"date":"2026-01-20","weekday":2,"closed":false,"reasons":[]},{"date":"2026-01-21","weekday":3,"closed":false,"reasons":[]},{"date":"2026-01-22","weekday":4,"closed":false,"reasons":[]},{"date":"2026-01-23","weekday":5,"closed":false,"reasons":[]},{"date":"2026-01-24","weekday":6,"closed":false,"reasons":[]},{"date":"2026-01-25","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-01-26","weekday":1,"closed":false,"reasons":[]},{"date":"2026-01-27","weekday":2,"closed":false,"reasons":[]},{"date":"2026-01-28","weekday":3,"closed":false,"reasons":[]},{"date":"2026-01-29","weekday":4,"closed":false,"reasons":[]},{"date":"2026-01-30","weekday":5,"closed":false,"reasons":[]},{"date":"2026-01-31","weekday":6,"closed":false,"reasons":[]}],"closed_weekdays":[0],"closures":[{"id":1136,"start_date":"2026-01-01","end_date":"2026-01-01","description":"Capodanno","yearly":true,"branch_id":null,"branch_name":null,"preset_rule":"IT:new_year","active":true,"editable":false},{"id":1137,"start_date":"2026-01-06","end_date":"2026-01-06","description":"Epifania","yearly":true,"branch_id":null,"branch_name":null,"preset_rule":"IT:epiphany","active":true,"editable":false},{"id":1135,"start_date":"2026-08-15","end_date":"2026-08-15","description":"Ferragosto (prova app)","yearly":true,"branch_id":null,"branch_name":null,"preset_rule":null,"active":false,"editable":true},{"id":1138,"start_date":"2026-04-05","end_date":"2026-04-05","description":"Pasqua","yearly":false,"branch_id":null,"branch_name":null,"preset_rule":"IT:easter","active":true,"editable":false},{"id":1134,"start_date":"2026-12-24","end_date":"2026-12-26","description":"Chiusura natalizia (prova app)","yearly":false,"branch_id":null,"branch_name":null,"preset_rule":null,"active":true,"editable":true},{"id":1139,"start_date":"2027-03-28","end_date":"2027-03-28","description":"Pasqua","yearly":false,"branch_id":null,"branch_name":null,"preset_rule":"IT:easter","active":true,"editable":false},{"id":1140,"start_date":"2028-04-16","end_date":"2028-04-16","description":"Pasqua","yearly":false,"branch_id":null,"branch_name":null,"preset_rule":"IT:easter","active":true,"editable":false}],"custom_holidays_available":true,"custom_holidays":[{"id":52,"name":"Date (prova app)","kind":"dates","month":null,"day":null,"weekday":null,"occurrence":null,"offset_days":null,"calendar":null,"dates":["2026-12-28","2027-01-04"],"duration_days":1,"weekend_rule":null,"year_from":null,"year_until":null,"branch_id":null,"branch_name":null,"active":true,"description":"Date specifiche (2)","next_dates":["2026-12-28","2027-01-04"]},{"id":53,"name":"Rosh Hashanah (prova app)","kind":"calendar","month":1,"day":1,"weekday":null,"occurrence":null,"offset_days":null,"calendar":"hebrew","dates":[],"duration_days":2,"weekend_rule":null,"year_from":null,"year_until":null,"branch_id":null,"branch_name":null,"active":true,"description":"Calendario Ebraico: 1 Tishrei · 2 giorni","next_dates":["2027-10-02","2027-10-03","2028-09-21"]},{"id":51,"name":"Santo patrono (prova app)","kind":"nth_weekday","month":11,"day":null,"weekday":1,"occurrence":-1,"offset_days":null,"calendar":null,"dates":[],"duration_days":1,"weekend_rule":null,"year_from":null,"year_until":null,"branch_id":null,"branch_name":null,"active":false,"description":"L’ultimo lunedì di novembre","next_dates":["2026-11-30","2027-11-29","2028-11-27"]}],"custom_holiday_options":{"kinds":["fixed","nth_weekday","easter","orthodox_easter","calendar","dates"],"weekend_rules":["next_monday","us"],"occurrences":[1,2,3,4,5,-1],"calendars":[{"code":"islamic-umalqura","label":"Islamico (Umm al-Qura)","available":true,"months":["Muharram","Safar","Rabi' al-awwal","Rabi' al-thani","Jumada al-awwal","Jumada al-thani","Rajab","Sha'ban","Ramadan","Shawwal","Dhu al-Qa'dah","Dhu al-Hijjah"]},{"code":"hebrew","label":"Ebraico","available":true,"months":["Tishrei","Cheshvan","Kislev","Tevet","Shevat","Adar I","Adar (Adar II)","Nisan","Iyar","Sivan","Tammuz","Av","Elul"]},{"code":"chinese","label":"Cinese (lunisolare)","available":true,"months":["正月 (1)","二月 (2)","三月 (3)","四月 (4)","五月 (5)","六月 (6)","七月 (7)","八月 (8)","九月 (9)","十月 (10)","冬月 (11)","腊月 (12)"]},{"code":"persian","label":"Persiano (Hijri solare)","available":true,"months":["Farvardin","Ordibehesht","Khordad","Tir","Mordad","Shahrivar","Mehr","Aban","Azar","Dey","Bahman","Esfand"]},{"code":"indian","label":"Indiano (Saka)","available":true,"months":["Chaitra","Vaishakha","Jyeshtha","Ashadha","Shravana","Bhadra","Ashvin","Kartika","Agrahayana","Pausha","Magha","Phalguna"]},{"code":"ethiopic","label":"Etiope","available":true,"months":["Meskerem","Tikimt","Hidar","Tahsas","Tir","Yekatit","Megabit","Miyazya","Ginbot","Sene","Hamle","Nehase","Pagume"]},{"code":"coptic","label":"Copto","available":true,"months":["Thout","Paopi","Hathor","Koiak","Tobi","Meshir","Paremhat","Parmouti","Pashons","Paoni","Epip","Mesori","Pi Kogi Enavot"]}],"max_duration_days":14,"max_dates":400},"holiday_preset":{"country":"IT","country_name":"Italia","region":"","regions":[],"year":2026,"years":{"min":1900,"max":2199},"data_years":null,"saved":true,"schema_ready":true,"missing":[],"unknown_country":"","holidays":[{"id":"IT:new_year","name":"Capodanno","name_en":"Capodanno","date":"2026-01-01","dates":["2026-01-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:epiphany","name":"Epifania","name_en":"Epifania","date":"2026-01-06","dates":["2026-01-06"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter","name":"Pasqua","name_en":"Pasqua","date":"2026-04-05","dates":["2026-04-05"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter_monday","name":"Lunedì dell'Angelo (Pasquetta)","name_en":"Lunedì dell'Angelo (Pasquetta)","date":"2026-04-06","dates":["2026-04-06"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:liberation","name":"Festa della Liberazione","name_en":"Festa della Liberazione","date":"2026-04-25","dates":["2026-04-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:labour","name":"Festa del Lavoro","name_en":"Festa del Lavoro","date":"2026-05-01","dates":["2026-05-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:republic","name":"Festa della Repubblica","name_en":"Festa della Repubblica","date":"2026-06-02","dates":["2026-06-02"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:ferragosto","name":"Ferragosto","name_en":"Ferragosto","date":"2026-08-15","dates":["2026-08-15"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:san_francesco","name":"San Francesco d'Assisi, patrono d'Italia","name_en":"San Francesco d'Assisi, patrono d'Italia","date":"2026-10-04","dates":["2026-10-04"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:all_saints","name":"Ognissanti","name_en":"Ognissanti","date":"2026-11-01","dates":["2026-11-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:immaculate","name":"Immacolata Concezione","name_en":"Immacolata Concezione","date":"2026-12-08","dates":["2026-12-08"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:christmas","name":"Natale","name_en":"Natale","date":"2026-12-25","dates":["2026-12-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:st_stephen","name":"Santo Stefano","name_en":"Santo Stefano","date":"2026-12-26","dates":["2026-12-26"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false}]},"branches":[],"limits":{"yearly_span_days":363,"once_span_days":3660,"ics_max_bytes":1048576}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_calendar_staff_refused.json b/app/src/test/resources/server090/library/s090_library_calendar_staff_refused.json new file mode 100644 index 0000000..c7037c2 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_calendar_staff_refused.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"not_permitted","message":"Operazione riservata agli amministratori"}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_cards_admin.json b/app/src/test/resources/server090/library/s090_library_cards_admin.json new file mode 100644 index 0000000..8ef1462 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_cards_admin.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"settings":{"layout":"classic","code_type":"code128","format":"single","title":"","back_text":"","show_photo":true,"show_logo":true,"show_expiry":true},"options":{"layout":["classic","minimal","portrait"],"code_type":["code128","qr"],"format":["single","sheet"]},"limits":{"title_max":60,"back_text_max":600}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_cards_saved.json b/app/src/test/resources/server090/library/s090_library_cards_saved.json new file mode 100644 index 0000000..ccfca6a --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_cards_saved.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"settings":{"layout":"classic","code_type":"code128","format":"single","title":"Tessera di prova","back_text":"","show_photo":true,"show_logo":false,"show_expiry":true},"options":{"layout":["classic","minimal","portrait"],"code_type":["code128","qr"],"format":["single","sheet"]},"limits":{"title_max":60,"back_text_max":600}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_cards_staff.json b/app/src/test/resources/server090/library/s090_library_cards_staff.json new file mode 100644 index 0000000..8ef1462 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_cards_staff.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"settings":{"layout":"classic","code_type":"code128","format":"single","title":"","back_text":"","show_photo":true,"show_logo":true,"show_expiry":true},"options":{"layout":["classic","minimal","portrait"],"code_type":["code128","qr"],"format":["single","sheet"]},"limits":{"title_max":60,"back_text_max":600}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_cards_staff_disabled.json b/app/src/test/resources/server090/library/s090_library_cards_staff_disabled.json new file mode 100644 index 0000000..549c54d --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_cards_staff_disabled.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"feature_disabled","message":"Funzione non disponibile su questa istanza."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_closure_created.json b/app/src/test/resources/server090/library/s090_library_closure_created.json new file mode 100644 index 0000000..3565071 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_closure_created.json @@ -0,0 +1 @@ +{"data":{"id":1134,"start_date":"2026-12-24","end_date":"2026-12-26","description":"Natale (prova app)","yearly":false,"branch_id":null,"branch_name":null,"preset_rule":null,"active":true,"editable":true},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_closure_deleted.json b/app/src/test/resources/server090/library/s090_library_closure_deleted.json new file mode 100644 index 0000000..56a5000 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_closure_deleted.json @@ -0,0 +1 @@ +{"data":{"id":1134,"deleted":true},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_closure_invalid.json b/app/src/test/resources/server090/library/s090_library_closure_invalid.json new file mode 100644 index 0000000..43fb7c4 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_closure_invalid.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"start_date":"Data non valida: usa il formato AAAA-MM-GG."}},"error":{"code":"validation","message":"Data non valida: usa il formato AAAA-MM-GG."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_closure_preset_refused.json b/app/src/test/resources/server090/library/s090_library_closure_preset_refused.json new file mode 100644 index 0000000..faffd78 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_closure_preset_refused.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"preset_closure","message":"Le festività si modificano dalla selezione delle festività nazionali."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_closure_updated.json b/app/src/test/resources/server090/library/s090_library_closure_updated.json new file mode 100644 index 0000000..20fd471 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_closure_updated.json @@ -0,0 +1 @@ +{"data":{"id":1134,"start_date":"2026-12-24","end_date":"2026-12-26","description":"Chiusura natalizia (prova app)","yearly":false,"branch_id":null,"branch_name":null,"preset_rule":null,"active":true,"editable":true},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_countries_it.json b/app/src/test/resources/server090/library/s090_library_countries_it.json new file mode 100644 index 0000000..92c6bcb --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_countries_it.json @@ -0,0 +1 @@ +{"data":{"countries":[{"code":"AF","name":"Afghanistan"},{"code":"AL","name":"Albania"},{"code":"DZ","name":"Algeria"},{"code":"AD","name":"Andorra"},{"code":"AO","name":"Angola"},{"code":"AI","name":"Anguilla"},{"code":"AQ","name":"Antartide"},{"code":"AG","name":"Antigua e Barbuda"},{"code":"SA","name":"Arabia Saudita"},{"code":"AR","name":"Argentina"},{"code":"AM","name":"Armenia"},{"code":"AW","name":"Aruba"},{"code":"AU","name":"Australia"},{"code":"AT","name":"Austria"},{"code":"AZ","name":"Azerbaigian"},{"code":"BS","name":"Bahamas"},{"code":"BH","name":"Bahrein"},{"code":"BD","name":"Bangladesh"},{"code":"BB","name":"Barbados"},{"code":"BE","name":"Belgio"},{"code":"BZ","name":"Belize"},{"code":"BJ","name":"Benin"},{"code":"BM","name":"Bermuda"},{"code":"BT","name":"Bhutan"},{"code":"BY","name":"Bielorussia"},{"code":"BO","name":"Bolivia"},{"code":"BA","name":"Bosnia ed Erzegovina"},{"code":"BW","name":"Botswana"},{"code":"BR","name":"Brasile"},{"code":"BN","name":"Brunei"},{"code":"BG","name":"Bulgaria"},{"code":"BF","name":"Burkina Faso"},{"code":"BI","name":"Burundi"},{"code":"KH","name":"Cambogia"},{"code":"CM","name":"Camerun"},{"code":"CA","name":"Canada"},{"code":"CV","name":"Capo Verde"},{"code":"BQ","name":"Caraibi Olandesi"},{"code":"CZ","name":"Cechia"},{"code":"TD","name":"Ciad"},{"code":"CL","name":"Cile"},{"code":"CN","name":"Cina"},{"code":"CY","name":"Cipro"},{"code":"VA","name":"Città del Vaticano"},{"code":"CO","name":"Colombia"},{"code":"KM","name":"Comore"},{"code":"CD","name":"Congo - Kinshasa"},{"code":"CG","name":"Congo-Brazzaville"},{"code":"KP","name":"Corea del Nord"},{"code":"KR","name":"Corea del Sud"},{"code":"CI","name":"Costa d’Avorio"},{"code":"CR","name":"Costa Rica"},{"code":"HR","name":"Croazia"},{"code":"CU","name":"Cuba"},{"code":"CW","name":"Curaçao"},{"code":"DK","name":"Danimarca"},{"code":"DM","name":"Dominica"},{"code":"EC","name":"Ecuador"},{"code":"EG","name":"Egitto"},{"code":"SV","name":"El Salvador"},{"code":"AE","name":"Emirati Arabi Uniti"},{"code":"ER","name":"Eritrea"},{"code":"EE","name":"Estonia"},{"code":"SZ","name":"Eswatini"},{"code":"ET","name":"Etiopia"},{"code":"FJ","name":"Figi"},{"code":"PH","name":"Filippine"},{"code":"FI","name":"Finlandia"},{"code":"FR","name":"Francia"},{"code":"GA","name":"Gabon"},{"code":"GM","name":"Gambia"},{"code":"GE","name":"Georgia"},{"code":"GS","name":"Georgia del Sud e Sandwich Australi"},{"code":"DE","name":"Germania"},{"code":"GH","name":"Ghana"},{"code":"JM","name":"Giamaica"},{"code":"JP","name":"Giappone"},{"code":"GI","name":"Gibilterra"},{"code":"DJ","name":"Gibuti"},{"code":"JO","name":"Giordania"},{"code":"GR","name":"Grecia"},{"code":"GD","name":"Grenada"},{"code":"GL","name":"Groenlandia"},{"code":"GP","name":"Guadalupa"},{"code":"GU","name":"Guam"},{"code":"GT","name":"Guatemala"},{"code":"GG","name":"Guernsey"},{"code":"GN","name":"Guinea"},{"code":"GQ","name":"Guinea Equatoriale"},{"code":"GW","name":"Guinea-Bissau"},{"code":"GY","name":"Guyana"},{"code":"GF","name":"Guyana Francese"},{"code":"HT","name":"Haiti"},{"code":"HN","name":"Honduras"},{"code":"IN","name":"India"},{"code":"ID","name":"Indonesia"},{"code":"IR","name":"Iran"},{"code":"IQ","name":"Iraq"},{"code":"IE","name":"Irlanda"},{"code":"IS","name":"Islanda"},{"code":"BV","name":"Isola Bouvet"},{"code":"CX","name":"Isola Christmas"},{"code":"IM","name":"Isola di Man"},{"code":"NF","name":"Isola Norfolk"},{"code":"AX","name":"Isole Åland"},{"code":"KY","name":"Isole Cayman"},{"code":"CC","name":"Isole Cocos (Keeling)"},{"code":"CK","name":"Isole Cook"},{"code":"FK","name":"Isole Falkland"},{"code":"FO","name":"Isole Fær Øer"},{"code":"HM","name":"Isole Heard e McDonald"},{"code":"MP","name":"Isole Marianne Settentrionali"},{"code":"MH","name":"Isole Marshall"},{"code":"UM","name":"Isole Minori Esterne degli Stati Uniti"},{"code":"PN","name":"Isole Pitcairn"},{"code":"SB","name":"Isole Salomone"},{"code":"TC","name":"Isole Turks e Caicos"},{"code":"VI","name":"Isole Vergini Americane"},{"code":"VG","name":"Isole Vergini Britanniche"},{"code":"IL","name":"Israele"},{"code":"IT","name":"Italia"},{"code":"JE","name":"Jersey"},{"code":"KZ","name":"Kazakistan"},{"code":"KE","name":"Kenya"},{"code":"KG","name":"Kirghizistan"},{"code":"KI","name":"Kiribati"},{"code":"XK","name":"Kosovo"},{"code":"KW","name":"Kuwait"},{"code":"LA","name":"Laos"},{"code":"LS","name":"Lesotho"},{"code":"LV","name":"Lettonia"},{"code":"LB","name":"Libano"},{"code":"LR","name":"Liberia"},{"code":"LY","name":"Libia"},{"code":"LI","name":"Liechtenstein"},{"code":"LT","name":"Lituania"},{"code":"LU","name":"Lussemburgo"},{"code":"MK","name":"Macedonia del Nord"},{"code":"MG","name":"Madagascar"},{"code":"MW","name":"Malawi"},{"code":"MY","name":"Malaysia"},{"code":"MV","name":"Maldive"},{"code":"ML","name":"Mali"},{"code":"MT","name":"Malta"},{"code":"MA","name":"Marocco"},{"code":"MQ","name":"Martinica"},{"code":"MR","name":"Mauritania"},{"code":"MU","name":"Mauritius"},{"code":"YT","name":"Mayotte"},{"code":"MX","name":"Messico"},{"code":"FM","name":"Micronesia"},{"code":"MD","name":"Moldavia"},{"code":"MC","name":"Monaco"},{"code":"MN","name":"Mongolia"},{"code":"ME","name":"Montenegro"},{"code":"MS","name":"Montserrat"},{"code":"MZ","name":"Mozambico"},{"code":"MM","name":"Myanmar (Birmania)"},{"code":"NA","name":"Namibia"},{"code":"NR","name":"Nauru"},{"code":"NP","name":"Nepal"},{"code":"NI","name":"Nicaragua"},{"code":"NE","name":"Niger"},{"code":"NG","name":"Nigeria"},{"code":"NU","name":"Niue"},{"code":"NO","name":"Norvegia"},{"code":"NC","name":"Nuova Caledonia"},{"code":"NZ","name":"Nuova Zelanda"},{"code":"OM","name":"Oman"},{"code":"NL","name":"Paesi Bassi"},{"code":"PK","name":"Pakistan"},{"code":"PW","name":"Palau"},{"code":"PA","name":"Panama"},{"code":"PG","name":"Papua Nuova Guinea"},{"code":"PY","name":"Paraguay"},{"code":"PE","name":"Perù"},{"code":"PF","name":"Polinesia Francese"},{"code":"PL","name":"Polonia"},{"code":"PT","name":"Portogallo"},{"code":"PR","name":"Portorico"},{"code":"QA","name":"Qatar"},{"code":"HK","name":"RAS di Hong Kong"},{"code":"MO","name":"RAS di Macao"},{"code":"GB","name":"Regno Unito"},{"code":"CF","name":"Repubblica Centrafricana"},{"code":"DO","name":"Repubblica Dominicana"},{"code":"RE","name":"Riunione"},{"code":"RO","name":"Romania"},{"code":"RW","name":"Ruanda"},{"code":"RU","name":"Russia"},{"code":"EH","name":"Sahara Occidentale"},{"code":"KN","name":"Saint Kitts e Nevis"},{"code":"LC","name":"Saint Lucia"},{"code":"MF","name":"Saint Martin"},{"code":"VC","name":"Saint Vincent e Grenadine"},{"code":"BL","name":"Saint-Barthélemy"},{"code":"PM","name":"Saint-Pierre e Miquelon"},{"code":"WS","name":"Samoa"},{"code":"AS","name":"Samoa Americane"},{"code":"SM","name":"San Marino"},{"code":"SH","name":"Sant’Elena"},{"code":"ST","name":"São Tomé e Príncipe"},{"code":"SN","name":"Senegal"},{"code":"RS","name":"Serbia"},{"code":"SC","name":"Seychelles"},{"code":"SL","name":"Sierra Leone"},{"code":"SG","name":"Singapore"},{"code":"SX","name":"Sint Maarten"},{"code":"SY","name":"Siria"},{"code":"SK","name":"Slovacchia"},{"code":"SI","name":"Slovenia"},{"code":"SO","name":"Somalia"},{"code":"ES","name":"Spagna"},{"code":"LK","name":"Sri Lanka"},{"code":"US","name":"Stati Uniti"},{"code":"SS","name":"Sud Sudan"},{"code":"ZA","name":"Sudafrica"},{"code":"SD","name":"Sudan"},{"code":"SR","name":"Suriname"},{"code":"SJ","name":"Svalbard e Jan Mayen"},{"code":"SE","name":"Svezia"},{"code":"CH","name":"Svizzera"},{"code":"TJ","name":"Tagikistan"},{"code":"TW","name":"Taiwan"},{"code":"TZ","name":"Tanzania"},{"code":"TF","name":"Terre Australi Francesi"},{"code":"PS","name":"Territori Palestinesi"},{"code":"IO","name":"Territorio Britannico dell’Oceano Indiano"},{"code":"TH","name":"Thailandia"},{"code":"TL","name":"Timor Est"},{"code":"TG","name":"Togo"},{"code":"TK","name":"Tokelau"},{"code":"TO","name":"Tonga"},{"code":"TT","name":"Trinidad e Tobago"},{"code":"TN","name":"Tunisia"},{"code":"TR","name":"Turchia"},{"code":"TM","name":"Turkmenistan"},{"code":"TV","name":"Tuvalu"},{"code":"UA","name":"Ucraina"},{"code":"UG","name":"Uganda"},{"code":"HU","name":"Ungheria"},{"code":"UY","name":"Uruguay"},{"code":"UZ","name":"Uzbekistan"},{"code":"VU","name":"Vanuatu"},{"code":"VE","name":"Venezuela"},{"code":"VN","name":"Vietnam"},{"code":"WF","name":"Wallis e Futuna"},{"code":"YE","name":"Yemen"},{"code":"ZM","name":"Zambia"},{"code":"ZW","name":"Zimbabwe"}],"country":"IT","country_name":"Italia","region":"","regions":[],"year":2027,"years":{"min":1900,"max":2199},"data_years":null,"saved":false,"schema_ready":true,"missing":[],"unknown_country":"N","holidays":[{"id":"IT:new_year","name":"Capodanno","name_en":"Capodanno","date":"2027-01-01","dates":["2027-01-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:epiphany","name":"Epifania","name_en":"Epifania","date":"2027-01-06","dates":["2027-01-06"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter","name":"Pasqua","name_en":"Pasqua","date":"2027-03-28","dates":["2027-03-28"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter_monday","name":"Lunedì dell'Angelo (Pasquetta)","name_en":"Lunedì dell'Angelo (Pasquetta)","date":"2027-03-29","dates":["2027-03-29"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:liberation","name":"Festa della Liberazione","name_en":"Festa della Liberazione","date":"2027-04-25","dates":["2027-04-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:labour","name":"Festa del Lavoro","name_en":"Festa del Lavoro","date":"2027-05-01","dates":["2027-05-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:republic","name":"Festa della Repubblica","name_en":"Festa della Repubblica","date":"2027-06-02","dates":["2027-06-02"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:ferragosto","name":"Ferragosto","name_en":"Ferragosto","date":"2027-08-15","dates":["2027-08-15"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:san_francesco","name":"San Francesco d'Assisi, patrono d'Italia","name_en":"San Francesco d'Assisi, patrono d'Italia","date":"2027-10-04","dates":["2027-10-04"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:all_saints","name":"Ognissanti","name_en":"Ognissanti","date":"2027-11-01","dates":["2027-11-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:immaculate","name":"Immacolata Concezione","name_en":"Immacolata Concezione","date":"2027-12-08","dates":["2027-12-08"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:christmas","name":"Natale","name_en":"Natale","date":"2027-12-25","dates":["2027-12-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:st_stephen","name":"Santo Stefano","name_en":"Santo Stefano","date":"2027-12-26","dates":["2027-12-26"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_custom_created.json b/app/src/test/resources/server090/library/s090_library_custom_created.json new file mode 100644 index 0000000..57cd535 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_custom_created.json @@ -0,0 +1 @@ +{"data":{"id":51,"name":"Santo patrono (prova app)","kind":"fixed","month":12,"day":7,"weekday":null,"occurrence":null,"offset_days":null,"calendar":null,"dates":[],"duration_days":1,"weekend_rule":null,"year_from":null,"year_until":null,"branch_id":null,"branch_name":null,"active":true,"description":"Ogni anno il 7 dicembre","next_dates":["2026-12-07","2027-12-07","2028-12-07"]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_custom_deleted.json b/app/src/test/resources/server090/library/s090_library_custom_deleted.json new file mode 100644 index 0000000..eccd37e --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_custom_deleted.json @@ -0,0 +1 @@ +{"data":{"id":51,"deleted":true},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_custom_invalid.json b/app/src/test/resources/server090/library/s090_library_custom_invalid.json new file mode 100644 index 0000000..50cfa62 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_custom_invalid.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"name":"Indica il nome della festività."}},"error":{"code":"validation","message":"Indica il nome della festività."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_custom_toggled.json b/app/src/test/resources/server090/library/s090_library_custom_toggled.json new file mode 100644 index 0000000..bf18e70 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_custom_toggled.json @@ -0,0 +1 @@ +{"data":{"id":51,"name":"Santo patrono (prova app)","kind":"fixed","month":12,"day":7,"weekday":null,"occurrence":null,"offset_days":null,"calendar":null,"dates":[],"duration_days":1,"weekend_rule":null,"year_from":null,"year_until":null,"branch_id":null,"branch_name":null,"active":false,"description":"Ogni anno il 7 dicembre","next_dates":["2026-12-07","2027-12-07","2028-12-07"]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_custom_updated.json b/app/src/test/resources/server090/library/s090_library_custom_updated.json new file mode 100644 index 0000000..59c2b10 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_custom_updated.json @@ -0,0 +1 @@ +{"data":{"id":51,"name":"Santo patrono (prova app)","kind":"nth_weekday","month":11,"day":null,"weekday":1,"occurrence":-1,"offset_days":null,"calendar":null,"dates":[],"duration_days":1,"weekend_rule":null,"year_from":null,"year_until":null,"branch_id":null,"branch_name":null,"active":false,"description":"L’ultimo lunedì di novembre","next_dates":["2026-11-30","2027-11-29","2028-11-27"]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_health.json b/app/src/test/resources/server090/library/s090_library_health.json new file mode 100644 index 0000000..e42b536 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_health.json @@ -0,0 +1 @@ +{"data":{"name":"Pinakes","logo":null,"version":"0.9.0-rc.1","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true,"reviews":true,"archives":true,"desiderata":true,"staff_catalog":true,"catalog_sync":true,"calendar_feed":true,"automatic_fines":false,"debt_block":false,"consultation_only":true,"fines":false,"closures_calendar":true,"circulation_rules":true,"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true,"staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true,"staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true,"staff_circulation_rules":true,"staff_closures_calendar":true,"staff_ledger":false,"staff_library_settings":true,"staff_patrons":true,"staff_stocktake":false,"stocktake":false,"marc":true,"visitor_log":false,"catalogue_cards":true,"suppliers":false,"rda_types":false,"patron_cards":true,"user_import":true,"patron_photos":true,"automatic_card_expiry":true,"loss_penalties":false,"attendance_retention":false},"catalogue_mode":false,"loan_approval_required":true,"app_access_enabled":true,"registration_enabled":true,"registration":{"require_cognome":true,"require_telefono":true,"require_indirizzo":true,"show_sesso":true,"show_cod_fiscale":true,"custom_fields":[]},"private_mode":false,"vapid_public_key":"BAeIEfzXgH-K-K8vvexn2fopg0tHoj0O5q84qROQn8k5QTEVfK0mwsZoLPR6PLQ_l3ujMU4wQzf8hizluOTfgIk"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_ics_imported.json b/app/src/test/resources/server090/library/s090_library_ics_imported.json new file mode 100644 index 0000000..6a1446e --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_ics_imported.json @@ -0,0 +1 @@ +{"data":{"added":1,"added_ids":[1141],"message":"File ICS importato: 1 chiusure aggiunte, 0 già presenti."},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/library/s090_library_ics_invalid.json b/app/src/test/resources/server090/library/s090_library_ics_invalid.json new file mode 100644 index 0000000..f42b683 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_ics_invalid.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"ics_file":"Il file non è un calendario ICS valido."}},"error":{"code":"validation","message":"Il file non è un calendario ICS valido."}} diff --git a/app/src/test/resources/server090/library/s090_library_loans_admin.json b/app/src/test/resources/server090/library/s090_library_loans_admin.json new file mode 100644 index 0000000..9f4a774 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_loans_admin.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"currency":"EUR","settings":{"loan_duration_days":30,"pickup_expiry_days":3,"pickup_reminder_days":4,"card_expiry_warning_days":0,"max_renewals":3,"max_active_loans_per_user":0,"max_loan_duration_days":90,"recall_interval_days":7,"recall_max_count":3,"auto_approve_requests":false,"allow_multiple_loans_same_book":false,"recall_auto_enabled":false,"app_timezone":"Europe/Rome","daily_fine_cents":null,"fine_max_per_loan_cents":null,"debt_block_threshold_cents":null,"fine_grace_days":0,"fine_grace_mode":"threshold","card_renewal_base":"today","fine_charged_email":false},"editable_fields":["loan_duration_days","pickup_expiry_days","pickup_reminder_days","card_expiry_warning_days","max_renewals","max_active_loans_per_user","max_loan_duration_days","recall_interval_days","recall_max_count","auto_approve_requests","allow_multiple_loans_same_book","recall_auto_enabled","app_timezone","daily_fine_cents","fine_max_per_loan_cents","debt_block_threshold_cents","fine_grace_days","fine_grace_mode","card_renewal_base","fine_charged_email"],"admin_only_fields":["daily_fine_cents","fine_max_per_loan_cents","debt_block_threshold_cents","fine_grace_days","fine_grace_mode","card_renewal_base","fine_charged_email"],"limits":{"loan_duration_days":{"min":1,"max":3650},"pickup_expiry_days":{"min":1,"max":30},"pickup_reminder_days":{"min":0,"max":30},"card_expiry_warning_days":{"min":0,"max":365},"max_renewals":{"min":0,"max":100},"max_active_loans_per_user":{"min":0,"max":1000},"max_loan_duration_days":{"min":1,"max":3650},"recall_interval_days":{"min":1,"max":365},"recall_max_count":{"min":1,"max":50},"fine_grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"options":{"fine_grace_mode":["threshold","deduct"],"card_renewal_base":["today","expiry"]}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_loans_invalid.json b/app/src/test/resources/server090/library/s090_library_loans_invalid.json new file mode 100644 index 0000000..5250abd --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_loans_invalid.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"loan_duration_days":"Valore non valido."}},"error":{"code":"validation","message":"Valore non valido."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_loans_saved.json b/app/src/test/resources/server090/library/s090_library_loans_saved.json new file mode 100644 index 0000000..f75b931 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_loans_saved.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"currency":"EUR","settings":{"loan_duration_days":30,"pickup_expiry_days":3,"pickup_reminder_days":4,"card_expiry_warning_days":0,"max_renewals":3,"max_active_loans_per_user":0,"max_loan_duration_days":90,"recall_interval_days":8,"recall_max_count":3,"auto_approve_requests":false,"allow_multiple_loans_same_book":false,"recall_auto_enabled":false,"app_timezone":"Europe/Rome","daily_fine_cents":150,"fine_max_per_loan_cents":null,"debt_block_threshold_cents":null,"fine_grace_days":0,"fine_grace_mode":"threshold","card_renewal_base":"today","fine_charged_email":false},"editable_fields":["loan_duration_days","pickup_expiry_days","pickup_reminder_days","card_expiry_warning_days","max_renewals","max_active_loans_per_user","max_loan_duration_days","recall_interval_days","recall_max_count","auto_approve_requests","allow_multiple_loans_same_book","recall_auto_enabled","app_timezone","daily_fine_cents","fine_max_per_loan_cents","debt_block_threshold_cents","fine_grace_days","fine_grace_mode","card_renewal_base","fine_charged_email"],"admin_only_fields":["daily_fine_cents","fine_max_per_loan_cents","debt_block_threshold_cents","fine_grace_days","fine_grace_mode","card_renewal_base","fine_charged_email"],"limits":{"loan_duration_days":{"min":1,"max":3650},"pickup_expiry_days":{"min":1,"max":30},"pickup_reminder_days":{"min":0,"max":30},"card_expiry_warning_days":{"min":0,"max":365},"max_renewals":{"min":0,"max":100},"max_active_loans_per_user":{"min":0,"max":1000},"max_loan_duration_days":{"min":1,"max":3650},"recall_interval_days":{"min":1,"max":365},"recall_max_count":{"min":1,"max":50},"fine_grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"options":{"fine_grace_mode":["threshold","deduct"],"card_renewal_base":["today","expiry"]}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_loans_staff.json b/app/src/test/resources/server090/library/s090_library_loans_staff.json new file mode 100644 index 0000000..c498736 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_loans_staff.json @@ -0,0 +1 @@ +{"data":{"can_configure":false,"currency":"EUR","settings":{"loan_duration_days":30,"pickup_expiry_days":3,"pickup_reminder_days":4,"card_expiry_warning_days":0,"max_renewals":3,"max_active_loans_per_user":0,"max_loan_duration_days":90,"recall_interval_days":7,"recall_max_count":3,"auto_approve_requests":false,"allow_multiple_loans_same_book":false,"recall_auto_enabled":false,"app_timezone":"Europe/Rome","daily_fine_cents":null,"fine_max_per_loan_cents":null,"debt_block_threshold_cents":null,"fine_grace_days":0,"fine_grace_mode":"threshold","card_renewal_base":"today","fine_charged_email":false},"editable_fields":["loan_duration_days","pickup_expiry_days","pickup_reminder_days","card_expiry_warning_days","max_renewals","max_active_loans_per_user","max_loan_duration_days","recall_interval_days","recall_max_count","auto_approve_requests","allow_multiple_loans_same_book","recall_auto_enabled","app_timezone"],"admin_only_fields":["daily_fine_cents","fine_max_per_loan_cents","debt_block_threshold_cents","fine_grace_days","fine_grace_mode","card_renewal_base","fine_charged_email"],"limits":{"loan_duration_days":{"min":1,"max":3650},"pickup_expiry_days":{"min":1,"max":30},"pickup_reminder_days":{"min":0,"max":30},"card_expiry_warning_days":{"min":0,"max":365},"max_renewals":{"min":0,"max":100},"max_active_loans_per_user":{"min":0,"max":1000},"max_loan_duration_days":{"min":1,"max":3650},"recall_interval_days":{"min":1,"max":365},"recall_max_count":{"min":1,"max":50},"fine_grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"options":{"fine_grace_mode":["threshold","deduct"],"card_renewal_base":["today","expiry"]}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_loans_staff_refused.json b/app/src/test/resources/server090/library/s090_library_loans_staff_refused.json new file mode 100644 index 0000000..30d1ad0 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_loans_staff_refused.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"daily_fine_cents":"Operazione riservata agli amministratori"}},"error":{"code":"not_permitted","message":"Operazione riservata agli amministratori"}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_modules_admin.json b/app/src/test/resources/server090/library/s090_library_modules_admin.json new file mode 100644 index 0000000..c85c199 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_modules_admin.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"catalogue_mode":false,"attendance_retention_days":365,"attendance_retention_max_days":3650,"modules":[{"key":"stocktake","label":"Revisione inventariale","help":"Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo \"Revisore inventario\" resta ma non apre nulla.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"marc","label":"Import ed export MARC","help":"Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"visitor_log","label":"Registro presenze","help":"Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":true},{"key":"catalogue_cards","label":"Schede catalografiche","help":"Stampa della scheda catalografica di un libro o di una selezione.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"suppliers","label":"Fornitori","help":"Anagrafica fornitori e campo \"Fornitore\" nella scheda libro e nell'import CSV.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"rda_types","label":"Tipi RDA","help":"Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"patron_cards","label":"Stampa delle tessere","help":"Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"user_import","label":"Importazione utenti da CSV","help":"Caricamento massivo dei lettori con invito via email.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"circulation_rules","label":"Regole per tipologia di lettore","help":"Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":true},{"key":"closures_calendar","label":"Calendario chiusure","help":"Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":true},{"key":"fines","label":"Registro movimenti","help":"Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.","group":"loans","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":true},{"key":"patron_photos","label":"Foto dei lettori","help":"Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":"patron_cards","circulation":false},{"key":"consultation_only","label":"Copie in sola consultazione","help":"Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":"circulation_rules","circulation":true},{"key":"automatic_card_expiry","label":"Validità automatica delle tessere","help":"Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.","group":"loans","stored":true,"effective":true,"parent":"circulation_rules","inherits_from":"circulation_rules","circulation":true},{"key":"automatic_fines","label":"Multe automatiche per ritardo","help":"Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"debt_block","label":"Blocco dei prestiti per debito","help":"Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"loss_penalties","label":"Riporta penalità per perdita e danno nel registro","help":"Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"attendance_retention","label":"Conservazione a registro presenze sospeso","help":"Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_modules_invalid.json b/app/src/test/resources/server090/library/s090_library_modules_invalid.json new file mode 100644 index 0000000..b922ef2 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_modules_invalid.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"features.nope":"Valore non valido."}},"error":{"code":"validation","message":"Valore non valido."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_modules_saved.json b/app/src/test/resources/server090/library/s090_library_modules_saved.json new file mode 100644 index 0000000..35779aa --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_modules_saved.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"catalogue_mode":false,"attendance_retention_days":400,"attendance_retention_max_days":3650,"modules":[{"key":"stocktake","label":"Revisione inventariale","help":"Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo \"Revisore inventario\" resta ma non apre nulla.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"marc","label":"Import ed export MARC","help":"Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"visitor_log","label":"Registro presenze","help":"Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":true},{"key":"catalogue_cards","label":"Schede catalografiche","help":"Stampa della scheda catalografica di un libro o di una selezione.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"suppliers","label":"Fornitori","help":"Anagrafica fornitori e campo \"Fornitore\" nella scheda libro e nell'import CSV.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"rda_types","label":"Tipi RDA","help":"Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"patron_cards","label":"Stampa delle tessere","help":"Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"user_import","label":"Importazione utenti da CSV","help":"Caricamento massivo dei lettori con invito via email.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"circulation_rules","label":"Regole per tipologia di lettore","help":"Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":true},{"key":"closures_calendar","label":"Calendario chiusure","help":"Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":true},{"key":"fines","label":"Registro movimenti","help":"Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.","group":"loans","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":true},{"key":"patron_photos","label":"Foto dei lettori","help":"Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":"patron_cards","circulation":false},{"key":"consultation_only","label":"Copie in sola consultazione","help":"Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":"circulation_rules","circulation":true},{"key":"automatic_card_expiry","label":"Validità automatica delle tessere","help":"Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.","group":"loans","stored":true,"effective":true,"parent":"circulation_rules","inherits_from":"circulation_rules","circulation":true},{"key":"automatic_fines","label":"Multe automatiche per ritardo","help":"Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"debt_block","label":"Blocco dei prestiti per debito","help":"Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"loss_penalties","label":"Riporta penalità per perdita e danno nel registro","help":"Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"attendance_retention","label":"Conservazione a registro presenze sospeso","help":"Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_modules_staff.json b/app/src/test/resources/server090/library/s090_library_modules_staff.json new file mode 100644 index 0000000..d758655 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_modules_staff.json @@ -0,0 +1 @@ +{"data":{"can_configure":false,"catalogue_mode":false,"attendance_retention_days":365,"attendance_retention_max_days":3650,"modules":[{"key":"stocktake","label":"Revisione inventariale","help":"Sessioni di revisione con scansione delle copie, esiti e report. Spenta: voce di menu e pagine non disponibili, il ruolo \"Revisore inventario\" resta ma non apre nulla.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"marc","label":"Import ed export MARC","help":"Importazione massiva di record MARC21/UNIMARC/danMARC2, esportazione del catalogo e catalogazione derivata via SRU.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"visitor_log","label":"Registro presenze","help":"Registro degli ingressi in biblioteca con conservazione a tempo. Contiene dati personali: attivalo solo se la biblioteca lo usa davvero.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":true},{"key":"catalogue_cards","label":"Schede catalografiche","help":"Stampa della scheda catalografica di un libro o di una selezione.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"suppliers","label":"Fornitori","help":"Anagrafica fornitori e campo \"Fornitore\" nella scheda libro e nell'import CSV.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"rda_types","label":"Tipi RDA","help":"Campi tipo di contenuto, media e supporto RDA nella scheda libro. Spenti, i valori restano salvati e gli export li derivano dal tipo di materiale.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false},{"key":"patron_cards","label":"Stampa delle tessere","help":"Stampa delle tessere in PDF. Foto e validità automatica hanno controlli separati.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"user_import","label":"Importazione utenti da CSV","help":"Caricamento massivo dei lettori con invito via email.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":false},{"key":"circulation_rules","label":"Regole per tipologia di lettore","help":"Tipologie di lettore, matrice delle regole per tipo di materiale, limiti alle prenotazioni e durata automatica della tessera. Spente, valgono solo le regole globali di questa scheda.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":true},{"key":"closures_calendar","label":"Calendario chiusure","help":"Giorni di chiusura, festività e import ICS: le scadenze saltano i giorni chiusi. Spento, il calendario non viene applicato.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":null,"circulation":true},{"key":"fines","label":"Registro movimenti","help":"Addebiti manuali, pagamenti, ricevute e condoni. Multe automatiche, penalità e blocco per debito hanno controlli separati. Spento, conserva i movimenti.","group":"loans","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":true},{"key":"patron_photos","label":"Foto dei lettori","help":"Acquisizione e visualizzazione delle foto, indipendenti dalla stampa delle tessere. Spegnendo il controllo le foto restano conservate.","group":"modules","stored":true,"effective":true,"parent":null,"inherits_from":"patron_cards","circulation":false},{"key":"consultation_only","label":"Copie in sola consultazione","help":"Rispetta il vincolo di consultazione delle copie anche senza regole per tipologia, su banco, sito, app e integrazioni.","group":"loans","stored":true,"effective":true,"parent":null,"inherits_from":"circulation_rules","circulation":true},{"key":"automatic_card_expiry","label":"Validità automatica delle tessere","help":"Applica la durata della tipologia alle nuove iscrizioni e alle approvazioni. Non modifica le scadenze esistenti; il rinnovo manuale resta disponibile.","group":"loans","stored":true,"effective":true,"parent":"circulation_rules","inherits_from":"circulation_rules","circulation":true},{"key":"automatic_fines","label":"Multe automatiche per ritardo","help":"Richiede il registro attivo e una tariffa positiva. Spento, nessuna nuova multa per ritardo viene calcolata o addebitata; tariffe e movimenti restano salvati.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"debt_block","label":"Blocco dei prestiti per debito","help":"Richiede il registro attivo e una soglia positiva. Può bloccare i prestiti anche senza generare nuove multe automatiche.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"loss_penalties","label":"Riporta penalità per perdita e danno nel registro","help":"Riporta gli importi già assegnati ai prestiti nel registro contabile, comprese le penalità precedenti. Non determina nuovi importi.","group":"loans","stored":false,"effective":false,"parent":"fines","inherits_from":"fines","circulation":true},{"key":"attendance_retention","label":"Conservazione a registro presenze sospeso","help":"Continua la cancellazione secondo i giorni di conservazione anche quando il registro è spento o in modalità catalogo. Con 0 giorni non cancella nulla.","group":"modules","stored":false,"effective":false,"parent":null,"inherits_from":null,"circulation":false}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_modules_staff_refused.json b/app/src/test/resources/server090/library/s090_library_modules_staff_refused.json new file mode 100644 index 0000000..c7037c2 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_modules_staff_refused.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"not_permitted","message":"Operazione riservata agli amministratori"}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_preset_applied.json b/app/src/test/resources/server090/library/s090_library_preset_applied.json new file mode 100644 index 0000000..a6cfb03 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_preset_applied.json @@ -0,0 +1 @@ +{"data":{"message":"Festività applicate: 5 aggiunte, 0 rimosse.","holiday_preset":{"country":"IT","country_name":"Italia","region":"","regions":[],"year":2026,"years":{"min":1900,"max":2199},"data_years":null,"saved":true,"schema_ready":true,"missing":[],"unknown_country":"","holidays":[{"id":"IT:new_year","name":"Capodanno","name_en":"Capodanno","date":"2026-01-01","dates":["2026-01-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:epiphany","name":"Epifania","name_en":"Epifania","date":"2026-01-06","dates":["2026-01-06"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter","name":"Pasqua","name_en":"Pasqua","date":"2026-04-05","dates":["2026-04-05"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter_monday","name":"Lunedì dell'Angelo (Pasquetta)","name_en":"Lunedì dell'Angelo (Pasquetta)","date":"2026-04-06","dates":["2026-04-06"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:liberation","name":"Festa della Liberazione","name_en":"Festa della Liberazione","date":"2026-04-25","dates":["2026-04-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:labour","name":"Festa del Lavoro","name_en":"Festa del Lavoro","date":"2026-05-01","dates":["2026-05-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:republic","name":"Festa della Repubblica","name_en":"Festa della Repubblica","date":"2026-06-02","dates":["2026-06-02"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:ferragosto","name":"Ferragosto","name_en":"Ferragosto","date":"2026-08-15","dates":["2026-08-15"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:san_francesco","name":"San Francesco d'Assisi, patrono d'Italia","name_en":"San Francesco d'Assisi, patrono d'Italia","date":"2026-10-04","dates":["2026-10-04"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:all_saints","name":"Ognissanti","name_en":"Ognissanti","date":"2026-11-01","dates":["2026-11-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:immaculate","name":"Immacolata Concezione","name_en":"Immacolata Concezione","date":"2026-12-08","dates":["2026-12-08"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:christmas","name":"Natale","name_en":"Natale","date":"2026-12-25","dates":["2026-12-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false},{"id":"IT:st_stephen","name":"Santo Stefano","name_en":"Santo Stefano","date":"2026-12-26","dates":["2026-12-26"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":false}]}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_preset_invalid.json b/app/src/test/resources/server090/library/s090_library_preset_invalid.json new file mode 100644 index 0000000..f144cd0 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_preset_invalid.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"country":"Paese non valido."}},"error":{"code":"validation","message":"Paese non valido."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_role_gate_forbidden.json b/app/src/test/resources/server090/library/s090_library_role_gate_forbidden.json new file mode 100644 index 0000000..bc15ec9 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_role_gate_forbidden.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"forbidden","message":"Operazione riservata allo staff."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_rules.json b/app/src/test/resources/server090/library/s090_library_rules.json new file mode 100644 index 0000000..3b41f6a --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_rules.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"currency":"EUR","media":[{"key":"libro","label":"Libro"},{"key":"disco","label":"Disco"},{"key":"audiolibro","label":"Audiolibro"},{"key":"dvd","label":"DVD"},{"key":"altro","label":"Altro"}],"rows":[{"type_id":0,"name":"Tutte le tipologie"},{"type_id":1,"name":"Standard"}],"limits":{"max_loans":{"min":0,"max":1000},"loan_duration_days":{"min":1,"max":3650},"max_renewals":{"min":0,"max":100},"renewal_days":{"min":1,"max":3650},"grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"cells":[{"type_id":0,"media":"libro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"disco","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"audiolibro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"dvd","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"altro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"libro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"disco","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"audiolibro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"dvd","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"altro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_rules_invalid.json b/app/src/test/resources/server090/library/s090_library_rules_invalid.json new file mode 100644 index 0000000..9f274a9 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_rules_invalid.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"cells.0.max_loans":"Valore non valido."}},"error":{"code":"validation","message":"Valore non valido."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_rules_restored.json b/app/src/test/resources/server090/library/s090_library_rules_restored.json new file mode 100644 index 0000000..3b41f6a --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_rules_restored.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"currency":"EUR","media":[{"key":"libro","label":"Libro"},{"key":"disco","label":"Disco"},{"key":"audiolibro","label":"Audiolibro"},{"key":"dvd","label":"DVD"},{"key":"altro","label":"Altro"}],"rows":[{"type_id":0,"name":"Tutte le tipologie"},{"type_id":1,"name":"Standard"}],"limits":{"max_loans":{"min":0,"max":1000},"loan_duration_days":{"min":1,"max":3650},"max_renewals":{"min":0,"max":100},"renewal_days":{"min":1,"max":3650},"grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"cells":[{"type_id":0,"media":"libro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"disco","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"audiolibro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"dvd","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"altro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"libro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"disco","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"audiolibro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"dvd","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"altro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_rules_saved.json b/app/src/test/resources/server090/library/s090_library_rules_saved.json new file mode 100644 index 0000000..45ccfe7 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_rules_saved.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"currency":"EUR","media":[{"key":"libro","label":"Libro"},{"key":"disco","label":"Disco"},{"key":"audiolibro","label":"Audiolibro"},{"key":"dvd","label":"DVD"},{"key":"altro","label":"Altro"}],"rows":[{"type_id":0,"name":"Tutte le tipologie"},{"type_id":1,"name":"Standard"}],"limits":{"max_loans":{"min":0,"max":1000},"loan_duration_days":{"min":1,"max":3650},"max_renewals":{"min":0,"max":100},"renewal_days":{"min":1,"max":3650},"grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"cells":[{"type_id":0,"media":"libro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"disco","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"audiolibro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"dvd","has_rule":true,"loanable":false,"max_loans":null,"loan_duration_days":7,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":0,"media":"altro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"libro","has_rule":true,"loanable":null,"max_loans":4,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":25,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"disco","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"audiolibro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"dvd","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":false,"max_loans":0,"loan_duration_days":7,"max_renewals":3,"renewal_days":7,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}},{"type_id":1,"media":"altro","has_rule":false,"loanable":null,"max_loans":null,"loan_duration_days":null,"max_renewals":null,"renewal_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"inherited":{"loanable":true,"max_loans":0,"loan_duration_days":30,"max_renewals":3,"renewal_days":30,"grace_days":0,"daily_fine_cents":null,"max_fine_per_loan_cents":null}}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_type_created.json b/app/src/test/resources/server090/library/s090_library_type_created.json new file mode 100644 index 0000000..a42bb24 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_type_created.json @@ -0,0 +1 @@ +{"data":{"id":116,"code":"ricercatori_app","name":"Ricercatori app","description":null,"reservations_enabled":true,"active":true,"is_default":false,"order":20,"user_count":0,"max_loans":5,"loan_duration_days":null,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":5,"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":30,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_type_default.json b/app/src/test/resources/server090/library/s090_library_type_default.json new file mode 100644 index 0000000..1f2f755 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_type_default.json @@ -0,0 +1 @@ +{"data":{"id":116,"code":"ricercatori_app","name":"Ricercatori app","description":null,"reservations_enabled":true,"active":true,"is_default":true,"order":20,"user_count":0,"max_loans":null,"loan_duration_days":60,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":0,"loan_duration_days":60,"max_request_duration_days":90,"max_renewals":3,"renewal_days":60,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_type_delete_default.json b/app/src/test/resources/server090/library/s090_library_type_delete_default.json new file mode 100644 index 0000000..a51f26f --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_type_delete_default.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"default_type","message":"La tipologia predefinita non può essere eliminata: scegli prima un'altra predefinita."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_type_deleted.json b/app/src/test/resources/server090/library/s090_library_type_deleted.json new file mode 100644 index 0000000..f88d949 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_type_deleted.json @@ -0,0 +1 @@ +{"data":{"id":116,"deleted":true,"users_reassigned":0},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_type_get.json b/app/src/test/resources/server090/library/s090_library_type_get.json new file mode 100644 index 0000000..a42bb24 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_type_get.json @@ -0,0 +1 @@ +{"data":{"id":116,"code":"ricercatori_app","name":"Ricercatori app","description":null,"reservations_enabled":true,"active":true,"is_default":false,"order":20,"user_count":0,"max_loans":5,"loan_duration_days":null,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":5,"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":30,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_type_invalid.json b/app/src/test/resources/server090/library/s090_library_type_invalid.json new file mode 100644 index 0000000..500172d --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_type_invalid.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"code":"Il codice può contenere solo lettere minuscole, numeri, trattini e trattini bassi (massimo 40 caratteri)."}},"error":{"code":"validation","message":"Il codice può contenere solo lettere minuscole, numeri, trattini e trattini bassi (massimo 40 caratteri)."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_type_missing.json b/app/src/test/resources/server090/library/s090_library_type_missing.json new file mode 100644 index 0000000..71f749b --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_type_missing.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"not_found","message":"Tipologia lettore non trovata."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_type_replayed.json b/app/src/test/resources/server090/library/s090_library_type_replayed.json new file mode 100644 index 0000000..f02e64c --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_type_replayed.json @@ -0,0 +1 @@ +{"data":{"id":116,"code":"ricercatori_app","name":"Ricercatori app","description":null,"reservations_enabled":true,"active":true,"is_default":false,"order":20,"user_count":0,"max_loans":5,"loan_duration_days":null,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":5,"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":30,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}},"meta":{"replayed":true},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_type_updated.json b/app/src/test/resources/server090/library/s090_library_type_updated.json new file mode 100644 index 0000000..33fee7b --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_type_updated.json @@ -0,0 +1 @@ +{"data":{"id":116,"code":"ricercatori_app","name":"Ricercatori app","description":null,"reservations_enabled":true,"active":true,"is_default":false,"order":20,"user_count":0,"max_loans":null,"loan_duration_days":60,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":0,"loan_duration_days":60,"max_request_duration_days":90,"max_renewals":3,"renewal_days":60,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_types.json b/app/src/test/resources/server090/library/s090_library_types.json new file mode 100644 index 0000000..9c37222 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_types.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"currency":"EUR","default_type_id":1,"globals":{"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":null,"max_loans":0,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null},"limits":{"max_loans":{"min":0,"max":1000},"loan_duration_days":{"min":1,"max":3650},"max_request_duration_days":{"min":1,"max":3650},"max_renewals":{"min":0,"max":100},"renewal_days":{"min":1,"max":3650},"max_reservations":{"min":0,"max":1000},"card_duration_days":{"min":1,"max":36500},"grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"types":[{"id":1,"code":"standard","name":"Standard","description":null,"reservations_enabled":true,"active":true,"is_default":true,"order":10,"user_count":0,"max_loans":null,"loan_duration_days":null,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":0,"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":30,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_types_admin_module_off.json b/app/src/test/resources/server090/library/s090_library_types_admin_module_off.json new file mode 100644 index 0000000..2ab7b71 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_types_admin_module_off.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":false,"currency":"EUR","default_type_id":1,"globals":{"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":null,"max_loans":0,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null},"limits":{"max_loans":{"min":0,"max":1000},"loan_duration_days":{"min":1,"max":3650},"max_request_duration_days":{"min":1,"max":3650},"max_renewals":{"min":0,"max":100},"renewal_days":{"min":1,"max":3650},"max_reservations":{"min":0,"max":1000},"card_duration_days":{"min":1,"max":36500},"grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"types":[{"id":1,"code":"standard","name":"Standard","description":null,"reservations_enabled":true,"active":true,"is_default":true,"order":10,"user_count":0,"max_loans":null,"loan_duration_days":null,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":0,"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":30,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_types_moved.json b/app/src/test/resources/server090/library/s090_library_types_moved.json new file mode 100644 index 0000000..83236a5 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_types_moved.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"currency":"EUR","default_type_id":1,"globals":{"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":null,"max_loans":0,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null},"limits":{"max_loans":{"min":0,"max":1000},"loan_duration_days":{"min":1,"max":3650},"max_request_duration_days":{"min":1,"max":3650},"max_renewals":{"min":0,"max":100},"renewal_days":{"min":1,"max":3650},"max_reservations":{"min":0,"max":1000},"card_duration_days":{"min":1,"max":36500},"grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"types":[{"id":116,"code":"ricercatori_app","name":"Ricercatori app","description":null,"reservations_enabled":true,"active":true,"is_default":false,"order":10,"user_count":0,"max_loans":null,"loan_duration_days":60,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":0,"loan_duration_days":60,"max_request_duration_days":90,"max_renewals":3,"renewal_days":60,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}},{"id":1,"code":"standard","name":"Standard","description":null,"reservations_enabled":true,"active":true,"is_default":true,"order":20,"user_count":0,"max_loans":null,"loan_duration_days":null,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":0,"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":30,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_types_staff_disabled.json b/app/src/test/resources/server090/library/s090_library_types_staff_disabled.json new file mode 100644 index 0000000..549c54d --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_types_staff_disabled.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"feature_disabled","message":"Funzione non disponibile su questa istanza."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_types_staff_refused.json b/app/src/test/resources/server090/library/s090_library_types_staff_refused.json new file mode 100644 index 0000000..c7037c2 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_types_staff_refused.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"not_permitted","message":"Operazione riservata agli amministratori"}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_types_two.json b/app/src/test/resources/server090/library/s090_library_types_two.json new file mode 100644 index 0000000..b3d1a54 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_types_two.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"currency":"EUR","default_type_id":1,"globals":{"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":null,"max_loans":0,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null},"limits":{"max_loans":{"min":0,"max":1000},"loan_duration_days":{"min":1,"max":3650},"max_request_duration_days":{"min":1,"max":3650},"max_renewals":{"min":0,"max":100},"renewal_days":{"min":1,"max":3650},"max_reservations":{"min":0,"max":1000},"card_duration_days":{"min":1,"max":36500},"grace_days":{"min":0,"max":365},"money_max_cents":{"min":0,"max":999999}},"types":[{"id":1,"code":"standard","name":"Standard","description":null,"reservations_enabled":true,"active":true,"is_default":true,"order":10,"user_count":0,"max_loans":null,"loan_duration_days":null,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":0,"loan_duration_days":30,"max_request_duration_days":90,"max_renewals":3,"renewal_days":30,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":null,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}},{"id":116,"code":"ricercatori_app","name":"Ricercatori app","description":null,"reservations_enabled":true,"active":true,"is_default":false,"order":20,"user_count":0,"max_loans":null,"loan_duration_days":60,"max_request_duration_days":null,"max_renewals":null,"renewal_days":null,"max_reservations":null,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null,"effective":{"max_loans":0,"loan_duration_days":60,"max_request_duration_days":90,"max_renewals":3,"renewal_days":60,"reservations_enabled":true,"max_reservations":0,"card_duration_days":null,"grace_days":null,"daily_fine_cents":50,"max_fine_per_loan_cents":null,"debt_block_threshold_cents":null}}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_weekdays_all.json b/app/src/test/resources/server090/library/s090_library_weekdays_all.json new file mode 100644 index 0000000..d07a705 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_weekdays_all.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"closed_weekdays":"Almeno un giorno della settimana deve restare aperto."}},"error":{"code":"validation","message":"Almeno un giorno della settimana deve restare aperto."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_weekdays_saved.json b/app/src/test/resources/server090/library/s090_library_weekdays_saved.json new file mode 100644 index 0000000..26b70c0 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_weekdays_saved.json @@ -0,0 +1 @@ +{"data":{"can_configure":true,"module_enabled":true,"today":"2026-11-01","month":"2026-11","prev_month":"2026-10","next_month":"2026-12","branch_id":null,"days":[{"date":"2026-11-01","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-11-02","weekday":1,"closed":false,"reasons":[]},{"date":"2026-11-03","weekday":2,"closed":false,"reasons":[]},{"date":"2026-11-04","weekday":3,"closed":false,"reasons":[]},{"date":"2026-11-05","weekday":4,"closed":false,"reasons":[]},{"date":"2026-11-06","weekday":5,"closed":false,"reasons":[]},{"date":"2026-11-07","weekday":6,"closed":false,"reasons":[]},{"date":"2026-11-08","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-11-09","weekday":1,"closed":false,"reasons":[]},{"date":"2026-11-10","weekday":2,"closed":false,"reasons":[]},{"date":"2026-11-11","weekday":3,"closed":false,"reasons":[]},{"date":"2026-11-12","weekday":4,"closed":false,"reasons":[]},{"date":"2026-11-13","weekday":5,"closed":false,"reasons":[]},{"date":"2026-11-14","weekday":6,"closed":false,"reasons":[]},{"date":"2026-11-15","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-11-16","weekday":1,"closed":false,"reasons":[]},{"date":"2026-11-17","weekday":2,"closed":false,"reasons":[]},{"date":"2026-11-18","weekday":3,"closed":false,"reasons":[]},{"date":"2026-11-19","weekday":4,"closed":false,"reasons":[]},{"date":"2026-11-20","weekday":5,"closed":false,"reasons":[]},{"date":"2026-11-21","weekday":6,"closed":false,"reasons":[]},{"date":"2026-11-22","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-11-23","weekday":1,"closed":false,"reasons":[]},{"date":"2026-11-24","weekday":2,"closed":false,"reasons":[]},{"date":"2026-11-25","weekday":3,"closed":false,"reasons":[]},{"date":"2026-11-26","weekday":4,"closed":false,"reasons":[]},{"date":"2026-11-27","weekday":5,"closed":false,"reasons":[]},{"date":"2026-11-28","weekday":6,"closed":false,"reasons":[]},{"date":"2026-11-29","weekday":0,"closed":true,"reasons":[{"type":"weekly","id":null,"name":"Chiusura settimanale"}]},{"date":"2026-11-30","weekday":1,"closed":false,"reasons":[]}],"closed_weekdays":[0],"closures":[],"custom_holidays_available":true,"custom_holidays":[],"custom_holiday_options":{"kinds":["fixed","nth_weekday","easter","orthodox_easter","calendar","dates"],"weekend_rules":["next_monday","us"],"occurrences":[1,2,3,4,5,-1],"calendars":[{"code":"islamic-umalqura","label":"Islamico (Umm al-Qura)","available":true,"months":["Muharram","Safar","Rabi' al-awwal","Rabi' al-thani","Jumada al-awwal","Jumada al-thani","Rajab","Sha'ban","Ramadan","Shawwal","Dhu al-Qa'dah","Dhu al-Hijjah"]},{"code":"hebrew","label":"Ebraico","available":true,"months":["Tishrei","Cheshvan","Kislev","Tevet","Shevat","Adar I","Adar (Adar II)","Nisan","Iyar","Sivan","Tammuz","Av","Elul"]},{"code":"chinese","label":"Cinese (lunisolare)","available":true,"months":["正月 (1)","二月 (2)","三月 (3)","四月 (4)","五月 (5)","六月 (6)","七月 (7)","八月 (8)","九月 (9)","十月 (10)","冬月 (11)","腊月 (12)"]},{"code":"persian","label":"Persiano (Hijri solare)","available":true,"months":["Farvardin","Ordibehesht","Khordad","Tir","Mordad","Shahrivar","Mehr","Aban","Azar","Dey","Bahman","Esfand"]},{"code":"indian","label":"Indiano (Saka)","available":true,"months":["Chaitra","Vaishakha","Jyeshtha","Ashadha","Shravana","Bhadra","Ashvin","Kartika","Agrahayana","Pausha","Magha","Phalguna"]},{"code":"ethiopic","label":"Etiope","available":true,"months":["Meskerem","Tikimt","Hidar","Tahsas","Tir","Yekatit","Megabit","Miyazya","Ginbot","Sene","Hamle","Nehase","Pagume"]},{"code":"coptic","label":"Copto","available":true,"months":["Thout","Paopi","Hathor","Koiak","Tobi","Meshir","Paremhat","Parmouti","Pashons","Paoni","Epip","Mesori","Pi Kogi Enavot"]}],"max_duration_days":14,"max_dates":400},"holiday_preset":{"country":"IT","country_name":"Italia","region":"","regions":[],"year":2026,"years":{"min":1900,"max":2199},"data_years":null,"saved":false,"schema_ready":true,"missing":[],"unknown_country":"N","holidays":[{"id":"IT:new_year","name":"Capodanno","name_en":"Capodanno","date":"2026-01-01","dates":["2026-01-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:epiphany","name":"Epifania","name_en":"Epifania","date":"2026-01-06","dates":["2026-01-06"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter","name":"Pasqua","name_en":"Pasqua","date":"2026-04-05","dates":["2026-04-05"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:easter_monday","name":"Lunedì dell'Angelo (Pasquetta)","name_en":"Lunedì dell'Angelo (Pasquetta)","date":"2026-04-06","dates":["2026-04-06"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:liberation","name":"Festa della Liberazione","name_en":"Festa della Liberazione","date":"2026-04-25","dates":["2026-04-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:labour","name":"Festa del Lavoro","name_en":"Festa del Lavoro","date":"2026-05-01","dates":["2026-05-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:republic","name":"Festa della Repubblica","name_en":"Festa della Repubblica","date":"2026-06-02","dates":["2026-06-02"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:ferragosto","name":"Ferragosto","name_en":"Ferragosto","date":"2026-08-15","dates":["2026-08-15"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:san_francesco","name":"San Francesco d'Assisi, patrono d'Italia","name_en":"San Francesco d'Assisi, patrono d'Italia","date":"2026-10-04","dates":["2026-10-04"],"yearly":false,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:all_saints","name":"Ognissanti","name_en":"Ognissanti","date":"2026-11-01","dates":["2026-11-01"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:immaculate","name":"Immacolata Concezione","name_en":"Immacolata Concezione","date":"2026-12-08","dates":["2026-12-08"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:christmas","name":"Natale","name_en":"Natale","date":"2026-12-25","dates":["2026-12-25"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true},{"id":"IT:st_stephen","name":"Santo Stefano","name_en":"Santo Stefano","date":"2026-12-26","dates":["2026-12-26"],"yearly":true,"regional":false,"optional":false,"category":"public","estimated":false,"checked":true}]},"branches":[],"limits":{"yearly_span_days":363,"once_span_days":3660,"ics_max_bytes":1048576}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/library/s090_library_yearly_saved.json b/app/src/test/resources/server090/library/s090_library_yearly_saved.json new file mode 100644 index 0000000..fcbade4 --- /dev/null +++ b/app/src/test/resources/server090/library/s090_library_yearly_saved.json @@ -0,0 +1 @@ +{"data":{"changed":1,"yearly_closures":[{"id":1135,"start_date":"2026-08-15","end_date":"2026-08-15","description":"Ferragosto (prova app)","yearly":true,"branch_id":null,"branch_name":null,"preset_rule":null,"active":false,"editable":true}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/patrons/att_bad_branch.json b/app/src/test/resources/server090/patrons/att_bad_branch.json new file mode 100644 index 0000000..a32af73 --- /dev/null +++ b/app/src/test/resources/server090/patrons/att_bad_branch.json @@ -0,0 +1 @@ +{"data":null,"meta":{"field":"branch_id","fields":{"branch_id":"Branch not found."}},"error":{"code":"validation","message":"Branch not found."}} diff --git a/app/src/test/resources/server090/patrons/att_checkin.json b/app/src/test/resources/server090/patrons/att_checkin.json new file mode 100644 index 0000000..53ac67c --- /dev/null +++ b/app/src/test/resources/server090/patrons/att_checkin.json @@ -0,0 +1 @@ +{"data":{"id":32,"type":"member","created_at":"2026-11-01 19:38:12","branch":null,"member":{"id":113143,"short_name":"Marta P.","card_code":"PCAP090A","account_status":"attivo","card_active":true,"card_expires_at":"2026-12-01","card_expired":false,"has_photo":true,"photo_url":"http://localhost:8195/api/v1/staff/users/113143/photo?v=258b10f2a9"},"guest":null,"warning":null,"message":"Welcome, Marta P.: visit recorded."},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/att_checkin_suspended.json b/app/src/test/resources/server090/patrons/att_checkin_suspended.json new file mode 100644 index 0000000..d7fd68c --- /dev/null +++ b/app/src/test/resources/server090/patrons/att_checkin_suspended.json @@ -0,0 +1 @@ +{"data":{"id":34,"type":"member","created_at":"2026-11-01 19:38:22","branch":{"id":5,"name":"Sede Pcap"},"member":{"id":113143,"short_name":"Marta P.","card_code":"PCAP090A","account_status":"sospeso","card_active":false,"card_expires_at":"2026-01-31","card_expired":true,"has_photo":true,"photo_url":"http://localhost:8195/api/v1/staff/users/113143/photo?v=258b10f2a9"},"guest":null,"warning":"card_not_active","message":"Visit recorded for Marta P., but the card is not active."},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/att_guest.json b/app/src/test/resources/server090/patrons/att_guest.json new file mode 100644 index 0000000..45137c5 --- /dev/null +++ b/app/src/test/resources/server090/patrons/att_guest.json @@ -0,0 +1 @@ +{"data":{"id":33,"type":"guest","created_at":"2026-11-01 19:38:12","branch":null,"member":null,"guest":{"name":"Visiting pcap","reason":"Research"},"warning":null,"message":"Guest visit recorded."},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/att_overview.json b/app/src/test/resources/server090/patrons/att_overview.json new file mode 100644 index 0000000..74054cb --- /dev/null +++ b/app/src/test/resources/server090/patrons/att_overview.json @@ -0,0 +1 @@ +{"data":{"days":7,"periods":[7,30,90,365],"today":{"members":0,"guests":0,"total":0},"totals":{"members":0,"guests":0,"total":0},"daily":[{"date":"2026-10-26","members":0,"guests":0,"total":0},{"date":"2026-10-27","members":0,"guests":0,"total":0},{"date":"2026-10-28","members":0,"guests":0,"total":0},{"date":"2026-10-29","members":0,"guests":0,"total":0},{"date":"2026-10-30","members":0,"guests":0,"total":0},{"date":"2026-10-31","members":0,"guests":0,"total":0},{"date":"2026-11-01","members":0,"guests":0,"total":0}],"hourly":[{"hour":0,"total":0},{"hour":1,"total":0},{"hour":2,"total":0},{"hour":3,"total":0},{"hour":4,"total":0},{"hour":5,"total":0},{"hour":6,"total":0},{"hour":7,"total":0},{"hour":8,"total":0},{"hour":9,"total":0},{"hour":10,"total":0},{"hour":11,"total":0},{"hour":12,"total":0},{"hour":13,"total":0},{"hour":14,"total":0},{"hour":15,"total":0},{"hour":16,"total":0},{"hour":17,"total":0},{"hour":18,"total":0},{"hour":19,"total":0},{"hour":20,"total":0},{"hour":21,"total":0},{"hour":22,"total":0},{"hour":23,"total":0}],"recent":[],"branches":[],"retention":{"days":365,"max_days":3650,"default_days":365,"continues_when_off":false},"can_configure":true},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/att_overview_branches.json b/app/src/test/resources/server090/patrons/att_overview_branches.json new file mode 100644 index 0000000..edc8462 --- /dev/null +++ b/app/src/test/resources/server090/patrons/att_overview_branches.json @@ -0,0 +1 @@ +{"data":{"days":7,"periods":[7,30,90,365],"today":{"members":2,"guests":1,"total":3},"totals":{"members":2,"guests":1,"total":3},"daily":[{"date":"2026-10-26","members":0,"guests":0,"total":0},{"date":"2026-10-27","members":0,"guests":0,"total":0},{"date":"2026-10-28","members":0,"guests":0,"total":0},{"date":"2026-10-29","members":0,"guests":0,"total":0},{"date":"2026-10-30","members":0,"guests":0,"total":0},{"date":"2026-10-31","members":0,"guests":0,"total":0},{"date":"2026-11-01","members":2,"guests":1,"total":3}],"hourly":[{"hour":0,"total":0},{"hour":1,"total":0},{"hour":2,"total":0},{"hour":3,"total":0},{"hour":4,"total":0},{"hour":5,"total":0},{"hour":6,"total":0},{"hour":7,"total":0},{"hour":8,"total":0},{"hour":9,"total":0},{"hour":10,"total":0},{"hour":11,"total":0},{"hour":12,"total":0},{"hour":13,"total":0},{"hour":14,"total":0},{"hour":15,"total":0},{"hour":16,"total":0},{"hour":17,"total":0},{"hour":18,"total":0},{"hour":19,"total":3},{"hour":20,"total":0},{"hour":21,"total":0},{"hour":22,"total":0},{"hour":23,"total":0}],"recent":[{"created_at":"2026-11-01 19:38:22","type":"member","label":"Marta P."},{"created_at":"2026-11-01 19:38:12","type":"guest","label":"Visiting pcap"},{"created_at":"2026-11-01 19:38:12","type":"member","label":"Marta P."}],"branches":[{"id":5,"name":"Sede Pcap"}],"retention":{"days":365,"max_days":3650,"default_days":365,"continues_when_off":false},"can_configure":true},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/att_purge.json b/app/src/test/resources/server090/patrons/att_purge.json new file mode 100644 index 0000000..3021ade --- /dev/null +++ b/app/src/test/resources/server090/patrons/att_purge.json @@ -0,0 +1 @@ +{"data":{"deleted":0,"retention":{"days":365,"max_days":3650,"default_days":365,"continues_when_off":false},"message":"Visits deleted: 0."},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/att_settings.json b/app/src/test/resources/server090/patrons/att_settings.json new file mode 100644 index 0000000..d4eaa80 --- /dev/null +++ b/app/src/test/resources/server090/patrons/att_settings.json @@ -0,0 +1 @@ +{"data":{"retention":{"days":400,"max_days":3650,"default_days":365,"continues_when_off":false},"message":"Retention updated: visits older than 400 days will be deleted."},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/att_settings_bad.json b/app/src/test/resources/server090/patrons/att_settings_bad.json new file mode 100644 index 0000000..89f2105 --- /dev/null +++ b/app/src/test/resources/server090/patrons/att_settings_bad.json @@ -0,0 +1 @@ +{"data":null,"meta":{"field":"retention_days","fields":{"retention_days":"Enter a number of days between 0 and 3650."}},"error":{"code":"validation","message":"Enter a number of days between 0 and 3650."}} diff --git a/app/src/test/resources/server090/patrons/att_unknown.json b/app/src/test/resources/server090/patrons/att_unknown.json new file mode 100644 index 0000000..01b820c --- /dev/null +++ b/app/src/test/resources/server090/patrons/att_unknown.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"card_not_found","message":"Card not recognised. Check the code or record the visit as a guest."}} diff --git a/app/src/test/resources/server090/patrons/charge.json b/app/src/test/resources/server090/patrons/charge.json new file mode 100644 index 0000000..6aff7b1 --- /dev/null +++ b/app/src/test/resources/server090/patrons/charge.json @@ -0,0 +1 @@ +{"data":{"movement":{"id":12,"type":"addebito_manuale","type_label":"Manual charge","amount_cents":1250,"amount":"12.50","description":"Lost library card","payment_method":null,"loan_id":null,"book_title":null,"created_at":"2026-11-01 19:37:34","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false},"user_id":113143,"currency":"EUR","balance_cents":1250,"balance":"12.50","debt_blocked":false},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/charge_conflict.json b/app/src/test/resources/server090/patrons/charge_conflict.json new file mode 100644 index 0000000..1c5dafd --- /dev/null +++ b/app/src/test/resources/server090/patrons/charge_conflict.json @@ -0,0 +1 @@ +{"data":null,"meta":{"field":"idempotency_key"},"error":{"code":"idempotency_conflict","message":"This idempotency key was already used for a different request."}} diff --git a/app/src/test/resources/server090/patrons/charge_nokey.json b/app/src/test/resources/server090/patrons/charge_nokey.json new file mode 100644 index 0000000..542e7a2 --- /dev/null +++ b/app/src/test/resources/server090/patrons/charge_nokey.json @@ -0,0 +1 @@ +{"data":null,"meta":{"field":"idempotency_key","fields":{"idempotency_key":"An idempotency key is required: send the Idempotency-Key header."}},"error":{"code":"validation","message":"An idempotency key is required: send the Idempotency-Key header."}} diff --git a/app/src/test/resources/server090/patrons/health.json b/app/src/test/resources/server090/patrons/health.json new file mode 100644 index 0000000..25d8b8d --- /dev/null +++ b/app/src/test/resources/server090/patrons/health.json @@ -0,0 +1 @@ +{"data":{"name":"Pinakes","logo":null,"version":"0.9.0-rc.1","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true,"reviews":true,"archives":true,"desiderata":true,"staff_catalog":true,"catalog_sync":true,"calendar_feed":true,"automatic_fines":true,"debt_block":true,"consultation_only":true,"fines":true,"closures_calendar":true,"circulation_rules":true,"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true,"staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true,"staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true,"staff_attendance":true,"staff_circulation_rules":true,"staff_closures_calendar":true,"staff_ledger":true,"staff_library_settings":true,"staff_patrons":true,"staff_statistics":true,"staff_stocktake":false,"staff_user_import":true,"stocktake":false,"marc":true,"visitor_log":true,"catalogue_cards":true,"suppliers":false,"rda_types":false,"patron_cards":true,"user_import":true,"patron_photos":true,"automatic_card_expiry":true,"loss_penalties":false,"attendance_retention":false},"catalogue_mode":false,"loan_approval_required":true,"app_access_enabled":true,"registration_enabled":true,"registration":{"require_cognome":true,"require_telefono":true,"require_indirizzo":true,"show_sesso":true,"show_cod_fiscale":true,"custom_fields":[]},"private_mode":false,"vapid_public_key":"BPJ1AUzmSjOizW5uX6WW-7r61-rRpiP2hlXHlvGfpDFlq6KY1zZHKRdzny3uzpyPq2C51pppPmmMrkn8k3oUD9Q"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/patrons/ledger.json b/app/src/test/resources/server090/patrons/ledger.json new file mode 100644 index 0000000..fc0b4ba --- /dev/null +++ b/app/src/test/resources/server090/patrons/ledger.json @@ -0,0 +1 @@ +{"data":{"user_id":113143,"currency":"EUR","balance_cents":500,"balance":"5.00","accrued_cents":0,"accrued":[],"debt_block_threshold_cents":null,"debt_blocked":false,"automatic_fines":true,"payment_methods":[{"code":"contanti","label":"Cash"},{"code":"pos","label":"Card (POS)"},{"code":"bonifico","label":"Bank transfer"},{"code":"altro","label":"Other"}],"max_amount_cents":99999999,"can_record_payment":true,"can_charge":true,"can_waive":true,"items":[{"id":14,"type":"condono","type_label":"Waiver","amount_cents":-250,"amount":"-2.50","description":"Goodwill","payment_method":null,"loan_id":null,"book_title":null,"created_at":"2026-11-01 19:37:35","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false},{"id":13,"type":"pagamento","type_label":"Payment","amount_cents":-500,"amount":"-5.00","description":"Card payment","payment_method":"pos","loan_id":null,"book_title":null,"created_at":"2026-11-01 19:37:35","payment_method_label":"Card (POS)","operator":{"id":31650,"name":"E2E Audit"},"receipt_available":true}]},"meta":{"count":2,"limit":2,"next_cursor":"eyJvIjoyLCJ1IjoxNH0","has_more":true,"total_count":3},"error":null} diff --git a/app/src/test/resources/server090/patrons/ledger_accrued.json b/app/src/test/resources/server090/patrons/ledger_accrued.json new file mode 100644 index 0000000..8deb72e --- /dev/null +++ b/app/src/test/resources/server090/patrons/ledger_accrued.json @@ -0,0 +1 @@ +{"data":{"user_id":113143,"currency":"EUR","balance_cents":500,"balance":"5.00","accrued_cents":200,"accrued":[{"loan_id":98228,"title":"Patroncap fine book","due_at":"2026-10-28","days_late":4,"amount_cents":200}],"debt_block_threshold_cents":1000,"debt_blocked":false,"automatic_fines":true,"payment_methods":[{"code":"contanti","label":"Cash"},{"code":"pos","label":"Card (POS)"},{"code":"bonifico","label":"Bank transfer"},{"code":"altro","label":"Other"}],"max_amount_cents":99999999,"can_record_payment":true,"can_charge":true,"can_waive":true,"items":[{"id":14,"type":"condono","type_label":"Waiver","amount_cents":-250,"amount":"-2.50","description":"Goodwill","payment_method":null,"loan_id":null,"book_title":null,"created_at":"2026-11-01 19:37:35","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false},{"id":13,"type":"pagamento","type_label":"Payment","amount_cents":-500,"amount":"-5.00","description":"Card payment","payment_method":"pos","loan_id":null,"book_title":null,"created_at":"2026-11-01 19:37:35","payment_method_label":"Card (POS)","operator":{"id":31650,"name":"E2E Audit"},"receipt_available":true},{"id":12,"type":"addebito_manuale","type_label":"Manual charge","amount_cents":1250,"amount":"12.50","description":"Lost library card","payment_method":null,"loan_id":null,"book_title":null,"created_at":"2026-11-01 19:37:34","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false}]},"meta":{"count":3,"limit":50,"next_cursor":null,"has_more":false,"total_count":3},"error":null} diff --git a/app/src/test/resources/server090/patrons/ledger_blocked.json b/app/src/test/resources/server090/patrons/ledger_blocked.json new file mode 100644 index 0000000..781833c --- /dev/null +++ b/app/src/test/resources/server090/patrons/ledger_blocked.json @@ -0,0 +1 @@ +{"data":{"user_id":113143,"currency":"EUR","balance_cents":1100,"balance":"11.00","accrued_cents":0,"accrued":[],"debt_block_threshold_cents":1000,"debt_blocked":true,"automatic_fines":true,"payment_methods":[{"code":"contanti","label":"Cash"},{"code":"pos","label":"Card (POS)"},{"code":"bonifico","label":"Bank transfer"},{"code":"altro","label":"Other"}],"max_amount_cents":99999999,"can_record_payment":true,"can_charge":true,"can_waive":true,"items":[{"id":17,"type":"addebito_manuale","type_label":"Manual charge","amount_cents":600,"amount":"6.00","description":"Damaged cover","payment_method":null,"loan_id":null,"book_title":null,"created_at":"2026-11-01 19:38:11","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false},{"id":16,"type":"condono","type_label":"Waiver","amount_cents":-200,"amount":"-2.00","description":"First time","payment_method":null,"loan_id":98228,"book_title":"Patroncap fine book","created_at":"2026-11-01 19:38:02","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false},{"id":15,"type":"multa_ritardo","type_label":"Late fine","amount_cents":200,"amount":"2.00","description":"Fine for 4 days late","payment_method":null,"loan_id":98228,"book_title":"Patroncap fine book","created_at":"2026-11-01 19:38:02","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false},{"id":14,"type":"condono","type_label":"Waiver","amount_cents":-250,"amount":"-2.50","description":"Goodwill","payment_method":null,"loan_id":null,"book_title":null,"created_at":"2026-11-01 19:37:35","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false},{"id":13,"type":"pagamento","type_label":"Payment","amount_cents":-500,"amount":"-5.00","description":"Card payment","payment_method":"pos","loan_id":null,"book_title":null,"created_at":"2026-11-01 19:37:35","payment_method_label":"Card (POS)","operator":{"id":31650,"name":"E2E Audit"},"receipt_available":true},{"id":12,"type":"addebito_manuale","type_label":"Manual charge","amount_cents":1250,"amount":"12.50","description":"Lost library card","payment_method":null,"loan_id":null,"book_title":null,"created_at":"2026-11-01 19:37:34","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false}]},"meta":{"count":6,"limit":50,"next_cursor":null,"has_more":false,"total_count":6},"error":null} diff --git a/app/src/test/resources/server090/patrons/ledger_empty.json b/app/src/test/resources/server090/patrons/ledger_empty.json new file mode 100644 index 0000000..501eb98 --- /dev/null +++ b/app/src/test/resources/server090/patrons/ledger_empty.json @@ -0,0 +1 @@ +{"data":{"user_id":113143,"currency":"EUR","balance_cents":0,"balance":"0.00","accrued_cents":0,"accrued":[],"debt_block_threshold_cents":null,"debt_blocked":false,"automatic_fines":true,"payment_methods":[{"code":"contanti","label":"Cash"},{"code":"pos","label":"Card (POS)"},{"code":"bonifico","label":"Bank transfer"},{"code":"altro","label":"Other"}],"max_amount_cents":99999999,"can_record_payment":true,"can_charge":true,"can_waive":true,"items":[]},"meta":{"count":0,"limit":50,"next_cursor":null,"has_more":false,"total_count":0},"error":null} diff --git a/app/src/test/resources/server090/patrons/ledger_page2.json b/app/src/test/resources/server090/patrons/ledger_page2.json new file mode 100644 index 0000000..e095abb --- /dev/null +++ b/app/src/test/resources/server090/patrons/ledger_page2.json @@ -0,0 +1 @@ +{"data":{"user_id":113143,"currency":"EUR","balance_cents":500,"balance":"5.00","accrued_cents":0,"accrued":[],"debt_block_threshold_cents":null,"debt_blocked":false,"automatic_fines":true,"payment_methods":[{"code":"contanti","label":"Cash"},{"code":"pos","label":"Card (POS)"},{"code":"bonifico","label":"Bank transfer"},{"code":"altro","label":"Other"}],"max_amount_cents":99999999,"can_record_payment":true,"can_charge":true,"can_waive":true,"items":[{"id":12,"type":"addebito_manuale","type_label":"Manual charge","amount_cents":1250,"amount":"12.50","description":"Lost library card","payment_method":null,"loan_id":null,"book_title":null,"created_at":"2026-11-01 19:37:34","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false}]},"meta":{"count":1,"limit":2,"next_cursor":null,"has_more":false,"total_count":3},"error":null} diff --git a/app/src/test/resources/server090/patrons/loan_overdue.json b/app/src/test/resources/server090/patrons/loan_overdue.json new file mode 100644 index 0000000..1435f1a --- /dev/null +++ b/app/src/test/resources/server090/patrons/loan_overdue.json @@ -0,0 +1 @@ +{"data":{"id":98228,"status":"in_ritardo","status_label":"Overdue","origin":"diretto","book":{"id":343655,"title":"Patroncap fine book","cover_url":"http://localhost:8195/uploads/copertine/placeholder.jpg","isbn13":null},"copy":{"id":110232,"inventory_number":"LIB-343655-C1","status":"prestato"},"user":{"id":113143,"name":"Marta Patroncap","email":"patroncap_090@example.test","card_number":"PCAP090A","status":"attivo"},"processed_by":{"id":31650,"name":"E2E Audit"},"requested_at":"2026-11-01","loaned_at":"2026-10-12","due_at":"2026-10-28","pickup_deadline":null,"returned_at":null,"returned_late":false,"days_overdue":4,"renewals":0,"max_renewals":3,"penalty":null,"notes":null,"recall_count":0,"last_recall_at":null,"created_at":"2026-11-01 19:38:01","updated_at":"2026-11-01 19:38:01","actions":["return","edit","recall","receipt","email_receipt"],"late_fine":{"currency":"EUR","automatic_fines":true,"accruing_days":4,"accruing_cents":200,"charged_cents":0}},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/np_overview.json b/app/src/test/resources/server090/patrons/np_overview.json new file mode 100644 index 0000000..20b6f7e --- /dev/null +++ b/app/src/test/resources/server090/patrons/np_overview.json @@ -0,0 +1 @@ +{"data":{"days":7,"periods":[7,30,90,365],"today":{"members":0,"guests":0,"total":0},"totals":{"members":0,"guests":0,"total":0},"daily":[{"date":"2026-10-26","members":0,"guests":0,"total":0},{"date":"2026-10-27","members":0,"guests":0,"total":0},{"date":"2026-10-28","members":0,"guests":0,"total":0},{"date":"2026-10-29","members":0,"guests":0,"total":0},{"date":"2026-10-30","members":0,"guests":0,"total":0},{"date":"2026-10-31","members":0,"guests":0,"total":0},{"date":"2026-11-01","members":0,"guests":0,"total":0}],"hourly":[{"hour":0,"total":0},{"hour":1,"total":0},{"hour":2,"total":0},{"hour":3,"total":0},{"hour":4,"total":0},{"hour":5,"total":0},{"hour":6,"total":0},{"hour":7,"total":0},{"hour":8,"total":0},{"hour":9,"total":0},{"hour":10,"total":0},{"hour":11,"total":0},{"hour":12,"total":0},{"hour":13,"total":0},{"hour":14,"total":0},{"hour":15,"total":0},{"hour":16,"total":0},{"hour":17,"total":0},{"hour":18,"total":0},{"hour":19,"total":0},{"hour":20,"total":0},{"hour":21,"total":0},{"hour":22,"total":0},{"hour":23,"total":0}],"recent":[],"branches":[],"retention":{"days":365,"max_days":3650,"default_days":365,"continues_when_off":false},"can_configure":false},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/np_patron_type.json b/app/src/test/resources/server090/patrons/np_patron_type.json new file mode 100644 index 0000000..1ed840b --- /dev/null +++ b/app/src/test/resources/server090/patrons/np_patron_type.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"not_permitted","message":"Access denied."}} diff --git a/app/src/test/resources/server090/patrons/np_photo_delete.json b/app/src/test/resources/server090/patrons/np_photo_delete.json new file mode 100644 index 0000000..1ed840b --- /dev/null +++ b/app/src/test/resources/server090/patrons/np_photo_delete.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"not_permitted","message":"Access denied."}} diff --git a/app/src/test/resources/server090/patrons/np_purge.json b/app/src/test/resources/server090/patrons/np_purge.json new file mode 100644 index 0000000..2ffa88f --- /dev/null +++ b/app/src/test/resources/server090/patrons/np_purge.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"not_permitted","message":"Operation reserved for administrators."}} diff --git a/app/src/test/resources/server090/patrons/np_settings.json b/app/src/test/resources/server090/patrons/np_settings.json new file mode 100644 index 0000000..2ffa88f --- /dev/null +++ b/app/src/test/resources/server090/patrons/np_settings.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"not_permitted","message":"Operation reserved for administrators."}} diff --git a/app/src/test/resources/server090/patrons/patron_types.json b/app/src/test/resources/server090/patrons/patron_types.json new file mode 100644 index 0000000..92ca650 --- /dev/null +++ b/app/src/test/resources/server090/patrons/patron_types.json @@ -0,0 +1 @@ +{"data":[{"id":1,"code":"standard","name":"Standard","description":null,"is_default":true,"active":true,"card_duration_days":null}],"meta":{"count":1,"default_id":1,"can_assign":true},"error":null} diff --git a/app/src/test/resources/server090/patrons/patron_types2.json b/app/src/test/resources/server090/patrons/patron_types2.json new file mode 100644 index 0000000..0c0f28e --- /dev/null +++ b/app/src/test/resources/server090/patrons/patron_types2.json @@ -0,0 +1 @@ +{"data":[{"id":1,"code":"standard","name":"Standard","description":null,"is_default":true,"active":true,"card_duration_days":null},{"id":3,"code":"pcap_fines","name":"E2E Fines","description":null,"is_default":false,"active":true,"card_duration_days":30}],"meta":{"count":2,"default_id":1,"can_assign":true},"error":null} diff --git a/app/src/test/resources/server090/patrons/payment.json b/app/src/test/resources/server090/patrons/payment.json new file mode 100644 index 0000000..38a8a74 --- /dev/null +++ b/app/src/test/resources/server090/patrons/payment.json @@ -0,0 +1 @@ +{"data":{"movement":{"id":13,"type":"pagamento","type_label":"Payment","amount_cents":-500,"amount":"-5.00","description":"Card payment","payment_method":"pos","loan_id":null,"book_title":null,"created_at":"2026-11-01 19:37:35","payment_method_label":"Card (POS)","operator":{"id":31650,"name":"E2E Audit"},"receipt_available":true},"user_id":113143,"currency":"EUR","balance_cents":750,"balance":"7.50","debt_blocked":false},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/payment_too_much.json b/app/src/test/resources/server090/patrons/payment_too_much.json new file mode 100644 index 0000000..4fa958c --- /dev/null +++ b/app/src/test/resources/server090/patrons/payment_too_much.json @@ -0,0 +1 @@ +{"data":null,"meta":{"reason":"amount_exceeds_balance","balance_cents":1250},"error":{"code":"amount_exceeds_balance","message":"The amount exceeds what the reader owes."}} diff --git a/app/src/test/resources/server090/patrons/photo_delete.json b/app/src/test/resources/server090/patrons/photo_delete.json new file mode 100644 index 0000000..7821b56 --- /dev/null +++ b/app/src/test/resources/server090/patrons/photo_delete.json @@ -0,0 +1 @@ +{"data":{"user_id":113143,"has_photo":false,"photo_url":null},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/photo_upload.json b/app/src/test/resources/server090/patrons/photo_upload.json new file mode 100644 index 0000000..85f9109 --- /dev/null +++ b/app/src/test/resources/server090/patrons/photo_upload.json @@ -0,0 +1 @@ +{"data":{"user_id":113143,"has_photo":true,"photo_url":"http://localhost:8195/api/v1/staff/users/113143/photo?v=258b10f2a9"},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/renew.json b/app/src/test/resources/server090/patrons/renew.json new file mode 100644 index 0000000..dc594ac --- /dev/null +++ b/app/src/test/resources/server090/patrons/renew.json @@ -0,0 +1 @@ +{"data":{"id":113143,"name":"Marta Patroncap","email":"patroncap_090@example.test","card_number":"PCAP090A","status":"attivo","card_expires_at":"2027-11-01","role":"standard","active_loans":0,"active_reservations":0,"eligible":true,"eligibility_reason":null,"patron_type":{"id":1,"code":"standard","name":"Standard","assigned":false},"has_photo":false,"photo_url":null,"ledger":{"balance_cents":0,"currency":"EUR","debt_blocked":false},"can_edit":true},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/renew_admin.json b/app/src/test/resources/server090/patrons/renew_admin.json new file mode 100644 index 0000000..1a20e77 --- /dev/null +++ b/app/src/test/resources/server090/patrons/renew_admin.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"card_not_renewable","message":"This account's card cannot be renewed."}} diff --git a/app/src/test/resources/server090/patrons/renew_replay.json b/app/src/test/resources/server090/patrons/renew_replay.json new file mode 100644 index 0000000..778eaa9 --- /dev/null +++ b/app/src/test/resources/server090/patrons/renew_replay.json @@ -0,0 +1 @@ +{"data":{"id":113143,"name":"Marta Patroncap","email":"patroncap_090@example.test","card_number":"PCAP090A","status":"attivo","card_expires_at":"2027-11-01","role":"standard","active_loans":0,"active_reservations":0,"eligible":true,"eligibility_reason":null,"patron_type":{"id":1,"code":"standard","name":"Standard","assigned":false},"has_photo":false,"photo_url":null,"ledger":{"balance_cents":0,"currency":"EUR","debt_blocked":false},"can_edit":true},"meta":{"replayed":true},"error":null} diff --git a/app/src/test/resources/server090/patrons/return_noreason.json b/app/src/test/resources/server090/patrons/return_noreason.json new file mode 100644 index 0000000..440d7f5 --- /dev/null +++ b/app/src/test/resources/server090/patrons/return_noreason.json @@ -0,0 +1 @@ +{"data":null,"meta":{"field":"waive_reason"},"error":{"code":"validation_error","message":"A reason is required to waive the fine."}} diff --git a/app/src/test/resources/server090/patrons/return_waived.json b/app/src/test/resources/server090/patrons/return_waived.json new file mode 100644 index 0000000..a455567 --- /dev/null +++ b/app/src/test/resources/server090/patrons/return_waived.json @@ -0,0 +1 @@ +{"data":{"id":98228,"status":"restituito","status_label":"Returned","origin":"diretto","book":{"id":343655,"title":"Patroncap fine book","cover_url":"http://localhost:8195/uploads/copertine/placeholder.jpg","isbn13":null},"copy":{"id":110232,"inventory_number":"LIB-343655-C1","status":"disponibile"},"user":{"id":113143,"name":"Marta Patroncap","email":"patroncap_090@example.test","card_number":"PCAP090A","status":"attivo"},"processed_by":{"id":31650,"name":"E2E Audit"},"requested_at":"2026-11-01","loaned_at":"2026-10-12","due_at":"2026-10-28","pickup_deadline":null,"returned_at":"2026-11-01","returned_late":true,"days_overdue":0,"renewals":0,"max_renewals":3,"penalty":null,"notes":null,"recall_count":0,"last_recall_at":null,"created_at":"2026-11-01 19:38:01","updated_at":"2026-11-01 19:38:02","actions":["receipt"],"late_fine":{"currency":"EUR","automatic_fines":true,"accruing_days":0,"accruing_cents":0,"charged_cents":0}},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/set_type.json b/app/src/test/resources/server090/patrons/set_type.json new file mode 100644 index 0000000..831bc94 --- /dev/null +++ b/app/src/test/resources/server090/patrons/set_type.json @@ -0,0 +1 @@ +{"data":{"id":113143,"name":"Marta Patroncap","email":"patroncap_090@example.test","card_number":"PCAP090A","status":"attivo","card_expires_at":"2027-11-01","role":"standard","active_loans":0,"active_reservations":0,"eligible":true,"eligibility_reason":null,"patron_type":{"id":1,"code":"standard","name":"Standard","assigned":true},"has_photo":true,"photo_url":"http://localhost:8195/api/v1/staff/users/113143/photo?v=258b10f2a9","ledger":{"balance_cents":0,"currency":"EUR","debt_blocked":false},"can_edit":true},"meta":{"card_expiry_recomputed":false},"error":null} diff --git a/app/src/test/resources/server090/patrons/set_type2.json b/app/src/test/resources/server090/patrons/set_type2.json new file mode 100644 index 0000000..8e6d86c --- /dev/null +++ b/app/src/test/resources/server090/patrons/set_type2.json @@ -0,0 +1 @@ +{"data":{"id":113143,"name":"Marta Patroncap","email":"patroncap_090@example.test","card_number":"PCAP090A","status":"attivo","card_expires_at":"2026-12-01","role":"standard","active_loans":0,"active_reservations":0,"eligible":true,"eligibility_reason":null,"patron_type":{"id":3,"code":"pcap_fines","name":"E2E Fines","assigned":true},"has_photo":true,"photo_url":"http://localhost:8195/api/v1/staff/users/113143/photo?v=258b10f2a9","ledger":{"balance_cents":500,"currency":"EUR","debt_blocked":false},"can_edit":true},"meta":{"card_expiry_recomputed":true},"error":null} diff --git a/app/src/test/resources/server090/patrons/ui_cancel.json b/app/src/test/resources/server090/patrons/ui_cancel.json new file mode 100644 index 0000000..016aeee --- /dev/null +++ b/app/src/test/resources/server090/patrons/ui_cancel.json @@ -0,0 +1 @@ +{"data":{"id":"4abe16de-4380-4412-b5c2-46a9163e1cd0","cancelled":true,"status":"cancelled","import_id":null,"history_warning":null},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/ui_chunk.json b/app/src/test/resources/server090/patrons/ui_chunk.json new file mode 100644 index 0000000..9bbaa49 --- /dev/null +++ b/app/src/test/resources/server090/patrons/ui_chunk.json @@ -0,0 +1 @@ +{"data":{"id":"a28ade8d-4c86-4cf6-8add-77dfca4a54cc","kind":"users","file_name":"import.csv","status":"completed","started":true,"complete":true,"total_rows":4,"processed":4,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1_090@example.test","PCAPIMP1"],["Sara","Pcapimport","pcapimp2_090@example.test","PCAPIMP2"],["NoMail","Pcapimport","","PCAPIMP3"],["Dup","Pcapimport","patroncap_090@example.test","PCAPIMP4"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":false,"created":2,"duplicates":1,"failed":1,"invited":0,"invite_failed":0,"errors":[{"line":4,"title":"NoMail Pcapimport","message":"Email missing.","type":"validation"},{"line":5,"title":"Dup Pcapimport","message":"Email already registered: patroncap_090@example.test","type":"duplicate"}],"import_id":"025021cf01903d4db4891ab2b7aee14f1f11","report_available":true,"notice":null,"log_error":null,"created_at":"2026-11-01 19:38:39","updated_at":"2026-11-01 19:38:40","expires_at":"2026-11-02 19:38:40"},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/ui_chunk_gone.json b/app/src/test/resources/server090/patrons/ui_chunk_gone.json new file mode 100644 index 0000000..f09ae84 --- /dev/null +++ b/app/src/test/resources/server090/patrons/ui_chunk_gone.json @@ -0,0 +1 @@ +{"data":null,"meta":{"status":"completed"},"error":{"code":"gone","message":"Import completed, cancelled or expired."}} diff --git a/app/src/test/resources/server090/patrons/ui_chunk_notstarted.json b/app/src/test/resources/server090/patrons/ui_chunk_notstarted.json new file mode 100644 index 0000000..6802a6d --- /dev/null +++ b/app/src/test/resources/server090/patrons/ui_chunk_notstarted.json @@ -0,0 +1 @@ +{"data":null,"meta":{"processed":0},"error":{"code":"not_started","message":"Start the import before processing the rows."}} diff --git a/app/src/test/resources/server090/patrons/ui_chunk_ooo.json b/app/src/test/resources/server090/patrons/ui_chunk_ooo.json new file mode 100644 index 0000000..22ef0b5 --- /dev/null +++ b/app/src/test/resources/server090/patrons/ui_chunk_ooo.json @@ -0,0 +1 @@ +{"data":null,"meta":{"processed":0},"error":{"code":"out_of_order","message":"Chunk out of sequence: resume from the indicated row."}} diff --git a/app/src/test/resources/server090/patrons/ui_list.json b/app/src/test/resources/server090/patrons/ui_list.json new file mode 100644 index 0000000..79716c1 --- /dev/null +++ b/app/src/test/resources/server090/patrons/ui_list.json @@ -0,0 +1 @@ +{"data":[{"id":"7eb8631a-3ab1-46ec-b464-ee47f50aa144","kind":"users","file_name":"readers-b.csv","status":"ready","started":true,"complete":false,"total_rows":2,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1c_090@example.test","PCAPIMP1C"],["Sara","Pcapimport","pcapimp2c_090@example.test","PCAPIMP2C"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":false,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":null,"report_available":false,"notice":null,"log_error":null,"created_at":"2026-11-01 20:28:21","updated_at":"2026-11-01 20:28:21","expires_at":"2026-11-02 20:28:21"},{"id":"f15dde31-c5f2-4184-8f16-1a6df05246b8","kind":"users","file_name":"readers-a.csv","status":"ready","started":false,"complete":false,"total_rows":2,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1c_090@example.test","PCAPIMP1C"],["Sara","Pcapimport","pcapimp2c_090@example.test","PCAPIMP2C"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":null,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":null,"report_available":false,"notice":null,"log_error":null,"created_at":"2026-11-01 20:28:21","updated_at":"2026-11-01 20:28:21","expires_at":"2026-11-02 20:28:21"}],"meta":{"count":2,"limit":30,"next_cursor":null,"has_more":false},"error":null} diff --git a/app/src/test/resources/server090/patrons/ui_list_after.json b/app/src/test/resources/server090/patrons/ui_list_after.json new file mode 100644 index 0000000..0ff6b99 --- /dev/null +++ b/app/src/test/resources/server090/patrons/ui_list_after.json @@ -0,0 +1 @@ +{"data":[{"id":"7eb8631a-3ab1-46ec-b464-ee47f50aa144","kind":"users","file_name":"readers-b.csv","status":"cancelled","started":true,"complete":false,"total_rows":2,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1c_090@example.test","PCAPIMP1C"],["Sara","Pcapimport","pcapimp2c_090@example.test","PCAPIMP2C"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":false,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":"11680492bb5bd59b5f2564ebcd604a7ccd51","report_available":true,"notice":null,"log_error":null,"created_at":"2026-11-01 20:28:21","updated_at":"2026-11-01 20:28:30","expires_at":"2026-11-02 20:28:30"},{"id":"f15dde31-c5f2-4184-8f16-1a6df05246b8","kind":"users","file_name":"readers-a.csv","status":"cancelled","started":false,"complete":false,"total_rows":2,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1c_090@example.test","PCAPIMP1C"],["Sara","Pcapimport","pcapimp2c_090@example.test","PCAPIMP2C"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":null,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":null,"report_available":false,"notice":null,"log_error":null,"created_at":"2026-11-01 20:28:21","updated_at":"2026-11-01 20:28:30","expires_at":"2026-11-02 20:28:30"}],"meta":{"count":2,"limit":30,"next_cursor":null,"has_more":false},"error":null} diff --git a/app/src/test/resources/server090/patrons/ui_list_page.json b/app/src/test/resources/server090/patrons/ui_list_page.json new file mode 100644 index 0000000..449e0b4 --- /dev/null +++ b/app/src/test/resources/server090/patrons/ui_list_page.json @@ -0,0 +1 @@ +{"data":[{"id":"7eb8631a-3ab1-46ec-b464-ee47f50aa144","kind":"users","file_name":"readers-b.csv","status":"ready","started":true,"complete":false,"total_rows":2,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1c_090@example.test","PCAPIMP1C"],["Sara","Pcapimport","pcapimp2c_090@example.test","PCAPIMP2C"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":false,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":null,"report_available":false,"notice":null,"log_error":null,"created_at":"2026-11-01 20:28:21","updated_at":"2026-11-01 20:28:21","expires_at":"2026-11-02 20:28:21"}],"meta":{"count":1,"limit":1,"next_cursor":"eyJvIjoxfQ","has_more":true},"error":null} diff --git a/app/src/test/resources/server090/patrons/ui_start.json b/app/src/test/resources/server090/patrons/ui_start.json new file mode 100644 index 0000000..7ec1faa --- /dev/null +++ b/app/src/test/resources/server090/patrons/ui_start.json @@ -0,0 +1 @@ +{"data":{"id":"a28ade8d-4c86-4cf6-8add-77dfca4a54cc","kind":"users","file_name":"import.csv","status":"ready","started":true,"complete":false,"total_rows":4,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1_090@example.test","PCAPIMP1"],["Sara","Pcapimport","pcapimp2_090@example.test","PCAPIMP2"],["NoMail","Pcapimport","","PCAPIMP3"],["Dup","Pcapimport","patroncap_090@example.test","PCAPIMP4"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":false,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":null,"report_available":false,"notice":null,"log_error":null,"created_at":"2026-11-01 19:38:39","updated_at":"2026-11-01 19:38:40","expires_at":"2026-11-02 19:38:40"},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/ui_start_bad.json b/app/src/test/resources/server090/patrons/ui_start_bad.json new file mode 100644 index 0000000..7dbddd9 --- /dev/null +++ b/app/src/test/resources/server090/patrons/ui_start_bad.json @@ -0,0 +1 @@ +{"data":null,"meta":{"field":"mapping","fields":{"mapping":"The field \"Last Name\" is mapped to more than one column."}},"error":{"code":"validation","message":"The field \"Last Name\" is mapped to more than one column."}} diff --git a/app/src/test/resources/server090/patrons/ui_upload.json b/app/src/test/resources/server090/patrons/ui_upload.json new file mode 100644 index 0000000..ba5ebff --- /dev/null +++ b/app/src/test/resources/server090/patrons/ui_upload.json @@ -0,0 +1 @@ +{"data":{"id":"a28ade8d-4c86-4cf6-8add-77dfca4a54cc","kind":"users","file_name":"import.csv","status":"ready","started":false,"complete":false,"total_rows":4,"processed":0,"chunk_size":10,"headers":["Nome","Cognome","Email","Tessera"],"preview":[["Luca","Pcapimport","pcapimp1_090@example.test","PCAPIMP1"],["Sara","Pcapimport","pcapimp2_090@example.test","PCAPIMP2"],["NoMail","Pcapimport","","PCAPIMP3"],["Dup","Pcapimport","patroncap_090@example.test","PCAPIMP4"]],"fields":[{"key":"nome","label":"First Name","required":true},{"key":"cognome","label":"Last Name","required":false},{"key":"email","label":"Email","required":true},{"key":"telefono","label":"Phone","required":false},{"key":"indirizzo","label":"Address","required":false},{"key":"data_nascita","label":"Date of Birth","required":false},{"key":"sesso","label":"Gender","required":false},{"key":"cod_fiscale","label":"Tax ID","required":false},{"key":"codice_tessera","label":"Card Code","required":false},{"key":"data_scadenza_tessera","label":"Card Expiration","required":false},{"key":"note_utente","label":"Internal Notes","required":false},{"key":"locale","label":"Interface language","required":false},{"key":"tipologia","label":"Patron type (code or name)","required":false}],"mapping":["nome","cognome","email","codice_tessera"],"send_invites":null,"created":0,"duplicates":0,"failed":0,"invited":0,"invite_failed":0,"errors":[],"import_id":null,"report_available":false,"notice":null,"log_error":null,"created_at":"2026-11-01 19:38:39","updated_at":"2026-11-01 19:38:39","expires_at":"2026-11-02 19:38:39"},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/ui_upload_bad.json b/app/src/test/resources/server090/patrons/ui_upload_bad.json new file mode 100644 index 0000000..65998c6 --- /dev/null +++ b/app/src/test/resources/server090/patrons/ui_upload_bad.json @@ -0,0 +1 @@ +{"data":null,"meta":{"field":"file","fields":{"file":"File must have .tsv, .csv or .txt extension"}},"error":{"code":"validation","message":"File must have .tsv, .csv or .txt extension"}} diff --git a/app/src/test/resources/server090/patrons/user_detail.json b/app/src/test/resources/server090/patrons/user_detail.json new file mode 100644 index 0000000..823e2ad --- /dev/null +++ b/app/src/test/resources/server090/patrons/user_detail.json @@ -0,0 +1 @@ +{"data":{"id":113143,"name":"Marta Patroncap","email":"patroncap_090@example.test","card_number":"PCAP090A","status":"attivo","card_expires_at":"2027-03-31","role":"standard","active_loans":0,"active_reservations":0,"eligible":true,"eligibility_reason":null,"patron_type":{"id":1,"code":"standard","name":"Standard","assigned":false},"has_photo":false,"photo_url":null,"ledger":{"balance_cents":0,"currency":"EUR","debt_blocked":false},"can_edit":true,"loans":[],"reservations":[]},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/user_detail_blocked.json b/app/src/test/resources/server090/patrons/user_detail_blocked.json new file mode 100644 index 0000000..c41229d --- /dev/null +++ b/app/src/test/resources/server090/patrons/user_detail_blocked.json @@ -0,0 +1 @@ +{"data":{"id":113143,"name":"Marta Patroncap","email":"patroncap_090@example.test","card_number":"PCAP090A","status":"attivo","card_expires_at":"2026-12-01","role":"standard","active_loans":0,"active_reservations":0,"eligible":false,"eligibility_reason":"debt_block","patron_type":{"id":3,"code":"pcap_fines","name":"E2E Fines","assigned":true},"has_photo":true,"photo_url":"http://localhost:8195/api/v1/staff/users/113143/photo?v=258b10f2a9","ledger":{"balance_cents":1100,"currency":"EUR","debt_blocked":true},"can_edit":true,"loans":[],"reservations":[]},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/users_search.json b/app/src/test/resources/server090/patrons/users_search.json new file mode 100644 index 0000000..3bb50d7 --- /dev/null +++ b/app/src/test/resources/server090/patrons/users_search.json @@ -0,0 +1 @@ +{"data":[{"id":113143,"name":"Marta Patroncap","email":"patroncap_090@example.test","card_number":"PCAP090A","status":"attivo","card_expires_at":"2027-03-31","role":"standard","active_loans":0,"active_reservations":0,"eligible":true,"eligibility_reason":null,"patron_type":{"id":1,"code":"standard","name":"Standard","assigned":false},"has_photo":false,"photo_url":null,"ledger":{"balance_cents":0,"currency":"EUR","debt_blocked":false},"can_edit":true}],"meta":{"count":1,"limit":20,"next_cursor":null,"has_more":false},"error":null} diff --git a/app/src/test/resources/server090/patrons/waiver.json b/app/src/test/resources/server090/patrons/waiver.json new file mode 100644 index 0000000..c15fa9f --- /dev/null +++ b/app/src/test/resources/server090/patrons/waiver.json @@ -0,0 +1 @@ +{"data":{"movement":{"id":14,"type":"condono","type_label":"Waiver","amount_cents":-250,"amount":"-2.50","description":"Goodwill","payment_method":null,"loan_id":null,"book_title":null,"created_at":"2026-11-01 19:37:35","payment_method_label":null,"operator":{"id":31650,"name":"E2E Audit"},"receipt_available":false},"user_id":113143,"currency":"EUR","balance_cents":500,"balance":"5.00","debt_blocked":false},"meta":{},"error":null} diff --git a/app/src/test/resources/server090/patrons/waiver_noreason.json b/app/src/test/resources/server090/patrons/waiver_noreason.json new file mode 100644 index 0000000..2507737 --- /dev/null +++ b/app/src/test/resources/server090/patrons/waiver_noreason.json @@ -0,0 +1 @@ +{"data":null,"meta":{"field":"reason","fields":{"reason":"A reason for the waiver is required."}},"error":{"code":"validation","message":"A reason for the waiver is required."}} diff --git a/app/src/test/resources/server090/stocktake/s090_statistics_bad_date.json b/app/src/test/resources/server090/stocktake/s090_statistics_bad_date.json new file mode 100644 index 0000000..e712040 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_statistics_bad_date.json @@ -0,0 +1 @@ +{"data":null,"meta":{"field":"fines_from","fields":{"fines_from":"Data non valida (formato AAAA-MM-GG)."}},"error":{"code":"validation","message":"Data non valida (formato AAAA-MM-GG)."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_statistics_full.json b/app/src/test/resources/server090/stocktake/s090_statistics_full.json new file mode 100644 index 0000000..f3e1e45 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_statistics_full.json @@ -0,0 +1 @@ +{"data":{"overview":{"books_available":62,"books_on_loan":0,"active_loans":2,"overdue_loans":0,"completed_loans":15,"borrowers":8},"loans_by_month":[{"month":"2026-09","total":2},{"month":"2026-10","total":15},{"month":"2026-11","total":1}],"loans_by_status":[{"status":"in_corso","label":"In Corso","total":2}],"loans_by_media_type":[{"media_type":"libro","label":"Libro","total":8},{"media_type":"disco","label":"Disco","total":0},{"media_type":"audiolibro","label":"Audiolibro","total":0},{"media_type":"dvd","label":"DVD","total":0},{"media_type":"altro","label":"Altro","total":0}],"top_books":[{"id":62988,"title":"Il nome della rosa","cover_url":"http://localhost:8192/uploads/copertine/copertina_6a19b9feb8eb97.41923692.jpg","total_loans":1,"completed_loans":0,"active_loans":0,"overdue_loans":0},{"id":138376,"title":"E2E Edited Book mumjcgbp","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":138377,"title":"Fantastic Mr. Fox","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":221653,"title":"E2E Edited Book mux6dfk7","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":243897,"title":"Pride and Prejudice","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":253918,"title":"Pride and prejudice","cover_url":"http://localhost:8192/uploads/copertine/copertina_6ace511eebab47.54483099.jpg","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0},{"id":339352,"title":"E2E Edited Book mvrdo6d1","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":339386,"title":"E2E Manual Book mvreq1qg","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":341188,"title":"ASTD4A665 B2","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0}],"top_readers":[{"id":31650,"name":"E2E Audit","email":"e2e-audit@test.local","user_type":"admin","total_loans":9,"completed_loans":9,"active_loans":0,"overdue_loans":0},{"id":86263,"name":"Lettore Dimostrazione","email":"lettore.demo@test.local","user_type":"standard","total_loans":3,"completed_loans":2,"active_loans":0,"overdue_loans":0},{"id":6805,"name":"Giulia Bianchi","email":"giulia.bianchi@esempio.it","user_type":"standard","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0},{"id":47370,"name":"E2E Usermumjcgbp","email":"e2e-user-mumjcgbp@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":74168,"name":"E2E Usermux6dfk7","email":"e2e-user-mux6dfk7@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":112753,"name":"E2E Usermvrdo6d1","email":"e2e-user-mvrdo6d1@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":112758,"name":"E2E Usermvreq1qg","email":"e2e-user-mvreq1qg@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":113889,"name":"E2E ASTR","email":"ast-reader-astd4a665@stocktake.test.local","user_type":"standard","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0}],"visitors":{"days":30,"members":0,"guests":0,"total":0,"daily":[{"date":"2026-10-03","members":0,"guests":0,"total":0},{"date":"2026-10-04","members":0,"guests":0,"total":0},{"date":"2026-10-05","members":0,"guests":0,"total":0},{"date":"2026-10-06","members":0,"guests":0,"total":0},{"date":"2026-10-07","members":0,"guests":0,"total":0},{"date":"2026-10-08","members":0,"guests":0,"total":0},{"date":"2026-10-09","members":0,"guests":0,"total":0},{"date":"2026-10-10","members":0,"guests":0,"total":0},{"date":"2026-10-11","members":0,"guests":0,"total":0},{"date":"2026-10-12","members":0,"guests":0,"total":0},{"date":"2026-10-13","members":0,"guests":0,"total":0},{"date":"2026-10-14","members":0,"guests":0,"total":0},{"date":"2026-10-15","members":0,"guests":0,"total":0},{"date":"2026-10-16","members":0,"guests":0,"total":0},{"date":"2026-10-17","members":0,"guests":0,"total":0},{"date":"2026-10-18","members":0,"guests":0,"total":0},{"date":"2026-10-19","members":0,"guests":0,"total":0},{"date":"2026-10-20","members":0,"guests":0,"total":0},{"date":"2026-10-21","members":0,"guests":0,"total":0},{"date":"2026-10-22","members":0,"guests":0,"total":0},{"date":"2026-10-23","members":0,"guests":0,"total":0},{"date":"2026-10-24","members":0,"guests":0,"total":0},{"date":"2026-10-25","members":0,"guests":0,"total":0},{"date":"2026-10-26","members":0,"guests":0,"total":0},{"date":"2026-10-27","members":0,"guests":0,"total":0},{"date":"2026-10-28","members":0,"guests":0,"total":0},{"date":"2026-10-29","members":0,"guests":0,"total":0},{"date":"2026-10-30","members":0,"guests":0,"total":0},{"date":"2026-10-31","members":0,"guests":0,"total":0},{"date":"2026-11-01","members":0,"guests":0,"total":0}]},"sections":[{"id":"patron-types","title":"Prestiti per tipologia di lettore","rows":[{"label":"Standard","value":8}],"note":"Ultimi 12 mesi. I lettori senza tipologia sono contati in «Standard», la tipologia predefinita."},{"id":"fines","title":"Multe","rows":[{"label":"Addebitato","value":"12,50 €"},{"label":"Incassato","value":"5,00 €"},{"label":"Condonato","value":"0,00 €"},{"label":"Stornato","value":"0,00 €"},{"label":"Saldo da incassare","value":"7,50 €"},{"label":"Lettori con saldo a debito","value":1}],"note":"Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso."}],"fines_report":{"from":"2026-01-01","to":"2026-11-30","currency":"EUR","months":[{"month":"2026-11","charged_cents":1250,"collected_cents":500,"waived_cents":0,"reversed_cents":0}],"totals":{"charged_cents":1250,"collected_cents":500,"waived_cents":0,"reversed_cents":0}}},"meta":{"modules":{"visitor_log":true,"circulation_rules":true,"fines":true,"stocktake":true}},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_statistics_modules_off.json b/app/src/test/resources/server090/stocktake/s090_statistics_modules_off.json new file mode 100644 index 0000000..b79d8e2 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_statistics_modules_off.json @@ -0,0 +1 @@ +{"data":{"overview":{"books_available":59,"books_on_loan":0,"active_loans":1,"overdue_loans":0,"completed_loans":15,"borrowers":7},"loans_by_month":[{"month":"2026-09","total":2},{"month":"2026-10","total":15}],"loans_by_status":[{"status":"in_corso","label":"In Corso","total":1}],"loans_by_media_type":[{"media_type":"libro","label":"Libro","total":7},{"media_type":"disco","label":"Disco","total":0},{"media_type":"audiolibro","label":"Audiolibro","total":0},{"media_type":"dvd","label":"DVD","total":0},{"media_type":"altro","label":"Altro","total":0}],"top_books":[{"id":62988,"title":"Il nome della rosa","cover_url":"http://localhost:8192/uploads/copertine/copertina_6a19b9feb8eb97.41923692.jpg","total_loans":1,"completed_loans":0,"active_loans":0,"overdue_loans":0},{"id":138376,"title":"E2E Edited Book mumjcgbp","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":138377,"title":"Fantastic Mr. Fox","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":221653,"title":"E2E Edited Book mux6dfk7","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":243897,"title":"Pride and Prejudice","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":253918,"title":"Pride and prejudice","cover_url":"http://localhost:8192/uploads/copertine/copertina_6ace511eebab47.54483099.jpg","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0},{"id":339352,"title":"E2E Edited Book mvrdo6d1","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":339386,"title":"E2E Manual Book mvreq1qg","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0}],"top_readers":[{"id":31650,"name":"E2E Audit","email":"e2e-audit@test.local","user_type":"admin","total_loans":9,"completed_loans":9,"active_loans":0,"overdue_loans":0},{"id":86263,"name":"Lettore Dimostrazione","email":"lettore.demo@test.local","user_type":"standard","total_loans":3,"completed_loans":2,"active_loans":0,"overdue_loans":0},{"id":6805,"name":"Giulia Bianchi","email":"giulia.bianchi@esempio.it","user_type":"standard","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0},{"id":47370,"name":"E2E Usermumjcgbp","email":"e2e-user-mumjcgbp@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":74168,"name":"E2E Usermux6dfk7","email":"e2e-user-mux6dfk7@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":112753,"name":"E2E Usermvrdo6d1","email":"e2e-user-mvrdo6d1@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":112758,"name":"E2E Usermvreq1qg","email":"e2e-user-mvreq1qg@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0}],"sections":[{"id":"patron-types","title":"Prestiti per tipologia di lettore","rows":[{"label":"Standard","value":7}],"note":"Ultimi 12 mesi. I lettori senza tipologia sono contati in «Standard», la tipologia predefinita."}]},"meta":{"modules":{"visitor_log":false,"circulation_rules":true,"fines":false,"stocktake":false}},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_statistics_staff.json b/app/src/test/resources/server090/stocktake/s090_statistics_staff.json new file mode 100644 index 0000000..418297a --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_statistics_staff.json @@ -0,0 +1 @@ +{"data":{"overview":{"books_available":62,"books_on_loan":0,"active_loans":2,"overdue_loans":0,"completed_loans":15,"borrowers":8},"loans_by_month":[{"month":"2026-09","total":2},{"month":"2026-10","total":15},{"month":"2026-11","total":1}],"loans_by_status":[{"status":"in_corso","label":"In Corso","total":2}],"loans_by_media_type":[{"media_type":"libro","label":"Libro","total":8},{"media_type":"disco","label":"Disco","total":0},{"media_type":"audiolibro","label":"Audiolibro","total":0},{"media_type":"dvd","label":"DVD","total":0},{"media_type":"altro","label":"Altro","total":0}],"top_books":[{"id":62988,"title":"Il nome della rosa","cover_url":"http://localhost:8192/uploads/copertine/copertina_6a19b9feb8eb97.41923692.jpg","total_loans":1,"completed_loans":0,"active_loans":0,"overdue_loans":0},{"id":138376,"title":"E2E Edited Book mumjcgbp","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":138377,"title":"Fantastic Mr. Fox","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":221653,"title":"E2E Edited Book mux6dfk7","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":243897,"title":"Pride and Prejudice","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":253918,"title":"Pride and prejudice","cover_url":"http://localhost:8192/uploads/copertine/copertina_6ace511eebab47.54483099.jpg","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0},{"id":339352,"title":"E2E Edited Book mvrdo6d1","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":339386,"title":"E2E Manual Book mvreq1qg","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":341188,"title":"ASTD4A665 B2","cover_url":"http://localhost:8192/uploads/copertine/placeholder.jpg","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0}],"top_readers":[{"id":31650,"name":"E2E Audit","email":"e2e-audit@test.local","user_type":"admin","total_loans":9,"completed_loans":9,"active_loans":0,"overdue_loans":0},{"id":86263,"name":"Lettore Dimostrazione","email":"lettore.demo@test.local","user_type":"standard","total_loans":3,"completed_loans":2,"active_loans":0,"overdue_loans":0},{"id":6805,"name":"Giulia Bianchi","email":"giulia.bianchi@esempio.it","user_type":"standard","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0},{"id":47370,"name":"E2E Usermumjcgbp","email":"e2e-user-mumjcgbp@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":74168,"name":"E2E Usermux6dfk7","email":"e2e-user-mux6dfk7@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":112753,"name":"E2E Usermvrdo6d1","email":"e2e-user-mvrdo6d1@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":112758,"name":"E2E Usermvreq1qg","email":"e2e-user-mvreq1qg@test.local","user_type":"standard","total_loans":1,"completed_loans":1,"active_loans":0,"overdue_loans":0},{"id":113889,"name":"E2E ASTR","email":"ast-reader-astd4a665@stocktake.test.local","user_type":"standard","total_loans":1,"completed_loans":0,"active_loans":1,"overdue_loans":0}],"visitors":{"days":30,"members":0,"guests":0,"total":0,"daily":[{"date":"2026-10-03","members":0,"guests":0,"total":0},{"date":"2026-10-04","members":0,"guests":0,"total":0},{"date":"2026-10-05","members":0,"guests":0,"total":0},{"date":"2026-10-06","members":0,"guests":0,"total":0},{"date":"2026-10-07","members":0,"guests":0,"total":0},{"date":"2026-10-08","members":0,"guests":0,"total":0},{"date":"2026-10-09","members":0,"guests":0,"total":0},{"date":"2026-10-10","members":0,"guests":0,"total":0},{"date":"2026-10-11","members":0,"guests":0,"total":0},{"date":"2026-10-12","members":0,"guests":0,"total":0},{"date":"2026-10-13","members":0,"guests":0,"total":0},{"date":"2026-10-14","members":0,"guests":0,"total":0},{"date":"2026-10-15","members":0,"guests":0,"total":0},{"date":"2026-10-16","members":0,"guests":0,"total":0},{"date":"2026-10-17","members":0,"guests":0,"total":0},{"date":"2026-10-18","members":0,"guests":0,"total":0},{"date":"2026-10-19","members":0,"guests":0,"total":0},{"date":"2026-10-20","members":0,"guests":0,"total":0},{"date":"2026-10-21","members":0,"guests":0,"total":0},{"date":"2026-10-22","members":0,"guests":0,"total":0},{"date":"2026-10-23","members":0,"guests":0,"total":0},{"date":"2026-10-24","members":0,"guests":0,"total":0},{"date":"2026-10-25","members":0,"guests":0,"total":0},{"date":"2026-10-26","members":0,"guests":0,"total":0},{"date":"2026-10-27","members":0,"guests":0,"total":0},{"date":"2026-10-28","members":0,"guests":0,"total":0},{"date":"2026-10-29","members":0,"guests":0,"total":0},{"date":"2026-10-30","members":0,"guests":0,"total":0},{"date":"2026-10-31","members":0,"guests":0,"total":0},{"date":"2026-11-01","members":0,"guests":0,"total":0}]},"sections":[{"id":"patron-types","title":"Prestiti per tipologia di lettore","rows":[{"label":"Standard","value":8}],"note":"Ultimi 12 mesi. I lettori senza tipologia sono contati in «Standard», la tipologia predefinita."},{"id":"fines","title":"Multe","rows":[{"label":"Addebitato","value":"12,50 €"},{"label":"Incassato","value":"5,00 €"},{"label":"Condonato","value":"0,00 €"},{"label":"Stornato","value":"0,00 €"},{"label":"Saldo da incassare","value":"7,50 €"},{"label":"Lettori con saldo a debito","value":1}],"note":"Ultimi 12 mesi; il saldo da incassare è quello di oggi. Il dettaglio mese per mese è nel report delle multe più in basso."},{"id":"stocktake","title":"Esiti delle revisioni inventariali","rows":[{"label":"Copie attese","value":4},{"label":"Trovata","value":1},{"label":"Fuori posto","value":0},{"label":"In prestito","value":1},{"label":"Mancanti","value":2},{"label":"Codici sconosciuti","value":0}],"note":"Ultima revisione chiusa: «ASTD4A665 admin lost», 01/11/2026 20:17. Revisioni chiuse in totale: 2."}],"fines_report":{"from":"2026-01-01","to":"2026-10-31","currency":"EUR","months":[],"totals":{"charged_cents":0,"collected_cents":0,"waived_cents":0,"reversed_cents":0}}},"meta":{"modules":{"visitor_log":true,"circulation_rules":true,"fines":true,"stocktake":true}},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_cancelled.json b/app/src/test/resources/server090/stocktake/s090_stocktake_cancelled.json new file mode 100644 index 0000000..e938e51 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_cancelled.json @@ -0,0 +1 @@ +{"data":{"id":43,"name":"ASTD4A665 to cancel","status":"cancelled","status_label":"Annullata","status_color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"branch":null,"filter":{"shelf_id":null,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":[],"note":null,"expected_total":82,"opened_at":"2026-11-01T20:17:35+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":"2026-11-01T20:17:36+00:00","closed_by":{"id":113888,"name":"E2E ASTS"},"missing_action":null,"summary":{"expected":82,"found":0,"misplaced":0,"on_loan":2,"missing":80,"lost":0,"unknown":0,"out_of_scope":0,"duplicates":0,"scans":0,"mine":0},"pending_lost":0,"can_scan":false,"can_upload":false,"can_close":false,"can_cancel":false,"can_mark_lost":false,"recent_scans":[],"history":[{"event":"inventory.cancelled","label":"Revisione inventariale annullata","operator_name":"E2E ASTS","at":"2026-11-01T20:17:36+00:00"},{"event":"inventory.opened","label":"Revisione inventariale aperta","operator_name":"E2E ASTS","at":"2026-11-01T20:17:35+00:00"}],"message":"Revisione annullata. Nessuna copia è stata modificata."},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_close_lost_refused.json b/app/src/test/resources/server090/stocktake/s090_stocktake_close_lost_refused.json new file mode 100644 index 0000000..05f7637 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_close_lost_refused.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"not_permitted","message":"Solo un amministratore può chiudere la revisione segnando come perse le copie mancanti."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_closed_lost.json b/app/src/test/resources/server090/stocktake/s090_stocktake_closed_lost.json new file mode 100644 index 0000000..5cfa824 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_closed_lost.json @@ -0,0 +1 @@ +{"data":{"id":42,"name":"ASTD4A665 admin lost","status":"closed","status_label":"Chiusa","status_color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":null,"expected_total":4,"opened_at":"2026-11-01T20:17:35+00:00","opened_by":{"id":31650,"name":"E2E Audit"},"closed_at":"2026-11-01T20:17:35+00:00","closed_by":{"id":31650,"name":"E2E Audit"},"missing_action":"lost","summary":{"expected":4,"found":1,"misplaced":0,"on_loan":1,"missing":2,"lost":2,"unknown":0,"out_of_scope":0,"duplicates":0,"scans":1,"mine":1},"pending_lost":0,"can_scan":false,"can_upload":false,"can_close":false,"can_cancel":false,"can_mark_lost":false,"recent_scans":[{"id":100,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"found","outcome_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"copy_id":110851,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":31650,"name":"E2E Audit"},"scanned_at":"2026-11-01T20:17:35+00:00"}],"history":[{"event":"inventory.closed","label":"Revisione inventariale chiusa","operator_name":"E2E Audit","at":"2026-11-01T20:17:35+00:00"},{"event":"inventory.opened","label":"Revisione inventariale aperta","operator_name":"E2E Audit","at":"2026-11-01T20:17:35+00:00"}],"close_result":{"resumed":false,"marked_lost":2,"pending_lost":0,"partial":false,"message":"Revisione chiusa: 2 copie mancanti segnate come perse."}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_closed_none.json b/app/src/test/resources/server090/stocktake/s090_stocktake_closed_none.json new file mode 100644 index 0000000..e0b62ca --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_closed_none.json @@ -0,0 +1 @@ +{"data":{"id":41,"name":"ASTD4A665 shelf X","status":"closed","status_label":"Chiusa","status_color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":"2026-11-01T20:17:34+00:00","closed_by":{"id":113888,"name":"E2E ASTS"},"missing_action":"none","summary":{"expected":4,"found":2,"misplaced":1,"on_loan":1,"missing":0,"lost":0,"unknown":2,"out_of_scope":1,"duplicates":1,"scans":8,"mine":7},"pending_lost":0,"can_scan":false,"can_upload":false,"can_close":false,"can_cancel":false,"can_mark_lost":false,"recent_scans":[{"id":99,"code":"ASTD4A665-GHOST","inventory_number":"ASTD4A665-GHOST","outcome":"unknown","outcome_label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:34+00:00"},{"id":98,"code":"ASTD4A665-A3","inventory_number":"ASTD4A665-A3","outcome":"found","outcome_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"copy_id":110853,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:34+00:00"},{"id":97,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"duplicate","outcome_label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":31650,"name":"E2E Audit"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":96,"code":"ASTD4A665-Y1","inventory_number":"ASTD4A665-Y1","outcome":"out_of_scope","outcome_label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"copy_id":110855,"book_id":341189,"title":"ASTD4A665 B3","collocation":"ASTD4A665.B3","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":95,"code":"ASTD4A665-NOPE","inventory_number":"ASTD4A665-NOPE","outcome":"unknown","outcome_label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":94,"code":"ASTD4A665-L1","inventory_number":"ASTD4A665-L1","outcome":"on_loan","outcome_label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"copy_id":110854,"book_id":341188,"title":"ASTD4A665 B2","collocation":"ASTD4A665.B2","media_type":"libro","copy_status":"prestato","copy_status_label":"Prestato","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":93,"code":"ASTD4A665-A2","inventory_number":"ASTD4A665-A2","outcome":"misplaced","outcome_label":"Fuori posto","color":{"name":"amber","foreground":"#92400e","background":"#fef3c7"},"copy_id":110852,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":92,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"found","outcome_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"copy_id":110851,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"}],"history":[{"event":"inventory.closed","label":"Revisione inventariale chiusa","operator_name":"E2E ASTS","at":"2026-11-01T20:17:34+00:00"},{"event":"inventory.opened","label":"Revisione inventariale aperta","operator_name":"E2E ASTS","at":"2026-11-01T20:17:32+00:00"}],"close_result":{"resumed":false,"marked_lost":0,"pending_lost":0,"partial":false,"message":"Revisione chiusa: le copie mancanti sono rimaste invariate."}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_create_invalid.json b/app/src/test/resources/server090/stocktake/s090_stocktake_create_invalid.json new file mode 100644 index 0000000..5ef9577 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_create_invalid.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"name":"Indica un nome per la revisione."}},"error":{"code":"validation","message":"Indica un nome per la revisione."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_health.json b/app/src/test/resources/server090/stocktake/s090_stocktake_health.json new file mode 100644 index 0000000..946a152 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_health.json @@ -0,0 +1 @@ +{"data":{"name":"Pinakes","logo":null,"version":"0.9.0-rc.1","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true,"reviews":true,"archives":true,"desiderata":true,"staff_catalog":true,"catalog_sync":true,"calendar_feed":true,"automatic_fines":false,"debt_block":false,"consultation_only":true,"fines":true,"closures_calendar":true,"circulation_rules":true,"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true,"staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true,"staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true,"staff_attendance":true,"staff_catalogue_cards":true,"staff_circulation_rules":true,"staff_closures_calendar":true,"staff_ledger":true,"staff_library_settings":true,"staff_marc":true,"staff_patrons":true,"staff_statistics":true,"staff_stocktake":true,"staff_suppliers":false,"staff_user_import":true,"stocktake":true,"marc":true,"visitor_log":true,"catalogue_cards":true,"suppliers":false,"rda_types":false,"patron_cards":true,"user_import":true,"patron_photos":true,"automatic_card_expiry":true,"loss_penalties":false,"attendance_retention":false},"catalogue_mode":false,"loan_approval_required":true,"app_access_enabled":true,"registration_enabled":true,"registration":{"require_cognome":true,"require_telefono":true,"require_indirizzo":true,"show_sesso":true,"show_cod_fiscale":true,"custom_fields":[]},"private_mode":false,"vapid_public_key":"BC3bzpzhkfeec0L8zhwaSI3iEIgP6ZLNoAiUf7FWKTSY-Jph0N0H39LWQNdVoanG6aTr_nFAhhYoXUDIlaSdqdI"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_options_admin.json b/app/src/test/resources/server090/stocktake/s090_stocktake_options_admin.json new file mode 100644 index 0000000..6f25623 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_options_admin.json @@ -0,0 +1 @@ +{"data":{"branches":[],"shelves":[{"id":1504,"code":"ASTD4A665X","name":"Stocktake app X","label":"[ASTD4A665X] Stocktake app X","levels":[{"id":2154,"level":1}]},{"id":1505,"code":"ASTD4A665Y","name":"Stocktake app Y","label":"[ASTD4A665Y] Stocktake app Y","levels":[]},{"id":1099,"code":"ZT","name":"Armadio test","label":"[ZT] Armadio test","levels":[{"id":1607,"level":1}]}],"genres":[{"id":2742,"label":"CascRoot_muwg10hj"},{"id":2743,"label":"— CascGenre_muwg10hj"},{"id":2744,"label":"— — CascSub_muwg10hj"},{"id":2776,"label":"CascRoot_muwhtg4d"},{"id":2777,"label":"— CascGenre_muwhtg4d"},{"id":2778,"label":"— — CascSub_muwhtg4d"},{"id":2784,"label":"CascRoot_muwhv6ft"},{"id":2785,"label":"— CascGenre_muwhv6ft"},{"id":2786,"label":"— — CascSub_muwhv6ft"},{"id":2792,"label":"CascRoot_muwhwnha"},{"id":2793,"label":"— CascGenre_muwhwnha"},{"id":2794,"label":"— — CascSub_muwhwnha"},{"id":2853,"label":"CascRoot_mux6kb11"},{"id":2854,"label":"— CascGenre_mux6kb11"},{"id":2855,"label":"— — CascSub_mux6kb11"},{"id":3446,"label":"CascRoot_mv6ab6kt"},{"id":3447,"label":"— CascGenre_mv6ab6kt"},{"id":3448,"label":"— — CascSub_mv6ab6kt"},{"id":3286,"label":"Classici"},{"id":5088,"label":"E2E SEO Padre mvjjkous"},{"id":5089,"label":"— E2E SEO Figlio mvjjkous"},{"id":3288,"label":"Fantasy"},{"id":3287,"label":"Filosofia"},{"id":3285,"label":"Memorialistica"},{"id":2861,"label":"Narrativa"},{"id":2,"label":"Poesia"},{"id":8,"label":"— Didascalica"},{"id":125,"label":"— — epistola filosofica in versi"},{"id":124,"label":"— — favola in versi"},{"id":121,"label":"— — Poema didascalico - poemetto didascalico"},{"id":120,"label":"— — Poema didattico-allegorico - poemetto didattico-allegorico"},{"id":122,"label":"— — Poema sapienziale - poemetto sapienziale"},{"id":123,"label":"— — Profezia"},{"id":7,"label":"— Epica"},{"id":118,"label":"— — Epillio"},{"id":115,"label":"— — Poema epico - collettivo"},{"id":116,"label":"— — Poema epico - epico-cavalleresco"},{"id":114,"label":"— — Poema epico - mitologico"},{"id":117,"label":"— — Poema epico - poemetto epico"},{"id":113,"label":"— — Poema epico - storico"},{"id":119,"label":"— — Poema eroicomico - poemetto eroicomico"},{"id":9,"label":"— Lirica"},{"id":154,"label":"— — Ballata"},{"id":158,"label":"— — Calligramma"},{"id":149,"label":"— — Canzone - libera"},{"id":148,"label":"— — Canzone - petrarchesca"},{"id":150,"label":"— — Capitolo"},{"id":151,"label":"— — Carme"},{"id":156,"label":"— — Centone"},{"id":159,"label":"— — Epistola lirica"},{"id":144,"label":"— — Lauda"},{"id":129,"label":"— — lirica corale - Ditirambo"},{"id":130,"label":"— — lirica corale - Encomio"},{"id":131,"label":"— — lirica corale - Epicedio"},{"id":132,"label":"— — lirica corale - Epinicio"},{"id":133,"label":"— — lirica corale - Epitalamio"},{"id":134,"label":"— — lirica corale - Imeneo"},{"id":135,"label":"— — lirica corale - Inno"},{"id":136,"label":"— — lirica corale - Iporchema"},{"id":137,"label":"— — lirica corale - Nomo"},{"id":138,"label":"— — lirica corale - Partenio"},{"id":139,"label":"— — lirica corale - Peana"},{"id":140,"label":"— — lirica corale - Prosodio"},{"id":141,"label":"— — lirica corale - Scolio"},{"id":142,"label":"— — lirica corale - Treno"},{"id":126,"label":"— — lirica monodica - Elegia"},{"id":127,"label":"— — lirica monodica - Giambo - Cinedio"},{"id":128,"label":"— — lirica monodica - Giambo - Meliambo"},{"id":153,"label":"— — Madrigale"},{"id":152,"label":"— — Ode"},{"id":155,"label":"— — Poemetto lirico"},{"id":145,"label":"— — Poesia bucolica"},{"id":143,"label":"— — Preghiera"},{"id":146,"label":"— — Satira"},{"id":147,"label":"— — Sonetto"},{"id":157,"label":"— — Verso libero"},{"id":1,"label":"Prosa"},{"id":5,"label":"— Divulgativa"},{"id":98,"label":"— — Aforisma"},{"id":106,"label":"— — agiografia"},{"id":99,"label":"— — Apologia"},{"id":105,"label":"— — aretalogia"},{"id":107,"label":"— — biografia kerygmatica"},{"id":94,"label":"— — Dialogo"},{"id":102,"label":"— — Emblematica"},{"id":103,"label":"— — Epistola in prosa - privata"},{"id":104,"label":"— — Epistola in prosa - pubblica"},{"id":101,"label":"— — Manifesto"},{"id":97,"label":"— — Opuscolo - Libello"},{"id":100,"label":"— — Parabola"},{"id":109,"label":"— — romanzo d'inchiesta"},{"id":108,"label":"— — romanzo filosofico"},{"id":96,"label":"— — Saggio"},{"id":95,"label":"— — Trattato"},{"id":6,"label":"— Eloquenza"},{"id":112,"label":"— — Oratoria - Oratoria deliberativa"},{"id":110,"label":"— — Oratoria - Oratoria epidittica"},{"id":111,"label":"— — Oratoria - Oratoria giudiziaria"},{"id":4,"label":"— Narrativa"},{"id":91,"label":"— — Biografia - autobiografia"},{"id":92,"label":"— — Biografia - memorialistica"},{"id":93,"label":"— — Diario"},{"id":86,"label":"— — Favola in prosa"},{"id":85,"label":"— — Fiaba"},{"id":84,"label":"— — Racconto - stessi sottogeneri del romanzo"},{"id":83,"label":"— — Romanzo - epistolare"},{"id":77,"label":"— — Romanzo - erotico"},{"id":47,"label":"— — Romanzo - fantascienza - fantapolitica - distopia"},{"id":46,"label":"— — Romanzo - fantascienza - fantapolitica - utopia"},{"id":41,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - apocalittico e postapocalittico"},{"id":33,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - atompunk"},{"id":19,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - biopunk"},{"id":32,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - clockpunk"},{"id":22,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - decopunk"},{"id":21,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - dieselpunk"},{"id":29,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Dreampunk"},{"id":31,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Ecopunk"},{"id":30,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Elfpunk"},{"id":34,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - mythpunk"},{"id":20,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - nanopunk"},{"id":26,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - nowpunk"},{"id":18,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - postcyberpunk"},{"id":28,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - raypunk"},{"id":25,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Rococopunk"},{"id":24,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - sandalpunk"},{"id":37,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - solarpunk"},{"id":36,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - steampunk"},{"id":27,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - steelpunk"},{"id":35,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - stonepunk"},{"id":23,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Swordpunk"},{"id":45,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - edisonata"},{"id":44,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - new wave"},{"id":42,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - planetary romance e sword and planet"},{"id":17,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - Singularity Fiction"},{"id":38,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - space opera - space opera militare"},{"id":39,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - space opera - space western"},{"id":40,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - space opera - xenofiction"},{"id":43,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - viaggi nel tempo"},{"id":53,"label":"— — Romanzo - fantasy - fantasy contemporaneo - bangsian fantasy"},{"id":52,"label":"— — Romanzo - fantasy - fantasy contemporaneo - urban fantasy"},{"id":49,"label":"— — Romanzo - fantasy - high fantasy o fantasy epico - dark fantasy"},{"id":48,"label":"— — Romanzo - fantasy - high fantasy o fantasy epico - sword and sorcery"},{"id":50,"label":"— — Romanzo - fantasy - low fantasy"},{"id":51,"label":"— — Romanzo - fantasy - Realismo magico"},{"id":12,"label":"— — Romanzo - frammento"},{"id":71,"label":"— — Romanzo - giallo - Enigma della camera chiusa"},{"id":69,"label":"— — Romanzo - giallo - giallo giudiziario"},{"id":70,"label":"— — Romanzo - giallo - giallo medico"},{"id":73,"label":"— — Romanzo - hard boiled"},{"id":72,"label":"— — Romanzo - noir - noir metropolitano"},{"id":75,"label":"— — Romanzo - poliziesco - Police procedural"},{"id":78,"label":"— — Romanzo - psicologico"},{"id":56,"label":"— — Romanzo - romanzo d'appendice"},{"id":54,"label":"— — Romanzo - romanzo d'avventura"},{"id":59,"label":"— — Romanzo - romanzo d'azione"},{"id":58,"label":"— — Romanzo - romanzo dell'orrore - gotico"},{"id":55,"label":"— — Romanzo - romanzo di formazione"},{"id":57,"label":"— — Romanzo - romanzo per ragazzi"},{"id":76,"label":"— — Romanzo - rosa"},{"id":82,"label":"— — Romanzo - sociale"},{"id":74,"label":"— — Romanzo - soft boiled"},{"id":60,"label":"— — Romanzo - Spy story"},{"id":15,"label":"— — Romanzo - storico - cappa e spada o wuxia"},{"id":13,"label":"— — Romanzo - storico - fantastoria"},{"id":16,"label":"— — Romanzo - storico - Peplum o Sword and sandal"},{"id":14,"label":"— — Romanzo - storico - ucronia"},{"id":65,"label":"— — Romanzo - thriller - Action Thriller - Pulp - Crime Thriller"},{"id":67,"label":"— — Romanzo - thriller - legal thriller"},{"id":61,"label":"— — Romanzo - thriller - Mistery Thriller"},{"id":64,"label":"— — Romanzo - thriller - Noir Thriller"},{"id":66,"label":"— — Romanzo - thriller - Tecnothriller"},{"id":63,"label":"— — Romanzo - thriller - Thriller di spionaggio"},{"id":68,"label":"— — Romanzo - thriller - Thriller politico - Thriller cospirativo"},{"id":62,"label":"— — Romanzo - thriller - Thriller psicologico"},{"id":81,"label":"— — Romanzo - umoristico - parodia"},{"id":80,"label":"— — Romanzo - umoristico - satira"},{"id":79,"label":"— — Romanzo - umoristico - umorismo nero"},{"id":88,"label":"— — Storiografia - annali"},{"id":89,"label":"— — Storiografia - cronaca"},{"id":87,"label":"— — Storiografia - monografia"},{"id":90,"label":"— — Storiografia - storia universale"},{"id":3,"label":"Teatro"},{"id":11,"label":"— Teatro di musica"},{"id":174,"label":"— — Balletto"},{"id":180,"label":"— — Dramma musicale"},{"id":177,"label":"— — Mimo lirico - ilarodia"},{"id":178,"label":"— — Mimo lirico - magodia"},{"id":176,"label":"— — Mimo lirico - mimiambo"},{"id":179,"label":"— — Mimo lirico - Musicale"},{"id":171,"label":"— — Opera - farsa"},{"id":173,"label":"— — Opera - Intermezzi"},{"id":169,"label":"— — Opera - opera buffa"},{"id":168,"label":"— — Opera - opera seria"},{"id":170,"label":"— — Opera - operetta"},{"id":172,"label":"— — Opera - Zarzuela"},{"id":181,"label":"— — Oratorio"},{"id":175,"label":"— — Pantomima"},{"id":10,"label":"— Teatro di prosa"},{"id":161,"label":"— — Commedia"},{"id":164,"label":"— — Commedia dell'arte"},{"id":162,"label":"— — Dramma"},{"id":165,"label":"— — Farsa"},{"id":163,"label":"— — Melodramma"},{"id":167,"label":"— — Mimo prosastico"},{"id":166,"label":"— — Sketch"},{"id":160,"label":"— — Tragedia"},{"id":630,"label":"zz Interop140 Genere zz-int140-0a9da0db"},{"id":4515,"label":"ZZ_LANG282_3fd637ba4a_G"}],"media_types":[{"code":"libro","label":"Libro"},{"code":"disco","label":"Disco"},{"code":"audiolibro","label":"Audiolibro"},{"code":"dvd","label":"DVD"},{"code":"altro","label":"Altro"}],"outcomes":[{"code":"found","label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"tone":{"frequency_hz":880,"duration_ms":120},"scan_result":true},{"code":"misplaced","label":"Fuori posto","color":{"name":"amber","foreground":"#92400e","background":"#fef3c7"},"tone":{"frequency_hz":520,"duration_ms":250},"scan_result":true},{"code":"on_loan","label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"tone":{"frequency_hz":660,"duration_ms":250},"scan_result":true},{"code":"missing","label":"Mancante","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"tone":null,"scan_result":false},{"code":"unknown","label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"tone":{"frequency_hz":220,"duration_ms":450},"scan_result":true},{"code":"out_of_scope","label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"tone":{"frequency_hz":440,"duration_ms":300},"scan_result":true},{"code":"duplicate","label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"tone":{"frequency_hz":330,"duration_ms":200},"scan_result":true}],"statuses":[{"code":"open","label":"Aperta","color":{"name":"green","foreground":"#166534","background":"#dcfce7"}},{"code":"closed","label":"Chiusa","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"}},{"code":"cancelled","label":"Annullata","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"}}],"missing_actions":[{"code":"none","label":"Lascia le copie mancanti come sono","admin_only":false},{"code":"lost","label":"Segna come perse le copie mancanti","admin_only":true}],"can_mark_lost":true,"limits":{"max_code_length":100,"max_upload_codes":5000,"max_upload_bytes":2097152,"upload_extensions":["txt","csv"],"max_name_length":255,"max_collocation_length":50,"scans_per_minute":600}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_options_staff.json b/app/src/test/resources/server090/stocktake/s090_stocktake_options_staff.json new file mode 100644 index 0000000..3be12d8 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_options_staff.json @@ -0,0 +1 @@ +{"data":{"branches":[],"shelves":[{"id":1504,"code":"ASTD4A665X","name":"Stocktake app X","label":"[ASTD4A665X] Stocktake app X","levels":[{"id":2154,"level":1}]},{"id":1505,"code":"ASTD4A665Y","name":"Stocktake app Y","label":"[ASTD4A665Y] Stocktake app Y","levels":[]},{"id":1099,"code":"ZT","name":"Armadio test","label":"[ZT] Armadio test","levels":[{"id":1607,"level":1}]}],"genres":[{"id":2742,"label":"CascRoot_muwg10hj"},{"id":2743,"label":"— CascGenre_muwg10hj"},{"id":2744,"label":"— — CascSub_muwg10hj"},{"id":2776,"label":"CascRoot_muwhtg4d"},{"id":2777,"label":"— CascGenre_muwhtg4d"},{"id":2778,"label":"— — CascSub_muwhtg4d"},{"id":2784,"label":"CascRoot_muwhv6ft"},{"id":2785,"label":"— CascGenre_muwhv6ft"},{"id":2786,"label":"— — CascSub_muwhv6ft"},{"id":2792,"label":"CascRoot_muwhwnha"},{"id":2793,"label":"— CascGenre_muwhwnha"},{"id":2794,"label":"— — CascSub_muwhwnha"},{"id":2853,"label":"CascRoot_mux6kb11"},{"id":2854,"label":"— CascGenre_mux6kb11"},{"id":2855,"label":"— — CascSub_mux6kb11"},{"id":3446,"label":"CascRoot_mv6ab6kt"},{"id":3447,"label":"— CascGenre_mv6ab6kt"},{"id":3448,"label":"— — CascSub_mv6ab6kt"},{"id":3286,"label":"Classici"},{"id":5088,"label":"E2E SEO Padre mvjjkous"},{"id":5089,"label":"— E2E SEO Figlio mvjjkous"},{"id":3288,"label":"Fantasy"},{"id":3287,"label":"Filosofia"},{"id":3285,"label":"Memorialistica"},{"id":2861,"label":"Narrativa"},{"id":2,"label":"Poesia"},{"id":8,"label":"— Didascalica"},{"id":125,"label":"— — epistola filosofica in versi"},{"id":124,"label":"— — favola in versi"},{"id":121,"label":"— — Poema didascalico - poemetto didascalico"},{"id":120,"label":"— — Poema didattico-allegorico - poemetto didattico-allegorico"},{"id":122,"label":"— — Poema sapienziale - poemetto sapienziale"},{"id":123,"label":"— — Profezia"},{"id":7,"label":"— Epica"},{"id":118,"label":"— — Epillio"},{"id":115,"label":"— — Poema epico - collettivo"},{"id":116,"label":"— — Poema epico - epico-cavalleresco"},{"id":114,"label":"— — Poema epico - mitologico"},{"id":117,"label":"— — Poema epico - poemetto epico"},{"id":113,"label":"— — Poema epico - storico"},{"id":119,"label":"— — Poema eroicomico - poemetto eroicomico"},{"id":9,"label":"— Lirica"},{"id":154,"label":"— — Ballata"},{"id":158,"label":"— — Calligramma"},{"id":149,"label":"— — Canzone - libera"},{"id":148,"label":"— — Canzone - petrarchesca"},{"id":150,"label":"— — Capitolo"},{"id":151,"label":"— — Carme"},{"id":156,"label":"— — Centone"},{"id":159,"label":"— — Epistola lirica"},{"id":144,"label":"— — Lauda"},{"id":129,"label":"— — lirica corale - Ditirambo"},{"id":130,"label":"— — lirica corale - Encomio"},{"id":131,"label":"— — lirica corale - Epicedio"},{"id":132,"label":"— — lirica corale - Epinicio"},{"id":133,"label":"— — lirica corale - Epitalamio"},{"id":134,"label":"— — lirica corale - Imeneo"},{"id":135,"label":"— — lirica corale - Inno"},{"id":136,"label":"— — lirica corale - Iporchema"},{"id":137,"label":"— — lirica corale - Nomo"},{"id":138,"label":"— — lirica corale - Partenio"},{"id":139,"label":"— — lirica corale - Peana"},{"id":140,"label":"— — lirica corale - Prosodio"},{"id":141,"label":"— — lirica corale - Scolio"},{"id":142,"label":"— — lirica corale - Treno"},{"id":126,"label":"— — lirica monodica - Elegia"},{"id":127,"label":"— — lirica monodica - Giambo - Cinedio"},{"id":128,"label":"— — lirica monodica - Giambo - Meliambo"},{"id":153,"label":"— — Madrigale"},{"id":152,"label":"— — Ode"},{"id":155,"label":"— — Poemetto lirico"},{"id":145,"label":"— — Poesia bucolica"},{"id":143,"label":"— — Preghiera"},{"id":146,"label":"— — Satira"},{"id":147,"label":"— — Sonetto"},{"id":157,"label":"— — Verso libero"},{"id":1,"label":"Prosa"},{"id":5,"label":"— Divulgativa"},{"id":98,"label":"— — Aforisma"},{"id":106,"label":"— — agiografia"},{"id":99,"label":"— — Apologia"},{"id":105,"label":"— — aretalogia"},{"id":107,"label":"— — biografia kerygmatica"},{"id":94,"label":"— — Dialogo"},{"id":102,"label":"— — Emblematica"},{"id":103,"label":"— — Epistola in prosa - privata"},{"id":104,"label":"— — Epistola in prosa - pubblica"},{"id":101,"label":"— — Manifesto"},{"id":97,"label":"— — Opuscolo - Libello"},{"id":100,"label":"— — Parabola"},{"id":109,"label":"— — romanzo d'inchiesta"},{"id":108,"label":"— — romanzo filosofico"},{"id":96,"label":"— — Saggio"},{"id":95,"label":"— — Trattato"},{"id":6,"label":"— Eloquenza"},{"id":112,"label":"— — Oratoria - Oratoria deliberativa"},{"id":110,"label":"— — Oratoria - Oratoria epidittica"},{"id":111,"label":"— — Oratoria - Oratoria giudiziaria"},{"id":4,"label":"— Narrativa"},{"id":91,"label":"— — Biografia - autobiografia"},{"id":92,"label":"— — Biografia - memorialistica"},{"id":93,"label":"— — Diario"},{"id":86,"label":"— — Favola in prosa"},{"id":85,"label":"— — Fiaba"},{"id":84,"label":"— — Racconto - stessi sottogeneri del romanzo"},{"id":83,"label":"— — Romanzo - epistolare"},{"id":77,"label":"— — Romanzo - erotico"},{"id":47,"label":"— — Romanzo - fantascienza - fantapolitica - distopia"},{"id":46,"label":"— — Romanzo - fantascienza - fantapolitica - utopia"},{"id":41,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - apocalittico e postapocalittico"},{"id":33,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - atompunk"},{"id":19,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - biopunk"},{"id":32,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - clockpunk"},{"id":22,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - decopunk"},{"id":21,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - dieselpunk"},{"id":29,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Dreampunk"},{"id":31,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Ecopunk"},{"id":30,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Elfpunk"},{"id":34,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - mythpunk"},{"id":20,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - nanopunk"},{"id":26,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - nowpunk"},{"id":18,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - postcyberpunk"},{"id":28,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - raypunk"},{"id":25,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Rococopunk"},{"id":24,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - sandalpunk"},{"id":37,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - solarpunk"},{"id":36,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - steampunk"},{"id":27,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - steelpunk"},{"id":35,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - stonepunk"},{"id":23,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - cyberpunk - Swordpunk"},{"id":45,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - edisonata"},{"id":44,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - new wave"},{"id":42,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - planetary romance e sword and planet"},{"id":17,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - Singularity Fiction"},{"id":38,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - space opera - space opera militare"},{"id":39,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - space opera - space western"},{"id":40,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - space opera - xenofiction"},{"id":43,"label":"— — Romanzo - fantascienza - fantascienza tecnologica - viaggi nel tempo"},{"id":53,"label":"— — Romanzo - fantasy - fantasy contemporaneo - bangsian fantasy"},{"id":52,"label":"— — Romanzo - fantasy - fantasy contemporaneo - urban fantasy"},{"id":49,"label":"— — Romanzo - fantasy - high fantasy o fantasy epico - dark fantasy"},{"id":48,"label":"— — Romanzo - fantasy - high fantasy o fantasy epico - sword and sorcery"},{"id":50,"label":"— — Romanzo - fantasy - low fantasy"},{"id":51,"label":"— — Romanzo - fantasy - Realismo magico"},{"id":12,"label":"— — Romanzo - frammento"},{"id":71,"label":"— — Romanzo - giallo - Enigma della camera chiusa"},{"id":69,"label":"— — Romanzo - giallo - giallo giudiziario"},{"id":70,"label":"— — Romanzo - giallo - giallo medico"},{"id":73,"label":"— — Romanzo - hard boiled"},{"id":72,"label":"— — Romanzo - noir - noir metropolitano"},{"id":75,"label":"— — Romanzo - poliziesco - Police procedural"},{"id":78,"label":"— — Romanzo - psicologico"},{"id":56,"label":"— — Romanzo - romanzo d'appendice"},{"id":54,"label":"— — Romanzo - romanzo d'avventura"},{"id":59,"label":"— — Romanzo - romanzo d'azione"},{"id":58,"label":"— — Romanzo - romanzo dell'orrore - gotico"},{"id":55,"label":"— — Romanzo - romanzo di formazione"},{"id":57,"label":"— — Romanzo - romanzo per ragazzi"},{"id":76,"label":"— — Romanzo - rosa"},{"id":82,"label":"— — Romanzo - sociale"},{"id":74,"label":"— — Romanzo - soft boiled"},{"id":60,"label":"— — Romanzo - Spy story"},{"id":15,"label":"— — Romanzo - storico - cappa e spada o wuxia"},{"id":13,"label":"— — Romanzo - storico - fantastoria"},{"id":16,"label":"— — Romanzo - storico - Peplum o Sword and sandal"},{"id":14,"label":"— — Romanzo - storico - ucronia"},{"id":65,"label":"— — Romanzo - thriller - Action Thriller - Pulp - Crime Thriller"},{"id":67,"label":"— — Romanzo - thriller - legal thriller"},{"id":61,"label":"— — Romanzo - thriller - Mistery Thriller"},{"id":64,"label":"— — Romanzo - thriller - Noir Thriller"},{"id":66,"label":"— — Romanzo - thriller - Tecnothriller"},{"id":63,"label":"— — Romanzo - thriller - Thriller di spionaggio"},{"id":68,"label":"— — Romanzo - thriller - Thriller politico - Thriller cospirativo"},{"id":62,"label":"— — Romanzo - thriller - Thriller psicologico"},{"id":81,"label":"— — Romanzo - umoristico - parodia"},{"id":80,"label":"— — Romanzo - umoristico - satira"},{"id":79,"label":"— — Romanzo - umoristico - umorismo nero"},{"id":88,"label":"— — Storiografia - annali"},{"id":89,"label":"— — Storiografia - cronaca"},{"id":87,"label":"— — Storiografia - monografia"},{"id":90,"label":"— — Storiografia - storia universale"},{"id":3,"label":"Teatro"},{"id":11,"label":"— Teatro di musica"},{"id":174,"label":"— — Balletto"},{"id":180,"label":"— — Dramma musicale"},{"id":177,"label":"— — Mimo lirico - ilarodia"},{"id":178,"label":"— — Mimo lirico - magodia"},{"id":176,"label":"— — Mimo lirico - mimiambo"},{"id":179,"label":"— — Mimo lirico - Musicale"},{"id":171,"label":"— — Opera - farsa"},{"id":173,"label":"— — Opera - Intermezzi"},{"id":169,"label":"— — Opera - opera buffa"},{"id":168,"label":"— — Opera - opera seria"},{"id":170,"label":"— — Opera - operetta"},{"id":172,"label":"— — Opera - Zarzuela"},{"id":181,"label":"— — Oratorio"},{"id":175,"label":"— — Pantomima"},{"id":10,"label":"— Teatro di prosa"},{"id":161,"label":"— — Commedia"},{"id":164,"label":"— — Commedia dell'arte"},{"id":162,"label":"— — Dramma"},{"id":165,"label":"— — Farsa"},{"id":163,"label":"— — Melodramma"},{"id":167,"label":"— — Mimo prosastico"},{"id":166,"label":"— — Sketch"},{"id":160,"label":"— — Tragedia"},{"id":630,"label":"zz Interop140 Genere zz-int140-0a9da0db"},{"id":4515,"label":"ZZ_LANG282_3fd637ba4a_G"}],"media_types":[{"code":"libro","label":"Libro"},{"code":"disco","label":"Disco"},{"code":"audiolibro","label":"Audiolibro"},{"code":"dvd","label":"DVD"},{"code":"altro","label":"Altro"}],"outcomes":[{"code":"found","label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"tone":{"frequency_hz":880,"duration_ms":120},"scan_result":true},{"code":"misplaced","label":"Fuori posto","color":{"name":"amber","foreground":"#92400e","background":"#fef3c7"},"tone":{"frequency_hz":520,"duration_ms":250},"scan_result":true},{"code":"on_loan","label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"tone":{"frequency_hz":660,"duration_ms":250},"scan_result":true},{"code":"missing","label":"Mancante","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"tone":null,"scan_result":false},{"code":"unknown","label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"tone":{"frequency_hz":220,"duration_ms":450},"scan_result":true},{"code":"out_of_scope","label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"tone":{"frequency_hz":440,"duration_ms":300},"scan_result":true},{"code":"duplicate","label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"tone":{"frequency_hz":330,"duration_ms":200},"scan_result":true}],"statuses":[{"code":"open","label":"Aperta","color":{"name":"green","foreground":"#166534","background":"#dcfce7"}},{"code":"closed","label":"Chiusa","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"}},{"code":"cancelled","label":"Annullata","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"}}],"missing_actions":[{"code":"none","label":"Lascia le copie mancanti come sono","admin_only":false},{"code":"lost","label":"Segna come perse le copie mancanti","admin_only":true}],"can_mark_lost":false,"limits":{"max_code_length":100,"max_upload_codes":5000,"max_upload_bytes":2097152,"upload_extensions":["txt","csv"],"max_name_length":255,"max_collocation_length":50,"scans_per_minute":600}},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_report.json b/app/src/test/resources/server090/stocktake/s090_stocktake_report.json new file mode 100644 index 0000000..ad15544 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_report.json @@ -0,0 +1 @@ +{"data":[{"category":"missing","category_label":"Mancante","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"code":"ASTD4A665-A3","title":"ASTD4A665 B1","media_type":"libro","collocation":"ASTD4A665.B1","expected_shelf":"[ASTD4A665X] Stocktake app X","copy_status":"disponibile","copy_status_label":"Disponibile","operator_name":null,"scanned_at":null},{"category":"duplicate","category_label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"code":"ASTD4A665-A1","title":"","media_type":null,"collocation":null,"expected_shelf":null,"copy_status":null,"copy_status_label":null,"operator_name":"E2E Audit","scanned_at":"2026-11-01T20:17:33+00:00"},{"category":"out_of_scope","category_label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"code":"ASTD4A665-Y1","title":"ASTD4A665 B3","media_type":"libro","collocation":"ASTD4A665.B3","expected_shelf":null,"copy_status":"disponibile","copy_status_label":"Disponibile","operator_name":"E2E ASTS","scanned_at":"2026-11-01T20:17:33+00:00"},{"category":"unknown","category_label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"code":"ASTD4A665-NOPE","title":"","media_type":null,"collocation":null,"expected_shelf":null,"copy_status":null,"copy_status_label":null,"operator_name":"E2E ASTS","scanned_at":"2026-11-01T20:17:33+00:00"},{"category":"on_loan","category_label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"code":"ASTD4A665-L1","title":"ASTD4A665 B2","media_type":"libro","collocation":"ASTD4A665.B2","expected_shelf":"[ASTD4A665X] Stocktake app X","copy_status":"prestato","copy_status_label":"Prestato","operator_name":"E2E ASTS","scanned_at":"2026-11-01T20:17:33+00:00"},{"category":"misplaced","category_label":"Fuori posto","color":{"name":"amber","foreground":"#92400e","background":"#fef3c7"},"code":"ASTD4A665-A2","title":"ASTD4A665 B1","media_type":"libro","collocation":"ASTD4A665.B1","expected_shelf":"[ASTD4A665X] Stocktake app X","copy_status":"disponibile","copy_status_label":"Disponibile","operator_name":"E2E ASTS","scanned_at":"2026-11-01T20:17:33+00:00"},{"category":"found","category_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"code":"ASTD4A665-A1","title":"ASTD4A665 B1","media_type":"libro","collocation":"ASTD4A665.B1","expected_shelf":"[ASTD4A665X] Stocktake app X","copy_status":"disponibile","copy_status_label":"Disponibile","operator_name":"E2E ASTS","scanned_at":"2026-11-01T20:17:33+00:00"}],"meta":{"count":7,"limit":50,"next_cursor":null,"has_more":false,"total_count":7},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_report_missing.json b/app/src/test/resources/server090/stocktake/s090_stocktake_report_missing.json new file mode 100644 index 0000000..341ee02 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_report_missing.json @@ -0,0 +1 @@ +{"data":[{"category":"missing","category_label":"Mancante","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"code":"ASTD4A665-A3","title":"ASTD4A665 B1","media_type":"libro","collocation":"ASTD4A665.B1","expected_shelf":"[ASTD4A665X] Stocktake app X","copy_status":"disponibile","copy_status_label":"Disponibile","operator_name":null,"scanned_at":null}],"meta":{"count":1,"limit":50,"next_cursor":null,"has_more":false},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_scan_duplicate.json b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_duplicate.json new file mode 100644 index 0000000..f72abe3 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_duplicate.json @@ -0,0 +1 @@ +{"data":{"scan":{"id":97,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"duplicate","outcome_label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"copy_id":110851,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":null,"copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":null,"operator":{"id":31650,"name":"E2E Audit"},"scanned_at":"2026-11-01T20:17:33+00:00"},"tone":{"frequency_hz":330,"duration_ms":200},"summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":1,"out_of_scope":1,"duplicates":1,"scans":6,"mine":1},"session_status":"open"},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_scan_found.json b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_found.json new file mode 100644 index 0000000..18365ca --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_found.json @@ -0,0 +1 @@ +{"data":{"scan":{"id":92,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"found","outcome_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"copy_id":110851,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},"tone":{"frequency_hz":880,"duration_ms":120},"summary":{"expected":4,"found":1,"misplaced":0,"on_loan":1,"missing":2,"lost":0,"unknown":0,"out_of_scope":0,"duplicates":0,"scans":1,"mine":1},"session_status":"open"},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_scan_invalid.json b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_invalid.json new file mode 100644 index 0000000..009a002 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_invalid.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"code":"Codice mancante."}},"error":{"code":"validation","message":"Codice mancante."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_scan_misplaced.json b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_misplaced.json new file mode 100644 index 0000000..7a51e36 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_misplaced.json @@ -0,0 +1 @@ +{"data":{"scan":{"id":93,"code":"ASTD4A665-A2","inventory_number":"ASTD4A665-A2","outcome":"misplaced","outcome_label":"Fuori posto","color":{"name":"amber","foreground":"#92400e","background":"#fef3c7"},"copy_id":110852,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},"tone":{"frequency_hz":520,"duration_ms":250},"summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":0,"out_of_scope":0,"duplicates":0,"scans":2,"mine":2},"session_status":"open"},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_scan_on_loan.json b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_on_loan.json new file mode 100644 index 0000000..8d32bc6 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_on_loan.json @@ -0,0 +1 @@ +{"data":{"scan":{"id":94,"code":"ASTD4A665-L1","inventory_number":"ASTD4A665-L1","outcome":"on_loan","outcome_label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"copy_id":110854,"book_id":341188,"title":"ASTD4A665 B2","collocation":"ASTD4A665.B2","media_type":"libro","copy_status":"prestato","copy_status_label":"Prestato","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},"tone":{"frequency_hz":660,"duration_ms":250},"summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":0,"out_of_scope":0,"duplicates":0,"scans":3,"mine":3},"session_status":"open"},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_scan_out_of_scope.json b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_out_of_scope.json new file mode 100644 index 0000000..e91379a --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_out_of_scope.json @@ -0,0 +1 @@ +{"data":{"scan":{"id":96,"code":"ASTD4A665-Y1","inventory_number":"ASTD4A665-Y1","outcome":"out_of_scope","outcome_label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"copy_id":110855,"book_id":341189,"title":"ASTD4A665 B3","collocation":"ASTD4A665.B3","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},"tone":{"frequency_hz":440,"duration_ms":300},"summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":1,"out_of_scope":1,"duplicates":0,"scans":5,"mine":5},"session_status":"open"},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_scan_session_cancelled.json b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_session_cancelled.json new file mode 100644 index 0000000..3da1f54 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_session_cancelled.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"session_cancelled","message":"La revisione non è più aperta: non si possono registrare altre scansioni."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_scan_session_closed.json b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_session_closed.json new file mode 100644 index 0000000..ae36790 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_session_closed.json @@ -0,0 +1 @@ +{"data":null,"meta":{},"error":{"code":"session_closed","message":"La revisione non è più aperta: non si possono registrare altre scansioni."}} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_scan_unknown.json b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_unknown.json new file mode 100644 index 0000000..ab280ef --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_scan_unknown.json @@ -0,0 +1 @@ +{"data":{"scan":{"id":95,"code":"ASTD4A665-NOPE","inventory_number":"ASTD4A665-NOPE","outcome":"unknown","outcome_label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},"tone":{"frequency_hz":220,"duration_ms":450},"summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":1,"out_of_scope":0,"duplicates":0,"scans":4,"mine":4},"session_status":"open"},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_scans_found_mine.json b/app/src/test/resources/server090/stocktake/s090_stocktake_scans_found_mine.json new file mode 100644 index 0000000..1daa665 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_scans_found_mine.json @@ -0,0 +1 @@ +{"data":[{"id":92,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"found","outcome_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"copy_id":110851,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"}],"meta":{"count":1,"limit":50,"next_cursor":null,"has_more":false,"total_count":1},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_scans_page.json b/app/src/test/resources/server090/stocktake/s090_stocktake_scans_page.json new file mode 100644 index 0000000..387abcd --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_scans_page.json @@ -0,0 +1 @@ +{"data":[{"id":97,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"duplicate","outcome_label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":31650,"name":"E2E Audit"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":96,"code":"ASTD4A665-Y1","inventory_number":"ASTD4A665-Y1","outcome":"out_of_scope","outcome_label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"copy_id":110855,"book_id":341189,"title":"ASTD4A665 B3","collocation":"ASTD4A665.B3","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":95,"code":"ASTD4A665-NOPE","inventory_number":"ASTD4A665-NOPE","outcome":"unknown","outcome_label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":94,"code":"ASTD4A665-L1","inventory_number":"ASTD4A665-L1","outcome":"on_loan","outcome_label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"copy_id":110854,"book_id":341188,"title":"ASTD4A665 B2","collocation":"ASTD4A665.B2","media_type":"libro","copy_status":"prestato","copy_status_label":"Prestato","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"}],"meta":{"count":4,"limit":4,"next_cursor":"eyJvIjo0fQ","has_more":true,"total_count":6},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_session_created.json b/app/src/test/resources/server090/stocktake/s090_stocktake_session_created.json new file mode 100644 index 0000000..4452d36 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_session_created.json @@ -0,0 +1 @@ +{"data":{"id":41,"name":"ASTD4A665 shelf X","status":"open","status_label":"Aperta","status_color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":null,"closed_by":null,"missing_action":null,"summary":{"expected":4,"found":0,"misplaced":0,"on_loan":1,"missing":3,"lost":0,"unknown":0,"out_of_scope":0,"duplicates":0,"scans":0,"mine":0},"pending_lost":0,"can_scan":true,"can_upload":true,"can_close":true,"can_cancel":true,"can_mark_lost":false,"recent_scans":[],"history":[{"event":"inventory.opened","label":"Revisione inventariale aperta","operator_name":"E2E ASTS","at":"2026-11-01T20:17:32+00:00"}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_session_detail.json b/app/src/test/resources/server090/stocktake/s090_stocktake_session_detail.json new file mode 100644 index 0000000..f735a1b --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_session_detail.json @@ -0,0 +1 @@ +{"data":{"id":41,"name":"ASTD4A665 shelf X","status":"open","status_label":"Aperta","status_color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":null,"closed_by":null,"missing_action":null,"summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":1,"out_of_scope":1,"duplicates":1,"scans":6,"mine":5},"pending_lost":0,"can_scan":true,"can_upload":true,"can_close":true,"can_cancel":true,"can_mark_lost":false,"recent_scans":[{"id":97,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"duplicate","outcome_label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":31650,"name":"E2E Audit"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":96,"code":"ASTD4A665-Y1","inventory_number":"ASTD4A665-Y1","outcome":"out_of_scope","outcome_label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"copy_id":110855,"book_id":341189,"title":"ASTD4A665 B3","collocation":"ASTD4A665.B3","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":95,"code":"ASTD4A665-NOPE","inventory_number":"ASTD4A665-NOPE","outcome":"unknown","outcome_label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":94,"code":"ASTD4A665-L1","inventory_number":"ASTD4A665-L1","outcome":"on_loan","outcome_label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"copy_id":110854,"book_id":341188,"title":"ASTD4A665 B2","collocation":"ASTD4A665.B2","media_type":"libro","copy_status":"prestato","copy_status_label":"Prestato","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":93,"code":"ASTD4A665-A2","inventory_number":"ASTD4A665-A2","outcome":"misplaced","outcome_label":"Fuori posto","color":{"name":"amber","foreground":"#92400e","background":"#fef3c7"},"copy_id":110852,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":92,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"found","outcome_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"copy_id":110851,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"}],"history":[{"event":"inventory.opened","label":"Revisione inventariale aperta","operator_name":"E2E ASTS","at":"2026-11-01T20:17:32+00:00"}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_session_detail_mine.json b/app/src/test/resources/server090/stocktake/s090_stocktake_session_detail_mine.json new file mode 100644 index 0000000..0a3f34c --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_session_detail_mine.json @@ -0,0 +1 @@ +{"data":{"id":41,"name":"ASTD4A665 shelf X","status":"open","status_label":"Aperta","status_color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":null,"closed_by":null,"missing_action":null,"summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":1,"out_of_scope":1,"duplicates":1,"scans":6,"mine":1},"pending_lost":0,"can_scan":true,"can_upload":true,"can_close":true,"can_cancel":true,"can_mark_lost":true,"recent_scans":[{"id":97,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"duplicate","outcome_label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":31650,"name":"E2E Audit"},"scanned_at":"2026-11-01T20:17:33+00:00"}],"history":[{"event":"inventory.opened","label":"Revisione inventariale aperta","operator_name":"E2E ASTS","at":"2026-11-01T20:17:32+00:00"}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_session_replayed.json b/app/src/test/resources/server090/stocktake/s090_stocktake_session_replayed.json new file mode 100644 index 0000000..b9a0383 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_session_replayed.json @@ -0,0 +1 @@ +{"data":{"id":41,"name":"ASTD4A665 shelf X","status":"open","status_label":"Aperta","status_color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":null,"closed_by":null,"missing_action":null,"summary":{"expected":4,"found":0,"misplaced":0,"on_loan":1,"missing":3,"lost":0,"unknown":0,"out_of_scope":0,"duplicates":0,"scans":0,"mine":0},"pending_lost":0,"can_scan":true,"can_upload":true,"can_close":true,"can_cancel":true,"can_mark_lost":false,"recent_scans":[],"history":[{"event":"inventory.opened","label":"Revisione inventariale aperta","operator_name":"E2E ASTS","at":"2026-11-01T20:17:32+00:00"}]},"meta":{"replayed":true},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_sessions_all.json b/app/src/test/resources/server090/stocktake/s090_stocktake_sessions_all.json new file mode 100644 index 0000000..2703d29 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_sessions_all.json @@ -0,0 +1 @@ +{"data":[{"id":42,"name":"ASTD4A665 admin lost","status":"closed","status_label":"Chiusa","status_color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":null,"expected_total":4,"opened_at":"2026-11-01T20:17:35+00:00","opened_by":{"id":31650,"name":"E2E Audit"},"closed_at":"2026-11-01T20:17:35+00:00","closed_by":{"id":31650,"name":"E2E Audit"},"missing_action":"lost","summary":{"expected":4,"found":1,"misplaced":0,"on_loan":1,"missing":2,"lost":2,"unknown":0,"out_of_scope":0,"duplicates":0,"scans":1,"mine":0},"pending_lost":0,"can_scan":false,"can_upload":false,"can_close":false,"can_cancel":false,"can_mark_lost":false},{"id":41,"name":"ASTD4A665 shelf X","status":"closed","status_label":"Chiusa","status_color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":"2026-11-01T20:17:34+00:00","closed_by":{"id":113888,"name":"E2E ASTS"},"missing_action":"none","summary":{"expected":4,"found":2,"misplaced":1,"on_loan":1,"missing":0,"lost":0,"unknown":2,"out_of_scope":1,"duplicates":1,"scans":8,"mine":7},"pending_lost":0,"can_scan":false,"can_upload":false,"can_close":false,"can_cancel":false,"can_mark_lost":false},{"id":43,"name":"ASTD4A665 to cancel","status":"cancelled","status_label":"Annullata","status_color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"branch":null,"filter":{"shelf_id":null,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":[],"note":null,"expected_total":82,"opened_at":"2026-11-01T20:17:35+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":"2026-11-01T20:17:36+00:00","closed_by":{"id":113888,"name":"E2E ASTS"},"missing_action":null,"summary":{"expected":82,"found":0,"misplaced":0,"on_loan":2,"missing":80,"lost":0,"unknown":0,"out_of_scope":0,"duplicates":0,"scans":0,"mine":0},"pending_lost":0,"can_scan":false,"can_upload":false,"can_close":false,"can_cancel":false,"can_mark_lost":false}],"meta":{"count":3,"limit":30,"next_cursor":null,"has_more":false,"total_count":3},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_sessions_closed.json b/app/src/test/resources/server090/stocktake/s090_stocktake_sessions_closed.json new file mode 100644 index 0000000..d785482 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_sessions_closed.json @@ -0,0 +1 @@ +{"data":[{"id":41,"name":"ASTD4A665 shelf X","status":"closed","status_label":"Chiusa","status_color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":"2026-11-01T20:17:34+00:00","closed_by":{"id":113888,"name":"E2E ASTS"},"missing_action":"none","summary":{"expected":4,"found":2,"misplaced":1,"on_loan":1,"missing":0,"lost":0,"unknown":2,"out_of_scope":1,"duplicates":1,"scans":8,"mine":7},"pending_lost":0,"can_scan":false,"can_upload":false,"can_close":false,"can_cancel":false,"can_mark_lost":false}],"meta":{"count":1,"limit":30,"next_cursor":null,"has_more":false,"total_count":1},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_sessions_open.json b/app/src/test/resources/server090/stocktake/s090_stocktake_sessions_open.json new file mode 100644 index 0000000..1f275ac --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_sessions_open.json @@ -0,0 +1 @@ +{"data":[{"id":41,"name":"ASTD4A665 shelf X","status":"open","status_label":"Aperta","status_color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"branch":null,"filter":{"shelf_id":1504,"level_id":null,"genre_id":null,"media_type":null,"collocation_from":null,"collocation_to":null},"filter_labels":["Scaffale: [ASTD4A665X] Stocktake app X"],"note":"From the phone","expected_total":4,"opened_at":"2026-11-01T20:17:32+00:00","opened_by":{"id":113888,"name":"E2E ASTS"},"closed_at":null,"closed_by":null,"missing_action":null,"summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":1,"out_of_scope":1,"duplicates":1,"scans":6,"mine":5},"pending_lost":0,"can_scan":true,"can_upload":true,"can_close":true,"can_cancel":true,"can_mark_lost":false}],"meta":{"count":1,"limit":30,"next_cursor":null,"has_more":false,"total_count":1},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_state.json b/app/src/test/resources/server090/stocktake/s090_stocktake_state.json new file mode 100644 index 0000000..ce28211 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_state.json @@ -0,0 +1 @@ +{"data":{"id":41,"status":"open","summary":{"expected":4,"found":1,"misplaced":1,"on_loan":1,"missing":1,"lost":0,"unknown":1,"out_of_scope":1,"duplicates":1,"scans":6,"mine":5},"recent_scans":[{"id":97,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"duplicate","outcome_label":"Già scansionata","color":{"name":"gray","foreground":"#1f2937","background":"#f3f4f6"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":31650,"name":"E2E Audit"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":96,"code":"ASTD4A665-Y1","inventory_number":"ASTD4A665-Y1","outcome":"out_of_scope","outcome_label":"Fuori ambito","color":{"name":"purple","foreground":"#6b21a8","background":"#f3e8ff"},"copy_id":110855,"book_id":341189,"title":"ASTD4A665 B3","collocation":"ASTD4A665.B3","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":95,"code":"ASTD4A665-NOPE","inventory_number":"ASTD4A665-NOPE","outcome":"unknown","outcome_label":"Codice sconosciuto","color":{"name":"red","foreground":"#991b1b","background":"#fee2e2"},"copy_id":null,"book_id":null,"title":null,"collocation":null,"media_type":null,"copy_status":null,"copy_status_label":null,"off_shelf":false,"expected_shelf":null,"operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":94,"code":"ASTD4A665-L1","inventory_number":"ASTD4A665-L1","outcome":"on_loan","outcome_label":"In prestito o in attesa di ritiro","color":{"name":"blue","foreground":"#1e40af","background":"#dbeafe"},"copy_id":110854,"book_id":341188,"title":"ASTD4A665 B2","collocation":"ASTD4A665.B2","media_type":"libro","copy_status":"prestato","copy_status_label":"Prestato","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":93,"code":"ASTD4A665-A2","inventory_number":"ASTD4A665-A2","outcome":"misplaced","outcome_label":"Fuori posto","color":{"name":"amber","foreground":"#92400e","background":"#fef3c7"},"copy_id":110852,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"},{"id":92,"code":"ASTD4A665-A1","inventory_number":"ASTD4A665-A1","outcome":"found","outcome_label":"Trovata","color":{"name":"green","foreground":"#166534","background":"#dcfce7"},"copy_id":110851,"book_id":341187,"title":"ASTD4A665 B1","collocation":"ASTD4A665.B1","media_type":"libro","copy_status":"disponibile","copy_status_label":"Disponibile","off_shelf":false,"expected_shelf":"[ASTD4A665X] Stocktake app X","operator":{"id":113888,"name":"E2E ASTS"},"scanned_at":"2026-11-01T20:17:33+00:00"}]},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_upload.json b/app/src/test/resources/server090/stocktake/s090_stocktake_upload.json new file mode 100644 index 0000000..3766d0c --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_upload.json @@ -0,0 +1 @@ +{"data":{"total":2,"outcomes":[{"code":"found","label":"Trovata","count":1},{"code":"misplaced","label":"Fuori posto","count":0},{"code":"on_loan","label":"In prestito o in attesa di ritiro","count":0},{"code":"unknown","label":"Codice sconosciuto","count":1},{"code":"duplicate","label":"Già scansionata","count":0},{"code":"out_of_scope","label":"Fuori ambito","count":0}],"summary":{"expected":4,"found":2,"misplaced":1,"on_loan":1,"missing":0,"lost":0,"unknown":2,"out_of_scope":1,"duplicates":1,"scans":8,"mine":7},"message":"Elaborati 2 codici dal file."},"meta":{},"error":null} \ No newline at end of file diff --git a/app/src/test/resources/server090/stocktake/s090_stocktake_upload_invalid.json b/app/src/test/resources/server090/stocktake/s090_stocktake_upload_invalid.json new file mode 100644 index 0000000..3d02933 --- /dev/null +++ b/app/src/test/resources/server090/stocktake/s090_stocktake_upload_invalid.json @@ -0,0 +1 @@ +{"data":null,"meta":{"fields":{"file":"Il file deve essere un .txt o .csv di al massimo 2 MB."}},"error":{"code":"validation","message":"Il file deve essere un .txt o .csv di al massimo 2 MB."}} \ No newline at end of file diff --git a/i18n/de.json b/i18n/de.json index d8fd814..b7490a3 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -207,6 +207,7 @@ "profile_date_none": "Nicht festgelegt", "profile_card_number": "Ausweisnummer", "profile_card_expires": "Ausweis läuft ab", + "profile_card_barcode": "Barcode des Ausweises %1$s", "profile_change_password_title": "Passwort ändern", "profile_current_password": "Aktuelles Passwort", "profile_new_password": "Neues Passwort", @@ -269,6 +270,7 @@ "snackbar_request_for_date": "Ausleihe angefragt für %1$s", "snackbar_reservation_for_date": "Reservierung für den %1$s eingetragen", "snackbar_loan_ready_for_pickup": "Ausleihe genehmigt; zur Abholung bereit.", + "snackbar_loan_scheduled": "Ausleihe genehmigt: Sie können das Buch ab dem %1$s abholen.", "book_section_audiobook": "Hörbuch", "audio_play": "Abspielen", "audio_pause": "Pause", @@ -993,6 +995,8 @@ "staff_scan_next": "Nächstes Exemplar scannen", "staff_loan_new_with_copy": "Neue Ausleihe mit diesem Exemplar", "staff_loan_open": "Ausleihe öffnen", + "staff_reservation_override_limits": "Vormerkungsgrenzen der Leserkategorie ignorieren", + "staff_reservation_override_limits_hint": "Wie im Formular der Website: Die Vormerkung wird auch über die Grenzen der Leserkategorie hinaus angelegt.", "staff_hand_over_now": "Jetzt aushändigen", "staff_hand_over_now_hint": "Der Nutzer nimmt das Exemplar an der Theke mit: die Ausleihe läuft sofort.", "staff_hand_over_future": "Eine später beginnende Ausleihe wird geplant und an diesem Tag ausgehändigt.", @@ -1453,5 +1457,651 @@ "digital_locked_login": "Zum Öffnen anmelden", "digital_locked_loan": "Für Leser, die dieses Buch ausgeliehen haben", "digital_locked_other": "Für dein Konto nicht verfügbar", - "digital_link_refresh_error": "Der Link konnte nicht aktualisiert werden. Bitte versuche es erneut." + "digital_link_refresh_error": "Der Link konnte nicht aktualisiert werden. Bitte versuche es erneut.", + "book_loan_legend_closed": "Bibliothek geschlossen", + "book_loan_closed_day": "Am %1$s ist die Bibliothek geschlossen: Wähle einen anderen Tag.", + "book_consultation_only": "Nur zur Einsicht", + "book_consultation_only_hint": "Du kannst diesen Titel in der Bibliothek einsehen, aber mit deinem Ausweis nicht ausleihen.", + "book_reservations_disabled_hint": "Gerade ist kein Exemplar frei, und mit deinem Ausweis sind keine Vormerkungen möglich. Versuche es wieder, wenn ein Exemplar zurück ist.", + "request_err_not_loanable": "Dieser Titel ist für deinen Ausweis nur zur Einsicht: Er kann nicht ausgeliehen werden.", + "request_err_reservations_disabled": "Mit deinem Ausweis sind keine Vormerkungen möglich.", + "request_err_reservation_limit": "Du hast die maximale Anzahl an Vormerkungen erreicht.", + "request_err_media_limit": "Du hast die maximale Anzahl an Ausleihen für diese Medienart erreicht.", + "request_err_max_loans": "Du hast die maximale Anzahl aktiver Ausleihen erreicht.", + "request_err_debt_block": "Ausleihen sind gesperrt, bis dein Kontostand bei der Bibliothek ausgeglichen ist.", + "request_err_card_expired": "Dein Bibliotheksausweis ist abgelaufen: Bitte die Bibliothek um eine Verlängerung.", + "request_err_suspended": "Dein Konto ist gesperrt: Wende dich an die Bibliothek.", + "staff_err_media_limit": "Diese Person hat die maximale Anzahl an Ausleihen für diese Medienart erreicht.", + "staff_err_not_loanable": "Dieses Medium ist für die Leserkategorie dieser Person nur zur Einsicht.", + "staff_err_reservations_disabled": "Für die Leserkategorie dieser Person sind keine Vormerkungen erlaubt.", + "staff_err_reservation_limit": "Diese Person hat die maximale Anzahl an Vormerkungen erreicht.", + "staff_err_debt_block": "Ausleihen gesperrt: Der Kontostand dieser Person liegt über der Schuldengrenze.", + "staff_err_card_expired": "Der Bibliotheksausweis dieser Person ist abgelaufen.", + "staff_err_user_suspended": "Diese Person ist gesperrt.", + "staff_copy_consultation_only": "Nur zur Einsicht", + "staff_copy_consultation_switch": "Nur zur Einsicht vor Ort", + "staff_copy_consultation_switch_hint": "Das Exemplar wird nie ausgeliehen. Wie auf der Buchseite: nicht möglich, solange das Exemplar ausgeliehen, zur Abholung bereit oder angefragt ist.", + "profile_patron_type": "Leserkategorie", + "profile_action_movements": "Kontostand und Bewegungen", + "title_movements": "Kontostand und Bewegungen", + "movements_loading": "Kontostand wird geladen…", + "movements_error_load": "Der Kontostand konnte nicht geladen werden.", + "movements_balance": "Kontostand", + "movements_balance_owed": "Betrag, den du der Bibliothek schuldest.", + "movements_balance_credit": "Guthaben zu deinen Gunsten.", + "movements_balance_settled": "Nichts zu bezahlen.", + "movements_debt_blocked_title": "Ausleihen gesperrt", + "movements_debt_blocked_body": "Dein Kontostand liegt über der Grenze der Bibliothek von %1$s: Neue Ausleihen und Verlängerungen sind gesperrt, bis er ausgeglichen ist.", + "movements_debt_blocked_body_plain": "Neue Ausleihen und Verlängerungen sind gesperrt, bis dein Kontostand ausgeglichen ist.", + "movements_accrued_title": "Laufende Gebühren für überfällige Ausleihen: %1$s", + "movements_accrued_row": "Fällig am %1$s · Tage überfällig: %2$d · wird bei Rückgabe berechnet", + "movements_history_title": "Bewegungen", + "movements_empty_title": "Keine Bewegungen", + "movements_empty_subtitle": "Hier erscheinen Gebühren, Zahlungen und Erlasse.", + "movements_untitled": "Ausleihe", + "movements_refresh_failed": "Aktualisierung fehlgeschlagen: Der angezeigte Kontostand ist möglicherweise nicht aktuell.", + "staff_loan_closed_day": "Am %1$s ist die Bibliothek geschlossen: Wähle ein anderes Start- oder Rückgabedatum.", + "staff090_section_desk": "Theke", + "staff090_section_cataloguing": "Katalogisierung", + "staff090_section_library": "Bibliothek", + "staff090_attendance": "Besucherprotokoll", + "staff090_attendance_subtitle": "Ausweise am Eingang scannen, Gäste erfassen", + "staff090_stocktake": "Inventur", + "staff090_stocktake_subtitle": "Regale mit der Kamera prüfen", + "staff090_user_import": "Leser importieren", + "staff090_user_import_subtitle": "Aus einer CSV-Datei", + "staff090_marc": "MARC-Import und -Export", + "staff090_marc_subtitle": "ISO 2709, MARCXML, UNIMARC", + "staff090_copy_cataloging": "Fremddatenübernahme", + "staff090_copy_cataloging_subtitle": "In anderen Katalogen suchen", + "staff090_suppliers": "Lieferanten", + "staff090_suppliers_subtitle": "Buchhandlungen und Spender", + "staff090_statistics": "Statistik", + "staff090_statistics_subtitle": "Ausleihen, Leser, Besuche, Gebühren", + "staff090_calendar": "Schließtage", + "staff090_calendar_subtitle": "Schließtage und Feiertage", + "staff090_circulation_rules": "Lesergruppen und Ausleihregeln", + "staff090_circulation_rules_subtitle": "Grenzen nach Lesergruppe und Medium", + "staff090_settings": "Bibliothekseinstellungen", + "staff090_settings_subtitle": "Module, Ausleihe, Ausweise", + "staff090_ledger": "Saldo und Zahlungen", + "staff090_patrons_section": "Leser", + "staff090_patrons_tools_hint": "Foto und Leseausweis dieses Lesers.", + "staff090_patrons_type": "Lesertyp", + "staff090_patrons_type_default": "%1$s (Standard)", + "staff090_patrons_type_card_days": "Gültigkeit des Ausweises (Tage): %1$d", + "staff090_patrons_change_type": "Lesertyp ändern", + "staff090_patrons_recompute_expiry": "Ablaufdatum aus dem Typ berechnen", + "staff090_patrons_recompute_expiry_hint": "Heute plus die Ausweisgültigkeit des gewählten Typs, falls festgelegt.", + "staff090_patrons_type_saved": "Lesertyp gespeichert", + "staff090_patrons_type_saved_expiry": "Lesertyp gespeichert, Ablaufdatum aktualisiert", + "staff090_patrons_renew_card": "Ausweis verlängern", + "staff090_patrons_renew_body": "Der Ausweis wird um die Dauer des Lesertyps verlängert (ein Jahr, wenn keine festgelegt ist). Ein abgelaufenes Konto wird wieder aktiv.", + "staff090_patrons_card_renewed": "Ausweis verlängert bis %1$s", + "staff090_patrons_print_card": "Ausweis drucken", + "staff090_patrons_card_ready": "Leseausweis bereit", + "staff090_patrons_photo": "Foto des Lesers", + "staff090_patrons_photo_take": "Foto aufnehmen", + "staff090_patrons_photo_replace": "Foto ersetzen", + "staff090_patrons_photo_pick": "Bild wählen", + "staff090_patrons_photo_remove": "Foto entfernen", + "staff090_patrons_photo_remove_body": "Das Foto des Lesers wird vom Server gelöscht.", + "staff090_patrons_photo_saved": "Foto gespeichert", + "staff090_patrons_photo_removed": "Foto entfernt", + "staff090_patrons_photo_unreadable": "Dieses Bild kann nicht gelesen werden.", + "staff090_patrons_balance_line": "Saldo: %1$s", + "staff090_patrons_debt_blocked": "Wegen Schulden gesperrt", + "staff090_patrons_record_payment": "Zahlung erfassen", + "staff090_patrons_add_charge": "Belastung hinzufügen", + "staff090_patrons_waive": "Erlassen", + "staff090_patrons_amount": "Betrag (%1$s)", + "staff090_patrons_amount_invalid": "Gib einen Betrag über null mit höchstens zwei Nachkommastellen ein.", + "staff090_patrons_amount_too_large": "Höchstens %1$s", + "staff090_patrons_method": "Zahlungsart", + "staff090_patrons_note_optional": "Notiz (optional)", + "staff090_patrons_description": "Beschreibung", + "staff090_patrons_waive_reason": "Grund für den Erlass", + "staff090_patrons_reason_required": "Ein Grund ist erforderlich.", + "staff090_patrons_payment_recorded": "Zahlung erfasst", + "staff090_patrons_charge_recorded": "Belastung hinzugefügt", + "staff090_patrons_waiver_recorded": "Erlass erfasst", + "staff090_patrons_recorded_by": "Erfasst von %1$s", + "staff090_patrons_receipt": "Quittung", + "staff090_patrons_receipt_loading": "Wird geladen…", + "staff090_patrons_receipt_ready": "Zahlungsquittung bereit", + "staff090_patrons_return_fine": "Säumnisgebühr: %1$s (Verspätung in Tagen: %2$d)", + "staff090_patrons_return_waive": "Säumnisgebühr erlassen", + "staff090_patrons_tab_desk": "Eingang", + "staff090_patrons_tab_stats": "Statistik", + "staff090_patrons_scan_permission": "Erlaube die Kamera, um Leseausweise zu scannen. Du kannst den Code auch unten eingeben.", + "staff090_patrons_scan_hint": "Halte den Barcode oder QR-Code des Ausweises in den Rahmen.", + "staff090_patrons_card_code": "Ausweiscode", + "staff090_patrons_card_code_required": "Gib den Ausweiscode ein.", + "staff090_patrons_check_in": "Besuch erfassen", + "staff090_patrons_branch": "Zweigstelle", + "staff090_patrons_branch_none": "Nicht angegeben", + "staff090_patrons_card_line": "Ausweis %1$s", + "staff090_patrons_card_expired": "Ausweis abgelaufen am %1$s", + "staff090_patrons_card_not_active": "Das Konto ist gesperrt oder abgelaufen.", + "staff090_patrons_guest": "Gast erfassen", + "staff090_patrons_guest_name": "Name (optional)", + "staff090_patrons_guest_reason": "Grund des Besuchs (optional)", + "staff090_patrons_guest_save": "Besuch erfassen", + "staff090_patrons_guest_anonymous": "Gast", + "staff090_patrons_member": "Mitglied", + "staff090_patrons_today_counts": "Heute: Mitglieder %1$d, Gäste %2$d", + "staff090_patrons_period_days": "%1$d T.", + "staff090_patrons_today": "Heute", + "staff090_patrons_period_total": "Zeitraum", + "staff090_patrons_counts": "Gesamt %1$d · Mitglieder %2$d · Gäste %3$d", + "staff090_patrons_by_day": "Nach Tag", + "staff090_patrons_by_hour": "Nach Uhrzeit", + "staff090_patrons_no_visits": "Keine Besuche in diesem Zeitraum.", + "staff090_patrons_recent": "Letzte Besuche", + "staff090_patrons_export_csv": "Als CSV exportieren", + "staff090_patrons_retention": "Aufbewahrung", + "staff090_patrons_retention_hint": "Besuche, die älter als diese Anzahl Tage sind, werden bei der Wartung gelöscht (0 = immer behalten, höchstens %1$d).", + "staff090_patrons_retention_days": "Besuche behalten (Tage)", + "staff090_patrons_retention_invalid": "Gib eine ganze Zahl von 0 bis %1$d ein.", + "staff090_patrons_retention_saved": "Aufbewahrung gespeichert", + "staff090_patrons_purge": "Alte Besuche jetzt löschen", + "staff090_patrons_purge_body": "Besuche, die älter als die Aufbewahrungsfrist sind (Tage: %1$d), werden jetzt gelöscht. Das kann nicht rückgängig gemacht werden.", + "staff090_patrons_purged": "Gelöschte Besuche: %1$d", + "staff090_patrons_import_intro": "Leserkonten aus einer CSV-Datei anlegen. Zeilen mit bereits vorhandener E-Mail oder Ausweisnummer werden übersprungen; Passwörter werden nie importiert.", + "staff090_patrons_delimiter": "Trennzeichen", + "staff090_patrons_delimiter_auto": "Erkennen", + "staff090_patrons_delimiter_semicolon": "Semikolon", + "staff090_patrons_delimiter_comma": "Komma", + "staff090_patrons_delimiter_tab": "Tabulator", + "staff090_patrons_import_upload": "Datei hochladen", + "staff090_patrons_import_too_big": "Die Datei ist größer als 5 MB.", + "staff090_patrons_import_wrong_ext": "Wähle eine .csv-, .tsv- oder .txt-Datei.", + "staff090_patrons_import_ready": "Spalten prüfen, dann starten", + "staff090_patrons_import_rows": "Zeilen in der Datei", + "staff090_patrons_import_preview": "Vorschau", + "staff090_patrons_import_mapping": "Spalten", + "staff090_patrons_import_column": "Spalte %1$d", + "staff090_patrons_import_ignore": "Ignorieren", + "staff090_patrons_import_missing": "Pflichtfelder zuordnen: %1$s", + "staff090_patrons_import_invites": "Einladung per E-Mail senden", + "staff090_patrons_import_invites_hint": "Jeder neue Leser erhält den Link zum Festlegen eines Passworts.", + "staff090_patrons_import_start": "Import starten", + "staff090_patrons_import_created": "Angelegte Konten", + "staff090_patrons_import_duplicates": "Übersprungene Duplikate", + "staff090_patrons_import_failed": "Fehlerhafte Zeilen", + "staff090_patrons_import_invited": "Gesendete Einladungen", + "staff090_patrons_import_invite_failed": "Nicht gesendete Einladungen", + "staff090_patrons_import_error_line": "Zeile %1$d: %2$s", + "staff090_patrons_import_cancelled": "Import abgebrochen", + "staff090_patrons_import_new": "Neuer Import", + "staff090_patrons_err_card_not_renewable": "Dieses Konto hat keinen Ausweis zum Verlängern.", + "staff090_patrons_err_amount_exceeds_balance": "Der Betrag übersteigt die Schuld des Lesers.", + "staff090_patrons_err_card_not_found": "Ausweis nicht erkannt", + "staff090_patrons_err_not_started": "Starte den Import, bevor Zeilen verarbeitet werden.", + "staff090_patrons_err_already_started": "Dieser Import wurde bereits gestartet.", + "staff090_patrons_err_job_cancelled": "Der Import wurde während der Verarbeitung abgebrochen.", + "staff090_patrons_err_file_missing": "Die hochgeladene Datei ist nicht mehr auf dem Server: lade sie erneut hoch.", + "staff090_patrons_err_import_not_finished": "Der Fehlerbericht ist nach dem Import verfügbar.", + "staff090_patrons_err_db_update_pending": "Verfügbar, sobald das Datenbank-Update der Bibliothek gelaufen ist.", + "staff090_library_active": "Aktiv", + "staff090_library_admin_only": "Nur Administratoren können das ändern.", + "staff090_library_all_branches": "Alle Standorte", + "staff090_library_all_types": "Alle Lesertypen", + "staff090_library_apply": "Übernehmen", + "staff090_library_branch": "Standort", + "staff090_library_branch_preview": "Kalender des Standorts", + "staff090_library_calendar_module_off": "Der Schließtagekalender ist ausgeschaltet: Was hier eingestellt wird, bleibt gespeichert, aber Fristen berücksichtigen es erst, wenn das Modul unter Einstellungen → Ausleihe eingeschaltet ist.", + "staff090_library_card_back_text": "Text auf der Rückseite", + "staff090_library_card_back_text_hint": "Öffnungszeiten, Regeln, Kontakte. Leer = leere Rückseite.", + "staff090_library_card_code_barcode": "Strichcode (Code 128)", + "staff090_library_card_code_qr": "QR-Code", + "staff090_library_card_code_type": "Code auf dem Ausweis", + "staff090_library_card_format": "Druckformat", + "staff090_library_card_format_sheet": "A4-Bogen mit Ausweisen", + "staff090_library_card_format_single": "Ein Ausweis pro Seite", + "staff090_library_card_layout": "Layout", + "staff090_library_card_layout_classic": "Klassisch", + "staff090_library_card_layout_minimal": "Schlicht", + "staff090_library_card_layout_portrait": "Hochformat", + "staff090_library_card_section": "Leseausweis", + "staff090_library_card_show_expiry": "Ablaufdatum anzeigen", + "staff090_library_card_show_logo": "Bibliothekslogo anzeigen", + "staff090_library_card_show_photo": "Foto des Lesers anzeigen", + "staff090_library_card_title": "Titel des Ausweises", + "staff090_library_card_title_hint": "Leer: der Name der Bibliothek.", + "staff090_library_cards_layout_section": "Layout und Code", + "staff090_library_cards_module_off": "Der Ausweisdruck ist ausgeschaltet: Die Einstellungen lassen sich vorbereiten, gedruckt wird, sobald das Modul an ist.", + "staff090_library_cards_show_section": "Auf dem Ausweis", + "staff090_library_cards_text_section": "Texte", + "staff090_library_catalogue_mode": "Der Katalogmodus ist aktiv: Die Ausleihmodule bleiben aus, unabhängig von ihrem Schalter.", + "staff090_library_cell_custom": "Eigene Regel", + "staff090_library_cell_duration": "Tage: %1$d", + "staff090_library_cell_fine": "Gebühr pro Tag: %1$s", + "staff090_library_cell_loanable": "Ausleihbar", + "staff090_library_cell_max": "Max.: %1$d", + "staff090_library_cell_not_loanable": "Nicht ausleihbar", + "staff090_library_cell_pending": "Nicht gespeichert", + "staff090_library_cell_renewals": "Verlängerungen: %1$d", + "staff090_library_cell_title": "%1$s · %2$s", + "staff090_library_close": "Schließen", + "staff090_library_closure_delete": "Schließung löschen?", + "staff090_library_closure_delete_body": "%1$s wird wieder ein Öffnungstag; Fristen werden neu berechnet.", + "staff090_library_closure_edit": "Schließung bearbeiten", + "staff090_library_closure_end": "Letzter Tag (optional)", + "staff090_library_closure_end_before": "Der letzte Tag kann nicht vor dem ersten liegen.", + "staff090_library_closure_end_hint": "Für einen einzelnen Tag den letzten Tag leer lassen.", + "staff090_library_closure_new": "Schließung hinzufügen", + "staff090_library_closure_preset": "Feiertag", + "staff090_library_closure_start": "Erster Tag", + "staff090_library_closure_untitled": "Schließung", + "staff090_library_closure_yearly": "Jedes Jahr", + "staff090_library_closure_yearly_hint": "Wiederholt sich jedes Jahr an denselben Daten (höchstens %1$d Tage lang).", + "staff090_library_closure_yearly_switch": "Jedes Jahr wiederholen", + "staff090_library_closures": "Schließungen", + "staff090_library_closures_empty": "Noch keine Schließungen.", + "staff090_library_country": "Land", + "staff090_library_country_search": "Land suchen", + "staff090_library_custom": "Eigene Feiertage", + "staff090_library_custom_calendar": "Kalender", + "staff090_library_custom_calendar_hint": "Der islamische Kalender folgt den Umm-al-Qura-Tafeln: Wo der Monatsbeginn von der örtlichen Mondsichtung abhängt, kann sich das Datum um einen Tag verschieben. Dann bestimmte Daten verwenden.", + "staff090_library_custom_dates": "Daten (JJJJ-MM-TT, eines pro Zeile)", + "staff090_library_custom_dates_hint": "Höchstens %1$d Daten.", + "staff090_library_custom_day": "Tag", + "staff090_library_custom_delete": "Feiertag löschen?", + "staff090_library_custom_delete_body": "%1$s schließt die Bibliothek nicht mehr.", + "staff090_library_custom_duration": "Dauer (Tage)", + "staff090_library_custom_edit": "Feiertag bearbeiten", + "staff090_library_custom_help": "Eigene Feiertage: Stadtpatron, ein lokales Fest, ein religiöser Feiertag aus einem anderen Kalender.", + "staff090_library_custom_kind": "Berechnung", + "staff090_library_custom_month": "Monat", + "staff090_library_custom_more": "Dauer und Gültigkeit", + "staff090_library_custom_name_hint": "z. B. Stadtpatron", + "staff090_library_custom_new": "Feiertag hinzufügen", + "staff090_library_custom_next": "Nächste: %1$s", + "staff090_library_custom_occurrence": "Welcher", + "staff090_library_custom_offset": "Tage ab Ostersonntag", + "staff090_library_custom_offset_hint": "0 = Ostersonntag; 1 = der Montag danach; -2 = Karfreitag; 49 = Pfingsten.", + "staff090_library_custom_weekday": "Wochentag", + "staff090_library_custom_weekend": "Fällt er aufs Wochenende", + "staff090_library_custom_year_from": "Ab Jahr", + "staff090_library_custom_year_until": "Bis Jahr", + "staff090_library_data_years": "Die Feiertagsdaten decken die Jahre %1$d–%2$d ab.", + "staff090_library_day_closed": "Die Bibliothek ist geschlossen.", + "staff090_library_day_open": "Die Bibliothek ist geöffnet.", + "staff090_library_delete": "Löschen", + "staff090_library_deleted": "Gelöscht", + "staff090_library_discard": "Verwerfen", + "staff090_library_err_always_closed": "Mit dieser Änderung wäre die Bibliothek das ganze Jahr geschlossen: Mindestens ein Tag muss offen bleiben.", + "staff090_library_err_ics_too_large": "Die ICS-Datei ist größer als 1 MB.", + "staff090_library_err_preset_closure": "Nationale Feiertage werden über die Feiertagsauswahl geändert.", + "staff090_library_err_schema_not_ready": "Verfügbar, sobald das Datenbank-Update auf dem Server gelaufen ist.", + "staff090_library_fines_admin_only": "Nur Administratoren können Gebühren und die Schuldensperre ändern.", + "staff090_library_grace_deduct": "Abzug: Toleranztage immer abziehen", + "staff090_library_grace_threshold": "Schwelle: nach der Toleranz ab dem ersten Tag zählen", + "staff090_library_group_loans": "Ausleihe", + "staff090_library_group_modules": "Module", + "staff090_library_holiday_estimated": "geschätzt", + "staff090_library_holiday_optional": "optional", + "staff090_library_holiday_regional": "regional", + "staff090_library_holiday_yearly": "jedes Jahr am selben Datum", + "staff090_library_holidays": "Feiertage", + "staff090_library_holidays_apply": "Feiertage übernehmen (%1$d)", + "staff090_library_holidays_help": "Feiertage ankreuzen, an denen die Bibliothek geschlossen ist. Sie werden jedes Jahr neu berechnet.", + "staff090_library_holidays_national": "Gesetzliche Feiertage", + "staff090_library_holidays_none": "Noch keine gesetzlichen Feiertage gewählt", + "staff090_library_holidays_saved": "%1$s · Feiertage: %2$d", + "staff090_library_ics": "Aus einer Kalenderdatei importieren", + "staff090_library_ics_added": "Hinzugefügte Schließungen: %1$d", + "staff090_library_ics_failed": "Die Kalenderdatei konnte nicht importiert werden.", + "staff090_library_ics_file": "ICS-Datei", + "staff090_library_ics_help": "Jedes Ereignis der Datei wird eine Schließung. Höchstens 1 MB.", + "staff090_library_ics_import": "Importieren", + "staff090_library_ics_read_failed": "Die Datei konnte nicht gelesen werden.", + "staff090_library_inactive": "Aus", + "staff090_library_inherit_all": "Alles erben", + "staff090_library_inherit_note": "Ein leeres Feld übernimmt den Wert der Ebene darüber.", + "staff090_library_inherits": "Leer: %1$s", + "staff090_library_kind_calendar": "In einem anderen Kalender", + "staff090_library_kind_dates": "Bestimmte Daten", + "staff090_library_kind_easter": "Relativ zu Ostern", + "staff090_library_kind_fixed": "Jedes Jahr am selben Tag", + "staff090_library_kind_nth_weekday": "Ein Wochentag im Monat (z. B. der erste Montag)", + "staff090_library_kind_orthodox_easter": "Relativ zum orthodoxen Ostern", + "staff090_library_legend_closed": "Geschlossen", + "staff090_library_loan_auto_approve": "Anfragen automatisch genehmigen", + "staff090_library_loan_auto_approve_help": "Ausleihanfragen überspringen die Genehmigung und warten sofort auf Abholung.", + "staff090_library_loan_card_renewal_base": "Ausweis verlängern ab", + "staff090_library_loan_card_warning": "Hinweis zum Ausweisablauf (Tage vorher)", + "staff090_library_loan_card_warning_help": "Der Leser erhält vor Ablauf eine E-Mail. 0 = kein Hinweis.", + "staff090_library_loan_daily_fine": "Tägliche Gebühr", + "staff090_library_loan_daily_fine_help": "Globaler Tagessatz. Leer oder 0 = kein globaler Satz; Lesertypen können eigene festlegen.", + "staff090_library_loan_debt_block": "Schwelle der Schuldensperre", + "staff090_library_loan_debt_block_help": "Globale Schuldenschwelle. Leer oder 0 = keine globale Sperre.", + "staff090_library_loan_duration": "Standard-Leihdauer (Tage)", + "staff090_library_loan_duration_help": "Gilt ohne Rückgabedatum: Leseranfragen, Ausleihen an der Theke, genehmigte Vormerkungen.", + "staff090_library_loan_fine_email": "Leser per E-Mail informieren, wenn eine Gebühr anfällt", + "staff090_library_loan_fine_email_help": "Verwendet die E-Mail-Vorlage „Säumnisgebühr berechnet“.", + "staff090_library_loan_grace_days": "Toleranztage", + "staff090_library_loan_grace_days_help": "Eine Verspätung bis zu dieser Anzahl Tage wird nicht berechnet.", + "staff090_library_loan_grace_mode": "Toleranzmodus", + "staff090_library_loan_max_active": "Aktive Ausleihen pro Leser", + "staff090_library_loan_max_active_help": "0 = keine Grenze. Gezählt: vorgemerkt, abholbereit, ausgeliehen, überfällig.", + "staff090_library_loan_max_duration": "Längste anfragbare Leihdauer (Tage)", + "staff090_library_loan_max_duration_help": "Längere Anfragen und Vormerkungen werden abgelehnt.", + "staff090_library_loan_max_fine": "Höchstgebühr pro Ausleihe", + "staff090_library_loan_max_fine_help": "Obergrenze der Gebühr einer Ausleihe. Leer oder 0 = keine Grenze.", + "staff090_library_loan_max_renewals": "Verlängerungen pro Ausleihe", + "staff090_library_loan_max_renewals_help": "0 schaltet Verlängerungen ab.", + "staff090_library_loan_multiple_copies": "Mehrere Exemplare desselben Titels", + "staff090_library_loan_multiple_copies_help": "Ein Leser darf dasselbe Buch mehrfach ausleihen, jede Ausleihe auf einem anderen Exemplar.", + "staff090_library_loan_pickup_days": "Tage zur Abholung einer genehmigten Ausleihe", + "staff090_library_loan_pickup_days_help": "Danach verfällt die genehmigte Ausleihe.", + "staff090_library_loan_pickup_reminder": "Abholerinnerung (Tage vorher)", + "staff090_library_loan_pickup_reminder_help": "Wird gesendet, wenn das Buch noch nicht abgeholt wurde.", + "staff090_library_loan_recall_interval": "Tage zwischen Mahnungen", + "staff090_library_loan_recall_max": "Automatische Mahnungen, höchstens", + "staff090_library_loan_recalls": "Automatische Mahnungen", + "staff090_library_loan_recalls_help": "Wiederholte E-Mails für überfällige, nicht zurückgegebene Ausleihen.", + "staff090_library_loan_timezone": "Zeitzone", + "staff090_library_loan_timezone_help": "Gilt für Fristen, Aktivierungen und Automatiken: die Zeitzone der Bibliothek.", + "staff090_library_loan_unknown": "Einstellung", + "staff090_library_loanable_inherit": "Erben (%1$s)", + "staff090_library_loans_approval": "Genehmigung", + "staff090_library_loans_card": "Leseausweis", + "staff090_library_loans_duration": "Dauer", + "staff090_library_loans_fines": "Gebühren und Schuldensperre", + "staff090_library_loans_limits": "Grenzen", + "staff090_library_loans_pickup": "Abholung", + "staff090_library_loans_recalls": "Mahnungen", + "staff090_library_loans_renewals": "Verlängerungen", + "staff090_library_loans_section": "Ausleihe", + "staff090_library_matrix": "Regeln nach Medienart", + "staff090_library_matrix_empty": "Keine Medienarten zu konfigurieren", + "staff090_library_matrix_intro": "Jede Zelle ersetzt den Lesertyp für eine Medienart; „Alle Lesertypen“ gilt für jeden Typ, der einen Wert leer lässt.", + "staff090_library_matrix_subtitle": "Grenzen je Lesertyp und Medienart", + "staff090_library_module_catalogue_off": "Im Katalogmodus aus.", + "staff090_library_module_follows": "Bis zum eigenen Speichern folgt es „%1$s“.", + "staff090_library_module_needs": "Benötigt „%1$s“.", + "staff090_library_module_needs_off": "Inaktiv: „%1$s“ ist aus.", + "staff090_library_modules_read_only": "Nur Administratoren können Module ein- oder ausschalten.", + "staff090_library_money_invalid": "Betrag wie 1,50 eingeben", + "staff090_library_money_label": "%1$s (%2$s)", + "staff090_library_move_down": "Nach unten", + "staff090_library_move_up": "Nach oben", + "staff090_library_no": "Nein", + "staff090_library_nth_1": "Der erste", + "staff090_library_nth_2": "Der zweite", + "staff090_library_nth_3": "Der dritte", + "staff090_library_nth_4": "Der vierte", + "staff090_library_nth_5": "Der fünfte", + "staff090_library_nth_last": "Der letzte", + "staff090_library_num_card_duration": "Gültigkeit des Ausweises (Tage)", + "staff090_library_num_daily_fine": "Tägliche Gebühr", + "staff090_library_num_debt_block": "Schwelle der Schuldensperre", + "staff090_library_num_grace_days": "Toleranztage", + "staff090_library_num_loan_duration": "Leihdauer (Tage)", + "staff090_library_num_max_fine": "Höchstgebühr pro Ausleihe", + "staff090_library_num_max_loans": "Aktive Ausleihen höchstens", + "staff090_library_num_max_renewals": "Verlängerungen höchstens", + "staff090_library_num_max_request": "Längste Anfrage (Tage)", + "staff090_library_num_max_reservations": "Vormerkungen höchstens", + "staff090_library_num_renewal_days": "Tage pro Verlängerung", + "staff090_library_off": "Aus", + "staff090_library_on": "An", + "staff090_library_preview_year": "Jahr %1$d", + "staff090_library_range": "Von %1$d bis %2$d.", + "staff090_library_reason_closure": "Schließung: %1$s", + "staff090_library_reason_custom": "Eigener Feiertag: %1$s", + "staff090_library_reason_holiday": "Feiertag: %1$s", + "staff090_library_reason_other": "Geschlossen: %1$s", + "staff090_library_reason_weekly": "Wöchentlicher Schließtag: %1$s", + "staff090_library_region": "Region", + "staff090_library_region_all": "Ganzes Land", + "staff090_library_renewal_expiry": "Dem aktuellen Ablauf, falls in der Zukunft; sonst heute", + "staff090_library_renewal_today": "Heute", + "staff090_library_reservations_section": "Vormerkungen", + "staff090_library_retention_days": "Aufbewahrung der Besucherliste (Tage)", + "staff090_library_retention_hint": "0 = nie automatisch gelöscht. Höchstens %1$d.", + "staff090_library_rules_module_off": "Die Regeln nach Lesertyp sind ausgeschaltet: Sie lassen sich hier vorbereiten und gelten, sobald das Modul unter Einstellungen → Ausleihe an ist.", + "staff090_library_save_cells": "Speichern (%1$d)", + "staff090_library_save_weekdays": "Schließtage speichern", + "staff090_library_saved": "Gespeichert", + "staff090_library_tab_cards": "Ausweise", + "staff090_library_tab_loans": "Ausleihe", + "staff090_library_tab_modules": "Module", + "staff090_library_type_active": "Aktiv", + "staff090_library_type_active_hint": "Ein inaktiver Typ wird neuen Lesern nicht mehr angeboten.", + "staff090_library_type_code": "Code", + "staff090_library_type_code_hint": "Kleinbuchstaben, Ziffern, - und _ (höchstens 40). Leer: aus dem Namen gebildet.", + "staff090_library_type_default": "Standard", + "staff090_library_type_default_hint": "Neue Leser erhalten diesen Typ.", + "staff090_library_type_delete": "Lesertyp löschen?", + "staff090_library_type_delete_body": "%1$s wird gelöscht, seine Leser erhalten den Standardtyp. Leser: %2$d", + "staff090_library_type_deleted": "%1$s gelöscht. Leser zum Standardtyp verschoben: %2$d", + "staff090_library_type_edit": "Lesertyp bearbeiten", + "staff090_library_type_inactive": "Inaktiv", + "staff090_library_type_make_default": "Als Standard festlegen", + "staff090_library_type_new": "Neuer Lesertyp", + "staff090_library_type_reservations": "Vormerkungen erlaubt", + "staff090_library_type_users": "Leser: %1$d", + "staff090_library_types": "Lesertypen", + "staff090_library_weekdays_all_closed": "Mindestens ein Wochentag muss offen bleiben.", + "staff090_library_weekend_next_monday": "Auch den folgenden Montag schließen", + "staff090_library_weekend_none": "Kein zusätzlicher Tag", + "staff090_library_weekend_us": "Samstag: auch den Freitag davor; Sonntag: auch den Montag danach", + "staff090_library_weekly": "Wöchentliche Schließtage", + "staff090_library_weekly_help": "Die Tage, an denen die Bibliothek jede Woche geschlossen ist.", + "staff090_library_year_next": "Nächstes Jahr", + "staff090_library_year_prev": "Vorheriges Jahr", + "staff090_library_yearly": "Jährliche Schließungen", + "staff090_library_yearly_help": "Eine jährliche Schließung ausschalten, um sie ohne Löschen auszusetzen.", + "staff090_library_yes": "Ja", + "staff090_catalogue_card": "Katalogkarte (PDF)", + "staff090_catalogue_cards_print": "Katalogkarten drucken (%1$d)", + "staff090_catalogue_cards_limit": "Karten pro PDF: höchstens %1$d. Die zuerst ausgewählten werden gedruckt.", + "staff090_catalogue_in_catalogue": "Bereits im Katalog", + "staff090_catalogue_supplier": "Lieferant", + "staff090_catalogue_supplier_new": "Neuer Lieferant", + "staff090_catalogue_supplier_edit": "Lieferant bearbeiten", + "staff090_catalogue_supplier_delete": "Lieferant löschen", + "staff090_catalogue_supplier_delete_confirm": "„%1$s“ löschen?", + "staff090_catalogue_supplier_delete_books": "„%1$s“ löschen? Bücher, die diesen Lieferanten verlieren: %2$d. Die Bücher bleiben im Katalog.", + "staff090_catalogue_supplier_deleted": "Lieferant gelöscht. Getrennte Bücher: %1$d", + "staff090_catalogue_supplier_saved": "Lieferant gespeichert", + "staff090_catalogue_supplier_search": "Lieferanten suchen", + "staff090_catalogue_supplier_books": "Bücher: %1$d", + "staff090_catalogue_supplier_contact": "Ansprechperson", + "staff090_catalogue_supplier_vat": "USt-IdNr.", + "staff090_catalogue_suppliers_empty": "Noch keine Lieferanten", + "staff090_catalogue_rda_title": "RDA-Typen", + "staff090_catalogue_rda_hint": "Lassen Sie „Abgeleitet“, außer der Datensatz braucht einen anderen RDA-Typ.", + "staff090_catalogue_rda_content": "Inhaltstyp", + "staff090_catalogue_rda_media": "Medientyp (RDA)", + "staff090_catalogue_rda_carrier": "Datenträgertyp", + "staff090_catalogue_rda_derived": "Aus dem Medientyp abgeleitet", + "staff090_catalogue_rda_derived_value": "Abgeleitet: %1$s", + "staff090_catalogue_marc_import": "Import", + "staff090_catalogue_marc_export": "Export", + "staff090_catalogue_marc_import_hint": "ISO 2709 (.mrc, .marc, .iso, .dat) oder MARCXML (.xml), höchstens 20 MB. MARC 21, UNIMARC und danMARC2.", + "staff090_catalogue_marc_wrong_ext": "Wählen Sie eine MARC-Datei (.mrc, .marc, .iso, .dat) oder MARCXML (.xml).", + "staff090_catalogue_marc_flavour": "MARC-Format", + "staff090_catalogue_marc_encoding": "Zeichenkodierung", + "staff090_catalogue_marc_duplicates": "Bücher bereits im Katalog", + "staff090_catalogue_marc_covers_switch": "Cover herunterladen", + "staff090_catalogue_marc_covers_hint": "Die in Feld 856 genannten Cover. Langsamer: zwei Datensätze auf einmal.", + "staff090_catalogue_marc_upload": "Hochladen und Vorschau zeigen", + "staff090_catalogue_marc_preview": "Bereit zum Import", + "staff090_catalogue_marc_records": "Datensätze in der Datei", + "staff090_catalogue_marc_container": "Dateityp", + "staff090_catalogue_marc_container_iso": "ISO 2709", + "staff090_catalogue_marc_container_xml": "MARCXML", + "staff090_catalogue_marc_unreadable": "Unlesbare Datensätze", + "staff090_catalogue_marc_unreadable_row": "Datensatz %1$d ist nicht lesbar", + "staff090_catalogue_marc_run": "Importieren (Datensätze: %1$d)", + "staff090_catalogue_marc_discard": "Diese Datei verwerfen", + "staff090_catalogue_marc_first_records": "Erste Datensätze der Datei", + "staff090_catalogue_marc_progress": "Datensätze: %1$d von %2$d", + "staff090_catalogue_marc_skipped": "Übersprungen", + "staff090_catalogue_marc_covers": "Heruntergeladene Cover", + "staff090_catalogue_marc_new": "Neuer Import", + "staff090_catalogue_marc_format": "Format", + "staff090_catalogue_marc_scope": "Was exportiert wird", + "staff090_catalogue_marc_scope_all": "Gesamter Katalog", + "staff090_catalogue_marc_scope_all_hint": "Alle Bücher außer den von der Bibliothek gesuchten Titeln.", + "staff090_catalogue_marc_scope_chosen": "Ausgewählte Bücher", + "staff090_catalogue_marc_chosen_count": "Ausgewählt: %1$d (höchstens %2$d)", + "staff090_catalogue_marc_add_books": "Bücher hinzufügen", + "staff090_catalogue_marc_pick_books": "Zu exportierende Bücher", + "staff090_catalogue_marc_export_run": "Exportieren", + "staff090_catalogue_marc_export_book": "Als MARC exportieren", + "staff090_catalogue_format_marc21": "MARC 21 (.mrc)", + "staff090_catalogue_format_marcxml": "MARCXML (.xml)", + "staff090_catalogue_format_unimarc": "UNIMARC (.mrc)", + "staff090_catalogue_copy_servers": "Kataloge", + "staff090_catalogue_copy_index": "Suchen in", + "staff090_catalogue_copy_term": "Suchbegriff", + "staff090_catalogue_copy_scan": "Barcode scannen", + "staff090_catalogue_copy_scan_permission": "Die Kamera liest ISBN- und ISSN-Barcodes. Sie können den Suchbegriff auch oben eintippen.", + "staff090_catalogue_copy_max": "Treffer pro Katalog", + "staff090_catalogue_copy_search": "Suchen", + "staff090_catalogue_copy_search_failed": "Die Suche konnte nicht ausgeführt werden.", + "staff090_catalogue_copy_results": "Treffer", + "staff090_catalogue_copy_server_count": "Angezeigt: %1$d von %2$d", + "staff090_catalogue_copy_no_results": "Kein Datensatz gefunden.", + "staff090_catalogue_copy_selected": "Ausgewählt", + "staff090_catalogue_copy_not_selected": "Nicht ausgewählt", + "staff090_catalogue_copy_covers": "Cover herunterladen", + "staff090_catalogue_copy_import": "Auswahl importieren (%1$d)", + "staff090_catalogue_copy_import_failed": "Die Datensätze konnten nicht importiert werden.", + "staff090_catalogue_copy_result": "Ergebnis des Imports", + "staff090_catalogue_copy_action_created": "Angelegt", + "staff090_catalogue_copy_action_updated": "Aktualisiert", + "staff090_catalogue_copy_action_skipped": "Übersprungen (bereits im Katalog)", + "staff090_catalogue_copy_action_failed": "Fehlgeschlagen", + "staff090_catalogue_copy_unavailable_title": "Fremddatenübernahme nicht verfügbar", + "staff090_catalogue_copy_reason_plugin": "Die Fremddatenübernahme braucht das Z39.50/SRU-Plugin: Ein Administrator kann es auf der Website unter Plugins aktivieren.", + "staff090_catalogue_err_copy_unavailable": "Die Fremddatenübernahme ist auf diesem Server nicht verfügbar.", + "staff090_catalogue_err_job_cancelled": "Der Import wurde abgebrochen, während er lief.", + "staff090_catalogue_err_schema_missing": "Dieses Werkzeug ist verfügbar, sobald der Server sein Datenbank-Update abgeschlossen hat.", + "staff090_catalogue_err_search_gone": "Diese Treffer sind nicht mehr verfügbar: Bitte erneut suchen.", + "staff090_patrons_import_open_started": "Gestartet · %1$s", + "staff090_patrons_import_open_not_started": "Noch nicht gestartet · Zeilen: %1$d", + "staff090_stocktake_filter_open": "Offen", + "staff090_stocktake_filter_closed": "Abgeschlossen", + "staff090_stocktake_filter_cancelled": "Abgebrochen", + "staff090_stocktake_filter_all": "Alle", + "staff090_stocktake_empty": "Keine Inventur hier", + "staff090_stocktake_empty_hint": "Starten Sie eine mit der Schaltfläche +: Das Telefon wird zum Scanner.", + "staff090_stocktake_new": "Neue Inventur", + "staff090_stocktake_row_counts": "Erwartet: %1$d · Gefunden: %2$d · Fehlend: %3$d · Scans: %4$d", + "staff090_stocktake_opened": "Geöffnet %1$s", + "staff090_stocktake_field_name": "Name", + "staff090_stocktake_name_required": "Geben Sie der Inventur einen Namen.", + "staff090_stocktake_field_note": "Notiz", + "staff090_stocktake_scope": "Was geprüft wird", + "staff090_stocktake_scope_hint": "Die Exemplare, die zu dieser Auswahl passen, sind die erwarteten. Lassen Sie alles leer, um die ganze Bibliothek zu prüfen.", + "staff090_stocktake_field_branch": "Zweigstelle", + "staff090_stocktake_field_shelf": "Regal", + "staff090_stocktake_field_level": "Regalboden", + "staff090_stocktake_level": "Ebene %1$d", + "staff090_stocktake_field_genre": "Genre", + "staff090_stocktake_field_media": "Medientyp", + "staff090_stocktake_field_collocation_from": "Signatur von", + "staff090_stocktake_field_collocation_to": "Signatur bis", + "staff090_stocktake_any_shelf": "Alle Regale", + "staff090_stocktake_any_genre": "Alle Genres", + "staff090_stocktake_search_shelf": "Regal suchen", + "staff090_stocktake_search_genre": "Genre suchen", + "staff090_stocktake_open_session": "Inventur starten", + "staff090_stocktake_camera_permission": "Erlauben Sie die Kamera, um die Etiketten der Exemplare zu lesen. Sie können die Codes auch eingeben oder hochladen.", + "staff090_stocktake_current_shelf": "Regal, das Sie prüfen", + "staff090_stocktake_no_shelf": "Nicht angegeben: Der Standort der Exemplare wird nicht geprüft", + "staff090_stocktake_scan_hint": "Richten Sie die Kamera auf ein Etikett: Jedes gelesene Exemplar wird sofort mit einem Signalton erfasst.", + "staff090_stocktake_sending": "Wird gesendet: %1$d", + "staff090_stocktake_belongs_on": "Gehört in %1$s", + "staff090_stocktake_recorded_as": "Erfasst als: %1$s", + "staff090_stocktake_manual_code": "Exemplarcode", + "staff090_stocktake_send_code": "Diesen Code erfassen", + "staff090_stocktake_upload": "Codeliste hochladen (.txt oder .csv)", + "staff090_stocktake_upload_wrong_file": "Wählen Sie eine .txt- oder .csv-Datei mit höchstens 2 MB, ein Code pro Zeile.", + "staff090_stocktake_upload_done": "Codeliste verarbeitet", + "staff090_stocktake_not_recorded": "Keine Verbindung: %1$s wurde nicht erfasst. Lesen Sie ihn erneut, wenn Sie wieder online sind.", + "staff090_stocktake_progress": "Fortschritt", + "staff090_stocktake_count_expected": "Erwartet", + "staff090_stocktake_count_found": "Gefunden", + "staff090_stocktake_count_misplaced": "Falsch eingestellt", + "staff090_stocktake_count_on_loan": "Ausgeliehen", + "staff090_stocktake_count_missing": "Fehlend", + "staff090_stocktake_count_lost": "Als verloren markiert", + "staff090_stocktake_count_unknown": "Unbekannte Codes", + "staff090_stocktake_count_out_of_scope": "Außerhalb des Umfangs", + "staff090_stocktake_count_duplicates": "Bereits gescannt", + "staff090_stocktake_count_mine": "Meine Scans", + "staff090_stocktake_report": "Bericht", + "staff090_stocktake_close": "Inventur abschließen", + "staff090_stocktake_close_resume": "Markierung als verloren abschließen", + "staff090_stocktake_action_none": "Fehlende Exemplare unverändert lassen", + "staff090_stocktake_action_lost": "Fehlende Exemplare als verloren markieren", + "staff090_stocktake_close_confirm_none": "Fehlende Exemplare: %1$d. Sie bleiben unverändert; danach können keine Codes mehr gescannt werden.", + "staff090_stocktake_close_confirm_lost": "Fehlende Exemplare: %1$d. Sie werden als verloren markiert; danach können keine Codes mehr gescannt werden.", + "staff090_stocktake_close_confirm_resume": "Noch als verloren zu markierende Exemplare: %1$d.", + "staff090_stocktake_closed": "Inventur abgeschlossen.", + "staff090_stocktake_cancel": "Inventur abbrechen", + "staff090_stocktake_cancel_title": "Diese Inventur abbrechen?", + "staff090_stocktake_cancel_body": "Kein Exemplar wird geändert, und es können keine Codes mehr gescannt werden. Die Scans bleiben im Bericht.", + "staff090_stocktake_keep": "Offen lassen", + "staff090_stocktake_cancelled": "Inventur abgebrochen.", + "staff090_stocktake_no_more_scans": "Diese Inventur nimmt keine Scans mehr an.", + "staff090_stocktake_pending_lost": "Noch nicht als verloren markierte Exemplare: %1$d", + "staff090_stocktake_recent": "Letzte Scans", + "staff090_stocktake_filter_latest": "Neueste", + "staff090_stocktake_only_mine": "Nur meine Scans", + "staff090_stocktake_no_scans": "Noch keine Scans.", + "staff090_stocktake_more": "Mehr anzeigen", + "staff090_stocktake_details": "Details", + "staff090_stocktake_whole_library": "Die ganze Bibliothek", + "staff090_stocktake_opened_label": "Geöffnet", + "staff090_stocktake_closed_label": "Abgeschlossen", + "staff090_stocktake_history": "Verlauf", + "staff090_stocktake_report_rows": "Zeilen: %1$d", + "staff090_stocktake_report_empty": "Nichts in dieser Kategorie", + "staff090_stocktake_download_csv": "Bericht als CSV herunterladen", + "staff090_stocktake_download_pdf": "Bericht als PDF herunterladen", + "staff090_stocktake_err_session_closed": "Diese Inventur wurde abgeschlossen: Es können keine Codes mehr gescannt werden.", + "staff090_stocktake_err_session_cancelled": "Diese Inventur wurde abgebrochen: Es können keine Codes mehr gescannt werden.", + "staff090_stocktake_err_report_too_large": "Der Bericht ist für ein PDF zu lang: Laden Sie stattdessen die CSV herunter.", + "staff090_stocktake_err_invalid_state": "Diese Aktion ist im aktuellen Zustand der Inventur nicht möglich.", + "staff090_stocktake_not_permitted": "Nur ein Administrator kann das tun.", + "staff090_statistics_overview": "Überblick", + "staff090_statistics_books_available": "Verfügbare Bücher", + "staff090_statistics_books_on_loan": "Ausgeliehene Bücher", + "staff090_statistics_active_loans": "Aktive Ausleihen", + "staff090_statistics_overdue_loans": "Überfällige Ausleihen", + "staff090_statistics_completed_loans": "Abgeschlossene Ausleihen", + "staff090_statistics_borrowers": "Leser mit Ausleihen", + "staff090_statistics_by_month": "Ausleihen pro Monat", + "staff090_statistics_by_status": "Aktive Ausleihen nach Status", + "staff090_statistics_by_media": "Ausleihen nach Medientyp (letzte 12 Monate)", + "staff090_statistics_top_books": "Meistausgeliehene Bücher", + "staff090_statistics_top_readers": "Aktivste Leser", + "staff090_statistics_loan_counts": "Ausleihen: %1$d · Aktiv: %2$d · Überfällig: %3$d", + "staff090_statistics_visitors": "Besucher, letzte Tage: %1$d", + "staff090_statistics_visitors_members": "Mitglieder", + "staff090_statistics_visitors_guests": "Gäste", + "staff090_statistics_visitors_total": "Gesamt", + "staff090_statistics_visitors_daily": "Besucher pro Tag", + "staff090_statistics_fines_report": "Gebührenbericht", + "staff090_statistics_from": "Von", + "staff090_statistics_to": "Bis", + "staff090_statistics_fines_empty": "Keine Bewegungen in diesem Zeitraum.", + "staff090_statistics_fines_totals": "Summen", + "staff090_statistics_charged": "Belastet", + "staff090_statistics_collected": "Eingenommen", + "staff090_statistics_waived": "Erlassen", + "staff090_statistics_reversed": "Storniert", + "staff090_statistics_fines_csv": "Gebührenbericht herunterladen (CSV)", + "staff090_patrons_balance_owed_staff": "Betrag, den die Person der Bibliothek schuldet.", + "staff090_patrons_balance_credit_staff": "Guthaben zugunsten der Person.", + "staff090_patrons_debt_blocked_body_staff": "Der Saldo der Person liegt über der Grenze der Bibliothek von %1$s: Neue Ausleihen und Verlängerungen sind gesperrt, bis er ausgeglichen ist.", + "staff090_patrons_debt_blocked_body_plain_staff": "Neue Ausleihen und Verlängerungen sind gesperrt, bis der Saldo der Person ausgeglichen ist." } diff --git a/i18n/en.json b/i18n/en.json index 33f4962..81de415 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -207,6 +207,7 @@ "profile_date_none": "Not set", "profile_card_number": "Card number", "profile_card_expires": "Card expires", + "profile_card_barcode": "Library card barcode %1$s", "profile_change_password_title": "Change password", "profile_current_password": "Current password", "profile_new_password": "New password", @@ -269,6 +270,7 @@ "snackbar_request_for_date": "Loan requested for %1$s", "snackbar_reservation_for_date": "Reservation added for %1$s", "snackbar_loan_ready_for_pickup": "Loan approved; ready for pickup.", + "snackbar_loan_scheduled": "Loan approved: you can pick up the book from %1$s.", "book_section_audiobook": "Audiobook", "audio_play": "Play", "audio_pause": "Pause", @@ -993,6 +995,8 @@ "staff_scan_next": "Scan the next copy", "staff_loan_new_with_copy": "New loan with this copy", "staff_loan_open": "Open the loan", + "staff_reservation_override_limits": "Ignore the reservation limits of the patron's type", + "staff_reservation_override_limits_hint": "As in the website form: the reservation is created even beyond the limits of the patron's type.", "staff_hand_over_now": "Hand over now", "staff_hand_over_now_hint": "The borrower takes the copy at the desk: the loan starts running at once.", "staff_hand_over_future": "A loan starting later is scheduled and handed over on that day.", @@ -1453,5 +1457,651 @@ "digital_locked_login": "Sign in to open it", "digital_locked_loan": "For readers who have this book on loan", "digital_locked_other": "Not available to your account", - "digital_link_refresh_error": "Could not refresh the link. Please try again." + "digital_link_refresh_error": "Could not refresh the link. Please try again.", + "book_loan_legend_closed": "Library closed", + "book_loan_closed_day": "The library is closed on %1$s: choose another day.", + "book_consultation_only": "Consultation only", + "book_consultation_only_hint": "You can consult this title in the library, but it cannot be borrowed with your card.", + "book_reservations_disabled_hint": "No copy is free right now, and reservations are not available with your card. Try again when a copy comes back.", + "request_err_not_loanable": "This title is consultation only for your card: it cannot be borrowed.", + "request_err_reservations_disabled": "Reservations are not available with your card.", + "request_err_reservation_limit": "You have reached the maximum number of reservations.", + "request_err_media_limit": "You have reached the maximum number of loans for this type of material.", + "request_err_max_loans": "You have reached the maximum number of active loans.", + "request_err_debt_block": "Loans are blocked until your balance with the library is settled.", + "request_err_card_expired": "Your library card has expired: ask the library to renew it.", + "request_err_suspended": "Your account is suspended: contact the library.", + "staff_err_media_limit": "This user has reached the maximum number of loans for this type of material.", + "staff_err_not_loanable": "This material is consultation only for the user's patron type.", + "staff_err_reservations_disabled": "Reservations are not allowed for the user's patron type.", + "staff_err_reservation_limit": "This user has reached the maximum number of reservations.", + "staff_err_debt_block": "Loans are blocked: this user's balance is above the debt limit.", + "staff_err_card_expired": "This user's library card has expired.", + "staff_err_user_suspended": "This user is suspended.", + "staff_copy_consultation_only": "Consultation only", + "staff_copy_consultation_switch": "Consultation only, in the library", + "staff_copy_consultation_switch_hint": "The copy is never lent. As on the book page: it cannot be set while the copy is on loan, awaiting pickup or requested.", + "profile_patron_type": "Patron type", + "profile_action_movements": "Balance and movements", + "title_movements": "Balance and movements", + "movements_loading": "Loading your balance…", + "movements_error_load": "Could not load your balance.", + "movements_balance": "Balance", + "movements_balance_owed": "Amount you owe the library.", + "movements_balance_credit": "Credit in your favour.", + "movements_balance_settled": "Nothing to pay.", + "movements_debt_blocked_title": "Loans blocked", + "movements_debt_blocked_body": "Your balance is above the library's limit of %1$s: new loans and renewals are blocked until it is settled.", + "movements_debt_blocked_body_plain": "New loans and renewals are blocked until your balance is settled.", + "movements_accrued_title": "Fines accruing on overdue loans: %1$s", + "movements_accrued_row": "Due %1$s · days late: %2$d · charged when returned", + "movements_history_title": "Movements", + "movements_empty_title": "No movements", + "movements_empty_subtitle": "Charges, payments and waivers will appear here.", + "movements_untitled": "Loan", + "movements_refresh_failed": "Could not refresh: the balance shown may be out of date.", + "staff_loan_closed_day": "The library is closed on %1$s: choose another start or return date.", + "staff090_section_desk": "Front desk", + "staff090_section_cataloguing": "Cataloguing", + "staff090_section_library": "Library", + "staff090_attendance": "Visitor log", + "staff090_attendance_subtitle": "Scan library cards at the entrance, record guests", + "staff090_stocktake": "Stocktake", + "staff090_stocktake_subtitle": "Check the shelves with the camera", + "staff090_user_import": "Import patrons", + "staff090_user_import_subtitle": "From a CSV file", + "staff090_marc": "MARC import and export", + "staff090_marc_subtitle": "ISO 2709, MARCXML, UNIMARC", + "staff090_copy_cataloging": "Copy cataloguing", + "staff090_copy_cataloging_subtitle": "Search other library catalogues", + "staff090_suppliers": "Suppliers", + "staff090_suppliers_subtitle": "Booksellers and donors of the books", + "staff090_statistics": "Statistics", + "staff090_statistics_subtitle": "Loans, readers, visitors, fines", + "staff090_calendar": "Closures calendar", + "staff090_calendar_subtitle": "Closed days and holidays", + "staff090_circulation_rules": "Patron types and loan rules", + "staff090_circulation_rules_subtitle": "Limits by patron type and material", + "staff090_settings": "Library settings", + "staff090_settings_subtitle": "Modules, loans, library cards", + "staff090_ledger": "Balance and payments", + "staff090_patrons_section": "Patron", + "staff090_patrons_tools_hint": "Photo and library card of this patron.", + "staff090_patrons_type": "Patron type", + "staff090_patrons_type_default": "%1$s (default)", + "staff090_patrons_type_card_days": "Card validity (days): %1$d", + "staff090_patrons_change_type": "Change patron type", + "staff090_patrons_recompute_expiry": "Set the card expiry from the type", + "staff090_patrons_recompute_expiry_hint": "Today plus the card validity of the chosen type, when it sets one.", + "staff090_patrons_type_saved": "Patron type saved", + "staff090_patrons_type_saved_expiry": "Patron type saved, card expiry updated", + "staff090_patrons_renew_card": "Renew card", + "staff090_patrons_renew_body": "The card validity is extended by the duration of the patron type (one year when it sets none). An expired account becomes active again.", + "staff090_patrons_card_renewed": "Card renewed until %1$s", + "staff090_patrons_print_card": "Print card", + "staff090_patrons_card_ready": "Library card ready", + "staff090_patrons_photo": "Patron photo", + "staff090_patrons_photo_take": "Take photo", + "staff090_patrons_photo_replace": "Replace photo", + "staff090_patrons_photo_pick": "Choose image", + "staff090_patrons_photo_remove": "Remove photo", + "staff090_patrons_photo_remove_body": "The patron photo is deleted from the server.", + "staff090_patrons_photo_saved": "Photo saved", + "staff090_patrons_photo_removed": "Photo removed", + "staff090_patrons_photo_unreadable": "This image cannot be read.", + "staff090_patrons_balance_line": "Balance: %1$s", + "staff090_patrons_debt_blocked": "Blocked for debt", + "staff090_patrons_record_payment": "Record payment", + "staff090_patrons_add_charge": "Add charge", + "staff090_patrons_waive": "Waive", + "staff090_patrons_amount": "Amount (%1$s)", + "staff090_patrons_amount_invalid": "Enter an amount greater than zero, with at most two decimals.", + "staff090_patrons_amount_too_large": "At most %1$s", + "staff090_patrons_method": "Payment method", + "staff090_patrons_note_optional": "Note (optional)", + "staff090_patrons_description": "Description", + "staff090_patrons_waive_reason": "Reason for the waiver", + "staff090_patrons_reason_required": "A reason is required.", + "staff090_patrons_payment_recorded": "Payment recorded", + "staff090_patrons_charge_recorded": "Charge added", + "staff090_patrons_waiver_recorded": "Waiver recorded", + "staff090_patrons_recorded_by": "Recorded by %1$s", + "staff090_patrons_receipt": "Receipt", + "staff090_patrons_receipt_loading": "Downloading…", + "staff090_patrons_receipt_ready": "Payment receipt ready", + "staff090_patrons_return_fine": "Late fine: %1$s (days late: %2$d)", + "staff090_patrons_return_waive": "Waive the late fine", + "staff090_patrons_tab_desk": "Entrance", + "staff090_patrons_tab_stats": "Statistics", + "staff090_patrons_scan_permission": "Allow the camera to scan library cards. You can also type the card code below.", + "staff090_patrons_scan_hint": "Hold the library card barcode or QR code inside the frame.", + "staff090_patrons_card_code": "Library card code", + "staff090_patrons_card_code_required": "Enter the card code.", + "staff090_patrons_check_in": "Record visit", + "staff090_patrons_branch": "Branch", + "staff090_patrons_branch_none": "Not specified", + "staff090_patrons_card_line": "Card %1$s", + "staff090_patrons_card_expired": "Card expired on %1$s", + "staff090_patrons_card_not_active": "The account is suspended or expired.", + "staff090_patrons_guest": "Record a guest", + "staff090_patrons_guest_name": "Name (optional)", + "staff090_patrons_guest_reason": "Reason for the visit (optional)", + "staff090_patrons_guest_save": "Record visit", + "staff090_patrons_guest_anonymous": "Guest", + "staff090_patrons_member": "Member", + "staff090_patrons_today_counts": "Today: members %1$d, guests %2$d", + "staff090_patrons_period_days": "%1$d d", + "staff090_patrons_today": "Today", + "staff090_patrons_period_total": "Period", + "staff090_patrons_counts": "Total %1$d · Members %2$d · Guests %3$d", + "staff090_patrons_by_day": "By day", + "staff090_patrons_by_hour": "By hour", + "staff090_patrons_no_visits": "No visits in this period.", + "staff090_patrons_recent": "Latest visits", + "staff090_patrons_export_csv": "Export CSV", + "staff090_patrons_retention": "Data retention", + "staff090_patrons_retention_hint": "Visits older than this number of days are deleted by the maintenance run (0 = keep forever, maximum %1$d).", + "staff090_patrons_retention_days": "Keep visits (days)", + "staff090_patrons_retention_invalid": "Enter a whole number from 0 to %1$d.", + "staff090_patrons_retention_saved": "Retention saved", + "staff090_patrons_purge": "Delete old visits now", + "staff090_patrons_purge_body": "Visits older than the retention period (days: %1$d) are deleted now. This cannot be undone.", + "staff090_patrons_purged": "Visits deleted: %1$d", + "staff090_patrons_import_intro": "Create reader accounts from a CSV file. Rows whose email or card code already exists are skipped; passwords are never imported.", + "staff090_patrons_delimiter": "Separator", + "staff090_patrons_delimiter_auto": "Detect", + "staff090_patrons_delimiter_semicolon": "Semicolon", + "staff090_patrons_delimiter_comma": "Comma", + "staff090_patrons_delimiter_tab": "Tab", + "staff090_patrons_import_upload": "Upload file", + "staff090_patrons_import_too_big": "The file is larger than 5 MB.", + "staff090_patrons_import_wrong_ext": "Choose a .csv, .tsv or .txt file.", + "staff090_patrons_import_ready": "Check the columns, then start", + "staff090_patrons_import_rows": "Rows in the file", + "staff090_patrons_import_preview": "Preview", + "staff090_patrons_import_mapping": "Columns", + "staff090_patrons_import_column": "Column %1$d", + "staff090_patrons_import_ignore": "Ignore", + "staff090_patrons_import_missing": "Map the required fields: %1$s", + "staff090_patrons_import_invites": "Email an invitation", + "staff090_patrons_import_invites_hint": "Each new reader receives the link to set a password.", + "staff090_patrons_import_start": "Start import", + "staff090_patrons_import_created": "Accounts created", + "staff090_patrons_import_duplicates": "Duplicates skipped", + "staff090_patrons_import_failed": "Rows with errors", + "staff090_patrons_import_invited": "Invitations sent", + "staff090_patrons_import_invite_failed": "Invitations not sent", + "staff090_patrons_import_error_line": "Line %1$d: %2$s", + "staff090_patrons_import_cancelled": "Import cancelled", + "staff090_patrons_import_new": "New import", + "staff090_patrons_err_card_not_renewable": "This account has no library card to renew.", + "staff090_patrons_err_amount_exceeds_balance": "The amount is more than the patron owes.", + "staff090_patrons_err_card_not_found": "Card not recognised", + "staff090_patrons_err_not_started": "Start the import before processing the rows.", + "staff090_patrons_err_already_started": "This import has already started.", + "staff090_patrons_err_job_cancelled": "The import was cancelled while rows were being processed.", + "staff090_patrons_err_file_missing": "The uploaded file is no longer on the server: upload it again.", + "staff090_patrons_err_import_not_finished": "The error report is available when the import is over.", + "staff090_patrons_err_db_update_pending": "Available once the library's database update has run.", + "staff090_library_active": "Active", + "staff090_library_admin_only": "Only administrators can change this.", + "staff090_library_all_branches": "All branches", + "staff090_library_all_types": "All patron types", + "staff090_library_apply": "Apply", + "staff090_library_branch": "Branch", + "staff090_library_branch_preview": "Calendar of the branch", + "staff090_library_calendar_module_off": "The closures calendar is switched off: what you set here is kept, but due dates ignore it until the module is switched on in Settings → Loans.", + "staff090_library_card_back_text": "Text on the back", + "staff090_library_card_back_text_hint": "Opening hours, rules, contacts. Leave empty for a blank back.", + "staff090_library_card_code_barcode": "Barcode (Code 128)", + "staff090_library_card_code_qr": "QR code", + "staff090_library_card_code_type": "Code on the card", + "staff090_library_card_format": "Print format", + "staff090_library_card_format_sheet": "A4 sheet of cards", + "staff090_library_card_format_single": "One card per page", + "staff090_library_card_layout": "Layout", + "staff090_library_card_layout_classic": "Classic", + "staff090_library_card_layout_minimal": "Minimal", + "staff090_library_card_layout_portrait": "Portrait", + "staff090_library_card_section": "Library card", + "staff090_library_card_show_expiry": "Show the expiry date", + "staff090_library_card_show_logo": "Show the library logo", + "staff090_library_card_show_photo": "Show the patron's photo", + "staff090_library_card_title": "Card title", + "staff090_library_card_title_hint": "Empty: the library name.", + "staff090_library_cards_layout_section": "Layout and code", + "staff090_library_cards_module_off": "Card printing is switched off: you can prepare the settings, the cards print once the module is on.", + "staff090_library_cards_show_section": "On the card", + "staff090_library_cards_text_section": "Texts", + "staff090_library_catalogue_mode": "Catalogue mode is on: the circulation modules stay off whatever their switch says.", + "staff090_library_cell_custom": "Own rule", + "staff090_library_cell_duration": "Days: %1$d", + "staff090_library_cell_fine": "Fine per day: %1$s", + "staff090_library_cell_loanable": "Loanable", + "staff090_library_cell_max": "Max: %1$d", + "staff090_library_cell_not_loanable": "Not loanable", + "staff090_library_cell_pending": "Not saved", + "staff090_library_cell_renewals": "Renewals: %1$d", + "staff090_library_cell_title": "%1$s · %2$s", + "staff090_library_close": "Close", + "staff090_library_closure_delete": "Delete the closure?", + "staff090_library_closure_delete_body": "%1$s will be an opening day again; due dates are recalculated.", + "staff090_library_closure_edit": "Edit closure", + "staff090_library_closure_end": "Last day (optional)", + "staff090_library_closure_end_before": "The last day cannot come before the first.", + "staff090_library_closure_end_hint": "Leave the last day empty for a single day.", + "staff090_library_closure_new": "Add closure", + "staff090_library_closure_preset": "Holiday", + "staff090_library_closure_start": "First day", + "staff090_library_closure_untitled": "Closure", + "staff090_library_closure_yearly": "Every year", + "staff090_library_closure_yearly_hint": "Repeats on the same dates each year (at most %1$d days long).", + "staff090_library_closure_yearly_switch": "Repeat every year", + "staff090_library_closures": "Closures", + "staff090_library_closures_empty": "No closures yet.", + "staff090_library_country": "Country", + "staff090_library_country_search": "Search a country", + "staff090_library_custom": "Library holidays", + "staff090_library_custom_calendar": "Calendar", + "staff090_library_custom_calendar_hint": "The Islamic calendar follows the Umm al-Qura tables: where the month starts with the local moon sighting the date may shift by a day. Use specific dates in that case.", + "staff090_library_custom_dates": "Dates (YYYY-MM-DD, one per line)", + "staff090_library_custom_dates_hint": "At most %1$d dates.", + "staff090_library_custom_day": "Day", + "staff090_library_custom_delete": "Delete the holiday?", + "staff090_library_custom_delete_body": "%1$s will no longer close the library.", + "staff090_library_custom_duration": "Duration (days)", + "staff090_library_custom_edit": "Edit holiday", + "staff090_library_custom_help": "Holidays of your own: the patron saint, a local feast, a religious holiday in another calendar.", + "staff090_library_custom_kind": "How it is calculated", + "staff090_library_custom_month": "Month", + "staff090_library_custom_more": "Duration and validity", + "staff090_library_custom_name_hint": "e.g. Patron saint", + "staff090_library_custom_new": "Add holiday", + "staff090_library_custom_next": "Next: %1$s", + "staff090_library_custom_occurrence": "Which", + "staff090_library_custom_offset": "Days from Easter Sunday", + "staff090_library_custom_offset_hint": "0 = Easter Sunday; 1 = the Monday after; -2 = Good Friday; 49 = Pentecost.", + "staff090_library_custom_weekday": "Day of the week", + "staff090_library_custom_weekend": "If it falls on a weekend", + "staff090_library_custom_year_from": "From year", + "staff090_library_custom_year_until": "Until year", + "staff090_library_data_years": "Holiday data covers the years %1$d–%2$d.", + "staff090_library_day_closed": "The library is closed.", + "staff090_library_day_open": "The library is open.", + "staff090_library_delete": "Delete", + "staff090_library_deleted": "Deleted", + "staff090_library_discard": "Discard", + "staff090_library_err_always_closed": "With this change the library would be closed all year: at least one day must stay open.", + "staff090_library_err_ics_too_large": "The ICS file is larger than 1 MB.", + "staff090_library_err_preset_closure": "National holidays are changed from the holiday selection.", + "staff090_library_err_schema_not_ready": "This will be available once the database update has run on the server.", + "staff090_library_fines_admin_only": "Only administrators can change fines and the debt block.", + "staff090_library_grace_deduct": "Deduct: always subtract the grace days", + "staff090_library_grace_threshold": "Threshold: past the grace days, count from day one", + "staff090_library_group_loans": "Loans", + "staff090_library_group_modules": "Modules", + "staff090_library_holiday_estimated": "estimated", + "staff090_library_holiday_optional": "optional", + "staff090_library_holiday_regional": "regional", + "staff090_library_holiday_yearly": "same date every year", + "staff090_library_holidays": "Holidays", + "staff090_library_holidays_apply": "Apply holidays (%1$d)", + "staff090_library_holidays_help": "Tick the holidays the library closes on. They are computed again every year.", + "staff090_library_holidays_national": "National holidays", + "staff090_library_holidays_none": "No national holidays chosen yet", + "staff090_library_holidays_saved": "%1$s · Holidays: %2$d", + "staff090_library_ics": "Import from a calendar file", + "staff090_library_ics_added": "Closures added: %1$d", + "staff090_library_ics_failed": "Could not import the calendar file.", + "staff090_library_ics_file": "ICS file", + "staff090_library_ics_help": "Every event of the file becomes a closure. At most 1 MB.", + "staff090_library_ics_import": "Import", + "staff090_library_ics_read_failed": "Could not read the file.", + "staff090_library_inactive": "Off", + "staff090_library_inherit_all": "Inherit everything", + "staff090_library_inherit_note": "An empty field inherits the value of the level above.", + "staff090_library_inherits": "Empty: %1$s", + "staff090_library_kind_calendar": "In another calendar", + "staff090_library_kind_dates": "Specific dates", + "staff090_library_kind_easter": "Relative to Easter", + "staff090_library_kind_fixed": "Same day every year", + "staff090_library_kind_nth_weekday": "A weekday of the month (e.g. the first Monday)", + "staff090_library_kind_orthodox_easter": "Relative to Orthodox Easter", + "staff090_library_legend_closed": "Closed", + "staff090_library_loan_auto_approve": "Approve requests automatically", + "staff090_library_loan_auto_approve_help": "Loan requests skip manual approval and wait for pickup straight away.", + "staff090_library_loan_card_renewal_base": "Renew the card from", + "staff090_library_loan_card_warning": "Card expiry warning (days before)", + "staff090_library_loan_card_warning_help": "The patron gets an email before the card expires. 0 = no warning.", + "staff090_library_loan_daily_fine": "Daily fine", + "staff090_library_loan_daily_fine_help": "Global rate per day. Empty or 0 = no global rate; patron types may set their own.", + "staff090_library_loan_debt_block": "Debt block threshold", + "staff090_library_loan_debt_block_help": "Global debt threshold. Empty or 0 = no global block.", + "staff090_library_loan_duration": "Default loan duration (days)", + "staff090_library_loan_duration_help": "Used when no return date is given: patron requests, desk loans, approved reservations.", + "staff090_library_loan_fine_email": "Email the patron when a fine is charged", + "staff090_library_loan_fine_email_help": "Uses the \"Overdue fine charged\" email template.", + "staff090_library_loan_grace_days": "Grace days", + "staff090_library_loan_grace_days_help": "A delay up to this many days is not fined.", + "staff090_library_loan_grace_mode": "Grace mode", + "staff090_library_loan_max_active": "Active loans per patron", + "staff090_library_loan_max_active_help": "0 = no limit. Counted: reserved, ready for pickup, on loan, overdue.", + "staff090_library_loan_max_duration": "Longest loan a patron may request (days)", + "staff090_library_loan_max_duration_help": "Longer requests and reservations are refused.", + "staff090_library_loan_max_fine": "Maximum fine per loan", + "staff090_library_loan_max_fine_help": "Cap on the fine of one loan. Empty or 0 = no cap.", + "staff090_library_loan_max_renewals": "Renewals per loan", + "staff090_library_loan_max_renewals_help": "0 turns renewals off.", + "staff090_library_loan_multiple_copies": "Several copies of the same title", + "staff090_library_loan_multiple_copies_help": "A patron may borrow the same book more than once, each loan on a different copy.", + "staff090_library_loan_pickup_days": "Days to pick up an approved loan", + "staff090_library_loan_pickup_days_help": "After that the approved loan expires.", + "staff090_library_loan_pickup_reminder": "Pickup reminder (days before)", + "staff090_library_loan_pickup_reminder_help": "Sent if the book has not been picked up yet.", + "staff090_library_loan_recall_interval": "Days between reminders", + "staff090_library_loan_recall_max": "Automatic reminders, at most", + "staff090_library_loan_recalls": "Automatic overdue reminders", + "staff090_library_loan_recalls_help": "Repeated emails for overdue loans not yet returned.", + "staff090_library_loan_timezone": "Time zone", + "staff090_library_loan_timezone_help": "Used for due dates, activations and loan automations: the library's own time zone.", + "staff090_library_loan_unknown": "Setting", + "staff090_library_loanable_inherit": "Inherit (%1$s)", + "staff090_library_loans_approval": "Approval", + "staff090_library_loans_card": "Library card", + "staff090_library_loans_duration": "Duration", + "staff090_library_loans_fines": "Fines and debt block", + "staff090_library_loans_limits": "Limits", + "staff090_library_loans_pickup": "Pickup", + "staff090_library_loans_recalls": "Overdue reminders", + "staff090_library_loans_renewals": "Renewals", + "staff090_library_loans_section": "Loans", + "staff090_library_matrix": "Loan rules by material", + "staff090_library_matrix_empty": "No materials to configure", + "staff090_library_matrix_intro": "Each cell overrides its patron type for one material; \"All patron types\" applies to every type that leaves a value empty.", + "staff090_library_matrix_subtitle": "Limits for each patron type and material", + "staff090_library_module_catalogue_off": "Off in catalogue mode.", + "staff090_library_module_follows": "Until saved on its own, it follows \"%1$s\".", + "staff090_library_module_needs": "Needs \"%1$s\".", + "staff090_library_module_needs_off": "Inactive: \"%1$s\" is off.", + "staff090_library_modules_read_only": "Only administrators can switch modules on or off.", + "staff090_library_money_invalid": "Enter an amount such as 1.50", + "staff090_library_money_label": "%1$s (%2$s)", + "staff090_library_move_down": "Move down", + "staff090_library_move_up": "Move up", + "staff090_library_no": "No", + "staff090_library_nth_1": "The first", + "staff090_library_nth_2": "The second", + "staff090_library_nth_3": "The third", + "staff090_library_nth_4": "The fourth", + "staff090_library_nth_5": "The fifth", + "staff090_library_nth_last": "The last", + "staff090_library_num_card_duration": "Card validity (days)", + "staff090_library_num_daily_fine": "Daily fine", + "staff090_library_num_debt_block": "Debt block threshold", + "staff090_library_num_grace_days": "Grace days", + "staff090_library_num_loan_duration": "Loan duration (days)", + "staff090_library_num_max_fine": "Maximum fine per loan", + "staff090_library_num_max_loans": "Active loans at most", + "staff090_library_num_max_renewals": "Renewals at most", + "staff090_library_num_max_request": "Longest request (days)", + "staff090_library_num_max_reservations": "Reservations at most", + "staff090_library_num_renewal_days": "Days added by a renewal", + "staff090_library_off": "Off", + "staff090_library_on": "On", + "staff090_library_preview_year": "Year %1$d", + "staff090_library_range": "From %1$d to %2$d.", + "staff090_library_reason_closure": "Closure: %1$s", + "staff090_library_reason_custom": "Library holiday: %1$s", + "staff090_library_reason_holiday": "Holiday: %1$s", + "staff090_library_reason_other": "Closed: %1$s", + "staff090_library_reason_weekly": "Weekly closing day: %1$s", + "staff090_library_region": "Region", + "staff090_library_region_all": "The whole country", + "staff090_library_renewal_expiry": "The current expiry, if in the future; otherwise today", + "staff090_library_renewal_today": "Today", + "staff090_library_reservations_section": "Reservations", + "staff090_library_retention_days": "Visitor log retention (days)", + "staff090_library_retention_hint": "0 = never deleted automatically. At most %1$d.", + "staff090_library_rules_module_off": "Rules by patron type are switched off: you can prepare them here, they apply once the module is on in Settings → Loans.", + "staff090_library_save_cells": "Save (%1$d)", + "staff090_library_save_weekdays": "Save closing days", + "staff090_library_saved": "Saved", + "staff090_library_tab_cards": "Cards", + "staff090_library_tab_loans": "Loans", + "staff090_library_tab_modules": "Modules", + "staff090_library_type_active": "Active", + "staff090_library_type_active_hint": "An inactive type is no longer offered for new patrons.", + "staff090_library_type_code": "Code", + "staff090_library_type_code_hint": "Lowercase letters, numbers, - and _ (at most 40). Empty: made from the name.", + "staff090_library_type_default": "Default", + "staff090_library_type_default_hint": "New patrons get this type.", + "staff090_library_type_delete": "Delete the patron type?", + "staff090_library_type_delete_body": "%1$s will be deleted and its patrons moved to the default type. Patrons: %2$d", + "staff090_library_type_deleted": "%1$s deleted. Patrons moved to the default type: %2$d", + "staff090_library_type_edit": "Edit patron type", + "staff090_library_type_inactive": "Inactive", + "staff090_library_type_make_default": "Make default", + "staff090_library_type_new": "New patron type", + "staff090_library_type_reservations": "Reservations allowed", + "staff090_library_type_users": "Patrons: %1$d", + "staff090_library_types": "Patron types", + "staff090_library_weekdays_all_closed": "At least one day of the week must stay open.", + "staff090_library_weekend_next_monday": "Close the following Monday too", + "staff090_library_weekend_none": "No extra day", + "staff090_library_weekend_us": "Saturday: the Friday before too; Sunday: the Monday after too", + "staff090_library_weekly": "Weekly closing days", + "staff090_library_weekly_help": "The days the library is closed every week.", + "staff090_library_year_next": "Next year", + "staff090_library_year_prev": "Previous year", + "staff090_library_yearly": "Yearly closures", + "staff090_library_yearly_help": "Switch a yearly closure off to skip it without deleting it.", + "staff090_library_yes": "Yes", + "staff090_catalogue_card": "Catalogue card (PDF)", + "staff090_catalogue_cards_print": "Print catalogue cards (%1$d)", + "staff090_catalogue_cards_limit": "Cards per PDF: at most %1$d. The first ones chosen are printed.", + "staff090_catalogue_in_catalogue": "Already in the catalogue", + "staff090_catalogue_supplier": "Supplier", + "staff090_catalogue_supplier_new": "New supplier", + "staff090_catalogue_supplier_edit": "Edit supplier", + "staff090_catalogue_supplier_delete": "Delete supplier", + "staff090_catalogue_supplier_delete_confirm": "Delete “%1$s”?", + "staff090_catalogue_supplier_delete_books": "Delete “%1$s”? Books that lose this supplier: %2$d. The books stay in the catalogue.", + "staff090_catalogue_supplier_deleted": "Supplier deleted. Books unlinked: %1$d", + "staff090_catalogue_supplier_saved": "Supplier saved", + "staff090_catalogue_supplier_search": "Search suppliers", + "staff090_catalogue_supplier_books": "Books: %1$d", + "staff090_catalogue_supplier_contact": "Contact person", + "staff090_catalogue_supplier_vat": "VAT number", + "staff090_catalogue_suppliers_empty": "No suppliers yet", + "staff090_catalogue_rda_title": "RDA types", + "staff090_catalogue_rda_hint": "Leave “Derived” unless the record needs a different RDA type.", + "staff090_catalogue_rda_content": "Content type", + "staff090_catalogue_rda_media": "Media type (RDA)", + "staff090_catalogue_rda_carrier": "Carrier type", + "staff090_catalogue_rda_derived": "Derived from the media type", + "staff090_catalogue_rda_derived_value": "Derived: %1$s", + "staff090_catalogue_marc_import": "Import", + "staff090_catalogue_marc_export": "Export", + "staff090_catalogue_marc_import_hint": "ISO 2709 (.mrc, .marc, .iso, .dat) or MARCXML (.xml), at most 20 MB. MARC 21, UNIMARC and danMARC2.", + "staff090_catalogue_marc_wrong_ext": "Choose a MARC file (.mrc, .marc, .iso, .dat) or MARCXML (.xml).", + "staff090_catalogue_marc_flavour": "MARC format", + "staff090_catalogue_marc_encoding": "Character encoding", + "staff090_catalogue_marc_duplicates": "Books already in the catalogue", + "staff090_catalogue_marc_covers_switch": "Download the covers", + "staff090_catalogue_marc_covers_hint": "Covers named in field 856. Slower: two records at a time.", + "staff090_catalogue_marc_upload": "Upload and preview", + "staff090_catalogue_marc_preview": "Ready to import", + "staff090_catalogue_marc_records": "Records in the file", + "staff090_catalogue_marc_container": "File type", + "staff090_catalogue_marc_container_iso": "ISO 2709", + "staff090_catalogue_marc_container_xml": "MARCXML", + "staff090_catalogue_marc_unreadable": "Unreadable records", + "staff090_catalogue_marc_unreadable_row": "Record %1$d cannot be read", + "staff090_catalogue_marc_run": "Import (records: %1$d)", + "staff090_catalogue_marc_discard": "Discard this file", + "staff090_catalogue_marc_first_records": "First records of the file", + "staff090_catalogue_marc_progress": "Records: %1$d of %2$d", + "staff090_catalogue_marc_skipped": "Skipped", + "staff090_catalogue_marc_covers": "Covers downloaded", + "staff090_catalogue_marc_new": "New import", + "staff090_catalogue_marc_format": "Format", + "staff090_catalogue_marc_scope": "What to export", + "staff090_catalogue_marc_scope_all": "Whole catalogue", + "staff090_catalogue_marc_scope_all_hint": "Every book except the titles wanted by the library.", + "staff090_catalogue_marc_scope_chosen": "Chosen books", + "staff090_catalogue_marc_chosen_count": "Chosen: %1$d (at most %2$d)", + "staff090_catalogue_marc_add_books": "Add books", + "staff090_catalogue_marc_pick_books": "Books to export", + "staff090_catalogue_marc_export_run": "Export", + "staff090_catalogue_marc_export_book": "Export in MARC", + "staff090_catalogue_format_marc21": "MARC 21 (.mrc)", + "staff090_catalogue_format_marcxml": "MARCXML (.xml)", + "staff090_catalogue_format_unimarc": "UNIMARC (.mrc)", + "staff090_catalogue_copy_servers": "Catalogues", + "staff090_catalogue_copy_index": "Search in", + "staff090_catalogue_copy_term": "Search term", + "staff090_catalogue_copy_scan": "Scan a barcode", + "staff090_catalogue_copy_scan_permission": "The camera reads ISBN and ISSN barcodes. You can also type the term above.", + "staff090_catalogue_copy_max": "Results per catalogue", + "staff090_catalogue_copy_search": "Search", + "staff090_catalogue_copy_search_failed": "The search could not be run.", + "staff090_catalogue_copy_results": "Results", + "staff090_catalogue_copy_server_count": "Shown: %1$d of %2$d", + "staff090_catalogue_copy_no_results": "No record found.", + "staff090_catalogue_copy_selected": "Selected", + "staff090_catalogue_copy_not_selected": "Not selected", + "staff090_catalogue_copy_covers": "Download the covers", + "staff090_catalogue_copy_import": "Import selected (%1$d)", + "staff090_catalogue_copy_import_failed": "The records could not be imported.", + "staff090_catalogue_copy_result": "Import result", + "staff090_catalogue_copy_action_created": "Created", + "staff090_catalogue_copy_action_updated": "Updated", + "staff090_catalogue_copy_action_skipped": "Skipped (already in the catalogue)", + "staff090_catalogue_copy_action_failed": "Failed", + "staff090_catalogue_copy_unavailable_title": "Copy cataloguing is not available", + "staff090_catalogue_copy_reason_plugin": "Copy cataloguing needs the Z39.50/SRU server plugin: an administrator can activate it in Plugins on the website.", + "staff090_catalogue_err_copy_unavailable": "Copy cataloguing is not available on this library server.", + "staff090_catalogue_err_job_cancelled": "The import was cancelled while it was running.", + "staff090_catalogue_err_schema_missing": "This tool will be available once the library server finishes its database update.", + "staff090_catalogue_err_search_gone": "These results are no longer available: search again.", + "staff090_patrons_import_open_started": "Started · %1$s", + "staff090_patrons_import_open_not_started": "Not started yet · rows: %1$d", + "staff090_stocktake_filter_open": "Open", + "staff090_stocktake_filter_closed": "Closed", + "staff090_stocktake_filter_cancelled": "Cancelled", + "staff090_stocktake_filter_all": "All", + "staff090_stocktake_empty": "No stocktake here", + "staff090_stocktake_empty_hint": "Start one with the + button: the phone becomes the scanner.", + "staff090_stocktake_new": "New stocktake", + "staff090_stocktake_row_counts": "Expected: %1$d · Found: %2$d · Missing: %3$d · Scans: %4$d", + "staff090_stocktake_opened": "Opened %1$s", + "staff090_stocktake_field_name": "Name", + "staff090_stocktake_name_required": "Give the stocktake a name.", + "staff090_stocktake_field_note": "Note", + "staff090_stocktake_scope": "What to check", + "staff090_stocktake_scope_hint": "The copies matching these choices are the expected ones. Leave everything empty to check the whole library.", + "staff090_stocktake_field_branch": "Branch", + "staff090_stocktake_field_shelf": "Shelf", + "staff090_stocktake_field_level": "Level", + "staff090_stocktake_level": "Level %1$d", + "staff090_stocktake_field_genre": "Genre", + "staff090_stocktake_field_media": "Media type", + "staff090_stocktake_field_collocation_from": "Shelfmark from", + "staff090_stocktake_field_collocation_to": "Shelfmark to", + "staff090_stocktake_any_shelf": "Every shelf", + "staff090_stocktake_any_genre": "Every genre", + "staff090_stocktake_search_shelf": "Search a shelf", + "staff090_stocktake_search_genre": "Search a genre", + "staff090_stocktake_open_session": "Start the stocktake", + "staff090_stocktake_camera_permission": "Allow the camera to read the copies' labels. You can also type or upload the codes.", + "staff090_stocktake_current_shelf": "Shelf you are checking", + "staff090_stocktake_no_shelf": "Not set: copies are not checked for their place", + "staff090_stocktake_scan_hint": "Point the camera at a label: every copy read is recorded at once, with a beep.", + "staff090_stocktake_sending": "Sending: %1$d", + "staff090_stocktake_belongs_on": "Belongs on %1$s", + "staff090_stocktake_recorded_as": "Recorded as: %1$s", + "staff090_stocktake_manual_code": "Copy code", + "staff090_stocktake_send_code": "Record this code", + "staff090_stocktake_upload": "Upload a list of codes (.txt or .csv)", + "staff090_stocktake_upload_wrong_file": "Choose a .txt or .csv file of at most 2 MB, one code per line.", + "staff090_stocktake_upload_done": "Code list processed", + "staff090_stocktake_not_recorded": "No connection: %1$s was not recorded. Read it again when you are back online.", + "staff090_stocktake_progress": "Progress", + "staff090_stocktake_count_expected": "Expected", + "staff090_stocktake_count_found": "Found", + "staff090_stocktake_count_misplaced": "Misplaced", + "staff090_stocktake_count_on_loan": "On loan", + "staff090_stocktake_count_missing": "Missing", + "staff090_stocktake_count_lost": "Marked lost", + "staff090_stocktake_count_unknown": "Unknown codes", + "staff090_stocktake_count_out_of_scope": "Out of scope", + "staff090_stocktake_count_duplicates": "Already scanned", + "staff090_stocktake_count_mine": "My scans", + "staff090_stocktake_report": "Report", + "staff090_stocktake_close": "Close the stocktake", + "staff090_stocktake_close_resume": "Finish marking as lost", + "staff090_stocktake_action_none": "Leave the missing copies as they are", + "staff090_stocktake_action_lost": "Mark the missing copies as lost", + "staff090_stocktake_close_confirm_none": "Missing copies: %1$d. They stay as they are; no more codes can be scanned afterwards.", + "staff090_stocktake_close_confirm_lost": "Missing copies: %1$d. They will be marked as lost; no more codes can be scanned afterwards.", + "staff090_stocktake_close_confirm_resume": "Copies still to mark as lost: %1$d.", + "staff090_stocktake_closed": "Stocktake closed.", + "staff090_stocktake_cancel": "Cancel the stocktake", + "staff090_stocktake_cancel_title": "Cancel this stocktake?", + "staff090_stocktake_cancel_body": "No copy is changed and no more codes can be scanned. The scans stay in the report.", + "staff090_stocktake_keep": "Keep it open", + "staff090_stocktake_cancelled": "Stocktake cancelled.", + "staff090_stocktake_no_more_scans": "This stocktake takes no more scans.", + "staff090_stocktake_pending_lost": "Copies not yet marked as lost: %1$d", + "staff090_stocktake_recent": "Latest scans", + "staff090_stocktake_filter_latest": "Latest", + "staff090_stocktake_only_mine": "Only my scans", + "staff090_stocktake_no_scans": "No scans yet.", + "staff090_stocktake_more": "Show more", + "staff090_stocktake_details": "Details", + "staff090_stocktake_whole_library": "The whole library", + "staff090_stocktake_opened_label": "Opened", + "staff090_stocktake_closed_label": "Closed", + "staff090_stocktake_history": "History", + "staff090_stocktake_report_rows": "Rows: %1$d", + "staff090_stocktake_report_empty": "Nothing in this category", + "staff090_stocktake_download_csv": "Download the report as CSV", + "staff090_stocktake_download_pdf": "Download the report as PDF", + "staff090_stocktake_err_session_closed": "This stocktake has been closed: no more codes can be scanned.", + "staff090_stocktake_err_session_cancelled": "This stocktake has been cancelled: no more codes can be scanned.", + "staff090_stocktake_err_report_too_large": "The report is too long for a PDF: download the CSV instead.", + "staff090_stocktake_err_invalid_state": "This action is not possible in the stocktake's current state.", + "staff090_stocktake_not_permitted": "Only an administrator can do this.", + "staff090_statistics_overview": "Overview", + "staff090_statistics_books_available": "Books available", + "staff090_statistics_books_on_loan": "Books on loan", + "staff090_statistics_active_loans": "Active loans", + "staff090_statistics_overdue_loans": "Overdue loans", + "staff090_statistics_completed_loans": "Completed loans", + "staff090_statistics_borrowers": "Readers with loans", + "staff090_statistics_by_month": "Loans by month", + "staff090_statistics_by_status": "Active loans by state", + "staff090_statistics_by_media": "Loans by material (last 12 months)", + "staff090_statistics_top_books": "Most borrowed books", + "staff090_statistics_top_readers": "Most active readers", + "staff090_statistics_loan_counts": "Loans: %1$d · Active: %2$d · Overdue: %3$d", + "staff090_statistics_visitors": "Visitors, last days: %1$d", + "staff090_statistics_visitors_members": "Members", + "staff090_statistics_visitors_guests": "Guests", + "staff090_statistics_visitors_total": "Total", + "staff090_statistics_visitors_daily": "Visitors by day", + "staff090_statistics_fines_report": "Fines report", + "staff090_statistics_from": "From", + "staff090_statistics_to": "To", + "staff090_statistics_fines_empty": "No movement in this period.", + "staff090_statistics_fines_totals": "Totals", + "staff090_statistics_charged": "Charged", + "staff090_statistics_collected": "Collected", + "staff090_statistics_waived": "Waived", + "staff090_statistics_reversed": "Reversed", + "staff090_statistics_fines_csv": "Download the fines report (CSV)", + "staff090_patrons_balance_owed_staff": "Amount the patron owes the library.", + "staff090_patrons_balance_credit_staff": "Credit in the patron's favour.", + "staff090_patrons_debt_blocked_body_staff": "The patron's balance is above the library's limit of %1$s: new loans and renewals are blocked until it is settled.", + "staff090_patrons_debt_blocked_body_plain_staff": "New loans and renewals are blocked until the patron's balance is settled." } diff --git a/i18n/fr.json b/i18n/fr.json index f51d7c5..b701981 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -207,6 +207,7 @@ "profile_date_none": "Non renseignée", "profile_card_number": "Numéro de carte", "profile_card_expires": "Expiration de la carte", + "profile_card_barcode": "Code-barres de la carte %1$s", "profile_change_password_title": "Changer le mot de passe", "profile_current_password": "Mot de passe actuel", "profile_new_password": "Nouveau mot de passe", @@ -269,6 +270,7 @@ "snackbar_request_for_date": "Prêt demandé pour le %1$s", "snackbar_reservation_for_date": "Réservation enregistrée pour le %1$s", "snackbar_loan_ready_for_pickup": "Prêt approuvé; prêt à être retiré.", + "snackbar_loan_scheduled": "Prêt approuvé : vous pourrez retirer le livre à partir du %1$s.", "book_section_audiobook": "Livre audio", "audio_play": "Lecture", "audio_pause": "Pause", @@ -993,6 +995,8 @@ "staff_scan_next": "Scanner l’exemplaire suivant", "staff_loan_new_with_copy": "Nouveau prêt avec cet exemplaire", "staff_loan_open": "Ouvrir le prêt", + "staff_reservation_override_limits": "Ignorer les limites de réservation de la catégorie du lecteur", + "staff_reservation_override_limits_hint": "Comme dans le formulaire du site : la réservation est créée même au-delà des limites de la catégorie du lecteur.", "staff_hand_over_now": "Remettre maintenant", "staff_hand_over_now_hint": "Le lecteur emporte l’exemplaire au guichet : le prêt démarre aussitôt.", "staff_hand_over_future": "Un prêt qui commence plus tard est programmé et remis ce jour-là.", @@ -1453,5 +1457,651 @@ "digital_locked_login": "Connectez-vous pour l'ouvrir", "digital_locked_loan": "Pour les lecteurs qui ont emprunté ce livre", "digital_locked_other": "Non disponible pour votre compte", - "digital_link_refresh_error": "Impossible d’actualiser le lien. Réessayez." + "digital_link_refresh_error": "Impossible d’actualiser le lien. Réessayez.", + "book_loan_legend_closed": "Bibliothèque fermée", + "book_loan_closed_day": "La bibliothèque est fermée le %1$s : choisissez un autre jour.", + "book_consultation_only": "Consultation sur place uniquement", + "book_consultation_only_hint": "Vous pouvez consulter ce titre à la bibliothèque, mais il ne peut pas être emprunté avec votre carte.", + "book_reservations_disabled_hint": "Aucun exemplaire n'est libre pour le moment et votre carte ne permet pas de réserver. Réessayez quand un exemplaire sera revenu.", + "request_err_not_loanable": "Ce titre est en consultation sur place uniquement pour votre carte : il ne peut pas être emprunté.", + "request_err_reservations_disabled": "Votre carte ne permet pas de réserver.", + "request_err_reservation_limit": "Vous avez atteint le nombre maximal de réservations.", + "request_err_media_limit": "Vous avez atteint le nombre maximal de prêts pour ce type de document.", + "request_err_max_loans": "Vous avez atteint le nombre maximal de prêts en cours.", + "request_err_debt_block": "Les prêts sont bloqués tant que votre solde auprès de la bibliothèque n'est pas réglé.", + "request_err_card_expired": "Votre carte de bibliothèque a expiré : demandez à la bibliothèque de la renouveler.", + "request_err_suspended": "Votre compte est suspendu : contactez la bibliothèque.", + "staff_err_media_limit": "Cet utilisateur a atteint le nombre maximal de prêts pour ce type de document.", + "staff_err_not_loanable": "Ce document est en consultation sur place uniquement pour la catégorie de ce lecteur.", + "staff_err_reservations_disabled": "Les réservations ne sont pas autorisées pour la catégorie de ce lecteur.", + "staff_err_reservation_limit": "Ce lecteur a atteint le nombre maximal de réservations.", + "staff_err_debt_block": "Prêts bloqués : le solde de cet utilisateur dépasse le seuil d'endettement.", + "staff_err_card_expired": "La carte de bibliothèque de cet utilisateur a expiré.", + "staff_err_user_suspended": "Cet utilisateur est suspendu.", + "staff_copy_consultation_only": "Consultation sur place", + "staff_copy_consultation_switch": "Consultation sur place uniquement", + "staff_copy_consultation_switch_hint": "L'exemplaire n'est jamais prêté. Comme sur la fiche du livre : impossible tant que l'exemplaire est prêté, en attente de retrait ou demandé.", + "profile_patron_type": "Catégorie de lecteur", + "profile_action_movements": "Solde et mouvements", + "title_movements": "Solde et mouvements", + "movements_loading": "Chargement du solde…", + "movements_error_load": "Impossible de charger le solde.", + "movements_balance": "Solde", + "movements_balance_owed": "Montant dû à la bibliothèque.", + "movements_balance_credit": "Crédit en votre faveur.", + "movements_balance_settled": "Rien à payer.", + "movements_debt_blocked_title": "Prêts bloqués", + "movements_debt_blocked_body": "Votre solde dépasse le seuil de %1$s fixé par la bibliothèque : les nouveaux prêts et les prolongations sont bloqués jusqu'à son règlement.", + "movements_debt_blocked_body_plain": "Les nouveaux prêts et les prolongations sont bloqués jusqu'au règlement de votre solde.", + "movements_accrued_title": "Amendes en cours sur les prêts en retard : %1$s", + "movements_accrued_row": "Échéance %1$s · jours de retard : %2$d · facturée au retour", + "movements_history_title": "Mouvements", + "movements_empty_title": "Aucun mouvement", + "movements_empty_subtitle": "Les frais, paiements et remises apparaîtront ici.", + "movements_untitled": "Prêt", + "movements_refresh_failed": "Échec de l'actualisation : le solde affiché n'est peut-être pas à jour.", + "staff_loan_closed_day": "La bibliothèque est fermée le %1$s : choisissez une autre date de début ou de retour.", + "staff090_section_desk": "Accueil", + "staff090_section_cataloguing": "Catalogage", + "staff090_section_library": "Bibliothèque", + "staff090_attendance": "Registre des visites", + "staff090_attendance_subtitle": "Scanner les cartes à l'entrée, enregistrer les invités", + "staff090_stocktake": "Récolement", + "staff090_stocktake_subtitle": "Vérifier les rayonnages avec l'appareil photo", + "staff090_user_import": "Importer des lecteurs", + "staff090_user_import_subtitle": "Depuis un fichier CSV", + "staff090_marc": "Import et export MARC", + "staff090_marc_subtitle": "ISO 2709, MARCXML, UNIMARC", + "staff090_copy_cataloging": "Catalogage dérivé", + "staff090_copy_cataloging_subtitle": "Chercher dans d'autres catalogues", + "staff090_suppliers": "Fournisseurs", + "staff090_suppliers_subtitle": "Libraires et donateurs des livres", + "staff090_statistics": "Statistiques", + "staff090_statistics_subtitle": "Prêts, lecteurs, visites, amendes", + "staff090_calendar": "Calendrier des fermetures", + "staff090_calendar_subtitle": "Jours de fermeture et jours fériés", + "staff090_circulation_rules": "Types de lecteurs et règles de prêt", + "staff090_circulation_rules_subtitle": "Limites par type de lecteur et support", + "staff090_settings": "Paramètres de la bibliothèque", + "staff090_settings_subtitle": "Modules, prêts, cartes", + "staff090_ledger": "Solde et paiements", + "staff090_patrons_section": "Lecteur", + "staff090_patrons_tools_hint": "Photo et carte de ce lecteur.", + "staff090_patrons_type": "Type de lecteur", + "staff090_patrons_type_default": "%1$s (par défaut)", + "staff090_patrons_type_card_days": "Validité de la carte (jours) : %1$d", + "staff090_patrons_change_type": "Changer le type", + "staff090_patrons_recompute_expiry": "Calculer l'échéance d'après le type", + "staff090_patrons_recompute_expiry_hint": "Aujourd'hui plus la validité de carte du type choisi, s'il en prévoit une.", + "staff090_patrons_type_saved": "Type de lecteur enregistré", + "staff090_patrons_type_saved_expiry": "Type enregistré, échéance de la carte mise à jour", + "staff090_patrons_renew_card": "Renouveler la carte", + "staff090_patrons_renew_body": "La validité de la carte est prolongée de la durée du type de lecteur (un an s'il n'en prévoit pas). Un compte expiré redevient actif.", + "staff090_patrons_card_renewed": "Carte renouvelée jusqu'au %1$s", + "staff090_patrons_print_card": "Imprimer la carte", + "staff090_patrons_card_ready": "Carte prête", + "staff090_patrons_photo": "Photo du lecteur", + "staff090_patrons_photo_take": "Prendre une photo", + "staff090_patrons_photo_replace": "Remplacer la photo", + "staff090_patrons_photo_pick": "Choisir une image", + "staff090_patrons_photo_remove": "Supprimer la photo", + "staff090_patrons_photo_remove_body": "La photo du lecteur est supprimée du serveur.", + "staff090_patrons_photo_saved": "Photo enregistrée", + "staff090_patrons_photo_removed": "Photo supprimée", + "staff090_patrons_photo_unreadable": "Impossible de lire cette image.", + "staff090_patrons_balance_line": "Solde : %1$s", + "staff090_patrons_debt_blocked": "Bloqué pour dette", + "staff090_patrons_record_payment": "Enregistrer un paiement", + "staff090_patrons_add_charge": "Ajouter un débit", + "staff090_patrons_waive": "Remettre", + "staff090_patrons_amount": "Montant (%1$s)", + "staff090_patrons_amount_invalid": "Saisissez un montant supérieur à zéro, avec au plus deux décimales.", + "staff090_patrons_amount_too_large": "Au maximum %1$s", + "staff090_patrons_method": "Moyen de paiement", + "staff090_patrons_note_optional": "Note (facultative)", + "staff090_patrons_description": "Description", + "staff090_patrons_waive_reason": "Motif de la remise", + "staff090_patrons_reason_required": "Un motif est obligatoire.", + "staff090_patrons_payment_recorded": "Paiement enregistré", + "staff090_patrons_charge_recorded": "Débit ajouté", + "staff090_patrons_waiver_recorded": "Remise enregistrée", + "staff090_patrons_recorded_by": "Enregistré par %1$s", + "staff090_patrons_receipt": "Reçu", + "staff090_patrons_receipt_loading": "Téléchargement…", + "staff090_patrons_receipt_ready": "Reçu de paiement prêt", + "staff090_patrons_return_fine": "Amende de retard : %1$s (jours de retard : %2$d)", + "staff090_patrons_return_waive": "Remettre l'amende", + "staff090_patrons_tab_desk": "Entrée", + "staff090_patrons_tab_stats": "Statistiques", + "staff090_patrons_scan_permission": "Autorisez l'appareil photo pour lire les cartes. Vous pouvez aussi saisir le code ci-dessous.", + "staff090_patrons_scan_hint": "Placez le code-barres ou le QR de la carte dans le cadre.", + "staff090_patrons_card_code": "Code de la carte", + "staff090_patrons_card_code_required": "Saisissez le code de la carte.", + "staff090_patrons_check_in": "Enregistrer la visite", + "staff090_patrons_branch": "Site", + "staff090_patrons_branch_none": "Non précisé", + "staff090_patrons_card_line": "Carte %1$s", + "staff090_patrons_card_expired": "Carte expirée le %1$s", + "staff090_patrons_card_not_active": "Le compte est suspendu ou expiré.", + "staff090_patrons_guest": "Enregistrer un visiteur", + "staff090_patrons_guest_name": "Nom (facultatif)", + "staff090_patrons_guest_reason": "Motif de la visite (facultatif)", + "staff090_patrons_guest_save": "Enregistrer la visite", + "staff090_patrons_guest_anonymous": "Visiteur", + "staff090_patrons_member": "Membre", + "staff090_patrons_today_counts": "Aujourd'hui : membres %1$d, visiteurs %2$d", + "staff090_patrons_period_days": "%1$d j", + "staff090_patrons_today": "Aujourd'hui", + "staff090_patrons_period_total": "Période", + "staff090_patrons_counts": "Total %1$d · Membres %2$d · Visiteurs %3$d", + "staff090_patrons_by_day": "Par jour", + "staff090_patrons_by_hour": "Par heure", + "staff090_patrons_no_visits": "Aucune visite sur la période.", + "staff090_patrons_recent": "Dernières visites", + "staff090_patrons_export_csv": "Exporter en CSV", + "staff090_patrons_retention": "Conservation des données", + "staff090_patrons_retention_hint": "Les visites plus anciennes que ce nombre de jours sont supprimées par la maintenance (0 = conserver toujours, maximum %1$d).", + "staff090_patrons_retention_days": "Conserver les visites (jours)", + "staff090_patrons_retention_invalid": "Saisissez un nombre entier de 0 à %1$d.", + "staff090_patrons_retention_saved": "Conservation enregistrée", + "staff090_patrons_purge": "Supprimer maintenant les anciennes visites", + "staff090_patrons_purge_body": "Les visites plus anciennes que la durée de conservation (jours : %1$d) sont supprimées maintenant. Action irréversible.", + "staff090_patrons_purged": "Visites supprimées : %1$d", + "staff090_patrons_import_intro": "Créez des comptes lecteur à partir d'un fichier CSV. Les lignes dont l'e-mail ou le code de carte existe déjà sont ignorées ; les mots de passe ne sont jamais importés.", + "staff090_patrons_delimiter": "Séparateur", + "staff090_patrons_delimiter_auto": "Détecter", + "staff090_patrons_delimiter_semicolon": "Point-virgule", + "staff090_patrons_delimiter_comma": "Virgule", + "staff090_patrons_delimiter_tab": "Tabulation", + "staff090_patrons_import_upload": "Envoyer le fichier", + "staff090_patrons_import_too_big": "Le fichier dépasse 5 Mo.", + "staff090_patrons_import_wrong_ext": "Choisissez un fichier .csv, .tsv ou .txt.", + "staff090_patrons_import_ready": "Vérifiez les colonnes, puis lancez", + "staff090_patrons_import_rows": "Lignes du fichier", + "staff090_patrons_import_preview": "Aperçu", + "staff090_patrons_import_mapping": "Colonnes", + "staff090_patrons_import_column": "Colonne %1$d", + "staff090_patrons_import_ignore": "Ignorer", + "staff090_patrons_import_missing": "Associez les champs obligatoires : %1$s", + "staff090_patrons_import_invites": "Envoyer une invitation par e-mail", + "staff090_patrons_import_invites_hint": "Chaque nouveau lecteur reçoit le lien pour définir son mot de passe.", + "staff090_patrons_import_start": "Lancer l'import", + "staff090_patrons_import_created": "Comptes créés", + "staff090_patrons_import_duplicates": "Doublons ignorés", + "staff090_patrons_import_failed": "Lignes en erreur", + "staff090_patrons_import_invited": "Invitations envoyées", + "staff090_patrons_import_invite_failed": "Invitations non envoyées", + "staff090_patrons_import_error_line": "Ligne %1$d : %2$s", + "staff090_patrons_import_cancelled": "Import annulé", + "staff090_patrons_import_new": "Nouvel import", + "staff090_patrons_err_card_not_renewable": "Ce compte n'a pas de carte à renouveler.", + "staff090_patrons_err_amount_exceeds_balance": "Le montant dépasse ce que doit le lecteur.", + "staff090_patrons_err_card_not_found": "Carte non reconnue", + "staff090_patrons_err_not_started": "Lancez l'import avant de traiter les lignes.", + "staff090_patrons_err_already_started": "Cet import a déjà commencé.", + "staff090_patrons_err_job_cancelled": "L'import a été annulé pendant le traitement.", + "staff090_patrons_err_file_missing": "Le fichier envoyé n'est plus sur le serveur : envoyez-le à nouveau.", + "staff090_patrons_err_import_not_finished": "Le rapport d'erreurs sera disponible à la fin de l'import.", + "staff090_patrons_err_db_update_pending": "Disponible après la mise à jour de la base de données de la bibliothèque.", + "staff090_library_active": "Active", + "staff090_library_admin_only": "Réservé aux administrateurs.", + "staff090_library_all_branches": "Tous les sites", + "staff090_library_all_types": "Tous les types de lecteurs", + "staff090_library_apply": "Appliquer", + "staff090_library_branch": "Site", + "staff090_library_branch_preview": "Calendrier du site", + "staff090_library_calendar_module_off": "Le calendrier des fermetures est désactivé : ce que vous réglez ici est conservé, mais les échéances l'ignorent tant que le module n'est pas activé dans Réglages → Prêts.", + "staff090_library_card_back_text": "Texte au verso", + "staff090_library_card_back_text_hint": "Horaires, règlement, contacts. Vide = verso blanc.", + "staff090_library_card_code_barcode": "Code-barres (Code 128)", + "staff090_library_card_code_qr": "Code QR", + "staff090_library_card_code_type": "Code sur la carte", + "staff090_library_card_format": "Format d'impression", + "staff090_library_card_format_sheet": "Planche A4 de cartes", + "staff090_library_card_format_single": "Une carte par page", + "staff090_library_card_layout": "Mise en page", + "staff090_library_card_layout_classic": "Classique", + "staff090_library_card_layout_minimal": "Épurée", + "staff090_library_card_layout_portrait": "Portrait", + "staff090_library_card_section": "Carte de lecteur", + "staff090_library_card_show_expiry": "Afficher la date d'expiration", + "staff090_library_card_show_logo": "Afficher le logo de la bibliothèque", + "staff090_library_card_show_photo": "Afficher la photo du lecteur", + "staff090_library_card_title": "Titre de la carte", + "staff090_library_card_title_hint": "Vide : le nom de la bibliothèque.", + "staff090_library_cards_layout_section": "Mise en page et code", + "staff090_library_cards_module_off": "L'impression des cartes est désactivée : vous pouvez préparer les réglages, les cartes s'impriment une fois le module activé.", + "staff090_library_cards_show_section": "Sur la carte", + "staff090_library_cards_text_section": "Textes", + "staff090_library_catalogue_mode": "Le mode catalogue est actif : les modules de circulation restent désactivés quel que soit leur interrupteur.", + "staff090_library_cell_custom": "Règle propre", + "staff090_library_cell_duration": "Jours : %1$d", + "staff090_library_cell_fine": "Amende par jour : %1$s", + "staff090_library_cell_loanable": "Empruntable", + "staff090_library_cell_max": "Max : %1$d", + "staff090_library_cell_not_loanable": "Non empruntable", + "staff090_library_cell_pending": "Non enregistré", + "staff090_library_cell_renewals": "Renouvellements : %1$d", + "staff090_library_cell_title": "%1$s · %2$s", + "staff090_library_close": "Fermer", + "staff090_library_closure_delete": "Supprimer la fermeture ?", + "staff090_library_closure_delete_body": "%1$s redeviendra un jour d'ouverture ; les échéances sont recalculées.", + "staff090_library_closure_edit": "Modifier la fermeture", + "staff090_library_closure_end": "Dernier jour (facultatif)", + "staff090_library_closure_end_before": "Le dernier jour ne peut pas précéder le premier.", + "staff090_library_closure_end_hint": "Laissez le dernier jour vide pour une seule journée.", + "staff090_library_closure_new": "Ajouter une fermeture", + "staff090_library_closure_preset": "Jour férié", + "staff090_library_closure_start": "Premier jour", + "staff090_library_closure_untitled": "Fermeture", + "staff090_library_closure_yearly": "Chaque année", + "staff090_library_closure_yearly_hint": "Se répète aux mêmes dates chaque année (au plus %1$d jours).", + "staff090_library_closure_yearly_switch": "Répéter chaque année", + "staff090_library_closures": "Fermetures", + "staff090_library_closures_empty": "Aucune fermeture.", + "staff090_library_country": "Pays", + "staff090_library_country_search": "Rechercher un pays", + "staff090_library_custom": "Jours fériés de la bibliothèque", + "staff090_library_custom_calendar": "Calendrier", + "staff090_library_custom_calendar_hint": "Le calendrier islamique suit les tables Umm al-Qura : là où le mois commence avec l'observation locale de la lune, la date peut varier d'un jour. Utilisez alors des dates précises.", + "staff090_library_custom_dates": "Dates (AAAA-MM-JJ, une par ligne)", + "staff090_library_custom_dates_hint": "Au plus %1$d dates.", + "staff090_library_custom_day": "Jour", + "staff090_library_custom_delete": "Supprimer le jour férié ?", + "staff090_library_custom_delete_body": "%1$s ne fermera plus la bibliothèque.", + "staff090_library_custom_duration": "Durée (jours)", + "staff090_library_custom_edit": "Modifier le jour férié", + "staff090_library_custom_help": "Vos propres jours fériés : saint patron, fête locale, fête religieuse d'un autre calendrier.", + "staff090_library_custom_kind": "Mode de calcul", + "staff090_library_custom_month": "Mois", + "staff090_library_custom_more": "Durée et validité", + "staff090_library_custom_name_hint": "p. ex. Saint patron", + "staff090_library_custom_new": "Ajouter un jour férié", + "staff090_library_custom_next": "Prochaines : %1$s", + "staff090_library_custom_occurrence": "Lequel", + "staff090_library_custom_offset": "Jours par rapport au dimanche de Pâques", + "staff090_library_custom_offset_hint": "0 = dimanche de Pâques ; 1 = le lundi suivant ; -2 = Vendredi saint ; 49 = Pentecôte.", + "staff090_library_custom_weekday": "Jour de la semaine", + "staff090_library_custom_weekend": "S'il tombe un week-end", + "staff090_library_custom_year_from": "À partir de (année)", + "staff090_library_custom_year_until": "Jusqu'à (année)", + "staff090_library_data_years": "Les données couvrent les années %1$d–%2$d.", + "staff090_library_day_closed": "La bibliothèque est fermée.", + "staff090_library_day_open": "La bibliothèque est ouverte.", + "staff090_library_delete": "Supprimer", + "staff090_library_deleted": "Supprimé", + "staff090_library_discard": "Annuler les modifications", + "staff090_library_err_always_closed": "Avec cette modification la bibliothèque serait fermée toute l'année : au moins un jour doit rester ouvert.", + "staff090_library_err_ics_too_large": "Le fichier ICS dépasse 1 Mo.", + "staff090_library_err_preset_closure": "Les jours fériés nationaux se modifient depuis la sélection des jours fériés.", + "staff090_library_err_schema_not_ready": "Disponible une fois la mise à jour de la base de données effectuée sur le serveur.", + "staff090_library_fines_admin_only": "Seuls les administrateurs peuvent modifier les amendes et le blocage pour dette.", + "staff090_library_grace_deduct": "Déduction : toujours retirer les jours de tolérance", + "staff090_library_grace_threshold": "Seuil : passé la tolérance, compter dès le premier jour", + "staff090_library_group_loans": "Prêts", + "staff090_library_group_modules": "Modules", + "staff090_library_holiday_estimated": "estimée", + "staff090_library_holiday_optional": "facultatif", + "staff090_library_holiday_regional": "régional", + "staff090_library_holiday_yearly": "même date chaque année", + "staff090_library_holidays": "Jours fériés", + "staff090_library_holidays_apply": "Appliquer les jours fériés (%1$d)", + "staff090_library_holidays_help": "Cochez les jours fériés où la bibliothèque est fermée. Ils sont recalculés chaque année.", + "staff090_library_holidays_national": "Jours fériés nationaux", + "staff090_library_holidays_none": "Aucun jour férié national choisi", + "staff090_library_holidays_saved": "%1$s · Jours fériés : %2$d", + "staff090_library_ics": "Importer depuis un fichier de calendrier", + "staff090_library_ics_added": "Fermetures ajoutées : %1$d", + "staff090_library_ics_failed": "Impossible d'importer le fichier de calendrier.", + "staff090_library_ics_file": "Fichier ICS", + "staff090_library_ics_help": "Chaque événement du fichier devient une fermeture. 1 Mo au plus.", + "staff090_library_ics_import": "Importer", + "staff090_library_ics_read_failed": "Impossible de lire le fichier.", + "staff090_library_inactive": "Désactivée", + "staff090_library_inherit_all": "Tout hériter", + "staff090_library_inherit_note": "Un champ vide hérite de la valeur du niveau supérieur.", + "staff090_library_inherits": "Vide : %1$s", + "staff090_library_kind_calendar": "Dans un autre calendrier", + "staff090_library_kind_dates": "Dates précises", + "staff090_library_kind_easter": "Par rapport à Pâques", + "staff090_library_kind_fixed": "Même jour chaque année", + "staff090_library_kind_nth_weekday": "Un jour de la semaine du mois (p. ex. le premier lundi)", + "staff090_library_kind_orthodox_easter": "Par rapport à la Pâque orthodoxe", + "staff090_library_legend_closed": "Fermé", + "staff090_library_loan_auto_approve": "Approuver automatiquement les demandes", + "staff090_library_loan_auto_approve_help": "Les demandes de prêt sautent l'approbation manuelle et attendent directement le retrait.", + "staff090_library_loan_card_renewal_base": "Renouveler la carte à partir de", + "staff090_library_loan_card_warning": "Avis d'expiration de carte (jours avant)", + "staff090_library_loan_card_warning_help": "Le lecteur reçoit un e-mail avant l'expiration de sa carte. 0 = aucun avis.", + "staff090_library_loan_daily_fine": "Amende journalière", + "staff090_library_loan_daily_fine_help": "Tarif global par jour. Vide ou 0 = aucun tarif global ; les types de lecteurs peuvent avoir le leur.", + "staff090_library_loan_debt_block": "Seuil de blocage pour dette", + "staff090_library_loan_debt_block_help": "Seuil global de dette. Vide ou 0 = aucun blocage global.", + "staff090_library_loan_duration": "Durée de prêt par défaut (jours)", + "staff090_library_loan_duration_help": "Utilisée sans date de retour : demandes des lecteurs, prêts au comptoir, réservations approuvées.", + "staff090_library_loan_fine_email": "Prévenir le lecteur par e-mail quand une amende est facturée", + "staff090_library_loan_fine_email_help": "Utilise le modèle d'e-mail « Amende de retard facturée ».", + "staff090_library_loan_grace_days": "Jours de tolérance", + "staff090_library_loan_grace_days_help": "Un retard jusqu'à ce nombre de jours n'est pas sanctionné.", + "staff090_library_loan_grace_mode": "Mode de tolérance", + "staff090_library_loan_max_active": "Prêts actifs par lecteur", + "staff090_library_loan_max_active_help": "0 = aucune limite. Comptés : réservé, à retirer, en cours, en retard.", + "staff090_library_loan_max_duration": "Durée maximale demandable (jours)", + "staff090_library_loan_max_duration_help": "Les demandes et réservations plus longues sont refusées.", + "staff090_library_loan_max_fine": "Amende maximale par prêt", + "staff090_library_loan_max_fine_help": "Plafond de l'amende d'un prêt. Vide ou 0 = aucun plafond.", + "staff090_library_loan_max_renewals": "Renouvellements par prêt", + "staff090_library_loan_max_renewals_help": "0 désactive les renouvellements.", + "staff090_library_loan_multiple_copies": "Plusieurs exemplaires du même titre", + "staff090_library_loan_multiple_copies_help": "Un lecteur peut emprunter le même livre plusieurs fois, chaque prêt sur un exemplaire différent.", + "staff090_library_loan_pickup_days": "Jours pour retirer un prêt approuvé", + "staff090_library_loan_pickup_days_help": "Passé ce délai le prêt approuvé expire.", + "staff090_library_loan_pickup_reminder": "Rappel de retrait (jours avant)", + "staff090_library_loan_pickup_reminder_help": "Envoyé si le livre n'a pas encore été retiré.", + "staff090_library_loan_recall_interval": "Jours entre les relances", + "staff090_library_loan_recall_max": "Relances automatiques, au plus", + "staff090_library_loan_recalls": "Relances automatiques", + "staff090_library_loan_recalls_help": "E-mails répétés pour les prêts en retard non rendus.", + "staff090_library_loan_timezone": "Fuseau horaire", + "staff090_library_loan_timezone_help": "Utilisé pour les échéances, activations et automatismes des prêts : celui de la bibliothèque.", + "staff090_library_loan_unknown": "Réglage", + "staff090_library_loanable_inherit": "Hériter (%1$s)", + "staff090_library_loans_approval": "Approbation", + "staff090_library_loans_card": "Carte de lecteur", + "staff090_library_loans_duration": "Durée", + "staff090_library_loans_fines": "Amendes et blocage pour dette", + "staff090_library_loans_limits": "Limites", + "staff090_library_loans_pickup": "Retrait", + "staff090_library_loans_recalls": "Relances", + "staff090_library_loans_renewals": "Renouvellements", + "staff090_library_loans_section": "Prêts", + "staff090_library_matrix": "Règles par type de document", + "staff090_library_matrix_empty": "Aucun document à configurer", + "staff090_library_matrix_intro": "Chaque case remplace le type de lecteur pour un document ; « Tous les types de lecteurs » vaut pour chaque type qui laisse une valeur vide.", + "staff090_library_matrix_subtitle": "Limites par type de lecteur et document", + "staff090_library_module_catalogue_off": "Désactivé en mode catalogue.", + "staff090_library_module_follows": "Tant qu'il n'est pas enregistré, il suit « %1$s ».", + "staff090_library_module_needs": "Nécessite « %1$s ».", + "staff090_library_module_needs_off": "Inactif : « %1$s » est désactivé.", + "staff090_library_modules_read_only": "Seuls les administrateurs peuvent activer ou désactiver les modules.", + "staff090_library_money_invalid": "Saisissez un montant comme 1,50", + "staff090_library_money_label": "%1$s (%2$s)", + "staff090_library_move_down": "Descendre", + "staff090_library_move_up": "Monter", + "staff090_library_no": "Non", + "staff090_library_nth_1": "Le premier", + "staff090_library_nth_2": "Le deuxième", + "staff090_library_nth_3": "Le troisième", + "staff090_library_nth_4": "Le quatrième", + "staff090_library_nth_5": "Le cinquième", + "staff090_library_nth_last": "Le dernier", + "staff090_library_num_card_duration": "Validité de la carte (jours)", + "staff090_library_num_daily_fine": "Amende journalière", + "staff090_library_num_debt_block": "Seuil de blocage pour dette", + "staff090_library_num_grace_days": "Jours de tolérance", + "staff090_library_num_loan_duration": "Durée du prêt (jours)", + "staff090_library_num_max_fine": "Amende maximale par prêt", + "staff090_library_num_max_loans": "Prêts actifs au plus", + "staff090_library_num_max_renewals": "Renouvellements au plus", + "staff090_library_num_max_request": "Durée maximale demandable (jours)", + "staff090_library_num_max_reservations": "Réservations au plus", + "staff090_library_num_renewal_days": "Jours ajoutés par un renouvellement", + "staff090_library_off": "Désactivé", + "staff090_library_on": "Activé", + "staff090_library_preview_year": "Année %1$d", + "staff090_library_range": "De %1$d à %2$d.", + "staff090_library_reason_closure": "Fermeture : %1$s", + "staff090_library_reason_custom": "Jour férié de la bibliothèque : %1$s", + "staff090_library_reason_holiday": "Jour férié : %1$s", + "staff090_library_reason_other": "Fermé : %1$s", + "staff090_library_reason_weekly": "Fermeture hebdomadaire : %1$s", + "staff090_library_region": "Région", + "staff090_library_region_all": "Tout le pays", + "staff090_library_renewal_expiry": "L'échéance actuelle si elle est future ; sinon aujourd'hui", + "staff090_library_renewal_today": "Aujourd'hui", + "staff090_library_reservations_section": "Réservations", + "staff090_library_retention_days": "Conservation du registre des visites (jours)", + "staff090_library_retention_hint": "0 = jamais supprimé automatiquement. %1$d au plus.", + "staff090_library_rules_module_off": "Les règles par type de lecteur sont désactivées : vous pouvez les préparer ici, elles s'appliquent une fois le module activé dans Réglages → Prêts.", + "staff090_library_save_cells": "Enregistrer (%1$d)", + "staff090_library_save_weekdays": "Enregistrer les jours de fermeture", + "staff090_library_saved": "Enregistré", + "staff090_library_tab_cards": "Cartes", + "staff090_library_tab_loans": "Prêts", + "staff090_library_tab_modules": "Modules", + "staff090_library_type_active": "Actif", + "staff090_library_type_active_hint": "Un type inactif n'est plus proposé aux nouveaux lecteurs.", + "staff090_library_type_code": "Code", + "staff090_library_type_code_hint": "Minuscules, chiffres, - et _ (40 au plus). Vide : tiré du nom.", + "staff090_library_type_default": "Par défaut", + "staff090_library_type_default_hint": "Les nouveaux lecteurs reçoivent ce type.", + "staff090_library_type_delete": "Supprimer le type de lecteur ?", + "staff090_library_type_delete_body": "%1$s sera supprimé et ses lecteurs passeront au type par défaut. Lecteurs : %2$d", + "staff090_library_type_deleted": "%1$s supprimé. Lecteurs passés au type par défaut : %2$d", + "staff090_library_type_edit": "Modifier le type de lecteur", + "staff090_library_type_inactive": "Inactif", + "staff090_library_type_make_default": "Définir par défaut", + "staff090_library_type_new": "Nouveau type de lecteur", + "staff090_library_type_reservations": "Réservations autorisées", + "staff090_library_type_users": "Lecteurs : %1$d", + "staff090_library_types": "Types de lecteurs", + "staff090_library_weekdays_all_closed": "Au moins un jour de la semaine doit rester ouvert.", + "staff090_library_weekend_next_monday": "Fermer aussi le lundi suivant", + "staff090_library_weekend_none": "Aucun jour en plus", + "staff090_library_weekend_us": "Samedi : aussi le vendredi avant ; dimanche : aussi le lundi après", + "staff090_library_weekly": "Fermeture hebdomadaire", + "staff090_library_weekly_help": "Les jours où la bibliothèque est fermée chaque semaine.", + "staff090_library_year_next": "Année suivante", + "staff090_library_year_prev": "Année précédente", + "staff090_library_yearly": "Fermetures annuelles", + "staff090_library_yearly_help": "Désactivez une fermeture annuelle pour l'ignorer sans la supprimer.", + "staff090_library_yes": "Oui", + "staff090_catalogue_card": "Fiche catalographique (PDF)", + "staff090_catalogue_cards_print": "Imprimer les fiches catalographiques (%1$d)", + "staff090_catalogue_cards_limit": "Fiches par PDF : %1$d au plus. Les premières choisies sont imprimées.", + "staff090_catalogue_in_catalogue": "Déjà au catalogue", + "staff090_catalogue_supplier": "Fournisseur", + "staff090_catalogue_supplier_new": "Nouveau fournisseur", + "staff090_catalogue_supplier_edit": "Modifier le fournisseur", + "staff090_catalogue_supplier_delete": "Supprimer le fournisseur", + "staff090_catalogue_supplier_delete_confirm": "Supprimer « %1$s » ?", + "staff090_catalogue_supplier_delete_books": "Supprimer « %1$s » ? Livres qui perdent ce fournisseur : %2$d. Les livres restent au catalogue.", + "staff090_catalogue_supplier_deleted": "Fournisseur supprimé. Livres détachés : %1$d", + "staff090_catalogue_supplier_saved": "Fournisseur enregistré", + "staff090_catalogue_supplier_search": "Rechercher des fournisseurs", + "staff090_catalogue_supplier_books": "Livres : %1$d", + "staff090_catalogue_supplier_contact": "Personne de contact", + "staff090_catalogue_supplier_vat": "Numéro de TVA", + "staff090_catalogue_suppliers_empty": "Aucun fournisseur", + "staff090_catalogue_rda_title": "Types RDA", + "staff090_catalogue_rda_hint": "Laissez « Déduit » sauf si la notice demande un autre type RDA.", + "staff090_catalogue_rda_content": "Type de contenu", + "staff090_catalogue_rda_media": "Type de médiation", + "staff090_catalogue_rda_carrier": "Type de support", + "staff090_catalogue_rda_derived": "Déduit du type de média", + "staff090_catalogue_rda_derived_value": "Déduit : %1$s", + "staff090_catalogue_marc_import": "Importer", + "staff090_catalogue_marc_export": "Exporter", + "staff090_catalogue_marc_import_hint": "ISO 2709 (.mrc, .marc, .iso, .dat) ou MARCXML (.xml), 20 Mo au plus. MARC 21, UNIMARC et danMARC2.", + "staff090_catalogue_marc_wrong_ext": "Choisissez un fichier MARC (.mrc, .marc, .iso, .dat) ou MARCXML (.xml).", + "staff090_catalogue_marc_flavour": "Format MARC", + "staff090_catalogue_marc_encoding": "Encodage des caractères", + "staff090_catalogue_marc_duplicates": "Livres déjà au catalogue", + "staff090_catalogue_marc_covers_switch": "Télécharger les couvertures", + "staff090_catalogue_marc_covers_hint": "Les couvertures indiquées dans le champ 856. Plus lent : deux notices à la fois.", + "staff090_catalogue_marc_upload": "Envoyer et prévisualiser", + "staff090_catalogue_marc_preview": "Prêt à importer", + "staff090_catalogue_marc_records": "Notices dans le fichier", + "staff090_catalogue_marc_container": "Type de fichier", + "staff090_catalogue_marc_container_iso": "ISO 2709", + "staff090_catalogue_marc_container_xml": "MARCXML", + "staff090_catalogue_marc_unreadable": "Notices illisibles", + "staff090_catalogue_marc_unreadable_row": "La notice %1$d est illisible", + "staff090_catalogue_marc_run": "Importer (notices : %1$d)", + "staff090_catalogue_marc_discard": "Abandonner ce fichier", + "staff090_catalogue_marc_first_records": "Premières notices du fichier", + "staff090_catalogue_marc_progress": "Notices : %1$d sur %2$d", + "staff090_catalogue_marc_skipped": "Ignorés", + "staff090_catalogue_marc_covers": "Couvertures téléchargées", + "staff090_catalogue_marc_new": "Nouvel import", + "staff090_catalogue_marc_format": "Format", + "staff090_catalogue_marc_scope": "Quoi exporter", + "staff090_catalogue_marc_scope_all": "Tout le catalogue", + "staff090_catalogue_marc_scope_all_hint": "Tous les livres, sauf les titres recherchés par la bibliothèque.", + "staff090_catalogue_marc_scope_chosen": "Livres choisis", + "staff090_catalogue_marc_chosen_count": "Choisis : %1$d (au plus %2$d)", + "staff090_catalogue_marc_add_books": "Ajouter des livres", + "staff090_catalogue_marc_pick_books": "Livres à exporter", + "staff090_catalogue_marc_export_run": "Exporter", + "staff090_catalogue_marc_export_book": "Exporter en MARC", + "staff090_catalogue_format_marc21": "MARC 21 (.mrc)", + "staff090_catalogue_format_marcxml": "MARCXML (.xml)", + "staff090_catalogue_format_unimarc": "UNIMARC (.mrc)", + "staff090_catalogue_copy_servers": "Catalogues", + "staff090_catalogue_copy_index": "Rechercher dans", + "staff090_catalogue_copy_term": "Terme recherché", + "staff090_catalogue_copy_scan": "Scanner un code-barres", + "staff090_catalogue_copy_scan_permission": "L'appareil photo lit les codes-barres ISBN et ISSN. Vous pouvez aussi saisir le terme ci-dessus.", + "staff090_catalogue_copy_max": "Résultats par catalogue", + "staff090_catalogue_copy_search": "Rechercher", + "staff090_catalogue_copy_search_failed": "La recherche n'a pas pu être lancée.", + "staff090_catalogue_copy_results": "Résultats", + "staff090_catalogue_copy_server_count": "Affichés : %1$d sur %2$d", + "staff090_catalogue_copy_no_results": "Aucune notice trouvée.", + "staff090_catalogue_copy_selected": "Sélectionné", + "staff090_catalogue_copy_not_selected": "Non sélectionné", + "staff090_catalogue_copy_covers": "Télécharger les couvertures", + "staff090_catalogue_copy_import": "Importer la sélection (%1$d)", + "staff090_catalogue_copy_import_failed": "Les notices n'ont pas pu être importées.", + "staff090_catalogue_copy_result": "Résultat de l'import", + "staff090_catalogue_copy_action_created": "Créé", + "staff090_catalogue_copy_action_updated": "Mis à jour", + "staff090_catalogue_copy_action_skipped": "Ignoré (déjà au catalogue)", + "staff090_catalogue_copy_action_failed": "Échec", + "staff090_catalogue_copy_unavailable_title": "Catalogage dérivé indisponible", + "staff090_catalogue_copy_reason_plugin": "Le catalogage dérivé nécessite le plugin Z39.50/SRU : un administrateur peut l'activer dans Plugins sur le site.", + "staff090_catalogue_err_copy_unavailable": "Le catalogage dérivé n'est pas disponible sur ce serveur.", + "staff090_catalogue_err_job_cancelled": "L'import a été annulé pendant son exécution.", + "staff090_catalogue_err_schema_missing": "Cet outil sera disponible une fois la mise à jour de la base de données du serveur terminée.", + "staff090_catalogue_err_search_gone": "Ces résultats ne sont plus disponibles : relancez la recherche.", + "staff090_patrons_import_open_started": "Lancé · %1$s", + "staff090_patrons_import_open_not_started": "Pas encore lancé · lignes : %1$d", + "staff090_stocktake_filter_open": "Ouvertes", + "staff090_stocktake_filter_closed": "Clôturées", + "staff090_stocktake_filter_cancelled": "Annulées", + "staff090_stocktake_filter_all": "Toutes", + "staff090_stocktake_empty": "Aucun inventaire ici", + "staff090_stocktake_empty_hint": "Commencez-en un avec le bouton + : le téléphone devient le lecteur.", + "staff090_stocktake_new": "Nouvel inventaire", + "staff090_stocktake_row_counts": "Attendus : %1$d · Trouvés : %2$d · Manquants : %3$d · Lectures : %4$d", + "staff090_stocktake_opened": "Ouvert %1$s", + "staff090_stocktake_field_name": "Nom", + "staff090_stocktake_name_required": "Donnez un nom à l'inventaire.", + "staff090_stocktake_field_note": "Note", + "staff090_stocktake_scope": "Ce qu'il faut vérifier", + "staff090_stocktake_scope_hint": "Les exemplaires correspondant à ces choix sont ceux attendus. Laissez tout vide pour vérifier toute la bibliothèque.", + "staff090_stocktake_field_branch": "Site", + "staff090_stocktake_field_shelf": "Rayonnage", + "staff090_stocktake_field_level": "Tablette", + "staff090_stocktake_level": "Niveau %1$d", + "staff090_stocktake_field_genre": "Genre", + "staff090_stocktake_field_media": "Type de support", + "staff090_stocktake_field_collocation_from": "Cote de", + "staff090_stocktake_field_collocation_to": "Cote à", + "staff090_stocktake_any_shelf": "Tous les rayonnages", + "staff090_stocktake_any_genre": "Tous les genres", + "staff090_stocktake_search_shelf": "Rechercher un rayonnage", + "staff090_stocktake_search_genre": "Rechercher un genre", + "staff090_stocktake_open_session": "Commencer l'inventaire", + "staff090_stocktake_camera_permission": "Autorisez l'appareil photo pour lire les étiquettes des exemplaires. Vous pouvez aussi saisir ou envoyer les codes.", + "staff090_stocktake_current_shelf": "Rayonnage que vous vérifiez", + "staff090_stocktake_no_shelf": "Non indiqué : l'emplacement des exemplaires n'est pas vérifié", + "staff090_stocktake_scan_hint": "Visez une étiquette : chaque exemplaire lu est enregistré aussitôt, avec un bip.", + "staff090_stocktake_sending": "Envoi en cours : %1$d", + "staff090_stocktake_belongs_on": "Va dans %1$s", + "staff090_stocktake_recorded_as": "Enregistré comme : %1$s", + "staff090_stocktake_manual_code": "Code de l'exemplaire", + "staff090_stocktake_send_code": "Enregistrer ce code", + "staff090_stocktake_upload": "Envoyer une liste de codes (.txt ou .csv)", + "staff090_stocktake_upload_wrong_file": "Choisissez un fichier .txt ou .csv de 2 Mo au plus, un code par ligne.", + "staff090_stocktake_upload_done": "Liste de codes traitée", + "staff090_stocktake_not_recorded": "Pas de connexion : %1$s n'a pas été enregistré. Relisez-le une fois de nouveau en ligne.", + "staff090_stocktake_progress": "Avancement", + "staff090_stocktake_count_expected": "Attendus", + "staff090_stocktake_count_found": "Trouvés", + "staff090_stocktake_count_misplaced": "Mal rangés", + "staff090_stocktake_count_on_loan": "En prêt", + "staff090_stocktake_count_missing": "Manquants", + "staff090_stocktake_count_lost": "Marqués perdus", + "staff090_stocktake_count_unknown": "Codes inconnus", + "staff090_stocktake_count_out_of_scope": "Hors périmètre", + "staff090_stocktake_count_duplicates": "Déjà lus", + "staff090_stocktake_count_mine": "Mes lectures", + "staff090_stocktake_report": "Rapport", + "staff090_stocktake_close": "Clôturer l'inventaire", + "staff090_stocktake_close_resume": "Terminer le marquage comme perdus", + "staff090_stocktake_action_none": "Laisser les exemplaires manquants tels quels", + "staff090_stocktake_action_lost": "Marquer les exemplaires manquants comme perdus", + "staff090_stocktake_close_confirm_none": "Exemplaires manquants : %1$d. Ils restent tels quels ; aucun code ne pourra plus être lu ensuite.", + "staff090_stocktake_close_confirm_lost": "Exemplaires manquants : %1$d. Ils seront marqués comme perdus ; aucun code ne pourra plus être lu ensuite.", + "staff090_stocktake_close_confirm_resume": "Exemplaires encore à marquer comme perdus : %1$d.", + "staff090_stocktake_closed": "Inventaire clôturé.", + "staff090_stocktake_cancel": "Annuler l'inventaire", + "staff090_stocktake_cancel_title": "Annuler cet inventaire ?", + "staff090_stocktake_cancel_body": "Aucun exemplaire n'est modifié et aucun code ne pourra plus être lu. Les lectures restent dans le rapport.", + "staff090_stocktake_keep": "La garder ouverte", + "staff090_stocktake_cancelled": "Inventaire annulé.", + "staff090_stocktake_no_more_scans": "Cet inventaire n'accepte plus de lectures.", + "staff090_stocktake_pending_lost": "Exemplaires pas encore marqués comme perdus : %1$d", + "staff090_stocktake_recent": "Dernières lectures", + "staff090_stocktake_filter_latest": "Dernières", + "staff090_stocktake_only_mine": "Seulement mes lectures", + "staff090_stocktake_no_scans": "Aucune lecture pour l'instant.", + "staff090_stocktake_more": "Afficher plus", + "staff090_stocktake_details": "Détails", + "staff090_stocktake_whole_library": "Toute la bibliothèque", + "staff090_stocktake_opened_label": "Ouvert", + "staff090_stocktake_closed_label": "Clôturé", + "staff090_stocktake_history": "Historique", + "staff090_stocktake_report_rows": "Lignes : %1$d", + "staff090_stocktake_report_empty": "Rien dans cette catégorie", + "staff090_stocktake_download_csv": "Télécharger le rapport en CSV", + "staff090_stocktake_download_pdf": "Télécharger le rapport en PDF", + "staff090_stocktake_err_session_closed": "Cet inventaire a été clôturé : aucun code ne peut plus être lu.", + "staff090_stocktake_err_session_cancelled": "Cet inventaire a été annulé : aucun code ne peut plus être lu.", + "staff090_stocktake_err_report_too_large": "Le rapport est trop long pour un PDF : téléchargez plutôt le CSV.", + "staff090_stocktake_err_invalid_state": "Cette action n'est pas possible dans l'état actuel de l'inventaire.", + "staff090_stocktake_not_permitted": "Seul un administrateur peut le faire.", + "staff090_statistics_overview": "Vue d'ensemble", + "staff090_statistics_books_available": "Livres disponibles", + "staff090_statistics_books_on_loan": "Livres en prêt", + "staff090_statistics_active_loans": "Prêts en cours", + "staff090_statistics_overdue_loans": "Prêts en retard", + "staff090_statistics_completed_loans": "Prêts terminés", + "staff090_statistics_borrowers": "Lecteurs avec des prêts", + "staff090_statistics_by_month": "Prêts par mois", + "staff090_statistics_by_status": "Prêts en cours par état", + "staff090_statistics_by_media": "Prêts par type de support (12 derniers mois)", + "staff090_statistics_top_books": "Livres les plus empruntés", + "staff090_statistics_top_readers": "Lecteurs les plus actifs", + "staff090_statistics_loan_counts": "Prêts : %1$d · En cours : %2$d · En retard : %3$d", + "staff090_statistics_visitors": "Visiteurs, derniers jours : %1$d", + "staff090_statistics_visitors_members": "Membres", + "staff090_statistics_visitors_guests": "Invités", + "staff090_statistics_visitors_total": "Total", + "staff090_statistics_visitors_daily": "Visiteurs par jour", + "staff090_statistics_fines_report": "Rapport des amendes", + "staff090_statistics_from": "Du", + "staff090_statistics_to": "Au", + "staff090_statistics_fines_empty": "Aucun mouvement sur cette période.", + "staff090_statistics_fines_totals": "Totaux", + "staff090_statistics_charged": "Facturé", + "staff090_statistics_collected": "Encaissé", + "staff090_statistics_waived": "Remis", + "staff090_statistics_reversed": "Annulé", + "staff090_statistics_fines_csv": "Télécharger le rapport des amendes (CSV)", + "staff090_patrons_balance_owed_staff": "Montant que l'usager doit à la bibliothèque.", + "staff090_patrons_balance_credit_staff": "Crédit en faveur de l'usager.", + "staff090_patrons_debt_blocked_body_staff": "Le solde de l'usager dépasse le seuil de %1$s fixé par la bibliothèque : les nouveaux prêts et les renouvellements sont bloqués jusqu'à son règlement.", + "staff090_patrons_debt_blocked_body_plain_staff": "Les nouveaux prêts et les renouvellements sont bloqués jusqu'au règlement du solde de l'usager." } diff --git a/i18n/it.json b/i18n/it.json index 580b07f..2c10996 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -207,6 +207,7 @@ "profile_date_none": "Non impostata", "profile_card_number": "Numero tessera", "profile_card_expires": "Scadenza tessera", + "profile_card_barcode": "Codice a barre della tessera %1$s", "profile_change_password_title": "Cambia password", "profile_current_password": "Password attuale", "profile_new_password": "Nuova password", @@ -269,6 +270,7 @@ "snackbar_request_for_date": "Prestito richiesto per il %1$s", "snackbar_reservation_for_date": "Prenotazione inserita per il %1$s", "snackbar_loan_ready_for_pickup": "Prestito approvato; pronto per il ritiro.", + "snackbar_loan_scheduled": "Prestito approvato: potrai ritirare il libro dal %1$s.", "book_section_audiobook": "Audiolibro", "audio_play": "Riproduci", "audio_pause": "Pausa", @@ -993,6 +995,8 @@ "staff_scan_next": "Scansiona la copia successiva", "staff_loan_new_with_copy": "Nuovo prestito con questa copia", "staff_loan_open": "Apri il prestito", + "staff_reservation_override_limits": "Ignora i limiti di prenotazione della tipologia del lettore", + "staff_reservation_override_limits_hint": "Come nel modulo del sito: la prenotazione viene creata anche oltre i limiti della tipologia.", "staff_hand_over_now": "Consegna subito", "staff_hand_over_now_hint": "L’utente prende la copia al banco: il prestito parte subito.", "staff_hand_over_future": "Un prestito che inizia più avanti viene programmato e consegnato quel giorno.", @@ -1453,5 +1457,651 @@ "digital_locked_login": "Accedi per aprirlo", "digital_locked_loan": "Per chi ha il libro in prestito", "digital_locked_other": "Non disponibile per il tuo account", - "digital_link_refresh_error": "Impossibile aggiornare il collegamento. Riprova." + "digital_link_refresh_error": "Impossibile aggiornare il collegamento. Riprova.", + "book_loan_legend_closed": "Biblioteca chiusa", + "book_loan_closed_day": "Il %1$s la biblioteca è chiusa: scegli un altro giorno.", + "book_consultation_only": "Solo consultazione", + "book_consultation_only_hint": "Puoi consultare questo titolo in biblioteca, ma non è prestabile con la tua tessera.", + "book_reservations_disabled_hint": "Al momento non ci sono copie libere e la tua tessera non consente prenotazioni. Riprova quando una copia torna disponibile.", + "request_err_not_loanable": "Questo titolo è solo in consultazione per la tua tessera: non si può prendere in prestito.", + "request_err_reservations_disabled": "La tua tessera non consente prenotazioni.", + "request_err_reservation_limit": "Hai raggiunto il numero massimo di prenotazioni.", + "request_err_media_limit": "Hai raggiunto il numero massimo di prestiti per questo tipo di materiale.", + "request_err_max_loans": "Hai raggiunto il numero massimo di prestiti attivi.", + "request_err_debt_block": "I prestiti sono bloccati finché non saldi il tuo debito con la biblioteca.", + "request_err_card_expired": "La tua tessera è scaduta: chiedi alla biblioteca di rinnovarla.", + "request_err_suspended": "Il tuo account è sospeso: contatta la biblioteca.", + "staff_err_media_limit": "L'utente ha raggiunto il numero massimo di prestiti per questo tipo di materiale.", + "staff_err_not_loanable": "Questo materiale è solo in consultazione per la tipologia del lettore.", + "staff_err_reservations_disabled": "Le prenotazioni non sono consentite per la tipologia del lettore.", + "staff_err_reservation_limit": "Il lettore ha raggiunto il numero massimo di prenotazioni.", + "staff_err_debt_block": "Prestiti bloccati: il saldo dell'utente supera la soglia di debito.", + "staff_err_card_expired": "La tessera dell'utente è scaduta.", + "staff_err_user_suspended": "L'utente è sospeso.", + "staff_copy_consultation_only": "Solo consultazione", + "staff_copy_consultation_switch": "Solo consultazione in sede", + "staff_copy_consultation_switch_hint": "La copia non viene mai prestata. Come nella scheda del libro: non si può impostare se la copia è in prestito, in ritiro o richiesta.", + "profile_patron_type": "Tipologia lettore", + "profile_action_movements": "Saldo e movimenti", + "title_movements": "Saldo e movimenti", + "movements_loading": "Caricamento del saldo…", + "movements_error_load": "Impossibile caricare il saldo.", + "movements_balance": "Saldo", + "movements_balance_owed": "Importo che devi alla biblioteca.", + "movements_balance_credit": "Credito a tuo favore.", + "movements_balance_settled": "Nulla da pagare.", + "movements_debt_blocked_title": "Prestiti bloccati", + "movements_debt_blocked_body": "Il tuo saldo supera la soglia di %1$s fissata dalla biblioteca: nuovi prestiti e rinnovi sono bloccati finché non lo saldi.", + "movements_debt_blocked_body_plain": "Nuovi prestiti e rinnovi sono bloccati finché non saldi il tuo debito.", + "movements_accrued_title": "Multe in maturazione sui prestiti scaduti: %1$s", + "movements_accrued_row": "Scadenza %1$s · giorni di ritardo: %2$d · addebitata alla restituzione", + "movements_history_title": "Movimenti", + "movements_empty_title": "Nessun movimento", + "movements_empty_subtitle": "Qui compariranno addebiti, pagamenti e condoni.", + "movements_untitled": "Prestito", + "movements_refresh_failed": "Aggiornamento non riuscito: il saldo mostrato potrebbe non essere aggiornato.", + "staff_loan_closed_day": "Il %1$s la biblioteca è chiusa: scegli un'altra data di inizio o di restituzione.", + "staff090_section_desk": "Banco", + "staff090_section_cataloguing": "Catalogazione", + "staff090_section_library": "Biblioteca", + "staff090_attendance": "Registro presenze", + "staff090_attendance_subtitle": "Leggi le tessere all'ingresso, registra gli ospiti", + "staff090_stocktake": "Revisione inventariale", + "staff090_stocktake_subtitle": "Controlla gli scaffali con la fotocamera", + "staff090_user_import": "Importa lettori", + "staff090_user_import_subtitle": "Da un file CSV", + "staff090_marc": "Import ed export MARC", + "staff090_marc_subtitle": "ISO 2709, MARCXML, UNIMARC", + "staff090_copy_cataloging": "Catalogazione derivata", + "staff090_copy_cataloging_subtitle": "Cerca nei cataloghi di altre biblioteche", + "staff090_suppliers": "Fornitori", + "staff090_suppliers_subtitle": "Librerie e donatori dei libri", + "staff090_statistics": "Statistiche", + "staff090_statistics_subtitle": "Prestiti, lettori, presenze, multe", + "staff090_calendar": "Calendario chiusure", + "staff090_calendar_subtitle": "Giorni di chiusura e festività", + "staff090_circulation_rules": "Tipologie lettore e regole di prestito", + "staff090_circulation_rules_subtitle": "Limiti per tipologia e materiale", + "staff090_settings": "Impostazioni biblioteca", + "staff090_settings_subtitle": "Moduli, prestiti, tessere", + "staff090_ledger": "Saldo e pagamenti", + "staff090_patrons_section": "Lettore", + "staff090_patrons_tools_hint": "Foto e tessera di questo lettore.", + "staff090_patrons_type": "Tipologia lettore", + "staff090_patrons_type_default": "%1$s (predefinita)", + "staff090_patrons_type_card_days": "Validità tessera (giorni): %1$d", + "staff090_patrons_change_type": "Cambia tipologia", + "staff090_patrons_recompute_expiry": "Calcola la scadenza dalla tipologia", + "staff090_patrons_recompute_expiry_hint": "Oggi più la validità della tessera della tipologia scelta, se ne prevede una.", + "staff090_patrons_type_saved": "Tipologia salvata", + "staff090_patrons_type_saved_expiry": "Tipologia salvata, scadenza della tessera aggiornata", + "staff090_patrons_renew_card": "Rinnova tessera", + "staff090_patrons_renew_body": "La validità della tessera viene estesa della durata prevista dalla tipologia (un anno se non ne prevede). Un account scaduto torna attivo.", + "staff090_patrons_card_renewed": "Tessera rinnovata fino al %1$s", + "staff090_patrons_print_card": "Stampa tessera", + "staff090_patrons_card_ready": "Tessera pronta", + "staff090_patrons_photo": "Foto del lettore", + "staff090_patrons_photo_take": "Scatta foto", + "staff090_patrons_photo_replace": "Sostituisci foto", + "staff090_patrons_photo_pick": "Scegli immagine", + "staff090_patrons_photo_remove": "Rimuovi foto", + "staff090_patrons_photo_remove_body": "La foto del lettore viene eliminata dal server.", + "staff090_patrons_photo_saved": "Foto salvata", + "staff090_patrons_photo_removed": "Foto rimossa", + "staff090_patrons_photo_unreadable": "Impossibile leggere questa immagine.", + "staff090_patrons_balance_line": "Saldo: %1$s", + "staff090_patrons_debt_blocked": "Bloccato per debito", + "staff090_patrons_record_payment": "Registra pagamento", + "staff090_patrons_add_charge": "Aggiungi addebito", + "staff090_patrons_waive": "Condona", + "staff090_patrons_amount": "Importo (%1$s)", + "staff090_patrons_amount_invalid": "Inserisci un importo maggiore di zero, con al massimo due decimali.", + "staff090_patrons_amount_too_large": "Al massimo %1$s", + "staff090_patrons_method": "Metodo di pagamento", + "staff090_patrons_note_optional": "Nota (facoltativa)", + "staff090_patrons_description": "Descrizione", + "staff090_patrons_waive_reason": "Motivo del condono", + "staff090_patrons_reason_required": "Il motivo è obbligatorio.", + "staff090_patrons_payment_recorded": "Pagamento registrato", + "staff090_patrons_charge_recorded": "Addebito aggiunto", + "staff090_patrons_waiver_recorded": "Condono registrato", + "staff090_patrons_recorded_by": "Registrato da %1$s", + "staff090_patrons_receipt": "Ricevuta", + "staff090_patrons_receipt_loading": "Download…", + "staff090_patrons_receipt_ready": "Ricevuta di pagamento pronta", + "staff090_patrons_return_fine": "Multa per ritardo: %1$s (giorni di ritardo: %2$d)", + "staff090_patrons_return_waive": "Condona la multa", + "staff090_patrons_tab_desk": "Ingresso", + "staff090_patrons_tab_stats": "Statistiche", + "staff090_patrons_scan_permission": "Consenti l'uso della fotocamera per leggere le tessere. Puoi anche digitare il codice qui sotto.", + "staff090_patrons_scan_hint": "Inquadra il codice a barre o il QR della tessera.", + "staff090_patrons_card_code": "Codice tessera", + "staff090_patrons_card_code_required": "Inserisci il codice della tessera.", + "staff090_patrons_check_in": "Registra presenza", + "staff090_patrons_branch": "Sede", + "staff090_patrons_branch_none": "Non indicata", + "staff090_patrons_card_line": "Tessera %1$s", + "staff090_patrons_card_expired": "Tessera scaduta il %1$s", + "staff090_patrons_card_not_active": "L'account è sospeso o scaduto.", + "staff090_patrons_guest": "Registra ospite", + "staff090_patrons_guest_name": "Nome (facoltativo)", + "staff090_patrons_guest_reason": "Motivo della visita (facoltativo)", + "staff090_patrons_guest_save": "Registra presenza", + "staff090_patrons_guest_anonymous": "Ospite", + "staff090_patrons_member": "Socio", + "staff090_patrons_today_counts": "Oggi: soci %1$d, ospiti %2$d", + "staff090_patrons_period_days": "%1$d gg", + "staff090_patrons_today": "Oggi", + "staff090_patrons_period_total": "Periodo", + "staff090_patrons_counts": "Totale %1$d · Soci %2$d · Ospiti %3$d", + "staff090_patrons_by_day": "Per giorno", + "staff090_patrons_by_hour": "Per fascia oraria", + "staff090_patrons_no_visits": "Nessuna presenza nel periodo.", + "staff090_patrons_recent": "Ultime presenze", + "staff090_patrons_export_csv": "Esporta CSV", + "staff090_patrons_retention": "Conservazione dei dati", + "staff090_patrons_retention_hint": "Le presenze più vecchie di questo numero di giorni vengono cancellate dalla manutenzione (0 = conserva sempre, massimo %1$d).", + "staff090_patrons_retention_days": "Conserva le presenze (giorni)", + "staff090_patrons_retention_invalid": "Inserisci un numero intero da 0 a %1$d.", + "staff090_patrons_retention_saved": "Conservazione salvata", + "staff090_patrons_purge": "Cancella ora le presenze vecchie", + "staff090_patrons_purge_body": "Le presenze più vecchie del periodo di conservazione (giorni: %1$d) vengono cancellate subito. L'operazione non si può annullare.", + "staff090_patrons_purged": "Presenze cancellate: %1$d", + "staff090_patrons_import_intro": "Crea account lettore da un file CSV. Le righe con email o codice tessera già presenti vengono saltate; le password non vengono mai importate.", + "staff090_patrons_delimiter": "Separatore", + "staff090_patrons_delimiter_auto": "Rileva", + "staff090_patrons_delimiter_semicolon": "Punto e virgola", + "staff090_patrons_delimiter_comma": "Virgola", + "staff090_patrons_delimiter_tab": "Tabulazione", + "staff090_patrons_import_upload": "Carica file", + "staff090_patrons_import_too_big": "Il file supera i 5 MB.", + "staff090_patrons_import_wrong_ext": "Scegli un file .csv, .tsv o .txt.", + "staff090_patrons_import_ready": "Controlla le colonne, poi avvia", + "staff090_patrons_import_rows": "Righe nel file", + "staff090_patrons_import_preview": "Anteprima", + "staff090_patrons_import_mapping": "Colonne", + "staff090_patrons_import_column": "Colonna %1$d", + "staff090_patrons_import_ignore": "Ignora", + "staff090_patrons_import_missing": "Associa i campi obbligatori: %1$s", + "staff090_patrons_import_invites": "Invia un invito per email", + "staff090_patrons_import_invites_hint": "Ogni nuovo lettore riceve il link per impostare la password.", + "staff090_patrons_import_start": "Avvia importazione", + "staff090_patrons_import_created": "Account creati", + "staff090_patrons_import_duplicates": "Duplicati saltati", + "staff090_patrons_import_failed": "Righe con errori", + "staff090_patrons_import_invited": "Inviti inviati", + "staff090_patrons_import_invite_failed": "Inviti non inviati", + "staff090_patrons_import_error_line": "Riga %1$d: %2$s", + "staff090_patrons_import_cancelled": "Importazione annullata", + "staff090_patrons_import_new": "Nuova importazione", + "staff090_patrons_err_card_not_renewable": "Questo account non ha una tessera da rinnovare.", + "staff090_patrons_err_amount_exceeds_balance": "L'importo supera quanto dovuto dal lettore.", + "staff090_patrons_err_card_not_found": "Tessera non riconosciuta", + "staff090_patrons_err_not_started": "Avvia l'importazione prima di elaborare le righe.", + "staff090_patrons_err_already_started": "Questa importazione è già stata avviata.", + "staff090_patrons_err_job_cancelled": "L'importazione è stata annullata durante l'elaborazione.", + "staff090_patrons_err_file_missing": "Il file caricato non è più sul server: caricalo di nuovo.", + "staff090_patrons_err_import_not_finished": "Il report degli errori sarà disponibile al termine dell'importazione.", + "staff090_patrons_err_db_update_pending": "Disponibile dopo l'aggiornamento del database della biblioteca.", + "staff090_library_active": "Attiva", + "staff090_library_admin_only": "Operazione riservata agli amministratori.", + "staff090_library_all_branches": "Tutte le sedi", + "staff090_library_all_types": "Tutte le tipologie", + "staff090_library_apply": "Applica", + "staff090_library_branch": "Sede", + "staff090_library_branch_preview": "Calendario della sede", + "staff090_library_calendar_module_off": "Il calendario chiusure è spento: quello che imposti qui resta salvato, ma le scadenze non ne tengono conto finché il modulo non viene acceso in Impostazioni → Prestiti.", + "staff090_library_card_back_text": "Testo sul retro", + "staff090_library_card_back_text_hint": "Orari, regole, contatti. Vuoto = retro bianco.", + "staff090_library_card_code_barcode": "Codice a barre (Code 128)", + "staff090_library_card_code_qr": "Codice QR", + "staff090_library_card_code_type": "Codice sulla tessera", + "staff090_library_card_format": "Formato di stampa", + "staff090_library_card_format_sheet": "Foglio A4 con più tessere", + "staff090_library_card_format_single": "Una tessera per pagina", + "staff090_library_card_layout": "Impaginazione", + "staff090_library_card_layout_classic": "Classica", + "staff090_library_card_layout_minimal": "Essenziale", + "staff090_library_card_layout_portrait": "Verticale", + "staff090_library_card_section": "Tessera", + "staff090_library_card_show_expiry": "Mostra la scadenza", + "staff090_library_card_show_logo": "Mostra il logo della biblioteca", + "staff090_library_card_show_photo": "Mostra la foto del lettore", + "staff090_library_card_title": "Titolo della tessera", + "staff090_library_card_title_hint": "Vuoto: il nome della biblioteca.", + "staff090_library_cards_layout_section": "Impaginazione e codice", + "staff090_library_cards_module_off": "La stampa delle tessere è spenta: puoi preparare le impostazioni, le tessere si stampano quando il modulo è acceso.", + "staff090_library_cards_show_section": "Sulla tessera", + "staff090_library_cards_text_section": "Testi", + "staff090_library_catalogue_mode": "La modalità catalogo è attiva: i moduli di circolazione restano spenti qualunque sia il loro interruttore.", + "staff090_library_cell_custom": "Regola propria", + "staff090_library_cell_duration": "Giorni: %1$d", + "staff090_library_cell_fine": "Multa al giorno: %1$s", + "staff090_library_cell_loanable": "Prestabile", + "staff090_library_cell_max": "Max: %1$d", + "staff090_library_cell_not_loanable": "Non prestabile", + "staff090_library_cell_pending": "Da salvare", + "staff090_library_cell_renewals": "Rinnovi: %1$d", + "staff090_library_cell_title": "%1$s · %2$s", + "staff090_library_close": "Chiudi", + "staff090_library_closure_delete": "Eliminare la chiusura?", + "staff090_library_closure_delete_body": "%1$s tornerà giorno di apertura; le scadenze vengono ricalcolate.", + "staff090_library_closure_edit": "Modifica chiusura", + "staff090_library_closure_end": "Ultimo giorno (facoltativo)", + "staff090_library_closure_end_before": "L'ultimo giorno non può precedere il primo.", + "staff090_library_closure_end_hint": "Lascia vuoto l'ultimo giorno per una chiusura di un solo giorno.", + "staff090_library_closure_new": "Aggiungi chiusura", + "staff090_library_closure_preset": "Festività", + "staff090_library_closure_start": "Primo giorno", + "staff090_library_closure_untitled": "Chiusura", + "staff090_library_closure_yearly": "Ogni anno", + "staff090_library_closure_yearly_hint": "Si ripete nelle stesse date ogni anno (al massimo %1$d giorni).", + "staff090_library_closure_yearly_switch": "Ripeti ogni anno", + "staff090_library_closures": "Chiusure", + "staff090_library_closures_empty": "Nessuna chiusura.", + "staff090_library_country": "Paese", + "staff090_library_country_search": "Cerca un paese", + "staff090_library_custom": "Festività della biblioteca", + "staff090_library_custom_calendar": "Calendario", + "staff090_library_custom_calendar_hint": "Il calendario islamico segue le tavole Umm al-Qura: dove l'inizio del mese dipende dall'avvistamento locale della luna la data può cambiare di un giorno. In quel caso usa le date specifiche.", + "staff090_library_custom_dates": "Date (AAAA-MM-GG, una per riga)", + "staff090_library_custom_dates_hint": "Al massimo %1$d date.", + "staff090_library_custom_day": "Giorno", + "staff090_library_custom_delete": "Eliminare la festività?", + "staff090_library_custom_delete_body": "%1$s non chiuderà più la biblioteca.", + "staff090_library_custom_duration": "Durata (giorni)", + "staff090_library_custom_edit": "Modifica festività", + "staff090_library_custom_help": "Festività proprie: il santo patrono, una festa locale, una ricorrenza religiosa di un altro calendario.", + "staff090_library_custom_kind": "Come si calcola", + "staff090_library_custom_month": "Mese", + "staff090_library_custom_more": "Durata e validità", + "staff090_library_custom_name_hint": "es. Santo patrono", + "staff090_library_custom_new": "Aggiungi festività", + "staff090_library_custom_next": "Prossime: %1$s", + "staff090_library_custom_occurrence": "Quale", + "staff090_library_custom_offset": "Giorni rispetto alla domenica di Pasqua", + "staff090_library_custom_offset_hint": "0 = la domenica di Pasqua; 1 = il lunedì dopo; -2 = il Venerdì santo; 49 = Pentecoste.", + "staff090_library_custom_weekday": "Giorno della settimana", + "staff090_library_custom_weekend": "Se cade nel fine settimana", + "staff090_library_custom_year_from": "Dal (anno)", + "staff090_library_custom_year_until": "Al (anno)", + "staff090_library_data_years": "I dati delle festività coprono gli anni %1$d–%2$d.", + "staff090_library_day_closed": "La biblioteca è chiusa.", + "staff090_library_day_open": "La biblioteca è aperta.", + "staff090_library_delete": "Elimina", + "staff090_library_deleted": "Eliminato", + "staff090_library_discard": "Annulla modifiche", + "staff090_library_err_always_closed": "Con questa modifica la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.", + "staff090_library_err_ics_too_large": "Il file ICS supera 1 MB.", + "staff090_library_err_preset_closure": "Le festività nazionali si modificano dalla selezione delle festività.", + "staff090_library_err_schema_not_ready": "Disponibile al termine dell'aggiornamento del database sul server.", + "staff090_library_fines_admin_only": "Solo gli amministratori possono modificare multe e soglia di blocco.", + "staff090_library_grace_deduct": "Abbuono: sottrai sempre i giorni di tolleranza", + "staff090_library_grace_threshold": "Soglia: superata la tolleranza, conta dal primo giorno", + "staff090_library_group_loans": "Prestiti", + "staff090_library_group_modules": "Moduli", + "staff090_library_holiday_estimated": "stimata", + "staff090_library_holiday_optional": "facoltativa", + "staff090_library_holiday_regional": "regionale", + "staff090_library_holiday_yearly": "stessa data ogni anno", + "staff090_library_holidays": "Festività", + "staff090_library_holidays_apply": "Applica festività (%1$d)", + "staff090_library_holidays_help": "Spunta le festività in cui la biblioteca è chiusa. Vengono ricalcolate ogni anno.", + "staff090_library_holidays_national": "Festività nazionali", + "staff090_library_holidays_none": "Nessuna festività nazionale scelta", + "staff090_library_holidays_saved": "%1$s · Festività: %2$d", + "staff090_library_ics": "Importa da un file di calendario", + "staff090_library_ics_added": "Chiusure aggiunte: %1$d", + "staff090_library_ics_failed": "Impossibile importare il file di calendario.", + "staff090_library_ics_file": "File ICS", + "staff090_library_ics_help": "Ogni evento del file diventa una chiusura. Al massimo 1 MB.", + "staff090_library_ics_import": "Importa", + "staff090_library_ics_read_failed": "Impossibile leggere il file.", + "staff090_library_inactive": "Disattivata", + "staff090_library_inherit_all": "Eredita tutto", + "staff090_library_inherit_note": "Un campo vuoto eredita il valore del livello superiore.", + "staff090_library_inherits": "Vuoto: %1$s", + "staff090_library_kind_calendar": "In un altro calendario", + "staff090_library_kind_dates": "Date specifiche", + "staff090_library_kind_easter": "Rispetto alla Pasqua", + "staff090_library_kind_fixed": "Stesso giorno ogni anno", + "staff090_library_kind_nth_weekday": "Un giorno della settimana del mese (es. il primo lunedì)", + "staff090_library_kind_orthodox_easter": "Rispetto alla Pasqua ortodossa", + "staff090_library_legend_closed": "Chiuso", + "staff090_library_loan_auto_approve": "Approva automaticamente le richieste", + "staff090_library_loan_auto_approve_help": "Le richieste di prestito saltano l'approvazione manuale e passano subito in attesa di ritiro.", + "staff090_library_loan_card_renewal_base": "Calcola il rinnovo della tessera da", + "staff090_library_loan_card_warning": "Avviso scadenza tessera (giorni prima)", + "staff090_library_loan_card_warning_help": "Il lettore riceve un'email prima della scadenza della tessera. 0 = nessun avviso.", + "staff090_library_loan_daily_fine": "Multa giornaliera", + "staff090_library_loan_daily_fine_help": "Tariffa globale al giorno. Vuoto o 0 = nessuna tariffa globale; le tipologie possono definirne una propria.", + "staff090_library_loan_debt_block": "Soglia di blocco per debito", + "staff090_library_loan_debt_block_help": "Soglia globale del debito. Vuoto o 0 = nessun blocco globale.", + "staff090_library_loan_duration": "Durata predefinita prestito (giorni)", + "staff090_library_loan_duration_help": "Usata quando la scadenza non è indicata: richieste dei lettori, prestiti al banco, prenotazioni approvate.", + "staff090_library_loan_fine_email": "Avvisa il lettore via email quando viene addebitata una multa", + "staff090_library_loan_fine_email_help": "Usa il modello email «Multa per ritardo addebitata».", + "staff090_library_loan_grace_days": "Giorni di tolleranza", + "staff090_library_loan_grace_days_help": "Un ritardo fino a questo numero di giorni non viene multato.", + "staff090_library_loan_grace_mode": "Modalità di tolleranza", + "staff090_library_loan_max_active": "Prestiti attivi per utente", + "staff090_library_loan_max_active_help": "0 = nessun limite. Conteggiati: prenotato, da ritirare, in corso, in ritardo.", + "staff090_library_loan_max_duration": "Durata massima richiedibile (giorni)", + "staff090_library_loan_max_duration_help": "Le richieste e prenotazioni più lunghe vengono rifiutate.", + "staff090_library_loan_max_fine": "Multa massima per prestito", + "staff090_library_loan_max_fine_help": "Tetto della multa di un singolo prestito. Vuoto o 0 = nessun tetto.", + "staff090_library_loan_max_renewals": "Rinnovi per prestito", + "staff090_library_loan_max_renewals_help": "0 disattiva i rinnovi.", + "staff090_library_loan_multiple_copies": "Più copie dello stesso titolo", + "staff090_library_loan_multiple_copies_help": "Un lettore può avere più prestiti dello stesso libro, ciascuno su una copia diversa.", + "staff090_library_loan_pickup_days": "Giorni per il ritiro del prestito approvato", + "staff090_library_loan_pickup_days_help": "Dopo questo termine il prestito approvato scade.", + "staff090_library_loan_pickup_reminder": "Promemoria di ritiro (giorni prima)", + "staff090_library_loan_pickup_reminder_help": "Inviato se il libro non è ancora stato ritirato.", + "staff090_library_loan_recall_interval": "Intervallo tra i solleciti (giorni)", + "staff090_library_loan_recall_max": "Numero massimo di solleciti automatici", + "staff090_library_loan_recalls": "Solleciti automatici", + "staff090_library_loan_recalls_help": "Email ricorrenti per i prestiti scaduti non ancora restituiti.", + "staff090_library_loan_timezone": "Fuso orario", + "staff090_library_loan_timezone_help": "Usato per scadenze, attivazioni e automatismi dei prestiti: quello della biblioteca.", + "staff090_library_loan_unknown": "Impostazione", + "staff090_library_loanable_inherit": "Eredita (%1$s)", + "staff090_library_loans_approval": "Approvazione", + "staff090_library_loans_card": "Tessera", + "staff090_library_loans_duration": "Durata", + "staff090_library_loans_fines": "Multe e blocco per debito", + "staff090_library_loans_limits": "Limiti", + "staff090_library_loans_pickup": "Ritiro", + "staff090_library_loans_recalls": "Solleciti", + "staff090_library_loans_renewals": "Rinnovi", + "staff090_library_loans_section": "Prestiti", + "staff090_library_matrix": "Regole per tipo di materiale", + "staff090_library_matrix_empty": "Nessun materiale da configurare", + "staff090_library_matrix_intro": "Ogni cella sostituisce la tipologia per un materiale; «Tutte le tipologie» vale per ogni tipologia che lascia vuoto un valore.", + "staff090_library_matrix_subtitle": "Limiti per tipologia e materiale", + "staff090_library_module_catalogue_off": "Spento in modalità catalogo.", + "staff090_library_module_follows": "Finché non viene salvato, segue «%1$s».", + "staff090_library_module_needs": "Richiede «%1$s».", + "staff090_library_module_needs_off": "Inattivo: «%1$s» è spento.", + "staff090_library_modules_read_only": "Solo gli amministratori possono attivare o disattivare i moduli.", + "staff090_library_money_invalid": "Inserisci un importo come 1,50", + "staff090_library_money_label": "%1$s (%2$s)", + "staff090_library_move_down": "Sposta giù", + "staff090_library_move_up": "Sposta su", + "staff090_library_no": "No", + "staff090_library_nth_1": "Il primo", + "staff090_library_nth_2": "Il secondo", + "staff090_library_nth_3": "Il terzo", + "staff090_library_nth_4": "Il quarto", + "staff090_library_nth_5": "Il quinto", + "staff090_library_nth_last": "L'ultimo", + "staff090_library_num_card_duration": "Durata della tessera (giorni)", + "staff090_library_num_daily_fine": "Multa giornaliera", + "staff090_library_num_debt_block": "Soglia di blocco per debito", + "staff090_library_num_grace_days": "Giorni di tolleranza", + "staff090_library_num_loan_duration": "Durata del prestito (giorni)", + "staff090_library_num_max_fine": "Multa massima per prestito", + "staff090_library_num_max_loans": "Prestiti attivi al massimo", + "staff090_library_num_max_renewals": "Rinnovi al massimo", + "staff090_library_num_max_request": "Durata massima richiedibile (giorni)", + "staff090_library_num_max_reservations": "Prenotazioni al massimo", + "staff090_library_num_renewal_days": "Giorni di rinnovo", + "staff090_library_off": "Spento", + "staff090_library_on": "Acceso", + "staff090_library_preview_year": "Anno %1$d", + "staff090_library_range": "Da %1$d a %2$d.", + "staff090_library_reason_closure": "Chiusura: %1$s", + "staff090_library_reason_custom": "Festività della biblioteca: %1$s", + "staff090_library_reason_holiday": "Festività: %1$s", + "staff090_library_reason_other": "Chiuso: %1$s", + "staff090_library_reason_weekly": "Chiusura settimanale: %1$s", + "staff090_library_region": "Regione", + "staff090_library_region_all": "Tutto il paese", + "staff090_library_renewal_expiry": "Scadenza esistente, se futura; altrimenti oggi", + "staff090_library_renewal_today": "Oggi", + "staff090_library_reservations_section": "Prenotazioni", + "staff090_library_retention_days": "Giorni di conservazione delle presenze", + "staff090_library_retention_hint": "0 = nessuna cancellazione automatica. Al massimo %1$d.", + "staff090_library_rules_module_off": "Le regole per tipologia sono spente: puoi prepararle qui, si applicano quando il modulo è acceso in Impostazioni → Prestiti.", + "staff090_library_save_cells": "Salva (%1$d)", + "staff090_library_save_weekdays": "Salva i giorni di chiusura", + "staff090_library_saved": "Salvato", + "staff090_library_tab_cards": "Tessere", + "staff090_library_tab_loans": "Prestiti", + "staff090_library_tab_modules": "Moduli", + "staff090_library_type_active": "Attiva", + "staff090_library_type_active_hint": "Una tipologia disattivata non viene più proposta per i nuovi lettori.", + "staff090_library_type_code": "Codice", + "staff090_library_type_code_hint": "Lettere minuscole, numeri, - e _ (al massimo 40). Vuoto: ricavato dal nome.", + "staff090_library_type_default": "Predefinita", + "staff090_library_type_default_hint": "I nuovi lettori ricevono questa tipologia.", + "staff090_library_type_delete": "Eliminare la tipologia?", + "staff090_library_type_delete_body": "%1$s verrà eliminata e i suoi lettori passeranno alla tipologia predefinita. Lettori: %2$d", + "staff090_library_type_deleted": "%1$s eliminata. Lettori spostati sulla predefinita: %2$d", + "staff090_library_type_edit": "Modifica tipologia", + "staff090_library_type_inactive": "Disattivata", + "staff090_library_type_make_default": "Imposta come predefinita", + "staff090_library_type_new": "Nuova tipologia", + "staff090_library_type_reservations": "Prenotazioni consentite", + "staff090_library_type_users": "Lettori: %1$d", + "staff090_library_types": "Tipologie di lettore", + "staff090_library_weekdays_all_closed": "Almeno un giorno della settimana deve restare aperto.", + "staff090_library_weekend_next_monday": "Chiudi anche il lunedì successivo", + "staff090_library_weekend_none": "Nessun giorno in più", + "staff090_library_weekend_us": "Sabato: anche il venerdì prima; domenica: anche il lunedì dopo", + "staff090_library_weekly": "Chiusura settimanale", + "staff090_library_weekly_help": "I giorni in cui la biblioteca è chiusa ogni settimana.", + "staff090_library_year_next": "Anno successivo", + "staff090_library_year_prev": "Anno precedente", + "staff090_library_yearly": "Chiusure annuali", + "staff090_library_yearly_help": "Disattiva una chiusura annuale per saltarla senza eliminarla.", + "staff090_library_yes": "Sì", + "staff090_catalogue_card": "Scheda catalografica (PDF)", + "staff090_catalogue_cards_print": "Stampa le schede catalografiche (%1$d)", + "staff090_catalogue_cards_limit": "Schede per PDF: al massimo %1$d. Si stampano le prime scelte.", + "staff090_catalogue_in_catalogue": "Già in catalogo", + "staff090_catalogue_supplier": "Fornitore", + "staff090_catalogue_supplier_new": "Nuovo fornitore", + "staff090_catalogue_supplier_edit": "Modifica fornitore", + "staff090_catalogue_supplier_delete": "Elimina fornitore", + "staff090_catalogue_supplier_delete_confirm": "Eliminare “%1$s”?", + "staff090_catalogue_supplier_delete_books": "Eliminare “%1$s”? Libri che perdono questo fornitore: %2$d. I libri restano in catalogo.", + "staff090_catalogue_supplier_deleted": "Fornitore eliminato. Libri scollegati: %1$d", + "staff090_catalogue_supplier_saved": "Fornitore salvato", + "staff090_catalogue_supplier_search": "Cerca fornitori", + "staff090_catalogue_supplier_books": "Libri: %1$d", + "staff090_catalogue_supplier_contact": "Referente", + "staff090_catalogue_supplier_vat": "Partita IVA", + "staff090_catalogue_suppliers_empty": "Nessun fornitore", + "staff090_catalogue_rda_title": "Tipi RDA", + "staff090_catalogue_rda_hint": "Lascia “Derivato” se il record non richiede un tipo RDA diverso.", + "staff090_catalogue_rda_content": "Tipo di contenuto", + "staff090_catalogue_rda_media": "Tipo di mediazione", + "staff090_catalogue_rda_carrier": "Tipo di supporto", + "staff090_catalogue_rda_derived": "Derivato dal tipo di media", + "staff090_catalogue_rda_derived_value": "Derivato: %1$s", + "staff090_catalogue_marc_import": "Importa", + "staff090_catalogue_marc_export": "Esporta", + "staff090_catalogue_marc_import_hint": "ISO 2709 (.mrc, .marc, .iso, .dat) o MARCXML (.xml), al massimo 20 MB. MARC 21, UNIMARC e danMARC2.", + "staff090_catalogue_marc_wrong_ext": "Scegli un file MARC (.mrc, .marc, .iso, .dat) o MARCXML (.xml).", + "staff090_catalogue_marc_flavour": "Formato MARC", + "staff090_catalogue_marc_encoding": "Codifica dei caratteri", + "staff090_catalogue_marc_duplicates": "Libri già in catalogo", + "staff090_catalogue_marc_covers_switch": "Scarica le copertine", + "staff090_catalogue_marc_covers_hint": "Le copertine indicate nel campo 856. Più lento: due record alla volta.", + "staff090_catalogue_marc_upload": "Carica e mostra l'anteprima", + "staff090_catalogue_marc_preview": "Pronto per l'importazione", + "staff090_catalogue_marc_records": "Record nel file", + "staff090_catalogue_marc_container": "Tipo di file", + "staff090_catalogue_marc_container_iso": "ISO 2709", + "staff090_catalogue_marc_container_xml": "MARCXML", + "staff090_catalogue_marc_unreadable": "Record illeggibili", + "staff090_catalogue_marc_unreadable_row": "Il record %1$d non è leggibile", + "staff090_catalogue_marc_run": "Importa (record: %1$d)", + "staff090_catalogue_marc_discard": "Scarta questo file", + "staff090_catalogue_marc_first_records": "Primi record del file", + "staff090_catalogue_marc_progress": "Record: %1$d di %2$d", + "staff090_catalogue_marc_skipped": "Saltati", + "staff090_catalogue_marc_covers": "Copertine scaricate", + "staff090_catalogue_marc_new": "Nuova importazione", + "staff090_catalogue_marc_format": "Formato", + "staff090_catalogue_marc_scope": "Cosa esportare", + "staff090_catalogue_marc_scope_all": "Tutto il catalogo", + "staff090_catalogue_marc_scope_all_hint": "Tutti i libri, esclusi i titoli cercati dalla biblioteca.", + "staff090_catalogue_marc_scope_chosen": "Libri scelti", + "staff090_catalogue_marc_chosen_count": "Scelti: %1$d (al massimo %2$d)", + "staff090_catalogue_marc_add_books": "Aggiungi libri", + "staff090_catalogue_marc_pick_books": "Libri da esportare", + "staff090_catalogue_marc_export_run": "Esporta", + "staff090_catalogue_marc_export_book": "Esporta in MARC", + "staff090_catalogue_format_marc21": "MARC 21 (.mrc)", + "staff090_catalogue_format_marcxml": "MARCXML (.xml)", + "staff090_catalogue_format_unimarc": "UNIMARC (.mrc)", + "staff090_catalogue_copy_servers": "Cataloghi", + "staff090_catalogue_copy_index": "Cerca in", + "staff090_catalogue_copy_term": "Termine di ricerca", + "staff090_catalogue_copy_scan": "Scansiona un codice a barre", + "staff090_catalogue_copy_scan_permission": "La fotocamera legge i codici a barre ISBN e ISSN. Puoi anche scrivere il termine qui sopra.", + "staff090_catalogue_copy_max": "Risultati per catalogo", + "staff090_catalogue_copy_search": "Cerca", + "staff090_catalogue_copy_search_failed": "Non è stato possibile eseguire la ricerca.", + "staff090_catalogue_copy_results": "Risultati", + "staff090_catalogue_copy_server_count": "Mostrati: %1$d di %2$d", + "staff090_catalogue_copy_no_results": "Nessun record trovato.", + "staff090_catalogue_copy_selected": "Selezionato", + "staff090_catalogue_copy_not_selected": "Non selezionato", + "staff090_catalogue_copy_covers": "Scarica le copertine", + "staff090_catalogue_copy_import": "Importa i selezionati (%1$d)", + "staff090_catalogue_copy_import_failed": "Non è stato possibile importare i record.", + "staff090_catalogue_copy_result": "Esito dell'importazione", + "staff090_catalogue_copy_action_created": "Creato", + "staff090_catalogue_copy_action_updated": "Aggiornato", + "staff090_catalogue_copy_action_skipped": "Saltato (già in catalogo)", + "staff090_catalogue_copy_action_failed": "Non riuscito", + "staff090_catalogue_copy_unavailable_title": "Catalogazione derivata non disponibile", + "staff090_catalogue_copy_reason_plugin": "La catalogazione derivata richiede il plugin Z39.50/SRU: un amministratore può attivarlo da Plugin sul sito.", + "staff090_catalogue_err_copy_unavailable": "La catalogazione derivata non è disponibile su questo server.", + "staff090_catalogue_err_job_cancelled": "L'importazione è stata annullata mentre era in corso.", + "staff090_catalogue_err_schema_missing": "Questa funzione sarà disponibile al termine dell'aggiornamento del database del server.", + "staff090_catalogue_err_search_gone": "Questi risultati non sono più disponibili: ripeti la ricerca.", + "staff090_patrons_import_open_started": "Avviata · %1$s", + "staff090_patrons_import_open_not_started": "Non ancora avviata · righe: %1$d", + "staff090_stocktake_filter_open": "Aperte", + "staff090_stocktake_filter_closed": "Chiuse", + "staff090_stocktake_filter_cancelled": "Annullate", + "staff090_stocktake_filter_all": "Tutte", + "staff090_stocktake_empty": "Nessuna revisione qui", + "staff090_stocktake_empty_hint": "Avviane una con il pulsante +: il telefono diventa lo scanner.", + "staff090_stocktake_new": "Nuova revisione", + "staff090_stocktake_row_counts": "Attese: %1$d · Trovate: %2$d · Mancanti: %3$d · Scansioni: %4$d", + "staff090_stocktake_opened": "Aperta %1$s", + "staff090_stocktake_field_name": "Nome", + "staff090_stocktake_name_required": "Indica un nome per la revisione.", + "staff090_stocktake_field_note": "Note", + "staff090_stocktake_scope": "Cosa controllare", + "staff090_stocktake_scope_hint": "Le copie che corrispondono a queste scelte sono quelle attese. Lascia tutto vuoto per controllare l'intera biblioteca.", + "staff090_stocktake_field_branch": "Sede", + "staff090_stocktake_field_shelf": "Scaffale", + "staff090_stocktake_field_level": "Mensola", + "staff090_stocktake_level": "Livello %1$d", + "staff090_stocktake_field_genre": "Genere", + "staff090_stocktake_field_media": "Tipo di supporto", + "staff090_stocktake_field_collocation_from": "Collocazione da", + "staff090_stocktake_field_collocation_to": "Collocazione a", + "staff090_stocktake_any_shelf": "Tutti gli scaffali", + "staff090_stocktake_any_genre": "Tutti i generi", + "staff090_stocktake_search_shelf": "Cerca uno scaffale", + "staff090_stocktake_search_genre": "Cerca un genere", + "staff090_stocktake_open_session": "Avvia la revisione", + "staff090_stocktake_camera_permission": "Consenti la fotocamera per leggere le etichette delle copie. Puoi anche digitare o caricare i codici.", + "staff090_stocktake_current_shelf": "Scaffale che stai controllando", + "staff090_stocktake_no_shelf": "Non indicato: la posizione delle copie non viene verificata", + "staff090_stocktake_scan_hint": "Inquadra un'etichetta: ogni copia letta viene registrata subito, con un segnale acustico.", + "staff090_stocktake_sending": "In invio: %1$d", + "staff090_stocktake_belongs_on": "Va in %1$s", + "staff090_stocktake_recorded_as": "Registrata come: %1$s", + "staff090_stocktake_manual_code": "Codice della copia", + "staff090_stocktake_send_code": "Registra questo codice", + "staff090_stocktake_upload": "Carica un elenco di codici (.txt o .csv)", + "staff090_stocktake_upload_wrong_file": "Scegli un file .txt o .csv di al massimo 2 MB, un codice per riga.", + "staff090_stocktake_upload_done": "Elenco di codici elaborato", + "staff090_stocktake_not_recorded": "Nessuna connessione: %1$s non è stato registrato. Leggilo di nuovo quando torni online.", + "staff090_stocktake_progress": "Avanzamento", + "staff090_stocktake_count_expected": "Attese", + "staff090_stocktake_count_found": "Trovate", + "staff090_stocktake_count_misplaced": "Fuori posto", + "staff090_stocktake_count_on_loan": "In prestito", + "staff090_stocktake_count_missing": "Mancanti", + "staff090_stocktake_count_lost": "Segnate perse", + "staff090_stocktake_count_unknown": "Codici sconosciuti", + "staff090_stocktake_count_out_of_scope": "Fuori ambito", + "staff090_stocktake_count_duplicates": "Già scansionate", + "staff090_stocktake_count_mine": "Mie scansioni", + "staff090_stocktake_report": "Report", + "staff090_stocktake_close": "Chiudi la revisione", + "staff090_stocktake_close_resume": "Completa la segnalazione come perse", + "staff090_stocktake_action_none": "Lascia le copie mancanti come sono", + "staff090_stocktake_action_lost": "Segna come perse le copie mancanti", + "staff090_stocktake_close_confirm_none": "Copie mancanti: %1$d. Restano come sono; dopo non si potranno scansionare altri codici.", + "staff090_stocktake_close_confirm_lost": "Copie mancanti: %1$d. Saranno segnate come perse; dopo non si potranno scansionare altri codici.", + "staff090_stocktake_close_confirm_resume": "Copie ancora da segnare come perse: %1$d.", + "staff090_stocktake_closed": "Revisione chiusa.", + "staff090_stocktake_cancel": "Annulla la revisione", + "staff090_stocktake_cancel_title": "Annullare questa revisione?", + "staff090_stocktake_cancel_body": "Nessuna copia viene modificata e non si potranno scansionare altri codici. Le scansioni restano nel report.", + "staff090_stocktake_keep": "Lasciala aperta", + "staff090_stocktake_cancelled": "Revisione annullata.", + "staff090_stocktake_no_more_scans": "Questa revisione non accetta più scansioni.", + "staff090_stocktake_pending_lost": "Copie non ancora segnate come perse: %1$d", + "staff090_stocktake_recent": "Ultime scansioni", + "staff090_stocktake_filter_latest": "Ultime", + "staff090_stocktake_only_mine": "Solo le mie scansioni", + "staff090_stocktake_no_scans": "Ancora nessuna scansione.", + "staff090_stocktake_more": "Mostra altre", + "staff090_stocktake_details": "Dettagli", + "staff090_stocktake_whole_library": "L'intera biblioteca", + "staff090_stocktake_opened_label": "Aperta", + "staff090_stocktake_closed_label": "Chiusa", + "staff090_stocktake_history": "Cronologia", + "staff090_stocktake_report_rows": "Righe: %1$d", + "staff090_stocktake_report_empty": "Niente in questa categoria", + "staff090_stocktake_download_csv": "Scarica il report in CSV", + "staff090_stocktake_download_pdf": "Scarica il report in PDF", + "staff090_stocktake_err_session_closed": "Questa revisione è stata chiusa: non si possono scansionare altri codici.", + "staff090_stocktake_err_session_cancelled": "Questa revisione è stata annullata: non si possono scansionare altri codici.", + "staff090_stocktake_err_report_too_large": "Il report è troppo lungo per un PDF: scarica il CSV.", + "staff090_stocktake_err_invalid_state": "Questa azione non è possibile nello stato attuale della revisione.", + "staff090_stocktake_not_permitted": "Solo un amministratore può farlo.", + "staff090_statistics_overview": "Panoramica", + "staff090_statistics_books_available": "Libri disponibili", + "staff090_statistics_books_on_loan": "Libri in prestito", + "staff090_statistics_active_loans": "Prestiti attivi", + "staff090_statistics_overdue_loans": "Prestiti in ritardo", + "staff090_statistics_completed_loans": "Prestiti completati", + "staff090_statistics_borrowers": "Utenti con prestiti", + "staff090_statistics_by_month": "Prestiti per mese", + "staff090_statistics_by_status": "Prestiti attivi per stato", + "staff090_statistics_by_media": "Prestiti per tipo di materiale (ultimi 12 mesi)", + "staff090_statistics_top_books": "Libri più prestati", + "staff090_statistics_top_readers": "Lettori più attivi", + "staff090_statistics_loan_counts": "Prestiti: %1$d · Attivi: %2$d · In ritardo: %3$d", + "staff090_statistics_visitors": "Visitatori, ultimi giorni: %1$d", + "staff090_statistics_visitors_members": "Soci", + "staff090_statistics_visitors_guests": "Ospiti", + "staff090_statistics_visitors_total": "Totale", + "staff090_statistics_visitors_daily": "Visitatori per giorno", + "staff090_statistics_fines_report": "Report delle multe", + "staff090_statistics_from": "Dal", + "staff090_statistics_to": "Al", + "staff090_statistics_fines_empty": "Nessun movimento in questo periodo.", + "staff090_statistics_fines_totals": "Totali", + "staff090_statistics_charged": "Addebitato", + "staff090_statistics_collected": "Incassato", + "staff090_statistics_waived": "Condonato", + "staff090_statistics_reversed": "Stornato", + "staff090_statistics_fines_csv": "Scarica il report delle multe (CSV)", + "staff090_patrons_balance_owed_staff": "Importo che l'utente deve alla biblioteca.", + "staff090_patrons_balance_credit_staff": "Credito a favore dell'utente.", + "staff090_patrons_debt_blocked_body_staff": "Il saldo dell'utente supera la soglia di %1$s fissata dalla biblioteca: nuovi prestiti e rinnovi sono bloccati finché non viene saldato.", + "staff090_patrons_debt_blocked_body_plain_staff": "Nuovi prestiti e rinnovi sono bloccati finché il saldo dell'utente non viene saldato." } diff --git a/tools/e2e/live-smoke-090.sh b/tools/e2e/live-smoke-090.sh new file mode 100755 index 0000000..ffb80d6 --- /dev/null +++ b/tools/e2e/live-smoke-090.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Live end-to-end smoke test of the Pinakes 0.9.0 staff areas (Mobile API 1.10.0): the real app on +# an emulator against a real Pinakes instance, driven like a librarian. +# +# PINAKES_E2E_URL=http://10.0.2.2:8081 PINAKES_E2E_EMAIL= PINAKES_E2E_PASSWORD=... \ +# PINAKES_E2E_DB_NAME=... PINAKES_E2E_DB_USER=... PINAKES_E2E_DB_PASSWORD=... PINAKES_E2E_DB_SOCKET=... \ +# PINAKES_E2E_MARC=/path/to/small.mrc PINAKES_E2E_SHOTS=/tmp/shots \ +# ANDROID_SERIAL=emulator-5554 tools/e2e/live-smoke-090.sh [--install] +# +# --install builds and installs the debug APK first. The device is a disposable test device: the +# app's data is cleared at the start and at the end. The database access is used for fixtures +# (reader, staff account, an overdue loan) and to remove everything the run created. Module +# switches and the daily fine changed for the run are restored. See tools/e2e/live_smoke_090.py. +set -euo pipefail +cd "$(dirname "$0")/../.." + +for var in PINAKES_E2E_URL PINAKES_E2E_EMAIL PINAKES_E2E_PASSWORD PINAKES_E2E_DB_NAME PINAKES_E2E_DB_USER; do + if [ -z "${!var:-}" ]; then echo "missing environment variable $var" >&2; exit 2; fi +done +command -v adb >/dev/null || { echo "adb not on PATH" >&2; exit 2; } +command -v python3 >/dev/null || { echo "python3 not on PATH" >&2; exit 2; } +command -v mysql >/dev/null || { echo "mysql client not on PATH" >&2; exit 2; } +command -v php >/dev/null || { echo "php not on PATH (used to hash the fixture passwords)" >&2; exit 2; } +adb ${ANDROID_SERIAL:+-s "$ANDROID_SERIAL"} get-state >/dev/null + +if [ "${1:-}" = "--install" ]; then + ./gradlew installDebug --console=plain -q +fi +adb ${ANDROID_SERIAL:+-s "$ANDROID_SERIAL"} shell pm path com.pinakes.app >/dev/null || { + echo "com.pinakes.app is not installed on the device (use --install)" >&2; exit 2; } + +exec python3 -u tools/e2e/live_smoke_090.py diff --git a/tools/e2e/live_smoke_090.py b/tools/e2e/live_smoke_090.py new file mode 100644 index 0000000..a2f9595 --- /dev/null +++ b/tools/e2e/live_smoke_090.py @@ -0,0 +1,1465 @@ +#!/usr/bin/env python3 +"""Live end-to-end smoke test of the Pinakes 0.9.0 staff areas (Mobile API 1.10.0). + +The real app on an emulator against a real Pinakes instance, driven like a librarian with +adb/uiautomator (``ui.py``). Every action under test is a tap or typed text in the app; the +Mobile API and the database are used only to prepare fixtures (a book with copies, a reader, +an overdue loan, module switches), to verify the server state after each step and to clean up. + +Environment: the same as live_smoke.py (PINAKES_E2E_URL, PINAKES_E2E_HOST_URL, +PINAKES_E2E_EMAIL/PASSWORD of an ADMIN, ANDROID_SERIAL) plus, required here: + PINAKES_E2E_DB_NAME, PINAKES_E2E_DB_USER, PINAKES_E2E_DB_PASSWORD, + PINAKES_E2E_DB_SOCKET (or _HOST + _PORT) direct access to the instance's database, + used for fixtures and hard cleanup only + PINAKES_E2E_SHOTS directory for one screenshot per step (default /tmp/pinakes-e2e-090) + PINAKES_E2E_MARC a small MARC 21 (.mrc) file to import (step 8 is partly skipped without) + PINAKES_E2E_ONLY comma-separated step numbers to run (default: all) + +Steps (numbered as in the report): + 1 hub sections and a module switched off and on from Library settings + 2 a loan setting changed in the app, checked on the server, restored + 3 patron types (create, edit, delete), one rules-matrix cell, a closure day + 4 a reader's patron section: type, card renewal, ledger charge + partial payment, receipt PDF; + a late loan returned with the fine waived + 5 stocktake session on a shelf with two copies: a known and an unknown code, close, report + 6 visitor log: a reader's card, an unknown card, a guest + 7 patron CSV import through the system document picker + 8 suppliers (create, set on a book, delete); MARC export and import through the picker + 9 statistics overview + 10 a staff (not admin) account: admin-only entries hidden, an admin-only action refused in place +Module switches changed for the run are restored, every record the run created is deleted, the +app data is cleared at the start and at the end. Exit code 0 only when every step passed. +""" +import json +import os +import re +import subprocess +import sys +import time +import traceback +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import ui # noqa: E402 +import live_smoke as base # noqa: E402 (Api, strings, navigation, sql) +from live_smoke import S, s, API, PKG, RUN, SkipStep # noqa: E402 + +SHOTS = os.environ.get("PINAKES_E2E_SHOTS", "/tmp/pinakes-e2e-090") +ONLY = {x.strip() for x in os.environ.get("PINAKES_E2E_ONLY", "").split(",") if x.strip()} +KEEP_APP = os.environ.get("PINAKES_E2E_KEEP_APP") == "1" # debugging: no pm clear / re-login +MARC_FILE = os.environ.get("PINAKES_E2E_MARC", "") +RESULTS = [] # (step, PASS/FAIL/SKIP, detail) +BUGS = [] # reproduction notes of app/server bugs found +SHOT_N = [0] + + +def shot(name): + os.makedirs(SHOTS, exist_ok=True) + SHOT_N[0] += 1 + path = os.path.join(SHOTS, f"{SHOT_N[0]:03d}-{re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')}.png") + try: + ui.screenshot(path) + except Exception: # noqa: BLE001 + return "" + return os.path.basename(path) + + +def bug(text): + BUGS.append(text) + print(" BUG " + text, flush=True) + + +def step(num, name): + def wrap(fn): + def run(*a, **kw): + if ONLY and num != 0 and str(num) not in ONLY and str(num).rstrip("b") not in ONLY: + return True + label = f"{num}. {name}" + print(f"--> {label}", flush=True) + try: + detail = fn(*a, **kw) + RESULTS.append((label, "PASS", detail or "")) + print(f" PASS {detail or ''}", flush=True) + return True + except SkipStep as e: + RESULTS.append((label, "SKIP", str(e))) + print(f" SKIP {e}", flush=True) + return True + except Exception as e: # noqa: BLE001 - report and continue + f = shot(f"fail-{num}") + RESULTS.append((label, "FAIL", f"{e} [{f}]")) + print(f" FAIL {e} [{f}]", flush=True) + traceback.print_exc() + return False + finally: + ui.hide_keyboard() + return run + return wrap + + +LANG = {"v": "en"} + + +def api(method, path, body=None, headers=None, **kw): + """Mobile API call in the device language (server-made labels match what the app shows).""" + st, payload, _ = API.call(method, path, body, headers={"Accept-Language": LANG["v"], **(headers or {})}, **kw) + return st, payload + + +def sql(q): + return base.sql(q) + + +def sql_rows(q): + return [line.split("\t") for line in sql(q).splitlines() if line.strip()] + + +def q(v): + """SQL string literal (fixtures only: values are generated by the run).""" + return "'" + str(v).replace("\\", "\\\\").replace("'", "''") + "'" + + +# ---------------------------------------------------------------- app session + +def launch(): + # `monkey` needs a physical key map that headless emulators lack: start the activity directly. + ui.shell(f"am start -W -n {PKG}/.MainActivity") + + +def sign_in(email, password): + """Clear the app, onboard on the instance and sign in as [email] (the same path as a user).""" + ui.adb("shell", "pm", "clear", PKG) + ui.airplane(False) + launch() + ui.wait(S["onboarding_url_label"], 30) + ui.tap(S["onboarding_url_label"]) + ui.type_text(base.DEVICE_URL) + ui.hide_keyboard() + ui.tap(S["action_discover_library"]) + ui.wait(S["action_continue"], 90) + ui.tap(S["action_continue"]) + ui.wait(S["login_email_label"], 30) + ui.tap(S["login_email_label"], exact=True) + ui.type_text(email) + ui.hide_keyboard() + ui.tap(S["field_password"], exact=True) + ui.type_text(password, secret=True) + ui.hide_keyboard() + for attempt in range(3): + ui.tap(S["login_action_sign_in"]) + which, _ = ui.wait_any([S["nav_profile"], S["login_error_network"]], 120) + if which == S["nav_profile"]: + return attempt + 1 + time.sleep(5) + raise AssertionError("sign-in kept failing with a network error") + + +def open_staff_hub(): + """Profile tab (scrolled back to the top, so it reloads /me and the role) → Admin area.""" + base.open_tab("nav_profile") + for _ in range(3): + ui.swipe_down() + ui.wait(S["profile_action_edit"], 90) + ui.scroll_to(S["profile_action_staff"]) + ui.tap(S["profile_action_staff"]) + ui.wait(S["staff_scan"]) + + +base.open_staff_hub = open_staff_hub # live_smoke's version assumes Profile is at the top + + +def hub(): + """The staff hub, from wherever the app is.""" + base.back_to_hub() + + +def open_entry(label): + hub() + ui.scroll_to(label, exact=True) + ui.tap(label, exact=True) + + +# ---------------------------------------------------------------- screen helpers + +def contains(outer, inner): + return outer[0] <= inner[0] and outer[1] <= inner[1] and outer[2] >= inner[2] and outer[3] >= inner[3] + + +def row_of(label, exact=True, nodes=None, kind="checkable"): + """The smallest checkable (or clickable) node around the text [label]: a switch row.""" + nodes = nodes if nodes is not None else ui.dump() + text = ui.find(label, exact=exact, nodes=nodes) + if text is None: + return None + rows = [n for n in nodes if getattr(n, kind) and contains(n.bounds, text.bounds)] + rows.sort(key=lambda n: (n.bounds[2] - n.bounds[0]) * (n.bounds[3] - n.bounds[1])) + return rows[0] if rows else None + + +def switch_state(label): + ui.scroll_to(label, exact=True) + row = row_of(label) + assert row is not None, f"no switch row for {label!r}" + return row.checked + + +def set_switch(label, on): + ui.scroll_to(label, exact=True) + row = row_of(label) + assert row is not None, f"no switch row for {label!r}" + if row.checked != on: + ui.tap_xy(*row.center) + time.sleep(0.5) + row = row_of(label) + assert row is not None and row.checked == on, f"switch {label!r} did not turn {'on' if on else 'off'}" + + +def hub_labels(): + """Every entry label of the staff hub, top to bottom.""" + hub() + seen = [] + for _ in range(3): + ui.swipe_down() + for _ in range(12): + before = len(seen) + for n in ui.dump(): + if n.text and n.text not in seen: + seen.append(n.text) + ui.swipe_up() + if len(seen) == before: + break + return seen + + +def toast_or_text(texts, timeout=30): + """One of [texts] shows up (snackbar or screen text).""" + return ui.wait_any(texts, timeout)[0] + + +def restart_app(): + """Kill and relaunch: the app re-reads /health on start.""" + ui.shell(f"am force-stop {PKG}") + launch() + ui.wait(S["nav_profile"], 60) + + +def health(): + _, body = api("GET", "health", auth=False) + return (body.get("data") or {}).get("features", {}) + + +def field_of(label, nodes=None, exact=True): + """The text field whose floating label is [label].""" + nodes = nodes if nodes is not None else ui.dump() + for n in nodes: + if n.cls == "android.widget.TextView" and (n.text.lower() == label.lower() if exact else label.lower() in n.text.lower()): + for f in nodes: + if f.cls == "android.widget.EditText" and contains(f.bounds, n.center + n.center): + return f + return None + + +def set_field(label, value, exact=True, scroll=True, down=True): + """Replace the content of the field labelled [label] with [value], as typed by a user. + [down]=False never swipes downwards (a swipe down at the top of a bottom sheet closes it).""" + if scroll: + for p in range(2 if down else 1): + for _ in range(8): + if field_of(label, exact=exact): + break + (ui.swipe_up if p == 0 else ui.swipe_down)() + if field_of(label, exact=exact): + break + f = field_of(label, exact=exact) + assert f is not None, f"no field labelled {label!r}" + ui.tap_xy(*f.center) + ui.shell("input keyevent KEYCODE_MOVE_END") + for _ in range((len(f.text) // 10 + 1)): + ui.shell("input keyevent " + " ".join(["KEYCODE_DEL"] * 12)) + if value != "": + ui.type_text(str(value)) + ui.hide_keyboard() + return f.text + + +# ---------------------------------------------------------------- fixtures and cleanup + +TAG = RUN[-10:] # short, unique per run: fits card codes and shelf codes +FX = { # what the run created (ids), for the checks and the cleanup + "users": [], "books": [], "shelves": [], "loans": [], "types": [], "closures": [], + "suppliers": [], "visits": [], "sessions": [], "imports": [], +} +STATE = {"modules_before": None, "loans_before": None} +READER = {"email": f"e2e-reader-{RUN}@test.local", "card": f"E2ER{TAG}", "name": "E2E", "surname": f"Reader {TAG}"} +STAFF = {"email": f"e2e-staff-{RUN}@test.local", "card": f"E2ES{TAG}", "name": "E2E", "surname": f"Staff {TAG}", + "password": "E2e-" + os.urandom(9).hex()} +STOCK_TITLE = f"E2E stocktake {TAG}" +LATE_TITLE = f"E2E late loan {TAG}" +TYPE_NAME = f"E2E type {TAG}" +SUPPLIER_NAME = f"E2E supplier {TAG}" +NEEDED_MODULES = {"stocktake": True, "visitor_log": True, "suppliers": True, "fines": True, "automatic_fines": True} +DAILY_FINE_CENTS = 50 + + +def php_hash(password): + """bcrypt hash as Pinakes stores it (password_hash); the password goes through stdin.""" + res = subprocess.run(["php", "-r", "echo password_hash(trim(fgets(STDIN)), PASSWORD_DEFAULT);"], + input=password + "\n", capture_output=True, text=True, check=True) + return res.stdout.strip() + + +def create_user(who, role): + pw = php_hash(who.get("password") or os.urandom(12).hex()) + sql("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata, " + "privacy_accettata, data_scadenza_tessera, locale) VALUES " + f"({q(who['card'])}, {q(who['name'])}, {q(who['surname'])}, {q(who['email'])}, {q(pw)}, 'attivo', {q(role)}, 1, 1, " + "DATE_ADD(CURDATE(), INTERVAL 30 DAY), 'en_US')") + uid = int(sql(f"SELECT id FROM utenti WHERE email={q(who['email'])}").strip()) + FX["users"].append(uid) + who["id"] = uid + return uid + + +def create_book(title, copies, shelf_id=None, level_id=None): + body = {"title": title, "copies": copies} + if shelf_id: + body.update({"shelf_id": shelf_id, "level_id": level_id}) + st, b = api("POST", "staff/books", body) + assert st in (200, 201), f"create book HTTP {st}: {b}" + FX["books"].append(b["data"]["id"]) + return API.book(b["data"]["id"]) + + +def fixtures(): + """Modules on, a daily fine, a reader, a staff account, a shelf with a two-copy book, an overdue loan.""" + _, mods = api("GET", "staff/settings/modules") + STATE["modules_before"] = {m["key"]: m["stored"] for m in mods["data"]["modules"]} + _, loans = api("GET", "staff/settings/loans") + STATE["loans_before"] = {"daily_fine_cents": loans["data"]["settings"]["daily_fine_cents"]} + st, b = api("PUT", "staff/settings/modules", {"features": NEEDED_MODULES}) + assert st == 200, f"modules HTTP {st}: {b}" + st, b = api("PUT", "staff/settings/loans", {"daily_fine_cents": DAILY_FINE_CENTS}) + assert st == 200, f"loans HTTP {st}: {b}" + create_user(READER, "standard") + create_user(STAFF, "staff") + st, shelf = api("POST", "staff/shelves", {"code": f"E2E{TAG}", "name": f"E2E shelf {TAG}"}) + assert st == 201, f"shelf HTTP {st}: {shelf}" + FX["shelves"].append(shelf["data"]["id"]) + st, level = api("POST", f"staff/shelves/{shelf['data']['id']}/levels", {"number": 1}) + assert st == 201, f"level HTTP {st}: {level}" + STATE["shelf"] = shelf["data"] + STATE["level"] = level["data"] + book = create_book(STOCK_TITLE, 2, shelf["data"]["id"], level["data"]["id"]) + STATE["stock_codes"] = [c["inventory_number"] for c in book["copies"]] + # The overdue loan: lent today by the API, then moved 20 days into the past in the database. + late = create_book(LATE_TITLE, 1) + code = late["copies"][0]["inventory_number"] + st, loan = api("POST", "staff/loans", {"user_id": READER["id"], "book_id": late["id"], "immediate": True, "copy_code": code}, + headers={"Idempotency-Key": f"e2e-{RUN}-late"}) + assert st in (200, 201), f"loan HTTP {st}: {loan}" + lid = loan["data"]["id"] + FX["loans"].append(lid) + sql("UPDATE prestiti SET data_prestito = DATE_SUB(CURDATE(), INTERVAL 20 DAY), " + f"data_scadenza = DATE_SUB(CURDATE(), INTERVAL 6 DAY), stato = 'in_ritardo' WHERE id = {int(lid)}") + _, detail = api("GET", f"staff/loans/{lid}") + fine = (detail["data"].get("late_fine") or {}) + STATE["late_loan"] = lid + STATE["late_fine_cents"] = fine.get("accruing_cents") + return (f"reader #{READER['id']}, staff #{STAFF['id']}, shelf #{shelf['data']['id']}, book #{book['id']} " + f"{STATE['stock_codes']}, overdue loan #{lid} accruing {fine.get('accruing_cents')} cents") + + +# ---------------------------------------------------------------- steps + +def module_label(key): + _, mods = api("GET", "staff/settings/modules") + return next(m["label"] for m in mods["data"]["modules"] if m["key"] == key) + + +def entries_090(): + """The 0.9.0 hub entries expected for the modules switched on by the fixtures (admin).""" + return [S[k] for k in ("staff090_attendance", "staff090_stocktake", "staff090_user_import", "staff090_marc", + "staff090_copy_cataloging", "staff090_suppliers", "staff090_statistics", "staff090_calendar", + "staff090_circulation_rules", "staff090_settings")] + + +def save_settings(): + ui.tap(S["action_save"], exact=True) + which = toast_or_text([S["staff090_library_saved"], S["staff_error_save"]], 60) + assert which == S["staff090_library_saved"], "the save failed in the app" + + +@step(1, "staff hub: 0.9.0 sections, a module switched off and on from Library settings") +def step_hub_and_module(): + labels = hub_labels() + missing = [e for e in entries_090() if e not in labels] + assert not missing, f"hub entries missing with their modules on: {missing}" + for section in ("staff090_section_desk", "staff090_section_cataloguing", "staff090_section_library"): + assert any(l.lower() == S[section].lower() for l in labels), f"section {S[section]!r} missing" + shot("hub-090-entries") + sup = module_label("suppliers") + open_entry(S["staff090_settings"]) + ui.tap(S["staff090_library_tab_modules"], exact=True) + set_switch(sup, False) + save_settings() + shot("settings-suppliers-off") + assert health().get("suppliers") is False, "/health still has suppliers on after the save" + # Back to the hub with no restart: the module save refreshes /health, the entry is gone. + assert S["staff090_suppliers"] not in hub_labels(), "Suppliers still in the hub after switching it off (no restart)" + shot("hub-suppliers-gone") + open_entry(S["staff090_settings"]) + ui.tap(S["staff090_library_tab_modules"], exact=True) + set_switch(sup, True) + save_settings() + assert health().get("suppliers") is True, "/health suppliers not back on" + assert S["staff090_suppliers"] in hub_labels(), "Suppliers not back in the hub after switching it on (no restart)" + shot("hub-suppliers-back") + return ("hub has the 10 entries in Front desk / Cataloguing / Library; suppliers off → /health false and the " + "entry gone at once; back on → entry back at once (no restart)") + + +@step(2, "library settings: a loan setting changed in the app, checked on the server, restored") +def step_loan_setting(): + _, before = api("GET", "staff/settings/loans") + old = before["data"]["settings"]["pickup_expiry_days"] + new = old + 1 if old < 30 else old - 1 + open_entry(S["staff090_settings"]) + ui.tap(S["staff090_library_tab_loans"], exact=True) + shown = set_field(S["staff090_library_loan_pickup_days"], str(new)) + assert shown == str(old), f"the field showed {shown!r}, the server has {old}" + save_settings() + _, after = api("GET", "staff/settings/loans") + got = after["data"]["settings"]["pickup_expiry_days"] + assert got == new, f"server pickup_expiry_days {got}, expected {new}" + others = {k: v for k, v in after["data"]["settings"].items() if k != "pickup_expiry_days"} + assert others == {k: v for k, v in before["data"]["settings"].items() if k != "pickup_expiry_days"}, "other loan settings changed" + shot("settings-loans-changed") + set_field(S["staff090_library_loan_pickup_days"], str(old)) + save_settings() + _, back = api("GET", "staff/settings/loans") + assert back["data"]["settings"]["pickup_expiry_days"] == old, "not restored" + return f"pickup_expiry_days {old} → {new} on the server (nothing else changed) → {old}" + + +def type_row_menu(name): + nodes = ui.dump() + row = ui.find(name, exact=True, nodes=nodes) + assert row is not None, f"patron type {name!r} not listed" + menu = [n for n in nodes if n.desc == S["staff_more_actions"] and abs(n.center[1] - row.center[1]) < 90] + assert menu, f"no actions menu next to {name!r}" + ui.tap_xy(*menu[0].center) + time.sleep(0.8) + + +def server_type(name): + _, b = api("GET", "staff/circulation/types") + return next((t for t in b["data"]["types"] if t["name"] == name), None) + + +def tap_last(text): + """The last node showing [text] (the dialog's button, not the screen's own action).""" + nodes = [n for n in ui.dump() if n.text.lower() == text.lower()] + assert nodes, f"no {text!r} on screen" + ui.tap_xy(*nodes[-1].center) + + +def open_matrix_cell(type_name, media_label): + for _ in range(10): + nodes = ui.dump() + head = ui.find(type_name, exact=True, nodes=nodes) + if head: + cells = [n for n in nodes if n.text == media_label and n.center[1] > head.center[1]] + if cells and cells[0].center[1] < ui.screen_size()[1] * 0.85: + ui.tap_xy(*cells[0].center) + time.sleep(1.2) + return + ui.swipe_up() + raise AssertionError(f"matrix cell {type_name} · {media_label} not found") + + +def pick_date(day): + """Material date picker: move to the month of [day], tap it, confirm.""" + import datetime + today = datetime.date.today() + for _ in range((day.year - today.year) * 12 + day.month - today.month): + n = next(n for n in ui.dump() if n.desc == "Change to next month") + ui.tap_xy(*n.center) + ui.tap(f"{day.strftime('%B')} {day.day}, {day.year}") + ui.tap(S["action_confirm"], exact=True) + + +@step(3, "patron types (create, edit), one rules-matrix cell, a closure day") +def step_types_matrix_closure(): + import datetime + # Patron type: created and edited in the app. + open_entry(S["staff090_circulation_rules"]) + ui.tap(S["staff090_library_type_new"]) + time.sleep(1.2) + set_field(S["staff_field_name"], TYPE_NAME, scroll=False) + set_field(S["staff090_library_num_card_duration"], "400", down=False) + ui.scroll_to(S["action_save"], exact=True, passes=1) + ui.tap(S["action_save"], exact=True) + toast_or_text([S["staff090_library_saved"]], 30) + t = server_type(TYPE_NAME) + assert t and t["card_duration_days"] == 400, f"type on the server: {t}" + FX["types"].append(t["id"]) + shot("type-created") + type_row_menu(TYPE_NAME) + ui.tap(S["staff090_library_type_edit"], exact=True) + time.sleep(1.2) + for _ in range(8): + if field_of(S["staff090_library_num_card_duration"]): + break + ui.swipe_up() + _, types = api("GET", "staff/circulation/types") + glob = types["data"]["globals"].get("card_duration_days") + want_hint = s("staff090_library_inherits", glob if glob is not None else "—") + nodes = ui.dump() + card = field_of(S["staff090_library_num_card_duration"], nodes=nodes) + assert card is not None, "Card validity field not on screen" + hints = [n.text for n in nodes if n.cls == "android.widget.TextView" and contains(card.bounds, n.center + n.center) + and n.text != S["staff090_library_num_card_duration"]] + assert want_hint in hints, f"Card validity hint {hints} is not the global fallback {want_hint!r}" + shot("type-edit-hint") + set_field(S["staff090_library_num_card_duration"], "500", down=False) + ui.scroll_to(S["action_save"], exact=True, passes=1) + ui.tap(S["action_save"], exact=True) + toast_or_text([S["staff090_library_saved"]], 30) + t = server_type(TYPE_NAME) + assert t["card_duration_days"] == 500, f"edit not saved: {t['card_duration_days']}" + # Rules matrix: the new type × DVD loan duration, then back to inherited. + ui.tap(S["staff090_library_matrix"]) + time.sleep(1.5) + _, rules = api("GET", "staff/circulation/rules") + media = rules["data"]["media"][-2] # the second-last material (DVD on a default install) + open_matrix_cell(TYPE_NAME, media["label"]) + set_field(S["staff090_library_num_loan_duration"], "14", scroll=False) + ui.scroll_to(S["staff090_library_apply"], exact=True, passes=1) + ui.tap(S["staff090_library_apply"], exact=True) + ui.tap(s("staff090_library_save_cells", 1), exact=True) + toast_or_text([S["staff090_library_saved"]], 30) + + def cell(): + _, r = api("GET", "staff/circulation/rules") + return next(c for c in r["data"]["cells"] if c["type_id"] == t["id"] and c["media"] == media["key"]) + c = cell() + assert c["has_rule"] and c["loan_duration_days"] == 14, f"cell on the server: {c}" + shot("matrix-cell-saved") + open_matrix_cell(TYPE_NAME, media["label"]) + ui.scroll_to(S["staff090_library_inherit_all"], exact=True, passes=1) + ui.tap(S["staff090_library_inherit_all"], exact=True) + ui.tap(S["staff090_library_apply"], exact=True) + ui.tap(s("staff090_library_save_cells", 1), exact=True) + toast_or_text([S["staff090_library_saved"]], 30) + c = cell() + assert not c["has_rule"] and c["loan_duration_days"] is None, f"cell not restored: {c}" + # Closures calendar: a future open day closed in the app, checked in the grid and in availability. + _, cal = api("GET", "staff/calendar") + today = cal["data"]["today"] + open_days = [d["date"] for d in cal["data"]["days"] if not d["closed"] and d["date"] > today] + if len(open_days) < 3: + _, cal = api("GET", f"staff/calendar?month={cal['data']['next_month']}") + open_days = [d["date"] for d in cal["data"]["days"] if not d["closed"]] + assert open_days, "no open day in this month or the next" + day = datetime.date.fromisoformat(open_days[len(open_days) // 2]) + STATE["closure_day"] = day.isoformat() + open_entry(S["staff090_calendar"]) + time.sleep(1.5) + ui.scroll_to(S["staff090_library_closure_new"], exact=True) + ui.tap(S["staff090_library_closure_new"], exact=True) + time.sleep(1.2) + ui.tap(S["staff_date_pick"], exact=True) + time.sleep(1) + pick_date(day) + desc = f"E2E closure {TAG}" + set_field(S["staff_field_description"], desc, scroll=False) + ui.tap(S["action_save"], exact=True) + toast_or_text([S["staff090_library_saved"]], 30) + _, cal = api("GET", f"staff/calendar?month={day.strftime('%Y-%m')}") + d = next(x for x in cal["data"]["days"] if x["date"] == day.isoformat()) + assert d["closed"] and any(r.get("name") == desc for r in d["reasons"]), f"calendar day on the server: {d}" + FX["closures"].extend(r["id"] for r in d["reasons"] if r.get("name") == desc) + _, avail = api("GET", f"catalog/books/{FX['books'][0]}/availability") + assert day.isoformat() in (avail["data"].get("closed_dates") or []), "the day is not in availability closed_dates" + for _ in range(4): + ui.swipe_down() + if day.strftime("%Y-%m") != today[:7]: + ui.tap(S["cd_next_month"], exact=True) + time.sleep(1.5) + cell_node = ui.find(str(day.day), exact=True) + ui.tap_xy(*cell_node.center) + ui.wait(s("staff090_library_reason_closure", desc), 15) + shot("closure-day-in-grid") + ui.tap(S["staff090_library_close"], exact=True) + ui.scroll_to(desc) + ui.tap(desc) + time.sleep(1.2) + tap_last(S["staff090_library_delete"]) + time.sleep(0.8) + tap_last(S["staff090_library_delete"]) + toast_or_text([S["staff090_library_deleted"]], 30) + _, cal = api("GET", f"staff/calendar?month={day.strftime('%Y-%m')}") + d = next(x for x in cal["data"]["days"] if x["date"] == day.isoformat()) + assert not d["closed"], f"day still closed after the delete: {d}" + FX["closures"].clear() + return (f"type #{t['id']} created (card 400) and edited (500); matrix {TYPE_NAME} × {media['key']} 14 days then " + f"inherited again; closure on {day} closed in the grid, in /staff/calendar and in closed_dates, then deleted") + + +@step("3b", "patron type deleted in the app (its patron goes back to the default type)") +def step_type_delete(): + t = server_type(TYPE_NAME) + if t is None: + raise SkipStep("the type was not created") + open_entry(S["staff090_circulation_rules"]) + time.sleep(1.5) + type_row_menu(TYPE_NAME) + ui.tap(S["staff090_library_delete"], exact=True) + time.sleep(0.8) + shot("type-delete-confirm") + tap_last(S["staff090_library_delete"]) + deadline = time.time() + 30 + while time.time() < deadline and server_type(TYPE_NAME) is not None: + time.sleep(1) + assert server_type(TYPE_NAME) is None, "type still on the server" + FX["types"].remove(t["id"]) + tid = sql(f"SELECT IFNULL(tipologia_id, 0) FROM utenti WHERE id = {int(READER['id'])}").strip() + return f"type #{t['id']} deleted; the reader's tipologia_id is now {tid} (0 = default)" + + +def reader_row(): + r = sql_rows(f"SELECT IFNULL(tipologia_id, 0), IFNULL(data_scadenza_tessera, ''), stato FROM utenti WHERE id = {int(READER['id'])}")[0] + return {"type": int(r[0]), "expiry": r[1], "status": r[2]} + + +def wait_for(fn, timeout=30, what="server state"): + deadline = time.time() + timeout + last = None + while time.time() < deadline: + last = fn() + if last: + return last + time.sleep(1) + raise AssertionError(f"{what} not reached in {timeout}s (last: {last})") + + +def open_late_loan(): + open_entry(S["staff_circulation_desk"]) + ui.tap(S["staff_loans_overdue"], exact=True) + ui.tap(LATE_TITLE, timeout=30) + ui.wait(S["staff_user_open"], 30) + + +@step(4, "patron section: type, card renewal, ledger charge + partial payment, receipt; late return with the fine waived") +def step_patron(): + import datetime + t = server_type(TYPE_NAME) + if t is None: + raise SkipStep("step 3 did not create the patron type") + today = datetime.date.fromisoformat(sql("SELECT CURDATE()").strip()) + # Fixture: the card expired three days ago, so the renewal has something to do. + sql(f"UPDATE utenti SET data_scadenza_tessera = DATE_SUB(CURDATE(), INTERVAL 3 DAY), stato = 'scaduto' WHERE id = {int(READER['id'])}") + open_late_loan() + ui.scroll_to(S["staff_user_open"], exact=True) + ui.tap(S["staff_user_open"], exact=True) + ui.wait(S["staff090_patrons_renew_card"], 30) + shot("patron-section") + # Card renewal (no patron type duration yet: one year, the account active again). + ui.tap(S["staff090_patrons_renew_card"], exact=True) + time.sleep(1) + tap_last(S["staff090_patrons_renew_card"]) + want = (today + datetime.timedelta(days=365)).isoformat() + row = wait_for(lambda: (lambda r: r if r["status"] == "attivo" and r["expiry"] == want else None)(reader_row()), 30, "renewed card") + renew_detail = f"card renewed: expired → active until {row['expiry']}" + # Patron type with the expiry recomputed from its card validity (500 days). + ui.scroll_to(S["staff090_patrons_change_type"], exact=True) + ui.tap(S["staff090_patrons_change_type"], exact=True) + time.sleep(1.2) + ui.tap(TYPE_NAME, exact=True) + set_switch(S["staff090_patrons_recompute_expiry"], True) + ui.tap(S["action_save"], exact=True) + want = (today + datetime.timedelta(days=500)).isoformat() + row = wait_for(lambda: (lambda r: r if r["type"] == t["id"] and r["expiry"] == want else None)(reader_row()), 30, "patron type") + ui.swipe_down() + ui.wait(TYPE_NAME, 15) + shot("patron-type-assigned") + # Ledger: a charge, then a payment of part of it. + ui.scroll_to(S["staff090_ledger"], exact=True) + ui.tap(S["staff090_ledger"], exact=True) + ui.wait(S["staff090_patrons_add_charge"], 30) + _, led = api("GET", f"staff/users/{READER['id']}/ledger") + base_cents = led["data"]["balance_cents"] + currency = led["data"]["currency"] + ui.tap(S["staff090_patrons_add_charge"], exact=True) + time.sleep(1.2) + set_field(s("staff090_patrons_amount", currency), "7.50", scroll=False) + set_field(S["staff090_patrons_description"], f"E2E charge {TAG}", scroll=False) + tap_last(S["staff090_patrons_add_charge"]) + wait_for(lambda: api("GET", f"staff/users/{READER['id']}/ledger")[1]["data"]["balance_cents"] == base_cents + 750, 30, "charge") + ui.tap(S["staff090_patrons_record_payment"], exact=True) + time.sleep(1.2) + set_field(s("staff090_patrons_amount", currency), "3.00", scroll=False) + method = led["data"]["payment_methods"][0] + ui.tap(method["label"], exact=True) + tap_last(S["staff090_patrons_record_payment"]) + wait_for(lambda: api("GET", f"staff/users/{READER['id']}/ledger")[1]["data"]["balance_cents"] == base_cents + 450, 30, "payment") + _, led = api("GET", f"staff/users/{READER['id']}/ledger") + pay = next(m for m in led["data"]["items"] if m["type"] == "pagamento") + assert pay["amount_cents"] == -300 and pay["payment_method"] == method["code"], f"payment row {pay}" + shot("ledger-charge-payment") + # Receipt PDF of the payment, downloaded into the app's exports. + ui.tap(S["staff090_patrons_receipt"], exact=True) + ui.wait(S["staff090_patrons_receipt_ready"], 60) + nodes = ui.dump() + names = [n.text for n in nodes if n.text.lower().endswith(".pdf")] + shot("receipt-ready") + assert names, "no file name in the receipt dialog" + head = ui.adb("exec-out", "run-as", PKG, "head", "-c", "5", f"cache/exports/{names[0]}", check=False) + assert head == b"%PDF-", f"cache/exports/{names[0]} is not a PDF ({head[:20]!r})" + ui.back() + balance_detail = f"balance {base_cents} → +750 charge → -300 {method['code']} = {base_cents + 450} cents; receipt {names[0]} (%PDF)" + # The late return with the fine waived, from the reader's open loan. + ui.back() + ui.scroll_to(LATE_TITLE) + nodes = ui.dump() + rows = [n for n in nodes if n.text == LATE_TITLE] + ui.tap_xy(*rows[-1].center) + ui.wait(S["staff_loan_action_return"], 30) + lid = STATE["late_loan"] + _, loan = api("GET", f"staff/loans/{lid}") + accruing = (loan["data"].get("late_fine") or {}).get("accruing_cents") + assert accruing and accruing > 0, f"no accruing late fine on loan #{lid}: {loan['data'].get('late_fine')}" + ui.tap(S["staff_loan_action_return"], exact=True) + time.sleep(1.5) + set_switch(S["staff090_patrons_return_waive"], True) + reason = f"E2E waiver {TAG}" + set_field(S["staff090_patrons_waive_reason"], reason, scroll=False) + shot("return-waive") + tap_last(S["staff_loan_action_return"]) + wait_for(lambda: api("GET", f"staff/loans/{lid}")[1]["data"]["status"] == "restituito", 60, "returned loan") + _, loan = api("GET", f"staff/loans/{lid}") + lf = loan["data"].get("late_fine") or {} + _, led = api("GET", f"staff/users/{READER['id']}/ledger") + waiver = [m for m in led["data"]["items"] if m["type"] == "condono" and m.get("loan_id") == lid] + fine = [m for m in led["data"]["items"] if m["type"] == "multa_ritardo" and m.get("loan_id") == lid] + assert lf.get("charged_cents") == 0, f"late fine still charged: {lf}" + assert waiver and waiver[0]["amount_cents"] == -accruing and waiver[0]["description"] == reason, f"waiver rows {waiver}" + assert led["data"]["balance_cents"] == base_cents + 450, f"balance changed by the waived return: {led['data']['balance_cents']}" + ui.wait(S["loan_status_returned"], 30, exact=True) + shot("loan-returned") + return (f"{renew_detail}; type #{t['id']} assigned, expiry {row['expiry']} (today+500); {balance_detail}; " + f"loan #{lid} returned, fine {fine[0]['amount_cents'] if fine else '?'} cents waived ('{reason}'), charged_cents 0") + + +STOCK_SESSION = f"E2E stocktake session {TAG}" +UNKNOWN_CODE = f"E2E-NOPE-{TAG}" + + +def stock_session(): + _, b = api("GET", "staff/stocktake/sessions?status=all&limit=50") + for row in b.get("data") or []: + if row["name"] == STOCK_SESSION: + if row["id"] not in FX["sessions"]: + FX["sessions"].append(row["id"]) + return row + return None + + +def stock_scan(code): + set_field(S["staff090_stocktake_manual_code"], code, scroll=False) + ui.tap(S["staff090_stocktake_send_code"]) + ui.wait(code, 30, exact=True) + time.sleep(1) + + +@step(5, "stocktake: a session on a shelf with two copies, a known and an unknown code, close, report") +def step_stocktake(): + shelf = STATE["shelf"] + shelf_label = f"[{shelf['code']}] {shelf['name']}" + open_entry(S["staff090_stocktake"]) + ui.tap(S["staff090_stocktake_new"]) + time.sleep(1.2) + set_field(S["staff090_stocktake_field_name"], STOCK_SESSION, scroll=False) + ui.tap(S["staff090_stocktake_any_shelf"], exact=True) + time.sleep(1) + ui.tap(S["staff090_stocktake_search_shelf"], exact=True) + ui.type_text(shelf["code"]) + ui.hide_keyboard() + ui.tap(shelf_label, exact=True) + ui.scroll_to(S["staff090_stocktake_open_session"], exact=True, passes=1) + ui.tap(S["staff090_stocktake_open_session"], exact=True) + base.deny_camera_prompt() + ui.wait(S["staff090_stocktake_manual_code"], 30) + sess = wait_for(stock_session, 30, "stocktake session") + assert sess["expected_total"] == 2 and sess["filter"]["shelf_id"] == shelf["id"], f"session {sess}" + known = STATE["stock_codes"][0] + stock_scan(known) + found = S["staff090_stocktake_count_found"] + shot("stocktake-found") + stock_scan(UNKNOWN_CODE) + shot("stocktake-unknown") + nodes = ui.dump() + cards = [n.text for n in nodes if n.text] + sm = stock_session()["summary"] + assert (sm["expected"], sm["found"], sm["missing"], sm["unknown"], sm["scans"]) == (2, 1, 1, 1, 2), f"server counters {sm}" + # The progress counters on screen: the number shown above each label. + ui.scroll_to(S["staff090_stocktake_count_unknown"], exact=True, passes=1) + nodes = ui.dump() + + def counter(label_key): + lab = ui.find(S[label_key], exact=True, nodes=nodes) + assert lab is not None, f"counter {S[label_key]!r} not on screen" + nums = [n for n in nodes if n.text.isdigit() and n.bounds[3] <= lab.bounds[1] + 4 and abs(n.bounds[0] - lab.bounds[0]) < 60] + nums.sort(key=lambda n: lab.bounds[1] - n.bounds[3]) + return int(nums[0].text) if nums else None + shown = {k: counter(k) for k in ("staff090_stocktake_count_found", "staff090_stocktake_count_missing", + "staff090_stocktake_count_unknown")} + assert list(shown.values()) == [1, 1, 1], f"counters on screen {shown}" + shot("stocktake-counters") + ui.scroll_to(S["staff090_stocktake_close"], exact=True) + ui.tap(S["staff090_stocktake_close"], exact=True) + time.sleep(1) + ui.tap(S["staff090_stocktake_action_none"], exact=True) + tap_last(S["staff090_stocktake_close"]) + sess = wait_for(lambda: (lambda x: x if x and x["status"] == "closed" else None)(stock_session()), 30, "closed session") + assert sess["missing_action"] == "none", f"missing_action {sess['missing_action']}" + shot("stocktake-closed") + rep = next(n for n in ui.dump() if n.desc == S["staff090_stocktake_report"]) + ui.tap_xy(*rep.center) + ui.wait(s("staff090_stocktake_report_rows", 3), 30) + for code in (known, STATE["stock_codes"][1], UNKNOWN_CODE): + assert ui.find(code, exact=True), f"{code} not in the report" + shot("stocktake-report") + return (f"session #{sess['id']} on shelf {shelf_label}: expected 2; {known} → {found}, {UNKNOWN_CODE} → unknown; " + f"counters found/missing/unknown 1/1/1 on screen and server, scans 2; closed with missing_action=none; " + f"report rows 3 (found, missing {STATE['stock_codes'][1]}, unknown)") + + + +PAGED_SESSION = f"E2E stocktake paging {TAG}" +PAGED_COPIES = 60 # more than one page (the app asks for 50) of report rows +PAGED_SCANS = 55 # more than one page of found scans + + +def merge_visible(acc, visible): + """Append a screen's rows to the rows seen so far, aligned on the overlap with the previous screen.""" + for k in range(min(len(acc), len(visible)), 0, -1): + if acc[-k:] == visible[:k]: + return acc + visible[k:] + return acc + visible + + +def visible_codes(prefix_text): + nodes = sorted((n for n in ui.dump() if n.text.startswith(prefix_text)), key=lambda n: n.bounds[1]) + return [n.text for n in nodes] + + +def scroll_collect(prefix_text, until=None, max_swipes=60): + """Scroll a list to its end collecting the rows whose text starts with [prefix_text], in screen order.""" + acc, still = [], 0 + for _ in range(max_swipes): + before = len(acc) + acc = merge_visible(acc, visible_codes(prefix_text)) + if until and ui.find(until, exact=True): + break + still = still + 1 if len(acc) == before else 0 + if still >= 3: + break + ui.swipe_up() + time.sleep(0.8) + return acc + + +def api_all(path): + rows, cursor = [], None + for _ in range(50): + sep = "&" if "?" in path else "?" + st, b = api("GET", f"{path}{sep}limit=50" + (f"&cursor={urllib.request.quote(cursor)}" if cursor else "")) + assert st == 200, f"GET {path} HTTP {st}: {b}" + rows += b.get("data") or [] + cursor = (b.get("meta") or {}).get("next_cursor") + if not cursor: + return rows + raise AssertionError(f"{path}: more than 50 pages") + + +@step("5b", "stocktake paging: a code list over one page, the scan list and the report loaded page by page") +def step_stocktake_paging(): + import tempfile + _, shelf = api("POST", "staff/shelves", {"code": f"E2P{TAG}", "name": f"E2E paging shelf {TAG}"}) + FX["shelves"].append(shelf["data"]["id"]) + _, level = api("POST", f"staff/shelves/{shelf['data']['id']}/levels", {"number": 1}) + book = create_book(f"E2E paging {TAG}", PAGED_COPIES, shelf["data"]["id"], level["data"]["id"]) + codes = sorted(c["inventory_number"] for c in book["copies"]) + assert len(codes) == PAGED_COPIES, f"{len(codes)} copies created" + pfx = codes[0].rsplit("-C", 1)[0] + "-C" + scanned = codes[:PAGED_SCANS] + name = f"e2e-codes-{TAG}.txt" + path = os.path.join(tempfile.mkdtemp(prefix="e2e090"), name) + with open(path, "w") as fh: + fh.write("\n".join(scanned) + "\n") + push_download(path, name) + shelf_label = f"[{shelf['data']['code']}] {shelf['data']['name']}" + open_entry(S["staff090_stocktake"]) + ui.tap(S["staff090_stocktake_new"]) + time.sleep(1.2) + set_field(S["staff090_stocktake_field_name"], PAGED_SESSION, scroll=False) + ui.tap(S["staff090_stocktake_any_shelf"], exact=True) + time.sleep(1) + ui.tap(S["staff090_stocktake_search_shelf"], exact=True) + ui.type_text(shelf["data"]["code"]) + ui.hide_keyboard() + ui.tap(shelf_label, exact=True) + ui.scroll_to(S["staff090_stocktake_open_session"], exact=True, passes=1) + ui.tap(S["staff090_stocktake_open_session"], exact=True) + base.deny_camera_prompt() + ui.wait(S["staff090_stocktake_upload"], 30) + + def sess(): + _, b = api("GET", "staff/stocktake/sessions?status=all&limit=50") + row = next((r for r in b.get("data") or [] if r["name"] == PAGED_SESSION), None) + if row and row["id"] not in FX["sessions"]: + FX["sessions"].append(row["id"]) + return row + sid = wait_for(sess, 30, "paging session")["id"] + ui.tap(S["staff090_stocktake_upload"], exact=True) + pick_document(name) + ui.wait(S["staff090_stocktake_upload_done"], 120) + sm = wait_for(lambda: (lambda r: r if r["summary"]["found"] == PAGED_SCANS else None)(sess()), 60, "uploaded scans")["summary"] + assert (sm["expected"], sm["found"], sm["missing"]) == (PAGED_COPIES, PAGED_SCANS, PAGED_COPIES - PAGED_SCANS), f"summary {sm}" + # The server's own paging: every row once. + api_scans = api_all(f"staff/stocktake/sessions/{sid}/scans?outcome=found") + api_report = api_all(f"staff/stocktake/sessions/{sid}/report") + scan_codes = [r.get("code") or r.get("scanned_code") or (r.get("copy") or {}).get("inventory_number") for r in api_scans] + report_codes = [r.get("code") or r.get("scanned_code") or (r.get("copy") or {}).get("inventory_number") for r in api_report] + notes = [] + if sorted(scan_codes) != sorted(scanned) or len(set(scan_codes)) != len(scan_codes): + notes.append(f"API scans: {len(scan_codes)} rows, {len(set(scan_codes))} distinct, expected {PAGED_SCANS}") + if sorted(report_codes) != codes or len(set(report_codes)) != len(report_codes): + notes.append(f"API report: {len(report_codes)} rows, {len(set(report_codes))} distinct, expected {PAGED_COPIES}") + # The scan list in the app, filter "found", then "Show more" until it goes away. + ui.scroll_to(S["staff090_stocktake_recent"], exact=True) + _, opts = api("GET", "staff/stocktake/options") + found_label = next((o["label"] for o in (opts["data"].get("outcomes") or []) if o.get("code") == "found"), S["staff090_stocktake_count_found"]) + chips = [n for n in ui.dump() if n.text == found_label] + ui.tap_xy(*chips[-1].center) + time.sleep(2) + # Newest first: page one ends at scanned[5], the second page holds the five oldest scans. + oldest = scanned[0] + + def to_list_end(): + for _ in range(40): + nodes = ui.dump() + more = ui.find(S["staff090_stocktake_more"], exact=True, nodes=nodes) + if more or ui.find(S["staff090_stocktake_details"], exact=True, nodes=nodes): + return more, visible_codes(pfx) + ui.swipe_up() + time.sleep(0.3) + return None, visible_codes(pfx) + more, rows_seen = to_list_end() + assert more is not None, "no 'Show more' under the first page of found scans" + ui.tap_xy(*more.center) + time.sleep(2) + more, rows_now = to_list_end() + appended = oldest in rows_now and more is None + time.sleep(9) # two polls of the session screen (every 4 s) + for _ in range(4): + ui.swipe_down() + more, rows_later = to_list_end() + shot("paging-scan-list") + if not appended: + notes.append(f"app scan list: after 'Show more' the second page is not on screen (last rows {rows_now[-3:]}, 'Show more' still " + f"shown: {more is not None}); the app fetches it (GET .../scans?cursor=...) but the 4-second poll, which " + "refreshFiltered() lets through once filteredCursor is null, puts page one back") + elif more is not None or oldest not in rows_later: + notes.append("app scan list: the second page loaded with 'Show more' (the oldest scan %s at the end) is dropped again " + "within a few seconds: the list is back to the first %d rows ending at %s and 'Show more' is shown again " + "(the 4-second poll refreshFiltered() replaces the list with page one once filteredCursor is null)" + % (oldest, 50, rows_later[-1] if rows_later else "?")) + seen = rows_later + # The report in the app: scrolling loads the next page. + rep = next(n for n in ui.dump() if n.desc == S["staff090_stocktake_report"]) + ui.tap_xy(*rep.center) + ui.wait(s("staff090_stocktake_report_rows", PAGED_COPIES), 60) + rows = scroll_collect(pfx) + shot("paging-report-end") + dup_rows = sorted({c for c in rows if rows.count(c) > 1}) + if len(rows) != PAGED_COPIES or dup_rows or sorted(rows) != codes: + notes.append(f"app report: {len(rows)} rows, {len(set(rows))} distinct (expected {PAGED_COPIES}), repeated {dup_rows[:5]}, " + f"missing {sorted(set(codes) - set(rows))[:5]}") + if [c for c in rows if c in report_codes] != [c for c in report_codes if c in rows]: + notes.append("app report order differs from the server's order") + for n in notes: + bug("Stocktake paging (session #%d, %d copies, %d found by a code list): %s" % (sid, PAGED_COPIES, PAGED_SCANS, n)) + # Cancel the session from its screen: the button sits above the (long) scan list. + ui.back() + for _ in range(40): + if ui.find(S["staff090_stocktake_cancel"], exact=True): + break + ui.swipe_down() + ui.tap(S["staff090_stocktake_cancel"], exact=True) + time.sleep(1) + tap_last(S["action_confirm"]) if ui.find(S["action_confirm"], exact=True) else tap_last(S["staff090_stocktake_cancel"]) + wait_for(lambda: (lambda r: r if r["status"] == "cancelled" else None)(sess()), 30, "cancelled paging session") + assert not notes, "; ".join(notes) + return (f"session #{sid}: {PAGED_SCANS} codes uploaded from {name} through the picker; API scans {len(scan_codes)} / report " + f"{len(report_codes)} rows, each once; app scan list keeps the second page after 'Show more' and the polls, report {len(rows)} rows " + f"after scrolling, no repeats, server order") + + +@step(6, "visitor log: a reader's card, an unknown card, a guest") +def step_visitors(): + top = int(sql("SELECT IFNULL(MAX(id), 0) FROM presenze").strip()) + STATE["presenze_top"] = top + open_entry(S["staff090_attendance"]) + base.deny_camera_prompt() + ui.wait(S["staff090_patrons_card_code"], 30) + set_field(S["staff090_patrons_card_code"], READER["card"], scroll=False) + ui.tap(S["staff090_patrons_check_in"]) + rows = wait_for(lambda: sql_rows(f"SELECT id, tipo FROM presenze WHERE id > {top} AND utente_id = {int(READER['id'])}"), 30, "member visit") + assert rows[0][1] == "socio", f"visit {rows}" + ui.wait(f"{READER['name']} {READER['surname'][0]}.", 15) + shot("visit-member") + set_field(S["staff090_patrons_card_code"], f"E2E-NOCARD-{TAG}", scroll=False) + ui.tap(S["staff090_patrons_check_in"]) + ui.wait(S["staff090_patrons_err_card_not_found"], 30, exact=True) + shot("visit-unknown-card") + count_after_unknown = int(sql(f"SELECT COUNT(*) FROM presenze WHERE id > {top}").strip()) + assert count_after_unknown == 1, f"the unknown card recorded something ({count_after_unknown} rows)" + guest = f"E2E guest {TAG}" + ui.scroll_to(S["staff090_patrons_guest"], exact=True) + ui.tap(S["staff090_patrons_guest"], exact=True) + time.sleep(1.2) + set_field(S["staff090_patrons_guest_name"], guest, scroll=False) + set_field(S["staff090_patrons_guest_reason"], "smoke", scroll=False) + tap_last(S["staff090_patrons_guest_save"]) + g = wait_for(lambda: sql_rows(f"SELECT id, tipo, IFNULL(utente_id, 0) FROM presenze WHERE id > {top} AND nome_ospite = {q(guest)}"), 30, "guest visit") + assert g[0][1] == "ospite" and g[0][2] == "0", f"guest row {g}" + ui.wait(guest, 15, exact=True) + shot("visit-guest") + FX["visits"] = [int(r[0]) for r in sql_rows(f"SELECT id FROM presenze WHERE id > {top} AND (utente_id = {int(READER['id'])} OR nome_ospite = {q(guest)})")] + return f"member visit #{rows[0][0]} (socio), unknown card refused with '{S['staff090_patrons_err_card_not_found']}' and nothing recorded, guest visit #{g[0][0]}" + + +IMPORT_FILE = f"e2e-patrons-{TAG}.csv" +IMPORT_ROWS = [("E2E", f"Import A {TAG}", f"e2e-imp-a-{RUN}@test.local", f"E2EIA{TAG}"), + ("E2E", f"Import B {TAG}", f"e2e-imp-b-{RUN}@test.local", f"E2EIB{TAG}")] + + +def push_download(local, name): + """Put a file in the emulator's Downloads, indexed so the system picker lists it.""" + ui.adb("push", local, f"/sdcard/Download/{name}") + ui.shell(f"am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d file:///sdcard/Download/{name}") + STATE.setdefault("pushed", []).append(name) + time.sleep(2) + + +def pick_document(name): + """The system document picker: the file from Recent, or found through its search.""" + ui.wait_any([name, "Recent", "Downloads"], 30) + if not ui.find(name, exact=True): + ui.tap("Search") + ui.type_text(name) + ui.shell("input keyevent KEYCODE_ENTER") + time.sleep(2) + ui.tap(name, exact=True, timeout=30) + + +@step(7, "patron CSV import through the system document picker") +def step_user_import(): + import tempfile + body = "nome;cognome;email;codice_tessera\n" + "".join(";".join(r) + "\n" for r in IMPORT_ROWS) + path = os.path.join(tempfile.mkdtemp(prefix="e2e090"), IMPORT_FILE) + with open(path, "w", encoding="utf-8") as fh: + fh.write(body) + push_download(path, IMPORT_FILE) + open_entry(S["staff090_user_import"]) + ui.tap(S["staff_import_choose_file"], exact=True) + pick_document(IMPORT_FILE) + ui.wait(IMPORT_FILE, 30, exact=True) + ui.tap(S["staff090_patrons_import_upload"], exact=True) + ui.wait(S["staff090_patrons_import_ready"], 60) + shot("user-import-preview") + set_switch(S["staff090_patrons_import_invites"], False) + ui.scroll_to(S["staff090_patrons_import_start"], exact=True, passes=1) + ui.tap(S["staff090_patrons_import_start"], exact=True) + which = toast_or_text([S["staff_import_status_done"], S["staff_import_status_failed"], S["staff_import_failed"]], 120) + shot("user-import-done") + assert which == S["staff_import_status_done"], f"import ended with {which!r}" + emails = ",".join(q(r[2]) for r in IMPORT_ROWS) + rows = sql_rows(f"SELECT id, email, codice_tessera, tipo_utente, stato FROM utenti WHERE email IN ({emails})") + FX["users"].extend(int(r[0]) for r in rows if int(r[0]) not in FX["users"]) + assert len(rows) == 2, f"created users {rows}" + for r in rows: + want = next(x for x in IMPORT_ROWS if x[2] == r[1]) + assert r[2] == want[3] and r[3] == "standard", f"user {r}" + nodes = ui.dump() + assert ui.find(S["staff090_patrons_import_created"], exact=True, nodes=nodes), "no created counter" + assert not ui.find(S["staff_import_errors_csv"], exact=True, nodes=nodes), "an errors-download button after 0 errors" + return f"{IMPORT_FILE} picked from Downloads, 2 accounts created ({', '.join(r[0] for r in rows)}) with their card codes, no invitation, no errors-download button" + + +MARC_NAME = f"e2e-marc-{TAG}.mrc" + + +def marc_titles(path): + """245 $a of each record of an ISO 2709 file (to recognise the imported books).""" + data = open(path, "rb").read() + out = [] + for rec in [r for r in data.split(b"\x1d") if len(r) > 24]: + base_addr = int(rec[12:17]) + directory = rec[24:base_addr - 1] + for i in range(0, len(directory), 12): + if directory[i:i + 3] == b"245": + ln, st = int(directory[i + 3:i + 7]), int(directory[i + 7:i + 12]) + field = rec[base_addr + st:base_addr + st + ln].decode("utf-8", "replace") + sub = field.split("\x1f") + a = next((x[1:] for x in sub[1:] if x.startswith("a")), "") + out.append(a.strip(" /:;,.").strip()) + return out + + +@step(8, "suppliers (create, set on a book, delete); MARC export and import through the picker") +def step_suppliers_marc(): + late_book = FX["books"][1] + # Supplier created in the app. + open_entry(S["staff090_suppliers"]) + ui.tap(S["staff090_catalogue_supplier_new"]) + time.sleep(1.2) + set_field(S["staff_field_name"], SUPPLIER_NAME, scroll=False) + set_field(S["staff_field_email"], f"supplier-{TAG}@test.local", scroll=False) + ui.scroll_to(S["action_save"], exact=True, passes=1) + ui.tap(S["action_save"], exact=True) + ui.wait(SUPPLIER_NAME, 30, exact=True) + _, sup = api("GET", "staff/suppliers?q=" + urllib.request.quote(SUPPLIER_NAME)) + rows = [x for x in sup.get("data") or [] if x["name"] == SUPPLIER_NAME] + assert len(rows) == 1 and rows[0]["email"] == f"supplier-{TAG}@test.local", f"supplier on the server {rows}" + sid = rows[0]["id"] + FX["suppliers"].append(sid) + shot("supplier-created") + # Set on a book in the book form. + open_entry(S["staff_books"]) + ui.tap(S["staff_books_search"]) + ui.type_text(LATE_TITLE) + ui.hide_keyboard() + ui.tap(LATE_TITLE, timeout=30) + ui.wait(S["staff_book_save"], 30) + for _ in range(12): + if field_of(S["staff090_catalogue_supplier"]): + break + ui.swipe_up() + f = field_of(S["staff090_catalogue_supplier"]) + assert f is not None, "no Supplier field in the book form" + ui.tap_xy(*f.center) + ui.tap(SUPPLIER_NAME, exact=True) + shot("book-form-supplier") + ui.tap(S["staff_book_save"]) + ui.gone(S["staff_book_edit"], 120) + book = API.book(late_book) + assert book.get("supplier_id") == sid, f"book supplier {book.get('supplier_id')} != {sid}" + # Deleted in the app: the book loses the link. + open_entry(S["staff090_suppliers"]) + ui.wait(SUPPLIER_NAME, 30, exact=True) + row = ui.find(SUPPLIER_NAME, exact=True) + menu = [n for n in ui.dump() if n.desc == S["cd_more_options"] and abs(n.center[1] - row.center[1]) < 90] + ui.tap_xy(*menu[0].center) + ui.tap(S["staff090_catalogue_supplier_delete"], exact=True) + ui.wait(s("staff090_catalogue_supplier_delete_books", SUPPLIER_NAME, 1), 15) + shot("supplier-delete-confirm") + tap_last(S["staff090_catalogue_supplier_delete"]) + ui.wait(s("staff090_catalogue_supplier_deleted", 1), 30) + _, sup = api("GET", "staff/suppliers?q=" + urllib.request.quote(SUPPLIER_NAME)) + assert not [x for x in sup.get("data") or [] if x["id"] == sid], "supplier still on the server" + FX["suppliers"].remove(sid) + assert API.book(late_book).get("supplier_id") is None, "the book kept the deleted supplier" + detail = f"supplier #{sid} created, set on book #{late_book} (supplier_id={sid}), deleted ('Books unlinked: 1', book unlinked)" + # MARC export of the whole catalogue (the default MARC 21). + open_entry(S["staff090_marc"]) + ui.tap(S["staff090_catalogue_marc_export"], exact=True) + ui.tap(S["staff090_catalogue_marc_scope_all"], exact=True) + tap_last(S["staff090_catalogue_marc_export_run"]) + ui.wait(S["staff_share_file"], 180) + name = next(n.text for n in ui.dump() if n.text.lower().endswith((".mrc", ".xml"))) + shot("marc-export-ready") + data = ui.adb("exec-out", "run-as", PKG, "cat", f"cache/exports/{name}", check=False) + records = data.count(b"\x1d") + assert records > 0 and LATE_TITLE.encode() in data, f"{name}: {records} records, run's book present: {LATE_TITLE.encode() in data}" + ui.back() + detail += f"; export {name}: {len(data)} bytes, {records} records, includes the run's book" + # MARC import through the picker. + if not MARC_FILE or not os.path.exists(MARC_FILE): + raise SkipStep(detail + "; MARC import skipped: PINAKES_E2E_MARC not set") + titles = marc_titles(MARC_FILE) + STATE["libri_top"] = int(sql("SELECT IFNULL(MAX(id), 0) FROM libri").strip()) + STATE["autori_top"] = int(sql("SELECT IFNULL(MAX(id), 0) FROM autori").strip()) + STATE["editori_top"] = int(sql("SELECT IFNULL(MAX(id), 0) FROM editori").strip()) + push_download(MARC_FILE, MARC_NAME) + ui.tap(S["staff090_catalogue_marc_import"], exact=True) + ui.tap(S["staff_import_choose_file"], exact=True) + pick_document(MARC_NAME) + ui.wait(MARC_NAME, 30, exact=True) + ui.scroll_to(S["staff090_catalogue_marc_upload"], exact=True) + ui.tap(S["staff090_catalogue_marc_upload"], exact=True) + run = s("staff090_catalogue_marc_run", len(titles)) + ui.wait(run, 90, exact=True) + shot("marc-import-preview") + ui.tap(run, exact=True) + which = toast_or_text([S["staff_import_status_done"], S["staff_import_status_failed"], S["staff_import_failed"]], 180) + shot("marc-import-done") + assert which == S["staff_import_status_done"], f"MARC import ended with {which!r}" + new = sql_rows(f"SELECT id FROM libri WHERE id > {STATE['libri_top']} AND deleted_at IS NULL") + ids = [int(r[0]) for r in new] + got = sorted(API.book(i)["title"] for i in ids) + STATE["marc_books"] = ids + FX["books"].extend(i for i in ids if i not in FX["books"]) + assert len(ids) == len(titles) and sorted(titles) == got, f"imported {got}, file has {titles}" + return detail + f"; {MARC_NAME} picked and imported: {len(ids)} new books {ids} ({'; '.join(got)})" + + +@step(9, "statistics: the overview opens with the server's numbers") +def step_statistics(): + open_entry(S["staff090_statistics"]) + ui.wait(S["staff090_statistics_books_available"], 60) + _, st = api("GET", "staff/statistics") + ov = st["data"]["overview"] + nodes = ui.dump() + + def value(label_key): + lab = ui.find(S[label_key], exact=True, nodes=nodes) + assert lab is not None, f"{S[label_key]!r} not on screen" + nums = [n for n in nodes if n.text.replace(",", "").replace(".", "").isdigit() and n.bounds[3] <= lab.bounds[1] + 4 + and abs(n.bounds[0] - lab.bounds[0]) < 60] + nums.sort(key=lambda n: lab.bounds[1] - n.bounds[3]) + return int(nums[0].text.replace(",", "").replace(".", "")) if nums else None + pairs = {"books_available": "staff090_statistics_books_available", "active_loans": "staff090_statistics_active_loans", + "completed_loans": "staff090_statistics_completed_loans"} + shown = {k: value(v) for k, v in pairs.items()} + shot("statistics-overview") + assert all(shown[k] == ov[k] for k in pairs), f"screen {shown} vs server {ov}" + assert ui.find(S["staff090_statistics_by_month"], exact=True, nodes=nodes), "no loans-by-month section" + return f"overview on screen {shown} = /staff/statistics" + + +@step(10, "staff account: admin-only entries hidden, an admin-only action refused in place") +def step_staff(): + sign_in(STAFF["email"], STAFF["password"]) + labels = hub_labels() + shot("staff-hub") + for hidden in ("staff090_circulation_rules", "staff090_calendar"): + assert S[hidden] not in labels, f"{S[hidden]!r} shown to staff" + for shown in ("staff090_settings", "staff090_stocktake", "staff090_attendance", "staff090_suppliers", "staff090_marc"): + assert S[shown] in labels, f"{S[shown]!r} missing for staff" + open_entry(S["staff090_settings"]) + ui.wait(S["staff090_library_modules_read_only"], 30) + assert not ui.find(S["action_save"], exact=True), "a Save button on the read-only modules tab" + shot("staff-settings-read-only") + # An admin-only write refused by the server while the screen is open: the account is an + # administrator when the calendar opens and is downgraded to staff before the save. + sql(f"UPDATE utenti SET tipo_utente = 'admin' WHERE id = {int(STAFF['id'])}") + restart_app() + open_staff_hub() + open_entry(S["staff090_calendar"]) + time.sleep(2) + sql(f"UPDATE utenti SET tipo_utente = 'staff' WHERE id = {int(STAFF['id'])}") + import datetime + day = datetime.date.fromisoformat(STATE.get("closure_day") or sql("SELECT DATE_ADD(CURDATE(), INTERVAL 10 DAY)").strip()) + ui.scroll_to(S["staff090_library_closure_new"], exact=True) + ui.tap(S["staff090_library_closure_new"], exact=True) + time.sleep(1.2) + ui.tap(S["staff_date_pick"], exact=True) + time.sleep(1) + pick_date(day) + set_field(S["staff_field_description"], f"E2E refused {TAG}", scroll=False) + # The server's own words for this refusal, asked with the staff account's token. + staff_api = base.Api() + st, b, _ = staff_api.call("POST", "auth/login", {"email": STAFF["email"], "password": STAFF["password"], + "device_name": f"e2e-090-{RUN}", "device_id": f"e2e-090-{RUN}", "platform": "other"}, auth=False) + staff_api.token = b["data"]["token"] + st, refusal, _ = staff_api.call("POST", "staff/calendar/closures", {"start_date": day.isoformat(), "description": "probe"}, + headers={"Accept-Language": LANG["v"]}) + staff_api.call("POST", "auth/logout") + assert st == 403 and refusal["error"]["code"] == "not_permitted", f"probe answer {st} {refusal}" + message = refusal["error"]["message"] + fields = (refusal["error"].get("details") or {}).get("fields") or {} + expected = [message] + [v for v in fields.values() if isinstance(v, str)] + ui.tap(S["action_save"], exact=True) + which = toast_or_text(expected, 30) + shot("staff-refused-in-place") + assert ui.find(S["staff090_library_closure_new"], exact=True), "the closure sheet closed" + _, cal = api("GET", f"staff/calendar?month={day.strftime('%Y-%m')}") + d = next(x for x in cal["data"]["days"] if x["date"] == day.isoformat()) + assert not d["closed"], f"a closure was written: {d}" + ui.back() + ui.back() + hub() + assert base.on_hub(), "the staff area was lost after the refusal" + restart_app() + labels = hub_labels() + assert S["staff090_calendar"] not in labels and S["staff090_settings"] in labels, "staff hub after the refusal" + shot("staff-hub-kept") + return (f"staff hub without '{S['staff090_circulation_rules']}' and '{S['staff090_calendar']}', Library settings read-only; " + f"closure save after the downgrade → server 403 not_permitted shown in the sheet ('{which}'), nothing written, staff area kept") + + +# ---------------------------------------------------------------- preflight, cleanup, main + +DEVICES_BEFORE = set() + + +@step(0, "preflight: API login, fixtures") +def preflight(): + API.login() + _, me = api("GET", "me") + assert me["data"].get("tipo_utente") == "admin", "PINAKES_E2E_EMAIL must be an administrator" + STATE["admin_id"] = me["data"]["id"] + STATE["started"] = sql("SELECT NOW()").strip() + _, devs = api("GET", "me/devices") + DEVICES_BEFORE.update(d["id"] for d in devs.get("data") or []) + STATE["devices_known"] = True + features = health() + for flag in ("staff_library_settings", "staff_patrons", "staff_marc", "staff_statistics", "staff_user_import"): + assert features.get(flag), f"/health {flag} is off: the server is not on Mobile API 1.10.0" + return fixtures() + + +def _try(label, fn): + try: + out = fn() + print(f" {label}: {out if out is not None else 'ok'}", flush=True) + except Exception as e: # noqa: BLE001 - keep cleaning + print(f" {label}: FAILED {e}", flush=True) + CLEANUP_ERRORS.append(f"{label}: {e}") + + +CLEANUP_ERRORS = [] + + +def ids(values): + return ",".join(str(int(v)) for v in values) or "0" + + +def cleanup(): + print("--> cleanup", flush=True) + _try("airplane mode off", lambda: ui.airplane(False)) + if API.token: + for lid in list(FX["loans"]): + def ret(lid=lid): + _, loan = api("GET", f"staff/loans/{lid}") + if loan.get("data") and "return" in (loan["data"].get("actions") or []): + return api("POST", f"staff/loans/{lid}/return", {"outcome": "restituito"})[0] + return "already closed" + _try(f"return loan #{lid}", ret) + for sid in list(FX["suppliers"]): + _try(f"delete supplier #{sid}", lambda sid=sid: api("DELETE", f"staff/suppliers/{sid}")[0]) + for bid in list(FX["books"]): + _try(f"soft-delete book #{bid} (API)", lambda bid=bid: api("DELETE", f"staff/books/{bid}")[0]) + if STATE.get("modules_before"): + restore = {k: bool(STATE["modules_before"].get(k)) for k in NEEDED_MODULES} + _try(f"restore modules {restore}", lambda: api("PUT", "staff/settings/modules", {"features": restore})[0]) + if STATE.get("loans_before"): + _try(f"restore loan settings {STATE['loans_before']}", lambda: api("PUT", "staff/settings/loans", STATE["loans_before"])[0]) + if STATE.get("devices_known"): + def revoke(): + _, devs = api("GET", "me/devices") + done = [] + for d in devs.get("data") or []: + if d["id"] not in DEVICES_BEFORE and not d.get("is_current"): + api("DELETE", f"me/devices/{d['id']}") + done.append(d["id"]) + return f"revoked {done}" + _try("app device tokens issued by this run (admin)", revoke) + # Hard clean of the run's own fixtures (slot database only). + users, books = FX["users"], FX["books"] + _try("closures", lambda: sql(f"DELETE FROM calendario_chiusure WHERE id IN ({ids(FX['closures'])}) OR descrizione LIKE {q('E2E % ' + TAG)}")) + _try("patron types", lambda: (sql(f"DELETE FROM regole_prestito WHERE tipologia_id IN (SELECT id FROM tipologie_lettore WHERE nome = {q(TYPE_NAME)})"), + sql(f"DELETE FROM tipologie_lettore WHERE nome = {q(TYPE_NAME)}"))) + _try("suppliers", lambda: sql(f"DELETE FROM fornitori WHERE nome = {q(SUPPLIER_NAME)}")) + _try("visits", lambda: sql(f"DELETE FROM presenze WHERE id IN ({ids(FX['visits'])}) OR utente_id IN ({ids(users)}) " + f"OR (nome_ospite = {q('E2E guest ' + TAG)})")) + _try("stocktake sessions", lambda: sql(f"DELETE FROM inventario_sessioni WHERE id IN ({ids(FX['sessions'])}) OR nome IN ({q(STOCK_SESSION)}, {q(PAGED_SESSION)})")) + _try("import jobs", lambda: sql(f"DELETE FROM mobile_import_jobs WHERE file_name IN ({q(IMPORT_FILE)}, {q(MARC_NAME)})")) + _try("loans and reservations", lambda: (sql(f"DELETE FROM prenotazioni WHERE libro_id IN ({ids(books)}) OR utente_id IN ({ids(users)})"), + sql(f"DELETE FROM prestiti WHERE libro_id IN ({ids(books)}) OR utente_id IN ({ids(users)})"))) + _try("ledger movements", lambda: sql(f"DELETE FROM movimenti_lettore WHERE utente_id IN ({ids(users)})")) + _try("books (hard)", lambda: sql(f"DELETE FROM libri WHERE id IN ({ids(books)})")) + if STATE.get("autori_top"): + _try("authors created by the MARC import", lambda: sql( + f"DELETE FROM autori WHERE id > {int(STATE['autori_top'])} AND id NOT IN (SELECT autore_id FROM libri_autori)")) + if STATE.get("editori_top"): + _try("publishers created by the MARC import", lambda: sql( + f"DELETE FROM editori WHERE id > {int(STATE['editori_top'])} AND id NOT IN (SELECT editore_id FROM libri_editori) " + "AND id NOT IN (SELECT editore_id FROM libri WHERE editore_id IS NOT NULL)")) + _try("users", lambda: sql(f"DELETE FROM utenti WHERE id IN ({ids(users)}) OR email IN " + f"({q(READER['email'])}, {q(STAFF['email'])}, {', '.join(q(r[2]) for r in IMPORT_ROWS)})")) + _try("shelves", lambda: sql(f"DELETE FROM scaffali WHERE id IN ({ids(FX['shelves'])}) OR codice IN ({q('E2E' + TAG)}, {q('E2P' + TAG)})")) + for name in STATE.get("pushed", []): + _try(f"remove /sdcard/Download/{name}", lambda name=name: ui.shell(f"rm -f /sdcard/Download/{name}") or + ui.shell(f"am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d file:///sdcard/Download/{name}") and None) + if API.token: + API.call("POST", "auth/logout") + ui.adb("shell", "pm", "clear", PKG, check=False) + + +def leftovers(): + """What the run might have left on the server (all zero after a clean run).""" + users = ids(FX["users"]) + checks = { + "users": f"SELECT COUNT(*) FROM utenti WHERE id IN ({users}) OR email LIKE {q('e2e-%-' + RUN + '@test.local')}", + "books": f"SELECT COUNT(*) FROM libri WHERE id IN ({ids(FX['books'])}) OR titolo LIKE {q('E2E % ' + TAG)}", + "loans": f"SELECT COUNT(*) FROM prestiti WHERE utente_id IN ({users}) OR libro_id IN ({ids(FX['books'])})", + "ledger": f"SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id IN ({users})", + "visits": f"SELECT COUNT(*) FROM presenze WHERE utente_id IN ({users}) OR nome_ospite = {q('E2E guest ' + TAG)}", + "stocktake": f"SELECT COUNT(*) FROM inventario_sessioni WHERE nome IN ({q(STOCK_SESSION)}, {q(PAGED_SESSION)})", + "types": f"SELECT COUNT(*) FROM tipologie_lettore WHERE nome = {q(TYPE_NAME)}", + "closures": f"SELECT COUNT(*) FROM calendario_chiusure WHERE descrizione LIKE {q('E2E % ' + TAG)}", + "suppliers": f"SELECT COUNT(*) FROM fornitori WHERE nome = {q(SUPPLIER_NAME)}", + "shelves": f"SELECT COUNT(*) FROM scaffali WHERE codice IN ({q('E2E' + TAG)}, {q('E2P' + TAG)})", + "import jobs": f"SELECT COUNT(*) FROM mobile_import_jobs WHERE file_name IN ({q(IMPORT_FILE)}, {q(MARC_NAME)})", + } + out = {} + for k, v in checks.items(): + try: + out[k] = int(sql(v).strip()) + except Exception as e: # noqa: BLE001 + out[k] = f"error {e}" + return out + + +def main(): + S.update(base.load_strings()) + LANG["v"] = (ui.shell("getprop persist.sys.locale").strip() or ui.shell("getprop ro.product.locale").strip() or "en").split("-")[0] + if not base.db_configured(): + sys.exit("PINAKES_E2E_DB_NAME/USER (and _PASSWORD, _SOCKET or _HOST) are required for the 0.9.0 smoke") + try: + if preflight(): + signed = False + try: + sign_in(base.EMAIL, base.PASSWORD) + signed = True + except Exception as e: # noqa: BLE001 + RESULTS.append(("sign-in as admin", "FAIL", str(e))) + if signed: + for fn in (step_hub_and_module, step_loan_setting, step_types_matrix_closure, step_patron, step_type_delete, + step_stocktake, step_stocktake_paging, step_visitors, step_user_import, step_suppliers_marc, step_statistics, step_staff): + fn() + finally: + cleanup() + left = leftovers() + passed = sum(1 for r in RESULTS if r[1] == "PASS") + skipped = sum(1 for r in RESULTS if r[1] == "SKIP") + failed = sum(1 for r in RESULTS if r[1] == "FAIL") + print(f"\nlive smoke 0.9.0: {passed} passed, {skipped} skipped, {failed} failed (screenshots: {SHOTS})") + for name, result, detail in RESULTS: + print(f" {result:4} {name}: {detail}") + print("\nbugs found:" if BUGS else "\nbugs found: none") + for b in BUGS: + print(" - " + b) + print(f"\nleft on the server after cleanup: {left}") + if CLEANUP_ERRORS: + print("cleanup errors: " + "; ".join(CLEANUP_ERRORS)) + dirty = any(v != 0 for v in left.values()) or CLEANUP_ERRORS + return 1 if failed or not passed or dirty else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/e2e/ui.py b/tools/e2e/ui.py index e51acdf..06998c8 100755 --- a/tools/e2e/ui.py +++ b/tools/e2e/ui.py @@ -40,6 +40,9 @@ def __init__(self, el): self.cls = el.get("class") or "" self.clickable = el.get("clickable") == "true" self.checked = el.get("checked") == "true" + self.checkable = el.get("checkable") == "true" + self.enabled = el.get("enabled") != "false" + self.scrollable = el.get("scrollable") == "true" b = [int(v) for v in re.findall(r"\d+", el.get("bounds", "[0,0][0,0]"))] self.bounds = b self.center = ((b[0] + b[2]) // 2, (b[1] + b[3]) // 2)