From d661680539349c9d39d35cbeaadb9f201588987b Mon Sep 17 00:00:00 2001 From: Nicola Belluti Date: Sat, 10 Oct 2026 08:48:05 +0200 Subject: [PATCH] feat(oxibadge): implement the review screen's export to ODS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the export button's placeholder (which only dbg!-printed the payload) with a real implementation: one employee exports a single .ods file, more than one bundles a .zip of one .ods per employee, written wherever the user picks via the native "save" dialog (tauri-plugin-dialog). The "hours worked" column is a live ODF formula with a cached fallback value, not just a static number, so the file stays correct if edited afterward in LibreOffice; the formula and the cached value both treat a day's own missing punches as 0 in the subtraction, matching how a day with only an entry and an exit (no lunch break) still nets out to the right span. Column headers are sent by the frontend (translated via its own i18n), not hardcoded in Rust. A stacked slot (more than one punch the device recorded in the same shift) exports every punch, one per line in the cell, instead of only the first. Also closes a real gap surfaced while building this: the new-employees screen already blocked "Continue" for a blank name, but not for a name the device sent as invalid UTF-8 (lossily converted to text containing the Unicode replacement character on the Rust side) — such a name is now flagged and blocks continuing too, so every employee reaching the review/export flow is guaranteed to have a real, displayable name. Assisted-by: Claude Sonnet 5 --- Cargo.lock | 668 +++++++++++++++++- oxibadge/bun.lock | 3 + oxibadge/package.json | 1 + oxibadge/src-tauri/Cargo.toml | 3 + oxibadge/src-tauri/capabilities/default.json | 3 +- oxibadge/src-tauri/src/export.rs | 383 ++++++++++ oxibadge/src-tauri/src/lib.rs | 40 +- .../src/lib/components/NewEmployees.svelte | 21 +- .../src/lib/components/TimesheetReview.svelte | 64 +- .../src/lib/components/new-employees/logic.ts | 7 + oxibadge/src/lib/locales/en.json | 13 +- oxibadge/src/lib/locales/it.json | 13 +- oxibadge/src/routes/+page.svelte | 26 +- 13 files changed, 1183 insertions(+), 62 deletions(-) create mode 100644 oxibadge/src-tauri/src/export.rs diff --git a/Cargo.lock b/Cargo.lock index 852a9f2..35240fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,12 +47,38 @@ dependencies = [ "libc", ] +[[package]] +name = "angle" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf965b6b142c8c68150e815ef527d17f0fe74e622b2c5287fd8626cb7c4a5fa" +dependencies = [ + "num-traits", + "serde", + "serde_derive", +] + [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-stream" version = "0.3.6" @@ -104,6 +130,36 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn 2.0.119", +] + [[package]] name = "autocfg" version = "1.3.0" @@ -176,6 +232,30 @@ dependencies = [ "objc2", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "brotli" version = "9.0.0" @@ -208,9 +288,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" @@ -340,6 +426,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.38" @@ -353,6 +445,25 @@ dependencies = [ "windows-targets 0.52.5", ] +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "color-rs2" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61da6f06f340226f2707c0d3a8de27e8b1cbe09050d440cbb80a0ec87ccbf79e" +dependencies = [ + "angle", + "half", + "num-traits", + "serde", + "serde_derive", +] + [[package]] name = "combine" version = "4.6.8" @@ -413,6 +524,12 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -446,6 +563,12 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -570,6 +693,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-where" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -739,6 +884,20 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1020,6 +1179,37 @@ dependencies = [ "version_check", ] +[[package]] +name = "get-size-derive2" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b6d1e2f75c16bfbcd0f95d84f99858a6e2f885c2287d1f5c3a96e8444a34b4" +dependencies = [ + "attribute-derive", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "get-size2" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cf31a6d70300cf81461098f7797571362387ef4bf85d32ac47eaa59b3a5a1a" +dependencies = [ + "get-size-derive2", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1191,6 +1381,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1489,6 +1690,12 @@ dependencies = [ "cfb", ] +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + [[package]] name = "ipnet" version = "2.12.2" @@ -1665,6 +1872,24 @@ dependencies = [ "serde", ] +[[package]] +name = "kparse" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503149c856ef5f8728156b55609019cf2714a29ebdb2a82dc1a831d2c743f28a" +dependencies = [ + "bytecount", + "memchr", + "nom", + "nom_locate", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libappindicator" version = "0.9.0" @@ -1740,9 +1965,32 @@ dependencies = [ [[package]] name = "log" -version = "0.4.21" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] [[package]] name = "markup5ever" @@ -1776,6 +2024,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1828,6 +2082,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "ndk" version = "0.9.0" @@ -1864,6 +2124,27 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom_locate" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2025,6 +2306,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2116,10 +2398,13 @@ dependencies = [ "chrono", "r701", "serde", + "spreadsheet-ods", "tauri", "tauri-build", + "tauri-plugin-dialog", "tokio-stream", "toml 0.9.12+spec-1.1.0", + "zip", ] [[package]] @@ -2249,7 +2534,7 @@ checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ "base64 0.23.1", "indexmap 2.14.2", - "quick-xml", + "quick-xml 0.42.0", "serde", "time", ] @@ -2310,6 +2595,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2369,6 +2663,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2378,6 +2683,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quick-xml" version = "0.42.0" @@ -2396,6 +2711,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "r-efi" version = "5.3.0" @@ -2419,6 +2756,65 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2527,6 +2923,57 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rust_decimal_macros" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2819,6 +3266,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.4" @@ -2895,6 +3348,28 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spreadsheet-ods" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60163f438f3d198d0f871cb0edd5577efeaa8d83ec41a96ede01fbb20d0dcb40" +dependencies = [ + "base64 0.22.1", + "chrono", + "color-rs2", + "get-size2", + "icu_locale_core", + "kparse", + "lazy_static", + "nom", + "nom_locate", + "quick-xml 0.38.4", + "rust_decimal", + "rust_decimal_macros", + "string_cache", + "zip", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2911,6 +3386,7 @@ dependencies = [ "parking_lot", "phf_shared", "precomputed-hash", + "serde", ] [[package]] @@ -3177,6 +3653,64 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf9a5c92e39bdd84f22f6be9230e39d0c4e140345f71ffe6058dca4f71127cf" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "466eb32466fe5021b6728b06862f9ff14a9f7e1af890047654e03a96d7c0e1bb" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "toml 1.1.6+spec-1.1.0", + "url", +] + [[package]] name = "tauri-runtime" version = "2.12.1" @@ -3776,6 +4310,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -4121,6 +4656,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4169,13 +4713,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.5", "windows_aarch64_msvc 0.52.5", "windows_i686_gnu 0.52.5", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.5", "windows_i686_msvc 0.52.5", "windows_x86_64_gnu 0.52.5", "windows_x86_64_gnullvm 0.52.5", "windows_x86_64_msvc 0.52.5", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -4212,6 +4773,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4230,6 +4797,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4248,12 +4821,24 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4272,6 +4857,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4290,6 +4881,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4308,6 +4905,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4326,6 +4929,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -4460,6 +5069,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -4514,6 +5143,21 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.14.2", + "memchr", + "time", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -4525,3 +5169,15 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/oxibadge/bun.lock b/oxibadge/bun.lock index e6ba8e4..8b59ed8 100644 --- a/oxibadge/bun.lock +++ b/oxibadge/bun.lock @@ -9,6 +9,7 @@ "@fontsource/geist": "^5.3.0", "@fontsource/geist-mono": "^5.3.0", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.8.1", "svelte-i18n": "^4.0.1", }, "devDependencies": { @@ -170,6 +171,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.12.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dIFaKslrzcbesb+bBh+E1R9km241pHyj32w6D9FPtvQeYpRUFbiCLEa/2S+S0UnIytfxepp4P+X8IerGqnXeYg=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.8.1", "", { "dependencies": { "@tauri-apps/api": "^2.12.0" } }, "sha512-/DtE3B62JgpYuWFL5ATXM6KOXUq7pca7cJWOgSqULhZsgyjLk0PWKek1dbRDvhNBW6flNsOfWphsKnb+738Dmw=="], + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], diff --git a/oxibadge/package.json b/oxibadge/package.json index 19747ab..1a27b26 100644 --- a/oxibadge/package.json +++ b/oxibadge/package.json @@ -18,6 +18,7 @@ "@fontsource/geist": "^5.3.0", "@fontsource/geist-mono": "^5.3.0", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.8.1", "svelte-i18n": "^4.0.1" }, "devDependencies": { diff --git a/oxibadge/src-tauri/Cargo.toml b/oxibadge/src-tauri/Cargo.toml index 71c98d5..df4dfc8 100644 --- a/oxibadge/src-tauri/Cargo.toml +++ b/oxibadge/src-tauri/Cargo.toml @@ -22,3 +22,6 @@ toml = "0.9" r701 = { path = "../../r701", features = ["serde"] } chrono = { version = "0.4.38", default-features = false, features = ["clock"] } tokio-stream = { version = "0.1.19", default-features = false } +tauri-plugin-dialog = "2.8.1" +spreadsheet-ods = "1.0.4" +zip = { version = "6", default-features = false, features = ["deflate"] } diff --git a/oxibadge/src-tauri/capabilities/default.json b/oxibadge/src-tauri/capabilities/default.json index 194c1d3..dd51a8f 100644 --- a/oxibadge/src-tauri/capabilities/default.json +++ b/oxibadge/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", - "core:window:allow-destroy" + "core:window:allow-destroy", + "dialog:default" ] } diff --git a/oxibadge/src-tauri/src/export.rs b/oxibadge/src-tauri/src/export.rs new file mode 100644 index 0000000..9ba0f62 --- /dev/null +++ b/oxibadge/src-tauri/src/export.rs @@ -0,0 +1,383 @@ +//! Builds the `.ods` timesheet file(s) the review screen's export button +//! produces, matching a specific layout/style the user already relies on +//! in their own spreadsheets (see the project history for the reference +//! file this was reverse-engineered from): a date column, four punch +//! columns (entry/exit for each shift) and a formula-driven "hours worked" +//! column with a monthly total. Column headers are plain text sent by the +//! frontend (translated via its own i18n), not hardcoded here. +//! +//! One employee gets one sheet in one `.ods` file. Exporting more than one +//! employee at once bundles one `.ods` per employee into a `.zip`. + +use chrono::{Duration, NaiveDate}; +use serde::Deserialize; +use spreadsheet_ods::color::Rgb; +use spreadsheet_ods::defaultstyles::DefaultFormat; +use spreadsheet_ods::style::units::{Border, FontSize, TextAlign}; +use spreadsheet_ods::{ + write_ods_buf, CellStyle, CellStyleRef, Length, Sheet, ValueFormatRef, ValueFormatTimeDuration, + WorkBook, +}; +use std::collections::HashSet; +use std::io::Write; +use std::path::Path; + +/// One employee's corrected timesheet for a month, as sent by the review +/// screen's export button. `days[d][c]` is the list of times punched in +/// slot `c` (entry/exit of the 1st/2nd shift) on day `d + 1`; usually 0 or +/// 1 entries, more if the day had stacked punches. A stacked slot's times +/// all end up in the same cell (one per line), matching how the review +/// screen itself stacks them — the export is a 1:1 copy of the app's data, +/// nothing is dropped. +#[derive(Debug, Deserialize)] +pub struct EmployeeTimesheet { + pub id: u32, + pub name: String, + pub days: Vec>>, +} + +/// The column header text, already translated by the frontend. +#[derive(Debug, Deserialize)] +pub struct ColumnHeaders { + pub date: String, + pub in1: String, + pub out1: String, + pub in2: String, + pub out2: String, + pub total: String, +} + +#[derive(Debug, Deserialize)] +pub struct ExportPayload { + #[allow(dead_code)] + pub scope: String, + pub year: i32, + pub month: u32, + pub employees: Vec, + pub headers: ColumnHeaders, +} + +// Column layout, 0-based. A and G are narrow blank spacer columns, matching +// the reference file. +const COL_DATE: u32 = 1; +const COL_IN1: u32 = 2; +const COL_OUT1: u32 = 3; +const COL_IN2: u32 = 4; +const COL_OUT2: u32 = 5; +const COL_TOTAL: u32 = 7; + +const ROW_HEADER: u32 = 1; +const ROW_FIRST_DAY: u32 = 2; + +struct Styles { + header_date: CellStyleRef, + header_time: CellStyleRef, + header_total: CellStyleRef, + date_a: CellStyleRef, + date_b: CellStyleRef, + time_a: CellStyleRef, + time_b: CellStyleRef, + total_a: CellStyleRef, + total_b: CellStyleRef, +} + +/// A centered, bordered, 10pt Liberation Sans cell style, optionally tinted. +fn styled(name: &str, format: &ValueFormatRef, bg: Option>) -> CellStyle { + let mut s = CellStyle::new(name, format); + s.set_font_name("Liberation Sans"); + s.set_font_size(FontSize::from(Length::Pt(10.0))); + s.set_color(Rgb::new(0, 0, 0)); + s.set_text_align(TextAlign::Center); + s.set_border(Length::Pt(0.74), Border::Solid, Rgb::new(0, 0, 0)); + if let Some(rgb) = bg { + s.set_background_color(rgb); + } + s +} + +fn build_styles(wb: &mut WorkBook) -> Styles { + let mut duration_fmt = ValueFormatTimeDuration::new_named("ox-duration"); + duration_fmt.set_truncate_on_overflow(false); + duration_fmt.part_hours().long_style().build(); + duration_fmt.part_text(":").build(); + duration_fmt.part_minutes().long_style().build(); + let duration_fmt = wb.add_timeduration_format(duration_fmt); + + let plain = DefaultFormat::default(); + + Styles { + header_date: wb.add_cellstyle(styled( + "ox-header-date", + &plain, + Some(Rgb::new(0x3f, 0xaf, 0x46)), + )), + header_time: wb.add_cellstyle(styled( + "ox-header-time", + &plain, + Some(Rgb::new(0x59, 0x83, 0xb0)), + )), + header_total: wb.add_cellstyle(styled( + "ox-header-total", + &plain, + Some(Rgb::new(0xed, 0x4c, 0x05)), + )), + date_a: wb.add_cellstyle(styled("ox-date-a", &plain, None)), + date_b: wb.add_cellstyle(styled( + "ox-date-b", + &plain, + Some(Rgb::new(0xdd, 0xe8, 0xcb)), + )), + time_a: wb.add_cellstyle(styled("ox-time-a", &plain, None)), + time_b: wb.add_cellstyle(styled( + "ox-time-b", + &plain, + Some(Rgb::new(0xde, 0xe6, 0xef)), + )), + total_a: wb.add_cellstyle(styled("ox-total-a", &duration_fmt, None)), + total_b: wb.add_cellstyle(styled( + "ox-total-b", + &duration_fmt, + Some(Rgb::new(0xff, 0xdb, 0xb6)), + )), + } +} + +fn parse_hhmm(s: &str) -> Option { + let (h, m) = s.split_once(':')?; + let h: i64 = h.parse().ok()?; + let m: i64 = m.parse().ok()?; + if !(0..24).contains(&h) || !(0..60).contains(&m) { + return None; + } + Some(h * 60 + m) +} + +/// First punch of a slot, parsed to minutes since midnight, or 0 (midnight) +/// if missing/unparseable — same as how the embedded ODF formula treats a +/// blank cell in a subtraction. +fn first_minutes_or_zero(slot: &[String]) -> i64 { + slot.first().and_then(|s| parse_hhmm(s)).unwrap_or(0) +} + +/// Worked minutes for a day: (out1 - in1) + (out2 - in2). Matches the +/// "hours worked" column's formula term for term (blank cells count as 0), +/// so e.g. a day with only an in1 and an out2 punched (no lunch break) still +/// nets out to the full span via cancellation, exactly like the live +/// formula would compute once the file is opened. +fn worked_minutes(day: &[Vec]) -> i64 { + let at = |i: usize| { + day.get(i) + .map(Vec::as_slice) + .map(first_minutes_or_zero) + .unwrap_or(0) + }; + (at(1) - at(0)) + (at(3) - at(2)) +} + +// The review screen already requires every employee to have a name before +// it lets the user continue (see NewEmployees.svelte), so `name` here is +// never genuinely empty. The `id` fallback below only guards the much +// narrower case of a name that sanitizes down to nothing (e.g. "***"): +// not a missing name, just one unusable verbatim as a sheet/file name. + +fn sanitize_sheet_name(name: &str, id: u32) -> String { + let cleaned: String = name + .chars() + .map(|c| if "[]*?:/\\'".contains(c) { ' ' } else { c }) + .collect(); + let trimmed: String = cleaned.trim().chars().take(31).collect(); + if trimmed.is_empty() { + id.to_string() + } else { + trimmed + } +} + +fn sanitize_filename(name: &str, id: u32) -> String { + let cleaned: String = name + .chars() + .map(|c| { + if "/\\:*?\"<>|".contains(c) || c.is_control() { + '_' + } else { + c + } + }) + .collect(); + let trimmed = cleaned.trim().trim_matches('.').to_string(); + if trimmed.is_empty() { + id.to_string() + } else { + trimmed + } +} + +/// Builds the `.ods` bytes for one employee's month. +fn build_ods_bytes( + year: i32, + month: u32, + employee: &EmployeeTimesheet, + headers: &ColumnHeaders, +) -> Result, String> { + let mut wb = WorkBook::new_empty(); + let styles = build_styles(&mut wb); + + let mut sheet = Sheet::new(sanitize_sheet_name(&employee.name, employee.id)); + sheet.set_col_width(0, Length::In(0.1799)); + for col in [COL_DATE, COL_IN1, COL_OUT1, COL_IN2, COL_OUT2, COL_TOTAL] { + sheet.set_col_width(col, Length::In(0.8335)); + } + sheet.set_col_width(6, Length::In(0.1665)); + + sheet.set_styled( + ROW_HEADER, + COL_DATE, + headers.date.as_str(), + &styles.header_date, + ); + sheet.set_styled( + ROW_HEADER, + COL_IN1, + headers.in1.as_str(), + &styles.header_time, + ); + sheet.set_styled( + ROW_HEADER, + COL_OUT1, + headers.out1.as_str(), + &styles.header_time, + ); + sheet.set_styled( + ROW_HEADER, + COL_IN2, + headers.in2.as_str(), + &styles.header_time, + ); + sheet.set_styled( + ROW_HEADER, + COL_OUT2, + headers.out2.as_str(), + &styles.header_time, + ); + sheet.set_styled( + ROW_HEADER, + COL_TOTAL, + headers.total.as_str(), + &styles.header_total, + ); + + let mut row = ROW_FIRST_DAY; + let mut total_minutes = 0i64; + for (di, day) in employee.days.iter().enumerate() { + let empty = day.iter().all(|slot| slot.iter().all(|t| t.is_empty())); + if empty { + continue; + } + + let zebra_b = (row - ROW_FIRST_DAY) % 2 == 1; + let (date_style, time_style, total_style) = if zebra_b { + (&styles.date_b, &styles.time_b, &styles.total_b) + } else { + (&styles.date_a, &styles.time_a, &styles.total_a) + }; + + let date = NaiveDate::from_ymd_opt(year, month, di as u32 + 1) + .map(|d| d.format("%d/%m/%Y").to_string()) + .unwrap_or_default(); + sheet.set_styled(row, COL_DATE, date, date_style); + + for (col, slot_idx) in [(COL_IN1, 0), (COL_OUT1, 1), (COL_IN2, 2), (COL_OUT2, 3)] { + sheet.set_cellstyle(row, col, time_style); + if let Some(slot) = day.get(slot_idx) { + // A stacked slot (more than one punch) goes into the cell one time per + // line, so no punch the app shows is ever silently dropped from the export. + let joined = slot + .iter() + .filter(|t| !t.is_empty()) + .cloned() + .collect::>() + .join("\n"); + if !joined.is_empty() { + sheet.set_value(row, col, joined); + } + } + } + + let minutes = worked_minutes(day); + total_minutes += minutes; + let odf_row = row + 1; // ODF cell references are 1-based. + // spreadsheet-ods doesn't serialize negative durations correctly (emits per-component + // negative numbers, which Calc can't parse), so the cached value is clamped to 0 for an + // all-zero-or-negative day (unresolved/invalid source data); the live formula, once the + // file is recalculated, still shows the real (possibly negative or #VALUE!) result. + sheet.set_styled_value( + row, + COL_TOTAL, + Duration::minutes(minutes.max(0)), + total_style, + ); + sheet.set_formula( + row, + COL_TOTAL, + format!("of:=[.F{odf_row}]-[.E{odf_row}]+[.D{odf_row}]-[.C{odf_row}]"), + ); + + row += 1; + } + + let last_data_row = row - 1; + if last_data_row >= ROW_FIRST_DAY { + let total_row = row + 1; // one blank spacer row, like the reference file. + let first_odf_row = ROW_FIRST_DAY + 1; + let last_odf_row = last_data_row + 1; + sheet.set_styled_value( + total_row, + COL_TOTAL, + Duration::minutes(total_minutes.max(0)), + &styles.total_b, + ); + sheet.set_formula( + total_row, + COL_TOTAL, + format!("of:=SUM([.H{first_odf_row}:.H{last_odf_row}])"), + ); + } + + wb.push_sheet(sheet); + write_ods_buf(&mut wb, Vec::new()).map_err(|err| err.to_string()) +} + +/// Writes the export to `path`: a single `.ods` for one employee, or a +/// `.zip` of one `.ods` per employee otherwise. +pub fn export_to_path(payload: ExportPayload, path: &Path) -> Result<(), String> { + if payload.employees.len() <= 1 { + let employee = payload + .employees + .first() + .ok_or_else(|| "no employees to export".to_string())?; + let bytes = build_ods_bytes(payload.year, payload.month, employee, &payload.headers)?; + std::fs::write(path, bytes).map_err(|err| err.to_string()) + } else { + let file = std::fs::File::create(path).map_err(|err| err.to_string())?; + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + let mut used = HashSet::new(); + for employee in &payload.employees { + let bytes = build_ods_bytes(payload.year, payload.month, employee, &payload.headers)?; + let base = sanitize_filename(&employee.name, employee.id); + let mut name = format!("{base}.ods"); + let mut n = 2; + while !used.insert(name.clone()) { + name = format!("{base} ({n}).ods"); + n += 1; + } + zip.start_file(&name, options) + .map_err(|err| err.to_string())?; + zip.write_all(&bytes).map_err(|err| err.to_string())?; + } + zip.finish().map_err(|err| err.to_string())?; + Ok(()) + } +} diff --git a/oxibadge/src-tauri/src/lib.rs b/oxibadge/src-tauri/src/lib.rs index be3df7b..5f50b40 100644 --- a/oxibadge/src-tauri/src/lib.rs +++ b/oxibadge/src-tauri/src/lib.rs @@ -1,11 +1,13 @@ mod employees; +mod export; mod preferences; use chrono::{Local, TimeZone}; +use export::ExportPayload; use preferences::Settings; use r701::{Record, R701}; -use serde::Deserialize; use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use tauri::AppHandle; use tokio_stream::StreamExt; @@ -130,42 +132,18 @@ async fn load_employees( Ok(employees) } -/// One employee's corrected timesheet for a month, as sent by the review -/// screen's export button. `days[d][c]` is the list of times (usually 0 -/// or 1, more if stacked) punched in slot `c` (entry/exit of the 1st/2nd -/// shift) on day `d + 1`. -/// -/// For now `export_timesheet` only `dbg!`-prints this, so none of the -/// fields are read outside of the derived `Debug` impl (which the -/// dead-code lint deliberately ignores). -#[derive(Debug, Deserialize)] -#[allow(dead_code)] -struct EmployeeTimesheet { - id: u32, - name: String, - days: Vec>>, -} - -#[derive(Debug, Deserialize)] -struct ExportPayload { - scope: String, - year: i32, - month: u32, - employees: Vec, -} - -/// Placeholder export: just prints each employee's corrected timesheet. +/// Writes the review screen's corrected timesheet to `path`, chosen by the +/// frontend via the native "save" dialog: a single `.ods` for one employee, +/// or a `.zip` of one `.ods` per employee for an "all employees" export. #[tauri::command] -fn export_timesheet(payload: ExportPayload) { - dbg!(&payload.scope, payload.year, payload.month); - for employee in &payload.employees { - dbg!(employee); - } +fn export_timesheet(payload: ExportPayload, path: String) -> Result<(), String> { + export::export_to_path(payload, &PathBuf::from(path)) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ load_settings, save_settings, diff --git a/oxibadge/src/lib/components/NewEmployees.svelte b/oxibadge/src/lib/components/NewEmployees.svelte index ab8946a..dc0b952 100644 --- a/oxibadge/src/lib/components/NewEmployees.svelte +++ b/oxibadge/src/lib/components/NewEmployees.svelte @@ -1,7 +1,7 @@ {#if step === 'review'} @@ -152,7 +176,7 @@ employees={reviewEmployees} shift={shifts} onback={() => requestLeaveReview(() => (step = 'confirm-month'))} - onexport={(payload) => invoke('export_timesheet', { payload })} + onexport={onExport} onrename={onEmployeeRenamed} ondirtychange={(dirty) => (reviewDirty = dirty)} />