feat(oxibadge): implement the review screen's export to ODS
Check, format and test / build (push) Successful in 11m23s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 08:48:05 +02:00
parent 5dbb3f51f1
commit d661680539
13 changed files with 1183 additions and 62 deletions
Generated
+662 -6
View File
@@ -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",
]
+3
View File
@@ -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=="],
+1
View File
@@ -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": {
+3
View File
@@ -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"] }
+2 -1
View File
@@ -5,6 +5,7 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-destroy"
"core:window:allow-destroy",
"dialog:default"
]
}
+383
View File
@@ -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<Vec<Vec<String>>>,
}
/// 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<EmployeeTimesheet>,
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<Rgb<u8>>) -> 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<i64> {
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<String>]) -> 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<Vec<u8>, 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::<Vec<_>>()
.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(())
}
}
+9 -31
View File
@@ -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<Vec<Vec<String>>>,
}
#[derive(Debug, Deserialize)]
struct ExportPayload {
scope: String,
year: i32,
month: u32,
employees: Vec<EmployeeTimesheet>,
}
/// 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,
@@ -1,7 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { _ } from 'svelte-i18n';
import { isEdited, isEmpty, type NewEmployee } from './new-employees/logic';
import { isEdited, isEmpty, isInvalid, type NewEmployee } from './new-employees/logic';
let {
employees,
@@ -17,12 +17,13 @@
const count = $derived(employees.length);
const empties = $derived(names.filter((n) => isEmpty(n)).length);
const invalids = $derived(names.filter((n) => isInvalid(n)).length);
const renamed = $derived(names.filter((n, i) => isEdited(n, employees[i].name)).length);
const inputs: HTMLInputElement[] = [];
function submit() {
if (empties > 0) return;
if (empties > 0 || invalids > 0) return;
oncontinue?.(employees.map((e, i) => ({ id: e.id, name: names[i].trim(), original: e.name })));
}
@@ -47,6 +48,7 @@
<div class="list scroll">
{#each employees as emp, i (emp.id)}
{@const empty = isEmpty(names[i])}
{@const invalid = isInvalid(names[i])}
{@const edited = isEdited(names[i], emp.name)}
<div class="item">
<span class="uid mono">#{emp.id}</span>
@@ -55,10 +57,12 @@
class="name"
class:edit={edited}
class:empty
class:invalid
type="text"
placeholder={$_('new_employees.missing_placeholder')}
title={invalid ? $_('new_employees.invalid_name_title') : undefined}
aria-label={$_('new_employees.name_aria', { values: { id: emp.id } })}
aria-invalid={empty}
aria-invalid={empty || invalid}
bind:value={names[i]}
onkeydown={(e) => onKeydown(e, i)}
/>
@@ -72,8 +76,11 @@
{#if empties > 0}
<span class="red">{$_('new_employees.missing_count', { values: { n: empties } })}</span>
{/if}
{#if invalids > 0}
<span class="red">{$_('new_employees.invalid_count', { values: { n: invalids } })}</span>
{/if}
</div>
<button type="button" class="btn" disabled={empties > 0} onclick={submit}>
<button type="button" class="btn" disabled={empties > 0 || invalids > 0} onclick={submit}>
<span>{$_('new_employees.continue')}</span>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</button>
@@ -200,12 +207,14 @@
background: #161616;
}
.name.empty {
.name.empty,
.name.invalid {
border: 1px dashed #a8383d;
background: rgba(229, 72, 77, 0.08);
}
.name.empty:focus {
.name.empty:focus,
.name.invalid:focus {
border-style: solid;
border-color: #e5484d;
box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.18);
@@ -27,6 +27,8 @@
import Toast from './review/Toast.svelte';
import ActionButton from './ActionButton.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import ErrorBanner from './ErrorBanner.svelte';
import { error, setError, clearError } from '$lib/error';
let {
year,
@@ -42,7 +44,14 @@
month: number;
employees: Employee[];
shift: Shifts;
onexport?: (p: { scope: 'all' | 'one'; year: number; month: number; employees: Employee[] }) => void;
/** Returns `false` if the user cancelled (e.g. the save dialog), in which case nothing changes. */
onexport?: (p: {
scope: 'all' | 'one';
year: number;
month: number;
employees: Employee[];
headers: { date: string; in1: string; out1: string; in2: string; out2: string; total: string };
}) => Promise<boolean>;
onrename?: (id: Employee['id'], name: string) => void;
onback?: () => void;
/** Called whenever there are (or stop being) edits not yet exported. */
@@ -74,6 +83,7 @@
let menuOpen = $state(false);
let confirmScope = $state<'all' | 'one' | null>(null);
let confirmingUndoAll = $state(false);
let exporting = $state(false);
let toast = $state('');
let toastTimer: ReturnType<typeof setTimeout> | undefined;
@@ -191,10 +201,11 @@
else doExport(scope);
}
function doExport(scope: 'all' | 'one') {
async function doExport(scope: 'all' | 'one') {
if (exporting) return;
const pick = scope === 'all' ? names.map((_name, i) => i) : [emp];
const snap = $state.snapshot(values);
onexport?.({
const payload = {
scope,
year,
month,
@@ -202,8 +213,22 @@
id: employees[ei].id,
name: names[ei],
days: snap[ei].map((d) => d.map((c) => c.filter((x) => x !== ''))) as Day[]
}))
});
})),
headers: {
date: $_('export.col_date'),
in1: $_('export.col_in1'),
out1: $_('export.col_out1'),
in2: $_('export.col_in2'),
out2: $_('export.col_out2'),
total: $_('export.col_total')
}
};
exporting = true;
clearError();
try {
const exported = await onexport?.(payload);
if (!exported) return; // the user cancelled (e.g. closed the save dialog)
confirmScope = null;
menuOpen = false;
hasUnexportedChanges = false;
@@ -212,12 +237,17 @@
});
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = ''), 3200);
} catch (err) {
setError(err);
} finally {
exporting = false;
}
}
function roundAndExport(scope: 'all' | 'one') {
async function roundAndExport(scope: 'all' | 'one') {
const eis = scope === 'all' ? names.map((_name, i) => i) : [emp];
for (const ei of eis) roundDaysOf(ei, all[ei].roundDays);
doExport(scope);
await doExport(scope);
}
$effect(() => () => clearTimeout(toastTimer));
@@ -357,6 +387,10 @@
edits={cur.edits}
/>
{#if $error}
<ErrorBanner title={$_('errors.export_title')} message={$error} />
{/if}
<div class="table-card">
<Toolbar
bind:onlyIssues
@@ -7,6 +7,13 @@ export function isEmpty(name: string): boolean {
return !name.trim();
}
/** True if `name` contains the Unicode replacement character — the device
* sent bytes that aren't valid UTF-8, which the backend lossily converts
* rather than fail the whole fetch over one bad name. */
export function isInvalid(name: string): boolean {
return name.includes('�');
}
export function isEdited(name: string, original: string): boolean {
return !isEmpty(name) && name.trim() !== original;
}
+12 -1
View File
@@ -32,7 +32,8 @@
"description": "Reading the employee names registered on the time clock. Just a moment."
},
"errors": {
"title": "Could not reach the time clock"
"title": "Could not reach the time clock",
"export_title": "Export failed"
},
"review": {
"title": "Clock-in review",
@@ -206,6 +207,16 @@
"name_aria": "Name of employee #{id}",
"renamed_count": "{n, plural, one {1 name edited} other {{n} names edited}}",
"missing_count": "{n, plural, one {1 name missing} other {{n} names missing}}",
"invalid_count": "{n, plural, one {1 invalid name} other {{n} invalid names}}",
"invalid_name_title": "The time clock sent unreadable characters: fix the name",
"continue": "Continue"
},
"export": {
"col_date": "Date",
"col_in1": "Entry 1",
"col_out1": "Exit 1",
"col_in2": "Entry 2",
"col_out2": "Exit 2",
"col_total": "Hours worked"
}
}
+12 -1
View File
@@ -32,7 +32,8 @@
"description": "Sto leggendo i nomi dei dipendenti registrati sul timbratore. Ci vuole solo un attimo."
},
"errors": {
"title": "Impossibile raggiungere il timbratore"
"title": "Impossibile raggiungere il timbratore",
"export_title": "Esportazione non riuscita"
},
"review": {
"title": "Revisione timbrature",
@@ -206,6 +207,16 @@
"name_aria": "Nome del dipendente #{id}",
"renamed_count": "{n, plural, one {1 nome modificato} other {{n} nomi modificati}}",
"missing_count": "{n, plural, one {1 nome mancante} other {{n} nomi mancanti}}",
"invalid_count": "{n, plural, one {1 nome non valido} other {{n} nomi non validi}}",
"invalid_name_title": "Il timbratore ha inviato caratteri non leggibili: correggi il nome",
"continue": "Continua"
},
"export": {
"col_date": "Data",
"col_in1": "Ingresso 1",
"col_out1": "Uscita 1",
"col_in2": "Ingresso 2",
"col_out2": "Uscita 2",
"col_total": "Ore lavorate"
}
}
+25 -1
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { save } from '@tauri-apps/plugin-dialog';
import { onMount } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import { setError, clearError } from '$lib/error';
import type { Employee } from '$lib/components/review/analysis';
import Screen from '$lib/components/Screen.svelte';
import Onboarding from '$lib/components/Onboarding.svelte';
import ConfirmMonth from '$lib/components/ConfirmMonth.svelte';
@@ -143,6 +145,28 @@
async function onEmployeeRenamed(id: number, name: string) {
await mergeEmployeeNames({ [String(id)]: name });
}
/** Lets the user pick a destination (native "save" dialog) and writes the export there.
* One employee exports a single `.ods`; more than one bundles a `.zip` of `.ods` files.
* Returns `false` without writing anything if the user cancels the dialog. */
async function onExport(payload: {
scope: 'all' | 'one';
year: number;
month: number;
employees: Employee[];
headers: { date: string; in1: string; out1: string; in2: string; out2: string; total: string };
}): Promise<boolean> {
const multi = payload.employees.length > 1;
const extension = multi ? 'zip' : 'ods';
const who = multi ? $_('review.all_employees') : payload.employees[0].name;
const path = await save({
defaultPath: `${monthName($locale, payload.month)} ${payload.year} - ${who}.${extension}`,
filters: [{ name: extension.toUpperCase(), extensions: [extension] }]
});
if (!path) return false;
await invoke('export_timesheet', { payload, path });
return true;
}
</script>
{#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)}
/>