Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d661680539 | ||
|
|
5dbb3f51f1 | ||
|
|
da5daf5bca | ||
|
|
0c320c39b0 | ||
|
|
25127bee96 | ||
|
|
b65e85f908 | ||
|
|
acd3efeac6 | ||
|
|
b4cd8e1796 | ||
|
|
28186c7745 | ||
|
|
99772b9e4a | ||
|
|
268a917a69 | ||
|
|
a96561d5f0 | ||
|
|
cc8181ebdc | ||
|
|
c2471ed548 | ||
|
|
9a637190b5 | ||
|
|
9977db0e46 | ||
|
|
f697660ff4 | ||
|
|
d8c6440a59 | ||
|
|
e0e453c9c5 | ||
|
|
9af4a82e39 | ||
|
|
1745ecc05e | ||
|
|
93f9e9d3af | ||
|
|
07ef2a96bc | ||
|
|
b2b5f0c66c | ||
|
|
987af19ec8 | ||
|
|
35e75745db | ||
|
|
0dd05c0d91 | ||
|
|
78248eb2a5 | ||
|
|
4d9361e383 | ||
|
|
85e07886e9 | ||
|
|
ba20f457f8 |
@@ -0,0 +1,33 @@
|
|||||||
|
name: Check, format and test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
container: docker.io/rust:1.98.1-alpine3.24
|
||||||
|
env:
|
||||||
|
RUSTFLAGS: -C target-feature=-crt-static
|
||||||
|
steps:
|
||||||
|
- name: Install the dependencies
|
||||||
|
run: |
|
||||||
|
rustup component add clippy rustfmt &&
|
||||||
|
apk update &&
|
||||||
|
apk add musl-dev git npm pkgconf gtk+3.0-dev webkit2gtk-4.1-dev librsvg-dev patchelf
|
||||||
|
|
||||||
|
- name: Checkout the code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Check if the code compiles
|
||||||
|
run: cargo check
|
||||||
|
|
||||||
|
- name: Check if the tests run correctly
|
||||||
|
run: cargo test
|
||||||
|
|
||||||
|
- name: Check if the code is formatted correctly
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
- name: Check if Clippy has something to say
|
||||||
|
run: cargo clippy --all-targets
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "r701"
|
resolver = "3"
|
||||||
version = "0.1.0"
|
members = ["r701", "oxibadge/src-tauri"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
license = "AGPL-3.0-or-later"
|
||||||
|
repository = "https://git.nicolabelluti.me/nicolabelluti/r701"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
||||||
|
[profile.release]
|
||||||
[dependencies]
|
codegen-units = 1
|
||||||
chrono = { version = "0.4.38", default-features = false, features = ["clock"] }
|
lto = true
|
||||||
serde = { version = "1.0.200", default-features = false, features = ["derive"], optional = true }
|
opt-level = 3
|
||||||
|
panic = "abort"
|
||||||
[features]
|
strip = true
|
||||||
serde = ["chrono/serde", "dep:serde"]
|
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
# R701 🦀
|
<div align="center">
|
||||||
|
|
||||||
> A reverse-engineered library to communicate with the
|
# R701 | Oxibadge
|
||||||
> [R701](https://ipsattendant.it/rilevatore-presenze-r701/) by [I.P.S.
|
|
||||||
> Informatica](https://ipsinformatica.info/) via TCP, written in Rust
|
|
||||||
|
|
||||||
If you want to read about this reverse engineering attempt you can check out
|
[](https://www.rust-lang.org)
|
||||||
<https://nicolabelluti.me/series/attendance-reader/>.
|
[](https://claude.com/product/claude-code)
|
||||||
|
[](https://choosealicense.com/licenses/agpl-3.0)
|
||||||
|
[](https://buymeacoffee.com/nicolabelluti)
|
||||||
|
<br>
|
||||||
|
[](https://git.nicolabelluti.me/nicolabelluti/r701/actions/?workflow=check-format-and-test.yaml)
|
||||||
|
|
||||||
|
</div><br>
|
||||||
|
|
||||||
|
> A reverse-engineered client for the
|
||||||
|
> [R701](https://ipsattendant.it/rilevatore-presenze-r701/) attendance reader by
|
||||||
|
> [I.P.S. Informatica](https://ipsinformatica.info/)
|
||||||
|
|
||||||
|
This repository is a Cargo workspace with two members:
|
||||||
|
|
||||||
|
- **[`r701/`](r701/README.md)** — the async Rust library that speaks the
|
||||||
|
device's TCP protocol.
|
||||||
|
- **[`oxibadge/`](oxibadge/README.md)** — Oxibadge, a minimal Tauri + Svelte
|
||||||
|
desktop client built on top of the library.
|
||||||
|
|
||||||
|
If you want to read about the reverse engineering attempt behind the library,
|
||||||
|
check out <https://nicolabelluti.me/series/attendance-reader/>.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# Oxibadge 🪪
|
||||||
|
|
||||||
|
[](https://www.rust-lang.org)
|
||||||
|
[](https://claude.com/product/claude-code)
|
||||||
|
[](https://choosealicense.com/licenses/agpl-3.0)
|
||||||
|
[](https://buymeacoffee.com/nicolabelluti)
|
||||||
|
<br>
|
||||||
|
[](https://git.nicolabelluti.me/nicolabelluti/r701/actions/?workflow=check-format-and-test.yaml)
|
||||||
|
|
||||||
|
</div><br>
|
||||||
|
|
||||||
|
> A reverse-engineered client for the
|
||||||
|
> [R701](https://ipsattendant.it/rilevatore-presenze-r701/) attendance reader by
|
||||||
|
> [I.P.S. Informatica](https://ipsinformatica.info/)
|
||||||
|
|
||||||
|
If you want to read about this reverse engineering attempt, check out
|
||||||
|
<https://nicolabelluti.me/series/attendance-reader/>.
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 2,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "oxibadge",
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/bricolage-grotesque": "^5.3.0",
|
||||||
|
"@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": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.65.1",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"svelte": "^5.56.3",
|
||||||
|
"svelte-check": "^4.6.0",
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
|
||||||
|
|
||||||
|
"@fontsource/bricolage-grotesque": ["@fontsource/bricolage-grotesque@5.3.0", "", {}, "sha512-MdOVb/5in11IfN/IQJOExMuu6AroCIQbirl0yX7NUvKk+h0HYIKfGaVAXRS/gCPil4aFxWhjZnOw28tUwv7tFw=="],
|
||||||
|
|
||||||
|
"@fontsource/geist": ["@fontsource/geist@5.3.0", "", {}, "sha512-u03ftlfVwb05uijq2jGDJ03/HKGqMKaNFk7KOhkpBUex8+BhrAOfMs/XciJ2UUNUX9fEsZbZQld/RWMA2oFU7g=="],
|
||||||
|
|
||||||
|
"@fontsource/geist-mono": ["@fontsource/geist-mono@5.3.0", "", {}, "sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg=="],
|
||||||
|
|
||||||
|
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="],
|
||||||
|
|
||||||
|
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
|
||||||
|
|
||||||
|
"@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="],
|
||||||
|
|
||||||
|
"@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="],
|
||||||
|
|
||||||
|
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="],
|
||||||
|
|
||||||
|
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
|
||||||
|
|
||||||
|
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
|
||||||
|
|
||||||
|
"@sveltejs/kit": ["@sveltejs/kit@2.70.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg=="],
|
||||||
|
|
||||||
|
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.3.1", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^1.0.0", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA=="],
|
||||||
|
|
||||||
|
"@tauri-apps/api": ["@tauri-apps/api@2.12.1", "", {}, "sha512-DeyFHa3wynpyoqTDikDEDGTJIq4LQ5USfolQGRmGIWT6JMADyxZBTDa5cAdT3tDg73rUXufPaCwN7aXBos4OnQ=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli": ["@tauri-apps/cli@2.12.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.12.1", "@tauri-apps/cli-darwin-x64": "2.12.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.1", "@tauri-apps/cli-linux-arm64-gnu": "2.12.1", "@tauri-apps/cli-linux-arm64-musl": "2.12.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.12.1", "@tauri-apps/cli-linux-x64-gnu": "2.12.1", "@tauri-apps/cli-linux-x64-musl": "2.12.1", "@tauri-apps/cli-win32-arm64-msvc": "2.12.1", "@tauri-apps/cli-win32-ia32-msvc": "2.12.1", "@tauri-apps/cli-win32-x64-msvc": "2.12.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-kEDEiGzG+yAc5FeLxtXpES/VN+F2C8H0r4gDVtfRLKxT9np9101d9REG/Kgo2lr0hKi0IpDsODiHnU3+naOmLg=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.12.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8bMnbpJ2jKO/ufrOPL65EMHC3ptw7fzeraoQkwibyfGLqIl+UCWsC0152kmNbW6r9NvVQduXqAZF5mH1QgTWhw=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.12.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-G+2qte8GNqNlEZ9ftd21rbu/APVkl3r/nzNDI7LVxEoqJWVNe9GbEgQUMH1r4PHT20Z2UxR3KjCkltIb0e/Wjg=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.12.1", "", { "os": "linux", "cpu": "arm" }, "sha512-5b3n4DaTW+A1Zntnh4Yn44VyLrlPEpB8kMOBRfWkzNnzolBk+AYhKIfKjCLmoC8Mob3ETodxAEOMkE2YPKEWzQ=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.12.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-APArHhDu+8pXSCkCQmvaz1nD3d8WTmlCoSS/oG475TJ6AATBvyQOAmf5qELgsQ4s2+8MOsop0Zd699RKELF1Cw=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.12.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JaCxcLoV0oGtllVkLr4UAHWG/wTfkEunnUqnsEsGYOp0VRxnUwHg63wFBD0jU2ul5eFfSLIDS4RF2gfzsSo5Jg=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.12.1", "", { "os": "linux", "cpu": "none" }, "sha512-K5+VXM7+SGVDwZleA2+lMgvbTVuA5e1tkiVEawu1Yxj26ZyEfO8h8xTC1rBdRX2xgJDSwCk6vPUBCS6i+Z5slQ=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.12.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Z1QGPr49HJZMktu+Spu6Q1b/f9ANadXvqZZZt2645ua1x1ev6prOgp85eiGv2jRzohFEMLo6aJyaLjLUX5KIvA=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.12.1", "", { "os": "linux", "cpu": "x64" }, "sha512-nHuEhRPOMSJ/8RRhtMRxB9B9lVr7xxpe4DapBz8x1GIau3vdzjHHJ1hAfmw+PyayPLZ6XoaVIHPgI17rX6ii8w=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.12.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-I0oPk2uGh86R8dAs2X5OxSP+/GqSc8jh1Px3l8EqrocSNivsRHDTh2k3PVOqdv163dlV6PIVvmhZAbH/lPfZrA=="],
|
||||||
|
|
||||||
|
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.12.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-baNZRBfepAJ5qNFRMwzgBk94xQmkcmTZrNDjZjnzrq2vAaTC3NAhallb/zo3hyoCGQlMS13sdovktrg46NZz6Q=="],
|
||||||
|
|
||||||
|
"@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=="],
|
||||||
|
|
||||||
|
"acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="],
|
||||||
|
|
||||||
|
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||||
|
|
||||||
|
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||||
|
|
||||||
|
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
|
"cli-color": ["cli-color@2.0.4", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.64", "es6-iterator": "^2.0.3", "memoizee": "^0.4.15", "timers-ext": "^0.1.7" } }, "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||||
|
|
||||||
|
"d": ["d@1.0.2", "", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="],
|
||||||
|
|
||||||
|
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||||
|
|
||||||
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="],
|
||||||
|
|
||||||
|
"es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="],
|
||||||
|
|
||||||
|
"es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="],
|
||||||
|
|
||||||
|
"es6-symbol": ["es6-symbol@3.1.4", "", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="],
|
||||||
|
|
||||||
|
"es6-weak-map": ["es6-weak-map@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.46", "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.1" } }, "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
|
||||||
|
|
||||||
|
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||||
|
|
||||||
|
"esniff": ["esniff@2.0.1", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="],
|
||||||
|
|
||||||
|
"esrap": ["esrap@2.4.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA=="],
|
||||||
|
|
||||||
|
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
|
"event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="],
|
||||||
|
|
||||||
|
"ext": ["ext@1.7.0", "", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"globalyzer": ["globalyzer@0.1.0", "", {}, "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q=="],
|
||||||
|
|
||||||
|
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
|
||||||
|
|
||||||
|
"intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
|
||||||
|
|
||||||
|
"is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="],
|
||||||
|
|
||||||
|
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||||
|
|
||||||
|
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||||
|
|
||||||
|
"lru-queue": ["lru-queue@0.1.0", "", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"memoizee": ["memoizee@0.4.17", "", { "dependencies": { "d": "^1.0.2", "es5-ext": "^0.10.64", "es6-weak-map": "^2.0.3", "event-emitter": "^0.3.5", "is-promise": "^2.2.2", "lru-queue": "^0.1.0", "next-tick": "^1.1.0", "timers-ext": "^0.1.7" } }, "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA=="],
|
||||||
|
|
||||||
|
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
|
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||||
|
|
||||||
|
"next-tick": ["next-tick@1.1.0", "", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="],
|
||||||
|
|
||||||
|
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="],
|
||||||
|
|
||||||
|
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
|
"rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="],
|
||||||
|
|
||||||
|
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||||
|
|
||||||
|
"set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
|
||||||
|
|
||||||
|
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||||
|
|
||||||
|
"svelte": ["svelte@5.57.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA=="],
|
||||||
|
|
||||||
|
"svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="],
|
||||||
|
|
||||||
|
"svelte-i18n": ["svelte-i18n@4.0.1", "", { "dependencies": { "cli-color": "^2.0.3", "deepmerge": "^4.2.2", "esbuild": "^0.19.2", "estree-walker": "^2", "intl-messageformat": "^10.5.3", "sade": "^1.8.1", "tiny-glob": "^0.2.9" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5" }, "bin": { "svelte-i18n": "dist/cli.js" } }, "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ=="],
|
||||||
|
|
||||||
|
"timers-ext": ["timers-ext@0.1.8", "", { "dependencies": { "es5-ext": "^0.10.64", "next-tick": "^1.1.0" } }, "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww=="],
|
||||||
|
|
||||||
|
"tiny-glob": ["tiny-glob@0.2.9", "", { "dependencies": { "globalyzer": "0.1.0", "globrex": "^0.1.2" } }, "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
|
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
|
|
||||||
|
"vite": ["vite@8.3.3", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg=="],
|
||||||
|
|
||||||
|
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||||
|
|
||||||
|
"zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "oxibadge",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "A reverse-engineered client for the R701 attendance reader by I.P.S. Informatica",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/bricolage-grotesque": "^5.3.0",
|
||||||
|
"@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": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.65.1",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"svelte": "^5.56.3",
|
||||||
|
"svelte-check": "^4.6.0",
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"@tauri-apps/cli": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# will have schema files for capabilities auto-completion
|
||||||
|
/gen/schemas
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "oxibadge"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
|
name = "oxibadge_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
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"] }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:allow-destroy",
|
||||||
|
"dialog:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 784 B |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
/// User-chosen employee names, by employee_id (as a string, to be a valid
|
||||||
|
/// TOML table).
|
||||||
|
pub type EmployeeNames = HashMap<String, String>;
|
||||||
|
|
||||||
|
/// `EmployeeNames` by reader index (as a string, same reason), since an
|
||||||
|
/// employee_id is only unique within one reader. Local to Oxibadge only:
|
||||||
|
/// the time clock is never written to.
|
||||||
|
pub type Cache = HashMap<String, EmployeeNames>;
|
||||||
|
|
||||||
|
fn path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
.join("employees.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(app: &AppHandle) -> Cache {
|
||||||
|
let Ok(path) = path(app) else {
|
||||||
|
return Cache::new();
|
||||||
|
};
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|contents| toml::from_str(&contents).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(app: &AppHandle, cache: &Cache) -> Result<(), String> {
|
||||||
|
let dest = path(app)?;
|
||||||
|
if let Some(dir) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(dir).map_err(|err| err.to_string())?;
|
||||||
|
}
|
||||||
|
let contents = toml::to_string_pretty(cache).map_err(|err| err.to_string())?;
|
||||||
|
std::fs::write(dest, contents).map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
mod employees;
|
||||||
|
mod export;
|
||||||
|
mod preferences;
|
||||||
|
|
||||||
|
use chrono::{Local, TimeZone};
|
||||||
|
use export::ExportPayload;
|
||||||
|
use preferences::Settings;
|
||||||
|
use r701::{Record, R701};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn load_settings(app: AppHandle) -> Settings {
|
||||||
|
preferences::load(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
|
||||||
|
preferences::save(&app, &settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn load_employee_cache(app: AppHandle) -> employees::Cache {
|
||||||
|
employees::load(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn save_employee_cache(app: AppHandle, cache: employees::Cache) -> Result<(), String> {
|
||||||
|
employees::save(&app, &cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn test_connection(host: String, port: u16) -> Result<(), String> {
|
||||||
|
connect_with_retries(&host, port).await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects to the device, trying up to 3 times, returning the last error
|
||||||
|
/// message as-is on failure.
|
||||||
|
async fn connect_with_retries(host: &str, port: u16) -> Result<R701, String> {
|
||||||
|
let mut last_error = String::new();
|
||||||
|
for _ in 0..3 {
|
||||||
|
match R701::connect((host, port)).await {
|
||||||
|
Ok(r701) => return Ok(r701),
|
||||||
|
Err(err) => last_error = err.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches every attendance record for `year`/`month` (1-12) from the
|
||||||
|
/// configured reader.
|
||||||
|
///
|
||||||
|
/// The device streams records newest first, so the target month's records
|
||||||
|
/// are skipped over (`skip_while`) until found, then taken (`take_while`)
|
||||||
|
/// until the stream moves past them into older months — without walking
|
||||||
|
/// the device's entire history. The result is reversed back to
|
||||||
|
/// chronological (oldest first) order before being returned.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Record>, String> {
|
||||||
|
let settings = preferences::load(&app);
|
||||||
|
let Some(reader) = settings.readers.first() else {
|
||||||
|
return Err("no reader configured".to_string());
|
||||||
|
};
|
||||||
|
let r701 = connect_with_retries(&reader.host, reader.port).await?;
|
||||||
|
|
||||||
|
let invalid = || "invalid year/month".to_string();
|
||||||
|
let start = Local
|
||||||
|
.with_ymd_and_hms(year, month, 1, 0, 0, 0)
|
||||||
|
.single()
|
||||||
|
.ok_or_else(invalid)?;
|
||||||
|
let (next_year, next_month) = if month == 12 {
|
||||||
|
(year + 1, 1)
|
||||||
|
} else {
|
||||||
|
(year, month + 1)
|
||||||
|
};
|
||||||
|
let end = Local
|
||||||
|
.with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0)
|
||||||
|
.single()
|
||||||
|
.ok_or_else(invalid)?;
|
||||||
|
|
||||||
|
let mut records: Vec<Record> = r701
|
||||||
|
.into_record_iter()
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
.skip_while(|record| matches!(record, Ok(record) if record.datetime >= end))
|
||||||
|
.take_while(|record| !matches!(record, Ok(record) if record.datetime < start))
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
records.reverse();
|
||||||
|
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the name of every distinct employee in `records`, skipping
|
||||||
|
/// `known` ones (already in the frontend's local cache) to avoid asking the
|
||||||
|
/// device for names it has already told us. Returns only the newly fetched
|
||||||
|
/// names, keyed by employee id.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn load_employees(
|
||||||
|
app: AppHandle,
|
||||||
|
records: Vec<Record>,
|
||||||
|
known: Vec<u32>,
|
||||||
|
) -> Result<HashMap<u32, String>, String> {
|
||||||
|
let settings = preferences::load(&app);
|
||||||
|
let Some(reader) = settings.readers.first() else {
|
||||||
|
return Err("no reader configured".to_string());
|
||||||
|
};
|
||||||
|
let r701 = connect_with_retries(&reader.host, reader.port).await?;
|
||||||
|
|
||||||
|
let known: HashSet<u32> = known.into_iter().collect();
|
||||||
|
let mut employees = HashMap::new();
|
||||||
|
for record in &records {
|
||||||
|
if known.contains(&record.employee_id) || employees.contains_key(&record.employee_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = r701
|
||||||
|
.get_name(record.employee_id)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
.map(|name| {
|
||||||
|
name.unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned())
|
||||||
|
});
|
||||||
|
if let Some(name) = name {
|
||||||
|
employees.insert(record.employee_id, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(employees)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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, 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,
|
||||||
|
load_employee_cache,
|
||||||
|
save_employee_cache,
|
||||||
|
test_connection,
|
||||||
|
load_records,
|
||||||
|
load_employees,
|
||||||
|
export_timesheet
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
oxibadge_lib::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Reader {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Shifts {
|
||||||
|
pub in1: String,
|
||||||
|
pub out1: String,
|
||||||
|
pub in2: String,
|
||||||
|
pub out2: String,
|
||||||
|
pub round_step_min: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// The configured time clock (only the first one is used today, but
|
||||||
|
/// this stays a list to support multiple readers in the future).
|
||||||
|
#[serde(default)]
|
||||||
|
pub readers: Vec<Reader>,
|
||||||
|
pub shifts: Shifts,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub lang: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
readers: Vec::new(),
|
||||||
|
shifts: Shifts {
|
||||||
|
in1: "07:30".to_string(),
|
||||||
|
out1: "12:00".to_string(),
|
||||||
|
in2: "13:30".to_string(),
|
||||||
|
out2: "17:00".to_string(),
|
||||||
|
round_step_min: 10,
|
||||||
|
},
|
||||||
|
lang: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
.join("preferences.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(app: &AppHandle) -> Settings {
|
||||||
|
let Ok(path) = path(app) else {
|
||||||
|
return Settings::default();
|
||||||
|
};
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|contents| toml::from_str(&contents).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(app: &AppHandle, settings: &Settings) -> Result<(), String> {
|
||||||
|
let dest = path(app)?;
|
||||||
|
if let Some(dir) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(dir).map_err(|err| err.to_string())?;
|
||||||
|
}
|
||||||
|
let contents = toml::to_string_pretty(settings).map_err(|err| err.to_string())?;
|
||||||
|
std::fs::write(dest, contents).map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "oxibadge",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "me.nicolabelluti.oxibadge",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "bun run dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "bun run build",
|
||||||
|
"frontendDist": "../build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Oxibadge",
|
||||||
|
"width": 1200,
|
||||||
|
"height": 700
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
@import '@fontsource/geist/latin-400.css';
|
||||||
|
@import '@fontsource/geist/latin-500.css';
|
||||||
|
@import '@fontsource/geist/latin-600.css';
|
||||||
|
@import '@fontsource/bricolage-grotesque/latin-700.css';
|
||||||
|
@import '@fontsource/geist-mono/latin-400.css';
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ox-bg: #121212;
|
||||||
|
--ox-text: #f2f2f2;
|
||||||
|
--ox-text-strong: #ffffff;
|
||||||
|
--ox-text-muted: #a3a3a3;
|
||||||
|
--ox-text-subtle: #8a8a8a;
|
||||||
|
--ox-label: #9a9a9a;
|
||||||
|
--ox-placeholder: #5e5e5e;
|
||||||
|
--ox-border: #333333;
|
||||||
|
--ox-border-hover: #4a4a4a;
|
||||||
|
|
||||||
|
--ox-accent: #f74c00;
|
||||||
|
--ox-accent-soft: #ff7a3d;
|
||||||
|
--ox-accent-ring: rgba(247, 76, 0, 0.16);
|
||||||
|
--ox-on-accent: #1a0700;
|
||||||
|
|
||||||
|
--ox-error-bg: #261316;
|
||||||
|
--ox-error-border: #5a2229;
|
||||||
|
--ox-error-text: #f5b5b9;
|
||||||
|
--ox-error-title: #ffd6d8;
|
||||||
|
--ox-error-icon: #ff6b74;
|
||||||
|
|
||||||
|
--ox-radius: 12px;
|
||||||
|
--ox-control-h: 52px;
|
||||||
|
|
||||||
|
--ox-font-display: 'Bricolage Grotesque', 'Geist', system-ui, sans-serif;
|
||||||
|
--ox-font-body: 'Geist', system-ui, sans-serif;
|
||||||
|
--ox-font-mono: 'Geist Mono', ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--ox-bg);
|
||||||
|
color: var(--ox-text);
|
||||||
|
font-family: var(--ox-font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 48px;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--ox-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--ox-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.red {
|
||||||
|
color: #ff9ea3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blue {
|
||||||
|
color: #8db8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orange {
|
||||||
|
color: #ff9a66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #3a3a3a transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: #333333;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/logo.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Oxibadge</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function autofocus(node: HTMLInputElement) {
|
||||||
|
node.focus();
|
||||||
|
node.select();
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
primary = false,
|
||||||
|
disabled = false,
|
||||||
|
onclick,
|
||||||
|
icon,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
primary?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onclick: () => void;
|
||||||
|
icon?: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button type="button" class="btn" class:primary {disabled} {onclick}>
|
||||||
|
{#if icon}{@render icon()}{/if}
|
||||||
|
<span>{@render children()}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.btn {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #e6e6e6;
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
padding: 0 18px;
|
||||||
|
border: none;
|
||||||
|
background: var(--ox-accent);
|
||||||
|
color: var(--ox-on-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
transition:
|
||||||
|
filter 0.15s,
|
||||||
|
transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover:not(:disabled) {
|
||||||
|
background: var(--ox-accent);
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:focus-visible {
|
||||||
|
outline: 2px solid #ffffff;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
type = 'button',
|
||||||
|
faded = false,
|
||||||
|
busy = false,
|
||||||
|
disabled = false,
|
||||||
|
onclick
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
type?: 'button' | 'submit';
|
||||||
|
faded?: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onclick?: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button {type} class="btn" class:faded class:busy {disabled} {onclick}>
|
||||||
|
<span>{label}</span>
|
||||||
|
{#if busy}
|
||||||
|
<span class="spinner"></span>
|
||||||
|
{:else}
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true"
|
||||||
|
><path d="M5 12h14M13 6l6 6-6 6" /></svg
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.btn {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: var(--ox-control-h);
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-radius: var(--ox-radius);
|
||||||
|
background: var(--ox-accent);
|
||||||
|
color: var(--ox-on-accent);
|
||||||
|
font-family: var(--ox-font-body);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
filter 0.15s,
|
||||||
|
transform 0.1s,
|
||||||
|
opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:not(:disabled):hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:not(:disabled):active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid #ffffff;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.faded {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.busy {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(26, 7, 0, 0.35);
|
||||||
|
border-top-color: var(--ox-on-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { children }: { children: Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.column {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 36px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ModalFrame from './ModalFrame.svelte';
|
||||||
|
import ActionButton from './ActionButton.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
fixed = false,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel,
|
||||||
|
onconfirm,
|
||||||
|
oncancel
|
||||||
|
}: {
|
||||||
|
/** See `ModalFrame`'s `fixed` prop. */
|
||||||
|
fixed?: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
onconfirm: () => void;
|
||||||
|
oncancel: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Unique per instance, so two ConfirmDialogs never clash if ever shown
|
||||||
|
// at once (e.g. this one plus the app-wide "quit with unsaved changes" one).
|
||||||
|
const uid = $props.id();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ModalFrame
|
||||||
|
{fixed}
|
||||||
|
titleId="{uid}-title"
|
||||||
|
descId="{uid}-desc"
|
||||||
|
closeAriaLabel={cancelLabel}
|
||||||
|
onclose={oncancel}
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
|
||||||
|
d="M12 9.5v4M12 17h.01"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
<div class="modal-text">
|
||||||
|
<h2 id="{uid}-title">{title}</h2>
|
||||||
|
<p id="{uid}-desc">{message}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<ActionButton onclick={oncancel}>{cancelLabel}</ActionButton>
|
||||||
|
<ActionButton primary onclick={onconfirm}>{confirmLabel}</ActionButton>
|
||||||
|
</div>
|
||||||
|
</ModalFrame>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-text h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-text p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { _, locale } from 'svelte-i18n';
|
||||||
|
import { monthName } from '$lib/month';
|
||||||
|
import { error, clearError } from '$lib/error';
|
||||||
|
import Column from './Column.svelte';
|
||||||
|
import Intro from './Intro.svelte';
|
||||||
|
import Button from './Button.svelte';
|
||||||
|
import ErrorBanner from './ErrorBanner.svelte';
|
||||||
|
|
||||||
|
let { onstart }: { onstart: (selection: { year: number; month: number }) => void } = $props();
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
const currentMonth = now.getMonth(); // 0-11
|
||||||
|
const lastMonth = new Date(currentYear, currentMonth - 1, 1);
|
||||||
|
|
||||||
|
let year = $state(lastMonth.getFullYear());
|
||||||
|
let month = $state(lastMonth.getMonth()); // 0-11
|
||||||
|
let open = $state<'month' | 'year' | null>(null);
|
||||||
|
let yearCount = $state(25);
|
||||||
|
let yearList: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
const years = $derived(
|
||||||
|
Array.from({ length: Math.max(yearCount, currentYear - year + 8) }, (_, k) => currentYear - k)
|
||||||
|
);
|
||||||
|
|
||||||
|
const monthShort = $derived(Array.from({ length: 12 }, (_, i) => monthName($locale, i + 1, 'short')));
|
||||||
|
const monthLong = $derived(Array.from({ length: 12 }, (_, i) => monthName($locale, i + 1, 'long')));
|
||||||
|
|
||||||
|
function isFuture(i: number) {
|
||||||
|
return year === currentYear && i > currentMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(which: 'month' | 'year') {
|
||||||
|
open = open === which ? null : which;
|
||||||
|
if (open === 'year') {
|
||||||
|
await tick();
|
||||||
|
const selected = yearList?.querySelector('[aria-pressed="true"]');
|
||||||
|
if (yearList && selected instanceof HTMLElement) {
|
||||||
|
yearList.scrollTop = selected.offsetTop - yearList.clientHeight / 2 + selected.offsetHeight / 2;
|
||||||
|
selected.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMonth(i: number) {
|
||||||
|
if (isFuture(i)) return;
|
||||||
|
month = i;
|
||||||
|
open = null;
|
||||||
|
clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickYear(y: number) {
|
||||||
|
year = y;
|
||||||
|
if (y === currentYear && month > currentMonth) month = currentMonth;
|
||||||
|
open = null;
|
||||||
|
clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onYearScroll() {
|
||||||
|
if (!yearList) return;
|
||||||
|
if (yearList.scrollTop + yearList.clientHeight > yearList.scrollHeight - 80) {
|
||||||
|
yearCount = years.length + 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && open) open = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
onstart({ year, month: month + 1 });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="backdrop"
|
||||||
|
aria-label={$_('confirm_month.close_aria')}
|
||||||
|
onclick={() => (open = null)}
|
||||||
|
></button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Column>
|
||||||
|
<Intro title={$_('confirm_month.title')} subtitle={$_('confirm_month.subtitle')} />
|
||||||
|
|
||||||
|
<div class="period">
|
||||||
|
<div class="anchor">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pick"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={open === 'month'}
|
||||||
|
aria-label={$_('confirm_month.change_month_aria', { values: { month: monthLong[month] } })}
|
||||||
|
onclick={() => toggle('month')}
|
||||||
|
>
|
||||||
|
<span>{monthLong[month]}</span>
|
||||||
|
<svg class="chev" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M6 9l6 6 6-6" /></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open === 'month'}
|
||||||
|
<div class="pop months" role="group" aria-label={$_('confirm_month.choose_month_aria')}>
|
||||||
|
{#each monthShort as label, i}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="opt"
|
||||||
|
class:sel={i === month}
|
||||||
|
disabled={isFuture(i)}
|
||||||
|
aria-pressed={i === month}
|
||||||
|
aria-label={$_('confirm_month.month_option_aria', { values: { month: monthLong[i], year } })}
|
||||||
|
onclick={() => pickMonth(i)}>{label}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="slash" aria-hidden="true">/</span>
|
||||||
|
|
||||||
|
<div class="anchor">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pick"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={open === 'year'}
|
||||||
|
aria-label={$_('confirm_month.change_year_aria', { values: { year } })}
|
||||||
|
onclick={() => toggle('year')}
|
||||||
|
>
|
||||||
|
<span>{year}</span>
|
||||||
|
<svg class="chev" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M6 9l6 6 6-6" /></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open === 'year'}
|
||||||
|
<div
|
||||||
|
class="pop years"
|
||||||
|
role="group"
|
||||||
|
aria-label={$_('confirm_month.choose_year_aria')}
|
||||||
|
bind:this={yearList}
|
||||||
|
onscroll={onYearScroll}
|
||||||
|
>
|
||||||
|
{#each years as y (y)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="opt mono"
|
||||||
|
class:sel={y === year}
|
||||||
|
aria-pressed={y === year}
|
||||||
|
onclick={() => pickYear(y)}>{y}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button label={$_('confirm_month.button')} onclick={start} />
|
||||||
|
|
||||||
|
{#if $error}
|
||||||
|
<ErrorBanner title={$_('errors.title')} message={$error} />
|
||||||
|
{/if}
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
all: unset;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period {
|
||||||
|
position: relative;
|
||||||
|
z-index: 6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--ox-text-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick .chev {
|
||||||
|
stroke-width: 2.2;
|
||||||
|
color: #6e6e6e;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick:hover {
|
||||||
|
background: #1c1c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick:hover .chev {
|
||||||
|
color: #c9c9c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash {
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 40px;
|
||||||
|
color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 10;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid var(--ox-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.months {
|
||||||
|
width: 340px;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.years {
|
||||||
|
width: 148px;
|
||||||
|
max-height: 292px;
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #3a3a3a transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.years::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.years::-webkit-scrollbar-thumb {
|
||||||
|
background: #3a3a3a;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: var(--ox-font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #d4d4d4;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt:not(:disabled):hover {
|
||||||
|
background: #262626;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt:disabled {
|
||||||
|
color: #454545;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt.mono {
|
||||||
|
flex: none;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt.sel,
|
||||||
|
.opt.sel:hover {
|
||||||
|
background: var(--ox-accent);
|
||||||
|
color: var(--ox-on-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { title, message }: { title: string; message: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="banner" role="alert">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"
|
||||||
|
/><path d="M12 9.5v4M12 17h.01" />
|
||||||
|
</svg>
|
||||||
|
<div class="banner-text">
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: var(--ox-radius);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
background: var(--ox-error-bg);
|
||||||
|
border: 1px solid var(--ox-error-border);
|
||||||
|
color: var(--ox-error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner svg {
|
||||||
|
color: var(--ox-error-icon);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ox-error-title);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value = $bindable(),
|
||||||
|
type = 'text',
|
||||||
|
placeholder = '',
|
||||||
|
maxlength,
|
||||||
|
inputmode,
|
||||||
|
err = false,
|
||||||
|
suffix,
|
||||||
|
oninput,
|
||||||
|
icon
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
type?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
maxlength?: number;
|
||||||
|
inputmode?: 'text' | 'numeric' | 'decimal' | 'none' | 'tel' | 'search' | 'email' | 'url';
|
||||||
|
err?: boolean;
|
||||||
|
suffix?: string;
|
||||||
|
oninput?: (e: Event & { currentTarget: HTMLInputElement }) => void;
|
||||||
|
icon?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="field" class:err>
|
||||||
|
<label for={id}>{label}</label>
|
||||||
|
{#if icon}{@render icon()}{/if}
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
{type}
|
||||||
|
{placeholder}
|
||||||
|
{maxlength}
|
||||||
|
{inputmode}
|
||||||
|
bind:value
|
||||||
|
{oninput}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
{#if suffix}<span class="suffix">{suffix}</span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.field {
|
||||||
|
position: relative;
|
||||||
|
height: var(--ox-control-h);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--ox-bg);
|
||||||
|
border: 1px solid var(--ox-border);
|
||||||
|
border-radius: var(--ox-radius);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field:hover {
|
||||||
|
border-color: var(--ox-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field:focus-within {
|
||||||
|
border-color: var(--ox-accent);
|
||||||
|
box-shadow: 0 0 0 4px var(--ox-accent-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.err,
|
||||||
|
.field.err:hover,
|
||||||
|
.field.err:focus-within {
|
||||||
|
border-color: #e5484d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.err:focus-within {
|
||||||
|
box-shadow: 0 0 0 4px rgba(229, 72, 77, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.err label {
|
||||||
|
color: #ff8a8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
position: absolute;
|
||||||
|
top: -9px;
|
||||||
|
left: 12px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: var(--ox-bg);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ox-label);
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field:focus-within label {
|
||||||
|
color: var(--ox-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field :global(svg) {
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--ox-font-mono);
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--ox-text);
|
||||||
|
caret-color: var(--ox-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder {
|
||||||
|
color: var(--ox-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type='number']::-webkit-outer-spin-button,
|
||||||
|
.field input[type='number']::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suffix {
|
||||||
|
flex: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ox-text-subtle);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { text }: { text: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
<span>{text}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ox-text-subtle);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
size = 'sm',
|
||||||
|
accent = false,
|
||||||
|
danger = false,
|
||||||
|
dangerFilled = false,
|
||||||
|
title,
|
||||||
|
ariaLabel,
|
||||||
|
onclick,
|
||||||
|
icon
|
||||||
|
}: {
|
||||||
|
size?: 'sm' | 'lg';
|
||||||
|
accent?: boolean;
|
||||||
|
/** Subtle red tint on hover only — for a destructive icon among others, e.g. in a list row. */
|
||||||
|
danger?: boolean;
|
||||||
|
/** Permanent red box, like `accent` but red — for a standalone destructive action. */
|
||||||
|
dangerFilled?: boolean;
|
||||||
|
title?: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
onclick: () => void;
|
||||||
|
icon: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn"
|
||||||
|
class:lg={size === 'lg'}
|
||||||
|
class:accent
|
||||||
|
class:danger
|
||||||
|
class:danger-filled={dangerFilled}
|
||||||
|
{title}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
|
{@render icon()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.icon-btn {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #8a8a8a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.lg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.accent {
|
||||||
|
background: var(--ox-accent);
|
||||||
|
color: var(--ox-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.accent:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.danger:hover {
|
||||||
|
background: rgba(229, 72, 77, 0.14);
|
||||||
|
color: #ff8a8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.danger-filled {
|
||||||
|
background: rgba(229, 72, 77, 0.14);
|
||||||
|
color: #ff6b74;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.danger-filled:hover {
|
||||||
|
background: rgba(229, 72, 77, 0.22);
|
||||||
|
color: #ff8a8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.danger-filled:focus-visible {
|
||||||
|
outline-color: #e5484d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { title, subtitle }: { title: string; subtitle: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="intro">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{subtitle}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.intro {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 340px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ox-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { title, description }: { title: string; description: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="content" role="status" aria-live="polite">
|
||||||
|
<svg class="spinner" width="56" height="56" viewBox="0 0 56 56" aria-hidden="true">
|
||||||
|
<circle class="track" cx="28" cy="28" r="24" />
|
||||||
|
<circle class="arc" cx="28" cy="28" r="24" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="text">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{@html description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner circle {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
stroke: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arc {
|
||||||
|
stroke: var(--ox-accent);
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-dasharray: 40 151;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.spinner {
|
||||||
|
animation-duration: 2.4s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 340px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ox-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
p :global(strong) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ox-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
fixed = false,
|
||||||
|
titleId,
|
||||||
|
descId,
|
||||||
|
closeAriaLabel,
|
||||||
|
onclose,
|
||||||
|
icon,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
/** Use for a modal that must sit above the whole app (e.g. shown from
|
||||||
|
* outside the screen it concerns), instead of within the nearest
|
||||||
|
* positioned ancestor. */
|
||||||
|
fixed?: boolean;
|
||||||
|
titleId: string;
|
||||||
|
descId?: string;
|
||||||
|
closeAriaLabel: string;
|
||||||
|
onclose: () => void;
|
||||||
|
icon?: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="modal-wrap" class:fixed>
|
||||||
|
<button type="button" class="modal-backdrop" aria-label={closeAriaLabel} onclick={onclose}></button>
|
||||||
|
<div class="modal" role="alertdialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descId}>
|
||||||
|
{#if icon}
|
||||||
|
<div class="modal-icon">{@render icon()}</div>
|
||||||
|
{/if}
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-wrap {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-wrap.fixed {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
all: unset;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 24px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(229, 72, 77, 0.14);
|
||||||
|
color: #ff6b74;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { isEdited, isEmpty, isInvalid, type NewEmployee } from './new-employees/logic';
|
||||||
|
|
||||||
|
let {
|
||||||
|
employees,
|
||||||
|
oncontinue
|
||||||
|
}: {
|
||||||
|
employees: NewEmployee[];
|
||||||
|
oncontinue?: (list: { id: number; name: string; original: string }[]) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Working copy: the props only serve as initial values. `untrack` makes
|
||||||
|
// it explicit that we read `employees` only once, on purpose.
|
||||||
|
let names = $state(untrack(() => employees.map((e) => e.name)));
|
||||||
|
|
||||||
|
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 || invalids > 0) return;
|
||||||
|
oncontinue?.(employees.map((e, i) => ({ id: e.id, name: names[i].trim(), original: e.name })));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent, i: number) {
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (i < inputs.length - 1) inputs[i + 1]?.focus();
|
||||||
|
else submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="screen">
|
||||||
|
<div class="column">
|
||||||
|
<header class="intro">
|
||||||
|
<h1>{$_('new_employees.title')}</h1>
|
||||||
|
<p>
|
||||||
|
{$_('new_employees.subtitle', { values: { n: count } })}
|
||||||
|
{$_('new_employees.hint')}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<input
|
||||||
|
bind:this={inputs[i]}
|
||||||
|
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 || invalid}
|
||||||
|
bind:value={names[i]}
|
||||||
|
onkeydown={(e) => onKeydown(e, i)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<div class="summary">
|
||||||
|
<span>{$_('new_employees.renamed_count', { values: { n: renamed } })}</span>
|
||||||
|
{#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 || 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.screen {
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 640px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
background: var(--ox-bg);
|
||||||
|
color: var(--ox-text);
|
||||||
|
font-family: var(--ox-font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
max-height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 380px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ox-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 18px, #000 calc(100% - 28px), transparent 100%);
|
||||||
|
mask-image: linear-gradient(to bottom, transparent 0, #000 18px, #000 calc(100% - 28px), transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uid {
|
||||||
|
width: 40px;
|
||||||
|
flex: none;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The name looks like plain text; it becomes a field on hover/focus. */
|
||||||
|
.name {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 16.5px;
|
||||||
|
color: var(--ox-text);
|
||||||
|
caret-color: var(--ox-accent);
|
||||||
|
cursor: text;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name::placeholder {
|
||||||
|
color: #b85a60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name:hover {
|
||||||
|
border-color: var(--ox-border);
|
||||||
|
background: #161616;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name:focus {
|
||||||
|
border-color: var(--ox-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--ox-accent-ring);
|
||||||
|
background: #161616;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name.edit {
|
||||||
|
color: #ffb08c;
|
||||||
|
background: rgba(247, 76, 0, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name.edit:focus {
|
||||||
|
background: #161616;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name.empty,
|
||||||
|
.name.invalid {
|
||||||
|
border: 1px dashed #a8383d;
|
||||||
|
background: rgba(229, 72, 77, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name.empty:focus,
|
||||||
|
.name.invalid:focus {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #e5484d;
|
||||||
|
box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ox-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: var(--ox-control-h);
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-radius: var(--ox-radius);
|
||||||
|
background: var(--ox-accent);
|
||||||
|
color: var(--ox-on-accent);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
filter 0.15s,
|
||||||
|
transform 0.1s,
|
||||||
|
opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:not(:disabled):hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:not(:disabled):active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid #ffffff;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { error, setError, clearError } from '$lib/error';
|
||||||
|
import type { Settings } from '$lib/settings';
|
||||||
|
import Column from './Column.svelte';
|
||||||
|
import Intro from './Intro.svelte';
|
||||||
|
import Field from './Field.svelte';
|
||||||
|
import Button from './Button.svelte';
|
||||||
|
import ErrorBanner from './ErrorBanner.svelte';
|
||||||
|
import Hint from './Hint.svelte';
|
||||||
|
|
||||||
|
let { onconnected }: { onconnected: (cfg: { host: string; port: number }) => void } = $props();
|
||||||
|
|
||||||
|
let host = $state('');
|
||||||
|
let port = $state(5005);
|
||||||
|
let testing = $state(false);
|
||||||
|
|
||||||
|
const trimmedHost = $derived(host.trim());
|
||||||
|
const empty = $derived(trimmedHost === '');
|
||||||
|
const disabled = $derived(empty || testing);
|
||||||
|
const btnLabel = $derived(
|
||||||
|
testing ? $_('onboarding.button.testing') : $error ? $_('onboarding.button.retry') : $_('onboarding.button.idle')
|
||||||
|
);
|
||||||
|
|
||||||
|
async function testAndSave() {
|
||||||
|
if (disabled) return;
|
||||||
|
testing = true;
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
await invoke('test_connection', { host: trimmedHost, port });
|
||||||
|
const settings = await invoke<Settings>('load_settings');
|
||||||
|
await invoke('save_settings', { settings: { ...settings, readers: [{ host: trimmedHost, port }] } });
|
||||||
|
onconnected({ host: trimmedHost, port });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
testing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Column>
|
||||||
|
<Intro title={$_('onboarding.title')} subtitle={$_('onboarding.subtitle')} />
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="form"
|
||||||
|
onsubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
testAndSave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<Field
|
||||||
|
id="ox-host"
|
||||||
|
label={$_('onboarding.host_label')}
|
||||||
|
bind:value={host}
|
||||||
|
placeholder="192.168.1.100"
|
||||||
|
oninput={clearError}
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<rect x="3" y="4" width="18" height="12" rx="2" /><path d="M8 20h8M12 16v4" />
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
id="ox-port"
|
||||||
|
label={$_('onboarding.port_label')}
|
||||||
|
bind:value={port}
|
||||||
|
type="number"
|
||||||
|
placeholder="5005"
|
||||||
|
oninput={clearError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" label={btnLabel} faded={empty} busy={testing} {disabled} />
|
||||||
|
|
||||||
|
{#if $error}
|
||||||
|
<ErrorBanner title={$_('errors.title')} message={$error} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Hint text={$_('onboarding.hint')} />
|
||||||
|
</form>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 108px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
disabled = false,
|
||||||
|
onclick,
|
||||||
|
icon,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
disabled?: boolean;
|
||||||
|
onclick: () => void;
|
||||||
|
icon?: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button type="button" class="pill" {disabled} {onclick}>
|
||||||
|
{#if icon}{@render icon()}{/if}
|
||||||
|
<span>{@render children()}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pill {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
color: #c9c9c9;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill:hover:not(:disabled) {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { children }: { children: Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="screen">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.screen {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { _, locale } from 'svelte-i18n';
|
||||||
|
import IconButton from './IconButton.svelte';
|
||||||
|
import ReaderCard from './settings/ReaderCard.svelte';
|
||||||
|
import ShiftsCard from './settings/ShiftsCard.svelte';
|
||||||
|
import RoundCard from './settings/RoundCard.svelte';
|
||||||
|
import LanguageSection from './settings/LanguageSection.svelte';
|
||||||
|
import { checkShifts, roundValid, type ShiftTimeKey, type Shifts } from './settings/logic';
|
||||||
|
import type { Settings as SettingsData } from '$lib/settings';
|
||||||
|
|
||||||
|
let { onback, onsettingschange }: { onback?: () => void; onsettingschange?: (s: SettingsData) => void } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const SAVE_DELAY = 700;
|
||||||
|
|
||||||
|
let loaded = $state(false);
|
||||||
|
let host = $state('');
|
||||||
|
let port = $state(5005);
|
||||||
|
// Placeholder: overwritten by load_settings before the cards are rendered ({#if loaded}).
|
||||||
|
let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 });
|
||||||
|
let roundStepStr = $state('');
|
||||||
|
let lang = $state<string | null>(null);
|
||||||
|
|
||||||
|
type SaveState = 'idle' | 'saving' | 'saved' | 'invalid' | 'failed';
|
||||||
|
let saveState = $state<SaveState>('idle');
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const settings = await invoke<SettingsData>('load_settings');
|
||||||
|
host = settings.readers[0]?.host ?? '';
|
||||||
|
port = settings.readers[0]?.port ?? 5005;
|
||||||
|
shifts = { ...settings.shifts };
|
||||||
|
roundStepStr = String(settings.shifts.round_step_min);
|
||||||
|
lang = settings.lang;
|
||||||
|
loaded = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function settingsValid() {
|
||||||
|
return !checkShifts(shifts).errorKey && roundValid(roundStepStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist() {
|
||||||
|
saveState = 'saving';
|
||||||
|
const settings: SettingsData = {
|
||||||
|
readers: [{ host, port }],
|
||||||
|
shifts: { ...shifts, round_step_min: Number(roundStepStr) },
|
||||||
|
lang
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await invoke('save_settings', { settings });
|
||||||
|
onsettingschange?.(settings);
|
||||||
|
saveState = 'saved';
|
||||||
|
} catch {
|
||||||
|
saveState = 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSave(delay: number) {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
if (!settingsValid()) {
|
||||||
|
saveState = 'invalid';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveState = 'saving';
|
||||||
|
saveTimer = setTimeout(persist, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => () => clearTimeout(saveTimer));
|
||||||
|
|
||||||
|
function onShiftInput(key: ShiftTimeKey, value: string) {
|
||||||
|
shifts[key] = value;
|
||||||
|
scheduleSave(SAVE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRoundInput(value: string) {
|
||||||
|
roundStepStr = value;
|
||||||
|
scheduleSave(SAVE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLangChange(next: string) {
|
||||||
|
lang = next;
|
||||||
|
locale.set(next);
|
||||||
|
scheduleSave(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddressSaved(newHost: string, newPort: number) {
|
||||||
|
host = newHost;
|
||||||
|
port = newPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveInfo = $derived(
|
||||||
|
{
|
||||||
|
idle: [$_('settings.save_idle'), '#6e6e6e'],
|
||||||
|
saving: [$_('settings.save_saving'), '#a3a3a3'],
|
||||||
|
saved: [$_('settings.save_saved'), '#4cc38a'],
|
||||||
|
invalid: [$_('settings.save_invalid'), '#ff9ea3'],
|
||||||
|
failed: [$_('settings.save_failed'), '#ff9ea3']
|
||||||
|
}[saveState]
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="screen">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="tb-left">
|
||||||
|
{#if onback}
|
||||||
|
<IconButton size="lg" ariaLabel={$_('review.back_to_month')} onclick={onback}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><path d="M15 6l-6 6 6 6" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
<h1>{$_('settings.title')}</h1>
|
||||||
|
</div>
|
||||||
|
<span class="save-state" role="status" aria-live="polite" style="color: {saveInfo[1]}">
|
||||||
|
{#if saveState === 'saving'}
|
||||||
|
<svg class="spin" width="15" height="15" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
|
||||||
|
><path d="M12 3a9 9 0 1 0 9 9" /></svg
|
||||||
|
>
|
||||||
|
{:else if saveState === 'saved'}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.2"
|
||||||
|
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
|
||||||
|
>
|
||||||
|
{:else if saveState === 'invalid' || saveState === 'failed'}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="12" cy="12" r="9" /><path d="M12 7.5v5.5M12 16.5h.01" /></svg
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{saveInfo[0]}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content scroll">
|
||||||
|
<div class="column">
|
||||||
|
{#if loaded}
|
||||||
|
<section aria-labelledby="s-dev">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2 id="s-dev">{$_('settings.device_title')}</h2>
|
||||||
|
<p>{$_('settings.device_desc')}</p>
|
||||||
|
</div>
|
||||||
|
<ReaderCard {host} {port} onsave={onAddressSaved} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="s-shift">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2 id="s-shift">{$_('settings.shifts_title')}</h2>
|
||||||
|
<p>{$_('settings.shifts_desc')}</p>
|
||||||
|
</div>
|
||||||
|
<ShiftsCard {shifts} oninput={onShiftInput} />
|
||||||
|
<RoundCard roundStep={roundStepStr} oninput={onRoundInput} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<LanguageSection lang={(lang ?? $locale ?? 'it').split('-')[0]} onchange={onLangChange} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.spin {
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
position: relative;
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 640px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ox-bg);
|
||||||
|
color: var(--ox-text);
|
||||||
|
font-family: var(--ox-font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
flex: none;
|
||||||
|
min-height: 76px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #222222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
color: var(--ox-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 32px 24px 64px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-head p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { ariaLabel, onclick }: { ariaLabel: string; onclick: () => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button type="button" class="gear" aria-label={ariaLabel} title={ariaLabel} {onclick}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path
|
||||||
|
d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.gear {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 4;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--ox-text-subtle);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gear:hover {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: var(--ox-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gear:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { _, locale } from 'svelte-i18n';
|
||||||
|
import { monthName } from '$lib/month';
|
||||||
|
import type { Shifts } from '$lib/shifts';
|
||||||
|
import {
|
||||||
|
type Day,
|
||||||
|
type Employee,
|
||||||
|
analyze,
|
||||||
|
assignToShifts,
|
||||||
|
dayWord,
|
||||||
|
formatTyped,
|
||||||
|
hm,
|
||||||
|
lunchSpan,
|
||||||
|
normDay,
|
||||||
|
roundT,
|
||||||
|
shiftT,
|
||||||
|
shortSummary,
|
||||||
|
summary
|
||||||
|
} from './review/analysis';
|
||||||
|
import TopBar from './review/TopBar.svelte';
|
||||||
|
import EmployeeSidebar from './review/EmployeeSidebar.svelte';
|
||||||
|
import EmployeeHeader from './review/EmployeeHeader.svelte';
|
||||||
|
import Toolbar from './review/Toolbar.svelte';
|
||||||
|
import TimesheetTable from './review/TimesheetTable.svelte';
|
||||||
|
import ExportModal from './review/ExportModal.svelte';
|
||||||
|
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,
|
||||||
|
month,
|
||||||
|
employees,
|
||||||
|
shift,
|
||||||
|
onexport,
|
||||||
|
onrename,
|
||||||
|
onback,
|
||||||
|
ondirtychange
|
||||||
|
}: {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
employees: Employee[];
|
||||||
|
shift: Shifts;
|
||||||
|
/** 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. */
|
||||||
|
ondirtychange?: (dirty: boolean) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Working copies: the component never mutates the props. Loading another
|
||||||
|
// month remounts it (already the case in this app, since this screen
|
||||||
|
// sits behind a {#if step === 'review'}). `untrack` makes it explicit
|
||||||
|
// that we read `employees` only once, on purpose.
|
||||||
|
const orig: Day[][] = untrack(() => employees.map((e) => e.days.map(normDay)));
|
||||||
|
let values = $state<Day[][]>(untrack(() => employees.map((e) => e.days.map(normDay))));
|
||||||
|
let names = $state<string[]>(untrack(() => employees.map((e) => e.name)));
|
||||||
|
/** "employee-day-slot-index" -> time produced by rounding */
|
||||||
|
let rounded = $state<Record<string, string>>({});
|
||||||
|
let hasUnexportedChanges = $state(false);
|
||||||
|
const daysInMonth = orig[0]?.length ?? 0;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
ondirtychange?.(hasUnexportedChanges);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- UI state ----
|
||||||
|
let emp = $state(0);
|
||||||
|
let query = $state('');
|
||||||
|
let onlyIssues = $state(false);
|
||||||
|
let editingName = $state(false);
|
||||||
|
let draft = $state('');
|
||||||
|
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;
|
||||||
|
|
||||||
|
const all = $derived(
|
||||||
|
names.map((_name, ei) => analyze(year, month, values[ei], orig[ei], $locale, $_, shift))
|
||||||
|
);
|
||||||
|
const cur = $derived(all[emp]);
|
||||||
|
const issuesAll = $derived(all.reduce((n, x) => n + x.issues, 0));
|
||||||
|
const infosAll = $derived(all.reduce((n, x) => n + x.infos, 0));
|
||||||
|
|
||||||
|
const visibleEmps = $derived(
|
||||||
|
names
|
||||||
|
.map((name, ei) => ({ name, ei, issues: all[ei].issues, infos: all[ei].infos }))
|
||||||
|
.filter((e) => !query.trim() || e.name.toLowerCase().includes(query.trim().toLowerCase()))
|
||||||
|
);
|
||||||
|
const visibleRows = $derived(cur.rows.filter((r) => !onlyIssues || r.issue || r.info));
|
||||||
|
|
||||||
|
// ---- Data actions ----
|
||||||
|
function onCellInput(di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) {
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const cell = values[emp][di][ci];
|
||||||
|
const prev = cell[ii] ?? '';
|
||||||
|
const next = formatTyped(input.value, prev, (e as unknown as InputEvent).inputType ?? '');
|
||||||
|
if (next === '' && cell.length > 1) {
|
||||||
|
// One of the stacked punches was cleared by hand: the cell shrinks
|
||||||
|
// instead of leaving a dangling empty field.
|
||||||
|
cell.splice(ii, 1);
|
||||||
|
} else {
|
||||||
|
cell[ii] = next;
|
||||||
|
if (input.value !== next) input.value = next; // keep the field in sync even if the state doesn't change
|
||||||
|
}
|
||||||
|
hasUnexportedChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redistributes the day's punches into E1, U1, E2, U2 based on the expected shifts. */
|
||||||
|
function fixDays(dis: number[]) {
|
||||||
|
for (const di of dis) {
|
||||||
|
const times = values[emp][di].flat().filter((x) => x !== '');
|
||||||
|
values[emp][di] = assignToShifts(times, shift);
|
||||||
|
}
|
||||||
|
hasUnexportedChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLunch(dis: number[]) {
|
||||||
|
for (const di of dis) {
|
||||||
|
const span = lunchSpan(values[emp][di], shift);
|
||||||
|
if (span) values[emp][di] = [[span[0]], [shift.out1], [shift.in2], [span[1]]];
|
||||||
|
}
|
||||||
|
hasUnexportedChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundDaysOf(ei: number, dis: number[]) {
|
||||||
|
for (const di of dis) {
|
||||||
|
values[ei][di] = values[ei][di].map((c, ci) =>
|
||||||
|
c.map((x, ii) => {
|
||||||
|
const r = roundT(x, shift.round_step_min);
|
||||||
|
if (r !== x) rounded[`${ei}-${di}-${ci}-${ii}`] = r;
|
||||||
|
return r;
|
||||||
|
})
|
||||||
|
) as Day;
|
||||||
|
}
|
||||||
|
hasUnexportedChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies all three automatic corrections to the current employee in order: shifts, then lunch, then rounding. */
|
||||||
|
function applyAllCorrections() {
|
||||||
|
const allDays = values[emp].map((_d, di) => di);
|
||||||
|
fixDays(cur.stackedDays);
|
||||||
|
addLunch(allDays);
|
||||||
|
roundDaysOf(emp, allDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a fixed offset to every time of the chosen days, for the given employee(s) — e.g. to fix a
|
||||||
|
* daylight-saving change the time clock didn't follow. */
|
||||||
|
function applyShift(opts: { minutes: number; fromDay: number; toDay: number; scope: 'all' | 'one' }) {
|
||||||
|
const eis = opts.scope === 'all' ? names.map((_name, i) => i) : [emp];
|
||||||
|
const from = opts.fromDay - 1;
|
||||||
|
const to = opts.toDay - 1;
|
||||||
|
for (const ei of eis) {
|
||||||
|
for (let di = from; di <= to && di < values[ei].length; di++) {
|
||||||
|
values[ei][di] = values[ei][di].map((c) => c.map((x) => shiftT(x, opts.minutes))) as Day;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasUnexportedChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reverts one day back to what the time clock originally reported. */
|
||||||
|
function undoDay(di: number) {
|
||||||
|
values[emp][di] = orig[emp][di].map((c) => [...c]) as Day;
|
||||||
|
hasUnexportedChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestUndoAll() {
|
||||||
|
confirmingUndoAll = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reverts every day of the current employee back to the time clock's original data. */
|
||||||
|
function confirmUndoAll() {
|
||||||
|
confirmingUndoAll = false;
|
||||||
|
values[emp] = orig[emp].map((day) => day.map((c) => [...c]) as Day);
|
||||||
|
hasUnexportedChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Name ----
|
||||||
|
function renameCurrent(name: string) {
|
||||||
|
names[emp] = name;
|
||||||
|
onrename?.(employees[emp].id, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Export ----
|
||||||
|
function requestExport(scope: 'all' | 'one') {
|
||||||
|
menuOpen = false;
|
||||||
|
const pending = scope === 'all' ? issuesAll + infosAll : cur.issues + cur.infos;
|
||||||
|
if (pending > 0) confirmScope = scope;
|
||||||
|
else doExport(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doExport(scope: 'all' | 'one') {
|
||||||
|
if (exporting) return;
|
||||||
|
const pick = scope === 'all' ? names.map((_name, i) => i) : [emp];
|
||||||
|
const snap = $state.snapshot(values);
|
||||||
|
const payload = {
|
||||||
|
scope,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
employees: pick.map((ei) => ({
|
||||||
|
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;
|
||||||
|
toast = $_('review.export_done', {
|
||||||
|
values: { who: scope === 'all' ? $_('review.everyone') : names[emp] }
|
||||||
|
});
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => (toast = ''), 3200);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
exporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
await doExport(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => () => clearTimeout(toastTimer));
|
||||||
|
|
||||||
|
// Export warning dialog texts
|
||||||
|
const scopeIssues = $derived(confirmScope === 'all' ? issuesAll : cur.issues);
|
||||||
|
const scopeInfos = $derived(confirmScope === 'all' ? infosAll : cur.infos);
|
||||||
|
const confirmSummary = $derived(
|
||||||
|
scopeIssues
|
||||||
|
? summary(scopeIssues, scopeInfos, $_)
|
||||||
|
: $_('review.only_unrounded', { values: { days: dayWord(scopeInfos, $_) } })
|
||||||
|
);
|
||||||
|
const confirmTitle = $derived(
|
||||||
|
confirmScope === 'all'
|
||||||
|
? $_('review.confirm_title_all', { values: { summary: confirmSummary } })
|
||||||
|
: $_('review.confirm_title_one', { values: { name: names[emp], summary: confirmSummary } })
|
||||||
|
);
|
||||||
|
const confirmItems = $derived(
|
||||||
|
confirmScope === 'all'
|
||||||
|
? names
|
||||||
|
.map((name, ei) => ({ name, ei, iss: all[ei].issues, inf: all[ei].infos }))
|
||||||
|
.filter((x) => x.iss || x.inf)
|
||||||
|
.map((x) => ({
|
||||||
|
key: 'e' + x.ei,
|
||||||
|
label: x.name,
|
||||||
|
detail: shortSummary(x.iss, x.inf, $_),
|
||||||
|
red: x.iss > 0,
|
||||||
|
go: () => {
|
||||||
|
confirmScope = null;
|
||||||
|
emp = x.ei;
|
||||||
|
onlyIssues = true;
|
||||||
|
editingName = false;
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
: cur.rows
|
||||||
|
.filter((r) => r.issue || r.info)
|
||||||
|
.map((r) => ({
|
||||||
|
key: 'd' + r.di,
|
||||||
|
label: r.dow + ' ' + (r.di + 1),
|
||||||
|
detail: r.issue || $_('review.to_round_cap'),
|
||||||
|
red: !!r.issue,
|
||||||
|
go: () => {
|
||||||
|
confirmScope = null;
|
||||||
|
onlyIssues = true;
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (confirmScope) confirmScope = null;
|
||||||
|
else if (confirmingUndoAll) confirmingUndoAll = false;
|
||||||
|
else if (menuOpen) menuOpen = false;
|
||||||
|
else if (editingName) editingName = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div class="screen">
|
||||||
|
{#if menuOpen}
|
||||||
|
<button type="button" class="backdrop" aria-label={$_('review.close_menu_aria')} onclick={() => (menuOpen = false)}
|
||||||
|
></button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmScope}
|
||||||
|
<ExportModal
|
||||||
|
title={confirmTitle}
|
||||||
|
{scopeIssues}
|
||||||
|
{scopeInfos}
|
||||||
|
{shift}
|
||||||
|
items={confirmItems}
|
||||||
|
onclose={() => (confirmScope = null)}
|
||||||
|
onroundandexport={() => confirmScope && roundAndExport(confirmScope)}
|
||||||
|
onexportanyway={() => confirmScope && doExport(confirmScope)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmingUndoAll}
|
||||||
|
<ConfirmDialog
|
||||||
|
title={$_('review.undo_all_confirm_title')}
|
||||||
|
message={$_('review.undo_all_confirm_message')}
|
||||||
|
confirmLabel={$_('review.undo_all_confirm')}
|
||||||
|
cancelLabel={$_('review.cancel')}
|
||||||
|
onconfirm={confirmUndoAll}
|
||||||
|
oncancel={() => (confirmingUndoAll = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Toast message={toast} />
|
||||||
|
|
||||||
|
{#if names.length === 0}
|
||||||
|
<div class="empty-month">
|
||||||
|
<h1>{monthName($locale, month)} {year}</h1>
|
||||||
|
<p>{$_('review.no_records')}</p>
|
||||||
|
{#if onback}
|
||||||
|
<ActionButton onclick={onback}>{$_('review.back_to_month')}</ActionButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<TopBar
|
||||||
|
{year}
|
||||||
|
{month}
|
||||||
|
{issuesAll}
|
||||||
|
{infosAll}
|
||||||
|
{onback}
|
||||||
|
bind:menuOpen
|
||||||
|
employeeCount={names.length}
|
||||||
|
currentName={names[emp]}
|
||||||
|
currentIssues={cur.issues}
|
||||||
|
currentInfos={cur.infos}
|
||||||
|
onexportall={() => requestExport('all')}
|
||||||
|
onexportone={() => requestExport('one')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<EmployeeSidebar
|
||||||
|
employees={visibleEmps}
|
||||||
|
selected={emp}
|
||||||
|
bind:query
|
||||||
|
onselect={(ei) => {
|
||||||
|
emp = ei;
|
||||||
|
editingName = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<EmployeeHeader
|
||||||
|
name={names[emp]}
|
||||||
|
bind:editing={editingName}
|
||||||
|
bind:draft
|
||||||
|
onsave={renameCurrent}
|
||||||
|
worked={cur.worked}
|
||||||
|
totalLabel={hm(cur.minutes)}
|
||||||
|
issues={cur.issues}
|
||||||
|
infos={cur.infos}
|
||||||
|
edits={cur.edits}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if $error}
|
||||||
|
<ErrorBanner title={$_('errors.export_title')} message={$error} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<Toolbar
|
||||||
|
bind:onlyIssues
|
||||||
|
{year}
|
||||||
|
{month}
|
||||||
|
{daysInMonth}
|
||||||
|
currentName={names[emp]}
|
||||||
|
stackedCount={cur.stackedDays.length}
|
||||||
|
lunchCount={cur.lunchDays.length}
|
||||||
|
roundCount={cur.roundDays.length}
|
||||||
|
{shift}
|
||||||
|
onfixall={() => fixDays(cur.stackedDays)}
|
||||||
|
onaddlunchall={() => addLunch(cur.lunchDays)}
|
||||||
|
onroundall={() => roundDaysOf(emp, cur.roundDays)}
|
||||||
|
onapplyall={applyAllCorrections}
|
||||||
|
onshift={applyShift}
|
||||||
|
edits={cur.edits}
|
||||||
|
onundoall={requestUndoAll}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TimesheetTable
|
||||||
|
rows={visibleRows}
|
||||||
|
{rounded}
|
||||||
|
{emp}
|
||||||
|
{shift}
|
||||||
|
oncellinput={onCellInput}
|
||||||
|
onfixday={(di) => fixDays([di])}
|
||||||
|
onaddlunch={(di) => addLunch([di])}
|
||||||
|
onroundday={(di) => roundDaysOf(emp, [di])}
|
||||||
|
onundoday={undoDay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.screen {
|
||||||
|
position: relative;
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 640px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ox-bg);
|
||||||
|
color: var(--ox-text);
|
||||||
|
font-family: var(--ox-font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
all: unset;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 20px 24px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #262626;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #141414;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-month {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-month h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 32px;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
color: var(--ox-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-month p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ox-text-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.body {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export interface NewEmployee {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { autofocus } from '$lib/autofocus';
|
||||||
|
import IconButton from '../IconButton.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
editing = $bindable(false),
|
||||||
|
draft = $bindable(''),
|
||||||
|
onsave,
|
||||||
|
worked,
|
||||||
|
totalLabel,
|
||||||
|
issues,
|
||||||
|
infos,
|
||||||
|
edits
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
editing?: boolean;
|
||||||
|
draft?: string;
|
||||||
|
onsave: (name: string) => void;
|
||||||
|
worked: number;
|
||||||
|
totalLabel: string;
|
||||||
|
issues: number;
|
||||||
|
infos: number;
|
||||||
|
edits: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
draft = name;
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveName() {
|
||||||
|
const n = draft.trim();
|
||||||
|
if (n && n !== name) onsave(n);
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="main-head">
|
||||||
|
<div class="who">
|
||||||
|
{#if editing}
|
||||||
|
<div class="name-row">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
type="text"
|
||||||
|
aria-label={$_('review.employee_name_aria')}
|
||||||
|
bind:value={draft}
|
||||||
|
use:autofocus
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') saveName();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton accent ariaLabel={$_('review.save_name')} title={$_('review.save')} onclick={saveName}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
|
||||||
|
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton ariaLabel={$_('review.cancel')} title={$_('review.cancel')} onclick={() => (editing = false)}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><path d="M6 6l12 12M18 6L6 18" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="name-row">
|
||||||
|
<h2>{name}</h2>
|
||||||
|
<IconButton ariaLabel={$_('review.edit_name')} title={$_('review.edit_name')} onclick={startEdit}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="stats">
|
||||||
|
<span><b>{worked}</b> {$_('review.days_worked')}</span>
|
||||||
|
<span><b>{totalLabel}</b> {$_('review.total_hours')}</span>
|
||||||
|
<span><b class:red={issues > 0}>{issues}</b> {$_('review.to_check')}</span>
|
||||||
|
<span><b class:blue={infos > 0}>{infos}</b> {$_('review.to_round')}</span>
|
||||||
|
<span><b class:orange={edits > 0}>{edits}</b> {$_('review.edited')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.main-head {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-row h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 38px;
|
||||||
|
width: 300px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--ox-accent);
|
||||||
|
box-shadow: 0 0 0 4px var(--ox-accent-ring);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #ffffff;
|
||||||
|
caret-color: var(--ox-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats b {
|
||||||
|
font-family: var(--ox-font-mono);
|
||||||
|
font-weight: 400;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .stats b's own color above otherwise beats the global .red/.blue/.orange
|
||||||
|
utility on specificity, since it combines a class with a tag. */
|
||||||
|
.stats b.red {
|
||||||
|
color: #ff9ea3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats b.blue {
|
||||||
|
color: #8db8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats b.orange {
|
||||||
|
color: #ff9a66;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { initials } from './analysis';
|
||||||
|
|
||||||
|
let {
|
||||||
|
employees,
|
||||||
|
selected,
|
||||||
|
query = $bindable(''),
|
||||||
|
onselect
|
||||||
|
}: {
|
||||||
|
employees: { name: string; ei: number; issues: number; infos: number }[];
|
||||||
|
selected: number;
|
||||||
|
query?: string;
|
||||||
|
onselect: (ei: number) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="side scroll" aria-label={$_('review.employees_nav_aria')}>
|
||||||
|
<div class="search">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="11" cy="11" r="6.5" /><path d="M20 20l-4-4" /></svg
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder={$_('review.search_employee')}
|
||||||
|
aria-label={$_('review.search_employee')}
|
||||||
|
bind:value={query}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="emp-list">
|
||||||
|
{#each employees as e (e.ei)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="emp"
|
||||||
|
class:on={e.ei === selected}
|
||||||
|
aria-current={e.ei === selected ? 'true' : undefined}
|
||||||
|
onclick={() => onselect(e.ei)}
|
||||||
|
>
|
||||||
|
<span class="avatar">{initials(e.name)}</span>
|
||||||
|
<span class="emp-name">{e.name}</span>
|
||||||
|
{#if e.issues}
|
||||||
|
<span class="badge red" aria-label={$_('review.days_to_check_aria', { values: { n: e.issues } })}
|
||||||
|
>{e.issues}</span
|
||||||
|
>
|
||||||
|
{:else if e.infos}
|
||||||
|
<span class="badge blue" aria-label={$_('review.days_to_round_aria', { values: { n: e.infos } })}
|
||||||
|
>{e.infos}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<svg class="ok" width="16" height="16" viewBox="0 0 24 24" aria-label={$_('review.no_problem_aria')}
|
||||||
|
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.side {
|
||||||
|
width: 260px;
|
||||||
|
flex: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 16px 12px;
|
||||||
|
border-right: 1px solid #222222;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
height: 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #6e6e6e;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus-within {
|
||||||
|
border-color: var(--ox-accent);
|
||||||
|
box-shadow: 0 0 0 4px var(--ox-accent-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #f2f2f2;
|
||||||
|
caret-color: var(--ox-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input::placeholder {
|
||||||
|
color: #5e5e5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emp-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emp {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #bdbdbd;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emp:hover {
|
||||||
|
background: #1c1c1c;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emp:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emp.on {
|
||||||
|
background: #222222;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #262626;
|
||||||
|
color: #c9c9c9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emp-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 11px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.red {
|
||||||
|
background: rgba(229, 72, 77, 0.16);
|
||||||
|
color: #ff9ea3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.blue {
|
||||||
|
background: rgba(77, 141, 255, 0.14);
|
||||||
|
color: #8db8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: #4cc38a;
|
||||||
|
stroke-width: 2.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.side {
|
||||||
|
width: auto;
|
||||||
|
max-height: 220px;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #222222;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import type { Shifts } from '$lib/shifts';
|
||||||
|
import ModalFrame from '../ModalFrame.svelte';
|
||||||
|
import FixButton from './FixButton.svelte';
|
||||||
|
import ActionButton from '../ActionButton.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
scopeIssues,
|
||||||
|
scopeInfos,
|
||||||
|
shift,
|
||||||
|
items,
|
||||||
|
onclose,
|
||||||
|
onroundandexport,
|
||||||
|
onexportanyway
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
scopeIssues: number;
|
||||||
|
scopeInfos: number;
|
||||||
|
shift: Shifts;
|
||||||
|
items: { key: string; label: string; detail: string; red: boolean; go: () => void }[];
|
||||||
|
onclose: () => void;
|
||||||
|
onroundandexport: () => void;
|
||||||
|
onexportanyway: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ModalFrame
|
||||||
|
titleId="ox-exp-title"
|
||||||
|
descId="ox-exp-desc"
|
||||||
|
closeAriaLabel={$_('review.cancel_export_aria')}
|
||||||
|
{onclose}
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
|
||||||
|
d="M12 9.5v4M12 17h.01"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
<div class="modal-text">
|
||||||
|
<h2 id="ox-exp-title">{title}</h2>
|
||||||
|
<p id="ox-exp-desc">
|
||||||
|
{#if scopeIssues}
|
||||||
|
{$_('review.export_warning_issues')}
|
||||||
|
{:else}
|
||||||
|
{$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-list scroll">
|
||||||
|
{#each items as it (it.key)}
|
||||||
|
<button type="button" class="menu-item row-item" onclick={it.go}>
|
||||||
|
<span class="mi-label">{it.label}</span>
|
||||||
|
<span class="mi-detail" class:red={it.red} class:blue={!it.red}>{it.detail}</span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" class="mi-chev"
|
||||||
|
><path d="M9 6l6 6-6 6" /></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<ActionButton onclick={onclose}>{$_('review.back_to_review')}</ActionButton>
|
||||||
|
{#if scopeIssues === 0 && scopeInfos > 0}
|
||||||
|
<FixButton size="tall" blue onclick={onroundandexport}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
{$_('review.round_and_export')}
|
||||||
|
</FixButton>
|
||||||
|
{/if}
|
||||||
|
<ActionButton primary onclick={onexportanyway}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
{$_('review.export_anyway')}
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</ModalFrame>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-text h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-text p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-list {
|
||||||
|
max-height: 196px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #151515;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #bdbdbd;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background: #252525;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-item {
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: #e6e6e6;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-detail {
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-chev {
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
size = 'sm',
|
||||||
|
blue = false,
|
||||||
|
title,
|
||||||
|
ariaLabel,
|
||||||
|
onclick,
|
||||||
|
icon,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
size?: 'sm' | 'lg' | 'tall';
|
||||||
|
blue?: boolean;
|
||||||
|
title?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
onclick: () => void;
|
||||||
|
icon: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fix"
|
||||||
|
class:lg={size === 'lg'}
|
||||||
|
class:tall={size === 'tall'}
|
||||||
|
class:blue
|
||||||
|
{title}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
|
{@render icon()}
|
||||||
|
<span>{@render children()}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fix {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #5c3320;
|
||||||
|
color: #ff9a66;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fix:hover {
|
||||||
|
background: rgba(247, 76, 0, 0.12);
|
||||||
|
border-color: var(--ox-accent);
|
||||||
|
color: #ffb08c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fix:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fix.lg {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fix.tall {
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fix.blue {
|
||||||
|
border-color: #27406a;
|
||||||
|
color: #8db8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fix.blue:hover {
|
||||||
|
background: rgba(77, 141, 255, 0.12);
|
||||||
|
border-color: #4d8dff;
|
||||||
|
color: #b3ceff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fix.blue:focus-visible {
|
||||||
|
outline-color: #4d8dff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import type { Shifts } from '$lib/shifts';
|
||||||
|
import { COLS_KEYS, type Row, cellClass, dayLabel, hm, isRnd, noteOf } from './analysis';
|
||||||
|
import FixButton from './FixButton.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
rows,
|
||||||
|
rounded,
|
||||||
|
emp,
|
||||||
|
shift,
|
||||||
|
oncellinput,
|
||||||
|
onfixday,
|
||||||
|
onaddlunch,
|
||||||
|
onroundday,
|
||||||
|
onundoday
|
||||||
|
}: {
|
||||||
|
rows: Row[];
|
||||||
|
rounded: Record<string, string>;
|
||||||
|
emp: number;
|
||||||
|
shift: Shifts;
|
||||||
|
oncellinput: (di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) => void;
|
||||||
|
onfixday: (di: number) => void;
|
||||||
|
onaddlunch: (di: number) => void;
|
||||||
|
onroundday: (di: number) => void;
|
||||||
|
onundoday: (di: number) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="table-box scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 92px"></th>
|
||||||
|
<th class="grp" colspan="2"><span>{$_('review.shift_1')}</span></th>
|
||||||
|
<th class="grp" colspan="2"><span>{$_('review.shift_2')}</span></th>
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="first center">{$_('review.col_day')}</th>
|
||||||
|
<th class="center">{$_('review.col_in')}</th>
|
||||||
|
<th class="center">{$_('review.col_out')}</th>
|
||||||
|
<th class="center">{$_('review.col_in')}</th>
|
||||||
|
<th class="center">{$_('review.col_out')}</th>
|
||||||
|
<th class="center">{$_('review.col_hours')}</th>
|
||||||
|
<th style="width: 100%">{$_('review.col_notes')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r (r.di)}
|
||||||
|
{@const rnd = r.v.reduce(
|
||||||
|
(n, c, ci) => n + c.filter((x, ii) => isRnd(rounded, emp, r.di, ci, ii, x)).length,
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
{@const manual = r.rowEdits - rnd}
|
||||||
|
{@const note = noteOf(r, rnd, manual, $_, shift.round_step_min)}
|
||||||
|
{@const label = dayLabel(r)}
|
||||||
|
<tr class:we={r.weekend} class:anom={!!r.issue}>
|
||||||
|
<td class="first center"><span class="dow">{r.dow}</span><span class="mono">{r.di + 1}</span></td>
|
||||||
|
|
||||||
|
{#each r.v as list, ci}
|
||||||
|
{@const many = list.filter((x) => x !== '').length > 1}
|
||||||
|
<td class="center">
|
||||||
|
<div class="slot" class:stack={many} class:gone={r.stacked && r.miss[ci]}>
|
||||||
|
{#each list as val, ii}
|
||||||
|
<input
|
||||||
|
class={cellClass(r, ci, ii, isRnd(rounded, emp, r.di, ci, ii, val))}
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="5"
|
||||||
|
placeholder="--:--"
|
||||||
|
value={val}
|
||||||
|
aria-label={list.length > 1
|
||||||
|
? $_('review.cell_aria_stacked', {
|
||||||
|
values: { label, col: $_(COLS_KEYS[ci]), index: ii + 1, total: list.length }
|
||||||
|
})
|
||||||
|
: $_('review.cell_aria', { values: { label, col: $_(COLS_KEYS[ci]) } })}
|
||||||
|
oninput={(e) => oncellinput(r.di, ci, ii, e)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<td class="center mono" class:dim={!r.mins}>{r.mins ? hm(r.mins) : '—'}</td>
|
||||||
|
|
||||||
|
<td class="note {note.kind}">
|
||||||
|
<span class="note-in">
|
||||||
|
{#if r.rowEdits > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="undo-btn"
|
||||||
|
aria-label={$_('review.undo_day_aria', { values: { label } })}
|
||||||
|
title={$_('review.undo_short')}
|
||||||
|
onclick={() => onundoday(r.di)}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><path d="M9 14l-5-5 5-5" /><path d="M20 20v-7a4 4 0 0 0-4-4H4" /></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if note.kind === 'issue'}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="12" cy="12" r="9" /><path d="M12 7.5v5.5M12 16.5h.01" /></svg
|
||||||
|
>
|
||||||
|
{:else if note.kind === 'info'}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span>{note.text}</span>
|
||||||
|
|
||||||
|
{#if r.stacked}
|
||||||
|
<FixButton ariaLabel={$_('review.fix_shifts_day_aria', { values: { label } })} onclick={() => onfixday(r.di)}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><path d="M7 4v16M3 8l4-4 4 4M17 20V4M13 16l4 4 4-4" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
{$_('review.fix_shifts_short')}
|
||||||
|
</FixButton>
|
||||||
|
{/if}
|
||||||
|
{#if r.lunch}
|
||||||
|
<FixButton
|
||||||
|
ariaLabel={$_('review.add_lunch_day_aria', { values: { start: shift.out1, end: shift.in2, label } })}
|
||||||
|
onclick={() => onaddlunch(r.di)}
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><path d="M7 3v8M5 3v4a2 2 0 0 0 4 0V3M7 11v10M17 21V3c-2 1-3 3.5-3 7h3" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
{$_('review.lunch_short')} <span class="mono">{shift.out1}–{shift.in2}</span>
|
||||||
|
</FixButton>
|
||||||
|
{/if}
|
||||||
|
{#if r.info}
|
||||||
|
<FixButton blue ariaLabel={$_('review.round_day_aria', { values: { label } })} onclick={() => onroundday(r.di)}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
{$_('review.round_short')}
|
||||||
|
</FixButton>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{#if rows.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="12" cy="12" r="9" /><path d="M8 12.5l2.5 2.5L16 9.5" /></svg
|
||||||
|
>
|
||||||
|
<span>{$_('review.no_rows')}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.table-box {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
background: #161616;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #8a8a8a;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead tr:first-child th {
|
||||||
|
top: 0;
|
||||||
|
height: 30px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6e6e6e;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead tr:last-child th {
|
||||||
|
top: 30px;
|
||||||
|
height: 34px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.grp {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.grp span {
|
||||||
|
display: block;
|
||||||
|
border-bottom: 1px solid #2e2e2e;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.first,
|
||||||
|
td.first {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
height: 44px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-bottom: 1px solid #1f1f1f;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.dim {
|
||||||
|
color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.we td {
|
||||||
|
color: #5e5e5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.anom td {
|
||||||
|
background: rgba(229, 72, 77, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.anom:hover td {
|
||||||
|
background: rgba(229, 72, 77, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dow {
|
||||||
|
display: inline-block;
|
||||||
|
width: 34px;
|
||||||
|
color: #6e6e6e;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time cells */
|
||||||
|
.slot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
width: max-content;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot.stack {
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px dashed #a8383d;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: rgba(229, 72, 77, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot.stack .cell {
|
||||||
|
height: 30px;
|
||||||
|
color: #ffc2c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot.gone .cell {
|
||||||
|
border: 1px dashed #a8383d;
|
||||||
|
background: rgba(229, 72, 77, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot.gone .cell::placeholder {
|
||||||
|
color: #a8383d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 84px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-family: var(--ox-font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
color: #e6e6e6;
|
||||||
|
caret-color: var(--ox-accent);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell::placeholder {
|
||||||
|
color: #3e3e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:hover {
|
||||||
|
border-color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:focus {
|
||||||
|
border-color: var(--ox-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--ox-accent-ring);
|
||||||
|
background: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.we .cell {
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.edit {
|
||||||
|
color: #ffb08c;
|
||||||
|
background: rgba(247, 76, 0, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.miss {
|
||||||
|
border: 1px dashed #a8383d;
|
||||||
|
background: rgba(229, 72, 77, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.miss::placeholder {
|
||||||
|
color: #a8383d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.bad {
|
||||||
|
border-color: #e5484d;
|
||||||
|
color: #ff8a8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.unr,
|
||||||
|
tr.we .cell.unr {
|
||||||
|
color: #d6e4ff;
|
||||||
|
border: 1px dashed #3a5f99;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.unr:hover {
|
||||||
|
border-color: #4d8dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.unr:focus {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--ox-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.rnd,
|
||||||
|
tr.we .cell.rnd {
|
||||||
|
color: #9cc2ff;
|
||||||
|
background: rgba(77, 141, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notes */
|
||||||
|
td.note {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.note.issue {
|
||||||
|
color: #ff9ea3;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.note.info,
|
||||||
|
td.note.round {
|
||||||
|
color: #8db8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.note.edit {
|
||||||
|
color: #ff9a66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-in {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-btn {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #6e6e6e;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-btn:hover {
|
||||||
|
background: #262626;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 48px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8a8a8a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty svg {
|
||||||
|
color: #4cc38a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { message }: { message: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if message}
|
||||||
|
<div class="toast" role="status">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="12" cy="12" r="9" /><path d="M8 12.5l2.5 2.5L16 9.5" /></svg
|
||||||
|
>
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toast {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 24px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 45;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #13221a;
|
||||||
|
border: 1px solid #1f4a31;
|
||||||
|
color: #8be0ae;
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,778 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { _, locale } from 'svelte-i18n';
|
||||||
|
import { weekdayName } from '$lib/month';
|
||||||
|
import type { Shifts } from '$lib/shifts';
|
||||||
|
import { shiftT, signedHm } from './analysis';
|
||||||
|
import ActionButton from '../ActionButton.svelte';
|
||||||
|
import IconButton from '../IconButton.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onlyIssues = $bindable(false),
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
daysInMonth,
|
||||||
|
currentName,
|
||||||
|
stackedCount,
|
||||||
|
lunchCount,
|
||||||
|
roundCount,
|
||||||
|
shift,
|
||||||
|
onfixall,
|
||||||
|
onaddlunchall,
|
||||||
|
onroundall,
|
||||||
|
onapplyall,
|
||||||
|
onshift,
|
||||||
|
edits,
|
||||||
|
onundoall
|
||||||
|
}: {
|
||||||
|
onlyIssues?: boolean;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
daysInMonth: number;
|
||||||
|
currentName: string;
|
||||||
|
stackedCount: number;
|
||||||
|
lunchCount: number;
|
||||||
|
roundCount: number;
|
||||||
|
shift: Shifts;
|
||||||
|
onfixall: () => void;
|
||||||
|
onaddlunchall: () => void;
|
||||||
|
onroundall: () => void;
|
||||||
|
onapplyall: () => void;
|
||||||
|
onshift: (opts: { minutes: number; fromDay: number; toDay: number; scope: 'all' | 'one' }) => void;
|
||||||
|
edits: number;
|
||||||
|
onundoall: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Unique per instance, mainly so the shift panel's aria-labelledby never
|
||||||
|
// risks clashing with anything else on the page.
|
||||||
|
const uid = $props.id();
|
||||||
|
|
||||||
|
let fixMenu = $state(false);
|
||||||
|
let shiftOpen = $state(false);
|
||||||
|
const toolPopOpen = $derived(fixMenu || shiftOpen);
|
||||||
|
function closeToolPops() {
|
||||||
|
fixMenu = false;
|
||||||
|
shiftOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixTotal = $derived(stackedCount + lunchCount + roundCount);
|
||||||
|
const firstName = $derived(currentName.split(' ')[0]);
|
||||||
|
|
||||||
|
// ---- "Shift times" panel state ----
|
||||||
|
let shiftMin = $state(-60);
|
||||||
|
let shiftFrom = $state(1);
|
||||||
|
let shiftTo = $state(untrack(() => daysInMonth));
|
||||||
|
let shiftScope = $state<'all' | 'one'>('all');
|
||||||
|
|
||||||
|
const dayOptions = $derived(
|
||||||
|
Array.from({ length: daysInMonth }, (_v, i) => ({
|
||||||
|
v: i + 1,
|
||||||
|
label: `${weekdayName($locale, new Date(year, month - 1, i + 1).getDay())} ${i + 1}`
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
function decShiftMin() {
|
||||||
|
shiftMin = Math.max(-720, shiftMin - shift.round_step_min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function incShiftMin() {
|
||||||
|
shiftMin = Math.min(720, shiftMin + shift.round_step_min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyShift() {
|
||||||
|
if (!shiftMin) return;
|
||||||
|
onshift({
|
||||||
|
minutes: shiftMin,
|
||||||
|
fromDay: Math.min(shiftFrom, shiftTo),
|
||||||
|
toDay: Math.max(shiftFrom, shiftTo),
|
||||||
|
scope: shiftScope
|
||||||
|
});
|
||||||
|
shiftOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && toolPopOpen) closeToolPops();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
{#if toolPopOpen}
|
||||||
|
<button type="button" class="pop-backdrop" aria-label={$_('review.close_menu_aria')} onclick={closeToolPops}
|
||||||
|
></button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="seg" role="group" aria-label={$_('review.day_filter_aria')}>
|
||||||
|
<button type="button" class:on={!onlyIssues} aria-pressed={!onlyIssues} onclick={() => (onlyIssues = false)}
|
||||||
|
>{$_('review.all_days')}</button
|
||||||
|
>
|
||||||
|
<button type="button" class:on={onlyIssues} aria-pressed={onlyIssues} onclick={() => (onlyIssues = true)}
|
||||||
|
>{$_('review.to_check_cap')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tb-actions">
|
||||||
|
<div class="anchor">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool"
|
||||||
|
class:open={shiftOpen}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={shiftOpen}
|
||||||
|
onclick={() => {
|
||||||
|
shiftOpen = !shiftOpen;
|
||||||
|
fixMenu = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><circle cx="12" cy="12" r="8.5" /><path d="M12 7.5V12l3 2" /></svg
|
||||||
|
>
|
||||||
|
<span>{$_('review.shift_times')}</span>
|
||||||
|
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M6 9l6 6 6-6" /></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{#if shiftOpen}
|
||||||
|
<div class="pop shift-pop" role="dialog" aria-labelledby="{uid}-shift-title">
|
||||||
|
<div class="pop-head">
|
||||||
|
<span id="{uid}-shift-title" class="pop-title">{$_('review.shift_panel_title')}</span>
|
||||||
|
<span class="pop-desc">{$_('review.shift_panel_desc')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pop-group">
|
||||||
|
<span class="pop-label">{$_('review.shift_amount_label')}</span>
|
||||||
|
<div class="stepper">
|
||||||
|
<button type="button" class="step-btn" aria-label={$_('review.shift_minus_aria')} onclick={decShiftMin}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.2"
|
||||||
|
><path d="M5 12h14" /></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<div class="step-value mono" class:zero={shiftMin === 0}>{signedHm(shiftMin)}</div>
|
||||||
|
<button type="button" class="step-btn" aria-label={$_('review.shift_plus_aria')} onclick={incShiftMin}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.2"
|
||||||
|
><path d="M12 5v14M5 12h14" /></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="chips">
|
||||||
|
<button type="button" class="chip" class:on={shiftMin === -60} onclick={() => (shiftMin = -60)}
|
||||||
|
>{$_('review.shift_minus_hour')}</button
|
||||||
|
>
|
||||||
|
<button type="button" class="chip" class:on={shiftMin === 60} onclick={() => (shiftMin = 60)}
|
||||||
|
>{$_('review.shift_plus_hour')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pop-group">
|
||||||
|
<span class="pop-label">{$_('review.shift_days_label')}</span>
|
||||||
|
<div class="range">
|
||||||
|
<label for="{uid}-shift-from">{$_('review.shift_from')}</label>
|
||||||
|
<div class="mini-select">
|
||||||
|
<select id="{uid}-shift-from" bind:value={shiftFrom}>
|
||||||
|
{#each dayOptions as o (o.v)}<option value={o.v}>{o.label}</option>{/each}
|
||||||
|
</select>
|
||||||
|
<svg class="chev" width="12" height="12" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M6 9l6 6 6-6" /></svg
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label for="{uid}-shift-to">{$_('review.shift_to')}</label>
|
||||||
|
<div class="mini-select">
|
||||||
|
<select id="{uid}-shift-to" bind:value={shiftTo}>
|
||||||
|
{#each dayOptions as o (o.v)}<option value={o.v}>{o.label}</option>{/each}
|
||||||
|
</select>
|
||||||
|
<svg class="chev" width="12" height="12" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M6 9l6 6 6-6" /></svg
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pop-group">
|
||||||
|
<span class="pop-label">{$_('review.shift_who_label')}</span>
|
||||||
|
<div class="seg full" role="group" aria-label={$_('review.shift_who_label')}>
|
||||||
|
<button type="button" class:on={shiftScope === 'all'} aria-pressed={shiftScope === 'all'}
|
||||||
|
onclick={() => (shiftScope = 'all')}>{$_('review.shift_scope_all')}</button
|
||||||
|
>
|
||||||
|
<button type="button" class:on={shiftScope === 'one'} aria-pressed={shiftScope === 'one'}
|
||||||
|
onclick={() => (shiftScope = 'one')}
|
||||||
|
>{$_('review.only_name', { values: { name: firstName } })}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview">
|
||||||
|
{@html $_('review.shift_preview', { values: { from: '08:02', to: shiftT('08:02', shiftMin) } })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pop-actions">
|
||||||
|
<ActionButton onclick={() => (shiftOpen = false)}>{$_('review.cancel')}</ActionButton>
|
||||||
|
<ActionButton primary disabled={shiftMin === 0} onclick={applyShift}>{$_('review.apply')}</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="anchor">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool"
|
||||||
|
class:open={fixMenu}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={fixMenu}
|
||||||
|
onclick={() => {
|
||||||
|
fixMenu = !fixMenu;
|
||||||
|
shiftOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><path
|
||||||
|
d="M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8l1.4 1.4M17.8 6.2l1.4-1.4M12.2 6.2l-1.4-1.4M3 21l9-9"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
<span>{$_('review.corrections')}</span>
|
||||||
|
{#if fixTotal}<span class="tool-badge">{fixTotal}</span>{/if}
|
||||||
|
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M6 9l6 6 6-6" /></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{#if fixMenu}
|
||||||
|
<div class="pop fix-pop" role="menu" aria-label={$_('review.corrections')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
class="menu-item"
|
||||||
|
disabled={!stackedCount}
|
||||||
|
onclick={() => {
|
||||||
|
onfixall();
|
||||||
|
fixMenu = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" class="mi-icon orange"
|
||||||
|
><path d="M7 4v16M3 8l4-4 4 4M17 20V4M13 16l4 4 4-4" /></svg
|
||||||
|
>
|
||||||
|
<span class="mi-col grow">
|
||||||
|
<span class="mi-title">{$_('review.fix_shifts_short')}</span>
|
||||||
|
<span class="mi-sub">{$_('review.fix_shifts_title')}</span>
|
||||||
|
</span>
|
||||||
|
<span class="count">{stackedCount}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
class="menu-item"
|
||||||
|
disabled={!lunchCount}
|
||||||
|
onclick={() => {
|
||||||
|
onaddlunchall();
|
||||||
|
fixMenu = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" class="mi-icon orange"
|
||||||
|
><path d="M7 3v8M5 3v4a2 2 0 0 0 4 0V3M7 11v10M17 21V3c-2 1-3 3.5-3 7h3" /></svg
|
||||||
|
>
|
||||||
|
<span class="mi-col grow">
|
||||||
|
<span class="mi-title">{$_('review.add_lunch_action')}</span>
|
||||||
|
<span class="mi-sub"
|
||||||
|
>{$_('review.add_lunch_title', { values: { start: shift.out1, end: shift.in2 } })}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span class="count">{lunchCount}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
class="menu-item"
|
||||||
|
disabled={!roundCount}
|
||||||
|
onclick={() => {
|
||||||
|
onroundall();
|
||||||
|
fixMenu = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" class="mi-icon blue"
|
||||||
|
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
|
||||||
|
>
|
||||||
|
<span class="mi-col grow">
|
||||||
|
<span class="mi-title">{$_('review.round_action', { values: { step: shift.round_step_min } })}</span>
|
||||||
|
<span class="mi-sub">{$_('review.round_title')}</span>
|
||||||
|
</span>
|
||||||
|
<span class="count">{roundCount}</span>
|
||||||
|
</button>
|
||||||
|
<div class="pop-sep"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
class="menu-item center"
|
||||||
|
disabled={!fixTotal}
|
||||||
|
onclick={() => {
|
||||||
|
onapplyall();
|
||||||
|
fixMenu = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" class="mi-icon accent" style="stroke-width: 2.2"
|
||||||
|
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
|
||||||
|
>
|
||||||
|
<span class="grow apply-all">{$_('review.apply_all_corrections')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if edits}
|
||||||
|
<span class="tb-sep" aria-hidden="true"></span>
|
||||||
|
<IconButton
|
||||||
|
size="lg"
|
||||||
|
dangerFilled
|
||||||
|
ariaLabel={$_('review.undo_all')}
|
||||||
|
title={$_('review.undo_all_title')}
|
||||||
|
onclick={onundoall}
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><path d="M9 14l-5-5 5-5" /><path d="M20 20v-7a4 4 0 0 0-4-4H4" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pop-backdrop {
|
||||||
|
all: unset;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 25;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg button {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #a3a3a3;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg button:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg button:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg button.on {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg.full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg.full button {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
color: #c9c9c9;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool:hover,
|
||||||
|
.tool.open {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool .chev {
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-badge {
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(247, 76, 0, 0.18);
|
||||||
|
color: #ffb08c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popovers */
|
||||||
|
.pop {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 30;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 14px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-pop {
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fix-pop {
|
||||||
|
width: 320px;
|
||||||
|
padding: 6px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-desc {
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-btn {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #c9c9c9;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-btn:hover {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #161616;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-value.zero {
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #a3a3a3;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.on {
|
||||||
|
background: rgba(247, 76, 0, 0.14);
|
||||||
|
border-color: var(--ox-accent);
|
||||||
|
color: #ffb08c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range label {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-select {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-select select {
|
||||||
|
all: unset;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 26px 0 12px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e6e6e6;
|
||||||
|
background: #1b1b1b;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-select select:hover {
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-select select:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-select .chev {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 9px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #6e6e6e;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #151515;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #c9c9c9;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview :global(b) {
|
||||||
|
color: #ffb08c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corrections menu */
|
||||||
|
.menu-item {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #bdbdbd;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover:not(:disabled) {
|
||||||
|
background: #252525;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item.center {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-icon {
|
||||||
|
margin-top: 1px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-icon.orange {
|
||||||
|
color: #ff9a66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-icon.blue {
|
||||||
|
color: #8db8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-icon.accent {
|
||||||
|
color: var(--ox-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-sub {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #7a7a7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
flex: none;
|
||||||
|
font-family: var(--ox-font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-sep {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 6px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-all {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ox-accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _, locale } from 'svelte-i18n';
|
||||||
|
import { monthName } from '$lib/month';
|
||||||
|
import { dayWord, shortSummary } from './analysis';
|
||||||
|
import IconButton from '../IconButton.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
issuesAll,
|
||||||
|
infosAll,
|
||||||
|
onback,
|
||||||
|
menuOpen = $bindable(false),
|
||||||
|
employeeCount,
|
||||||
|
currentName,
|
||||||
|
currentIssues,
|
||||||
|
currentInfos,
|
||||||
|
onexportall,
|
||||||
|
onexportone
|
||||||
|
}: {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
issuesAll: number;
|
||||||
|
infosAll: number;
|
||||||
|
onback?: () => void;
|
||||||
|
menuOpen?: boolean;
|
||||||
|
employeeCount: number;
|
||||||
|
currentName: string;
|
||||||
|
currentIssues: number;
|
||||||
|
currentInfos: number;
|
||||||
|
onexportall: () => void;
|
||||||
|
onexportone: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="tb-left">
|
||||||
|
{#if onback}
|
||||||
|
<IconButton size="lg" ariaLabel={$_('review.back_to_month')} onclick={onback}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
|
||||||
|
><path d="M15 6l-6 6 6 6" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
<div class="tb-title">
|
||||||
|
<span>{$_('review.title')}</span>
|
||||||
|
<h1>{monthName($locale, month)} {year}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tb-right">
|
||||||
|
{#if issuesAll}
|
||||||
|
<span class="tb-count red">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
|
||||||
|
d="M12 9.5v4M12 17h.01"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
{dayWord(issuesAll, $_)} {$_('review.to_check')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if infosAll}
|
||||||
|
<span class="tb-count blue">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg
|
||||||
|
>
|
||||||
|
{dayWord(infosAll, $_)} {$_('review.to_round')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Split export button -->
|
||||||
|
<div class="split-wrap">
|
||||||
|
<div class="split">
|
||||||
|
<button type="button" class="split-main" onclick={onexportall}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
|
||||||
|
>
|
||||||
|
<span>{$_('review.export')}</span>
|
||||||
|
</button>
|
||||||
|
<span class="split-sep" aria-hidden="true"></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="split-arrow"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-label={$_('review.export_options_aria')}
|
||||||
|
onclick={() => (menuOpen = !menuOpen)}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if menuOpen}
|
||||||
|
<div class="dropdown" role="menu" aria-label={$_('review.export')}>
|
||||||
|
<button type="button" role="menuitem" class="menu-item" onclick={onexportall}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c.8-3.3 3.3-5 6.5-5s5.7 1.7 6.5 5" /><path
|
||||||
|
d="M16 4.5a3.5 3.5 0 0 1 0 7M18 15.2c1.8.7 3 2.3 3.5 4.8"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
<span class="mi-col">
|
||||||
|
<span class="mi-title">{$_('review.all_employees')}</span>
|
||||||
|
<span class="mi-sub" class:red={issuesAll > 0} class:blue={!issuesAll && infosAll > 0}>
|
||||||
|
{$_('review.employee_count', { values: { n: employeeCount } })} ·
|
||||||
|
{shortSummary(issuesAll, infosAll, $_) || $_('review.all_good')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" role="menuitem" class="menu-item" onclick={onexportone}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="12" cy="8" r="3.5" /><path d="M5 20c.8-3.3 3.6-5 7-5s6.2 1.7 7 5" /></svg
|
||||||
|
>
|
||||||
|
<span class="mi-col">
|
||||||
|
<span class="mi-title">{$_('review.only_name', { values: { name: currentName } })}</span>
|
||||||
|
<span class="mi-sub" class:red={currentIssues > 0} class:blue={!currentIssues && currentInfos > 0}>
|
||||||
|
{shortSummary(currentIssues, currentInfos, $_) || $_('review.all_good_cap')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.topbar {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #222222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-title span {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-title h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
color: var(--ox-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-count {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Split button */
|
||||||
|
.split-wrap {
|
||||||
|
position: relative;
|
||||||
|
z-index: 21;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--ox-accent);
|
||||||
|
color: var(--ox-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-main,
|
||||||
|
.split-arrow {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ox-on-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-main {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 16px 0 18px;
|
||||||
|
border-radius: 12px 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-arrow {
|
||||||
|
width: 40px;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0 12px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-arrow svg {
|
||||||
|
stroke-width: 2.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-main:hover,
|
||||||
|
.split-arrow:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-main:active,
|
||||||
|
.split-arrow:active {
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-main:focus-visible,
|
||||||
|
.split-arrow:focus-visible {
|
||||||
|
outline: 2px solid #ffffff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-sep {
|
||||||
|
width: 1px;
|
||||||
|
margin: 10px 0;
|
||||||
|
background: rgba(26, 7, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
width: 300px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 6px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #bdbdbd;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background: #252525;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item > svg {
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-sub {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-sub.red {
|
||||||
|
color: #ff9ea3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-sub.blue {
|
||||||
|
color: #8db8ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// Pure logic for analyzing a month's attendance records, extracted from
|
||||||
|
// the review component to keep it readable. Translated strings are
|
||||||
|
// produced by passing the `t` function (usually svelte-i18n's `$_`) and,
|
||||||
|
// where Intl needs the language, `locale` ($locale).
|
||||||
|
|
||||||
|
import { weekdayName } from '$lib/month';
|
||||||
|
import type { Shifts } from '$lib/shifts';
|
||||||
|
import { initials } from '$lib/text';
|
||||||
|
import { fmt, formatTyped, hm, parseT } from '$lib/time';
|
||||||
|
|
||||||
|
export { formatTyped, hm, initials, parseT };
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches svelte-i18n's own loosely-typed `values`
|
||||||
|
export type Translate = (key: string, options?: { values?: Record<string, any> }) => string;
|
||||||
|
|
||||||
|
/** [In1[], Out1[], In2[], Out2[]] — each slot is a list of "HH:MM" times,
|
||||||
|
* to also represent stacked punches. */
|
||||||
|
export type Day = [string[], string[], string[], string[]];
|
||||||
|
|
||||||
|
export interface Employee {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
days: Day[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Row {
|
||||||
|
di: number;
|
||||||
|
v: Day;
|
||||||
|
dow: string;
|
||||||
|
weekend: boolean;
|
||||||
|
issue: string;
|
||||||
|
info: boolean;
|
||||||
|
miss: [boolean, boolean, boolean, boolean];
|
||||||
|
bad: boolean[][];
|
||||||
|
unr: boolean[][];
|
||||||
|
changed: boolean[][];
|
||||||
|
mins: number;
|
||||||
|
empty: boolean;
|
||||||
|
rowEdits: number;
|
||||||
|
stacked: boolean;
|
||||||
|
wasStacked: boolean;
|
||||||
|
lunch: boolean;
|
||||||
|
wasLunch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Analysis {
|
||||||
|
rows: Row[];
|
||||||
|
minutes: number;
|
||||||
|
worked: number;
|
||||||
|
issues: number;
|
||||||
|
infos: number;
|
||||||
|
edits: number;
|
||||||
|
stackedDays: number[];
|
||||||
|
lunchDays: number[];
|
||||||
|
roundDays: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLS_KEYS = ['review.col_in_1', 'review.col_out_1', 'review.col_in_2', 'review.col_out_2'];
|
||||||
|
|
||||||
|
export function normDay(day: Day): Day {
|
||||||
|
return [0, 1, 2, 3].map((i) => {
|
||||||
|
const c = (day?.[i] ?? []).filter((x) => x !== '');
|
||||||
|
return c.length ? c.slice() : [''];
|
||||||
|
}) as Day;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRounded(t: string, roundStep: number): boolean {
|
||||||
|
const m = parseT(t);
|
||||||
|
return m === null || m % roundStep === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundT(t: string, roundStep: number): string {
|
||||||
|
const m = parseT(t);
|
||||||
|
if (m === null) return t;
|
||||||
|
return fmt(Math.min(Math.round(m / roundStep) * roundStep, 24 * 60 - roundStep));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds `delta` minutes to `t`, clamped to the same day (00:00-23:59). */
|
||||||
|
export function shiftT(t: string, delta: number): string {
|
||||||
|
const m = parseT(t);
|
||||||
|
if (m === null) return t;
|
||||||
|
return fmt(Math.max(0, Math.min(24 * 60 - 1, m + delta)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats a signed minute offset as e.g. "+1:30", "−0:45", "0:00". */
|
||||||
|
export function signedHm(m: number): string {
|
||||||
|
const sign = m > 0 ? '+' : m < 0 ? '−' : '';
|
||||||
|
const abs = Math.abs(m);
|
||||||
|
return sign + Math.floor(abs / 60) + ':' + String(abs % 60).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dayWord(n: number, t: Translate): string {
|
||||||
|
return t('review.day_word', { values: { n } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [in, out] if the day has exactly 2 punches spanning the lunch break. */
|
||||||
|
export function lunchSpan(day: Day, shift: Shifts): [string, string] | null {
|
||||||
|
const all = day.flat().filter((x) => x !== '');
|
||||||
|
if (all.length !== 2) return null;
|
||||||
|
const t = all.map(parseT);
|
||||||
|
if (t.some((x) => x === null)) return null;
|
||||||
|
const [a, b] = (t[0] as number) <= (t[1] as number) ? [all[0], all[1]] : [all[1], all[0]];
|
||||||
|
return (parseT(a) as number) < (parseT(shift.out1) as number) &&
|
||||||
|
(parseT(b) as number) > (parseT(shift.in2) as number)
|
||||||
|
? [a, b]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redistributes punches that all ended up in the same shift (e.g. from a
|
||||||
|
* misconfigured time clock) into the correct 4 slots, picking for each one
|
||||||
|
* the closest expected shift time (E1/U1/E2/U2). Since both the punches and
|
||||||
|
* the expected shifts are sorted, it's enough to compare each punch against
|
||||||
|
* the midpoint between two consecutive shifts: that's exactly the boundary
|
||||||
|
* between "closer to the shift before" and "closer to the shift after".
|
||||||
|
*/
|
||||||
|
export function assignToShifts(times: string[], shift: Shifts): Day {
|
||||||
|
const targets = [shift.in1, shift.out1, shift.in2, shift.out2].map((x) => parseT(x) as number);
|
||||||
|
const bounds = [(targets[0] + targets[1]) / 2, (targets[1] + targets[2]) / 2, (targets[2] + targets[3]) / 2];
|
||||||
|
|
||||||
|
const day: Day = [[], [], [], []];
|
||||||
|
const sorted = [...times].sort((a, b) => (parseT(a) ?? Infinity) - (parseT(b) ?? Infinity));
|
||||||
|
for (const time of sorted) {
|
||||||
|
const m = parseT(time);
|
||||||
|
const slot = m === null ? 3 : m < bounds[0] ? 0 : m < bounds[1] ? 1 : m < bounds[2] ? 2 : 3;
|
||||||
|
day[slot].push(time);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
if (day[i].length === 0) day[i] = [''];
|
||||||
|
}
|
||||||
|
return day;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyze(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
values: Day[],
|
||||||
|
orig: Day[],
|
||||||
|
locale: string | null | undefined,
|
||||||
|
t: Translate,
|
||||||
|
shift: Shifts
|
||||||
|
): Analysis {
|
||||||
|
const rows: Row[] = [];
|
||||||
|
let minutes = 0;
|
||||||
|
let worked = 0;
|
||||||
|
let issues = 0;
|
||||||
|
let infos = 0;
|
||||||
|
let edits = 0;
|
||||||
|
const stackedDays: number[] = [];
|
||||||
|
const lunchDays: number[] = [];
|
||||||
|
const roundDays: number[] = [];
|
||||||
|
|
||||||
|
for (let di = 0; di < values.length; di++) {
|
||||||
|
const v = values[di];
|
||||||
|
const o = orig[di];
|
||||||
|
const dow = new Date(year, month - 1, di + 1).getDay();
|
||||||
|
const weekend = dow === 0 || dow === 6;
|
||||||
|
const lists = v.map((c) => c.filter((x) => x !== ''));
|
||||||
|
const filled = lists.map((c) => c.length > 0);
|
||||||
|
const stacked = lists.some((c) => c.length > 1);
|
||||||
|
const bad = v.map((c) => c.map((x) => x !== '' && parseT(x) === null));
|
||||||
|
const miss: [boolean, boolean, boolean, boolean] = [false, false, false, false];
|
||||||
|
let issue = '';
|
||||||
|
|
||||||
|
// 1. Wrong shift
|
||||||
|
if (stacked) {
|
||||||
|
const total = lists.reduce((n, c) => n + c.length, 0);
|
||||||
|
const shiftLabel = filled[0] || filled[1] ? t('review.shift_1') : t('review.shift_2');
|
||||||
|
issue = t('review.issue_stacked', { values: { total, shift: shiftLabel } });
|
||||||
|
[0, 1, 2, 3].forEach((i) => {
|
||||||
|
if (!filled[i]) miss[i] = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 2. Invalid time
|
||||||
|
if (!issue && bad.some((c) => c.some(Boolean))) issue = t('review.issue_invalid_time');
|
||||||
|
// 3. Unpunched lunch break
|
||||||
|
const lunch = !stacked && !issue && lunchSpan(v, shift) !== null;
|
||||||
|
if (lunch) {
|
||||||
|
issue = t('review.issue_lunch_missing');
|
||||||
|
[0, 1, 2, 3].forEach((i) => {
|
||||||
|
if (!filled[i]) miss[i] = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 4-5. Incomplete pairs / clock-out before clock-in
|
||||||
|
if (!stacked && !lunch) {
|
||||||
|
const p = lists.map((c) => (c.length ? parseT(c[0]) : null));
|
||||||
|
for (const [a, b, shiftKey] of [
|
||||||
|
[0, 1, 'review.shift_1'],
|
||||||
|
[2, 3, 'review.shift_2']
|
||||||
|
] as const) {
|
||||||
|
if (filled[a] !== filled[b]) {
|
||||||
|
miss[filled[a] ? b : a] = true;
|
||||||
|
if (!issue) {
|
||||||
|
issue = t(filled[a] ? 'review.issue_missing_out' : 'review.issue_missing_in', {
|
||||||
|
values: { shift: t(shiftKey) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (filled[a] && p[a] !== null && p[b] !== null && (p[b] as number) <= (p[a] as number) && !issue) {
|
||||||
|
issue = t('review.issue_out_before_in', { values: { shift: t(shiftKey) } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hours: pairs by position within each shift
|
||||||
|
let mins = 0;
|
||||||
|
for (const [a, b] of [
|
||||||
|
[0, 1],
|
||||||
|
[2, 3]
|
||||||
|
] as const) {
|
||||||
|
const n = Math.min(lists[a].length, lists[b].length);
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
const s = parseT(lists[a][k]);
|
||||||
|
const e = parseT(lists[b][k]);
|
||||||
|
if (s !== null && e !== null && e > s) mins += e - s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty = !filled.some(Boolean);
|
||||||
|
const changed = v.map((c, ci) => c.map((x, ii) => x !== (o[ci][ii] ?? '')));
|
||||||
|
const rowEdits = changed.reduce((n, c) => n + c.filter(Boolean).length, 0);
|
||||||
|
const wasStacked = o.some((c) => c.length > 1);
|
||||||
|
const wasLunch = !lunch && lunchSpan(o, shift) !== null && v[1][0] === shift.out1 && v[2][0] === shift.in2;
|
||||||
|
|
||||||
|
// 6. Info: unrounded times
|
||||||
|
const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x, shift.round_step_min)));
|
||||||
|
const hasUnr = unr.some((c) => c.some(Boolean));
|
||||||
|
const info = !issue && hasUnr;
|
||||||
|
|
||||||
|
if (issue) issues++;
|
||||||
|
if (info) infos++;
|
||||||
|
if (hasUnr) roundDays.push(di);
|
||||||
|
if (stacked) stackedDays.push(di);
|
||||||
|
if (lunch) lunchDays.push(di);
|
||||||
|
if (!empty) worked++;
|
||||||
|
minutes += mins;
|
||||||
|
edits += rowEdits;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
di, v, dow: weekdayName(locale, dow), weekend, issue, info, miss, bad, unr, changed,
|
||||||
|
mins, empty, rowEdits, stacked, wasStacked, lunch, wasLunch
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { rows, minutes, worked, issues, infos, edits, stackedDays, lunchDays, roundDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dayLabel(r: Row): string {
|
||||||
|
return `${r.dow} ${r.di + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "employee-day-slot-index" -> is the current time the result of a rounding? */
|
||||||
|
export function isRnd(
|
||||||
|
rounded: Record<string, string>,
|
||||||
|
emp: number,
|
||||||
|
di: number,
|
||||||
|
ci: number,
|
||||||
|
ii: number,
|
||||||
|
val: string
|
||||||
|
): boolean {
|
||||||
|
return val !== '' && rounded[`${emp}-${di}-${ci}-${ii}`] === val;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noteOf(
|
||||||
|
r: Row,
|
||||||
|
roundedCount: number,
|
||||||
|
manualCount: number,
|
||||||
|
t: Translate,
|
||||||
|
roundStep: number
|
||||||
|
): { text: string; kind: string } {
|
||||||
|
if (r.issue) return { text: r.issue, kind: 'issue' };
|
||||||
|
if (r.info) return { text: t('review.info_unrounded', { values: { step: roundStep } }), kind: 'info' };
|
||||||
|
if (r.empty && !r.weekend) return { text: t('review.note_empty'), kind: 'muted' };
|
||||||
|
if (r.wasLunch) return { text: t('review.note_lunch_added'), kind: 'edit' };
|
||||||
|
if (r.wasStacked) return { text: t('review.note_shifts_fixed'), kind: 'edit' };
|
||||||
|
if (manualCount > 0) return { text: t('review.note_edited', { values: { n: manualCount } }), kind: 'edit' };
|
||||||
|
if (roundedCount > 0) return { text: t('review.note_rounded', { values: { n: roundedCount } }), kind: 'round' };
|
||||||
|
return { text: '', kind: 'muted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cellClass(r: Row, ci: number, ii: number, rnd: boolean): string {
|
||||||
|
let c = 'cell';
|
||||||
|
if (r.bad[ci][ii]) c += ' bad';
|
||||||
|
else if (!r.stacked && r.miss[ci]) c += ' miss';
|
||||||
|
if (r.unr[ci][ii] && !r.bad[ci][ii]) c += ' unr';
|
||||||
|
else if (rnd) c += ' rnd';
|
||||||
|
else if (r.changed[ci][ii] && !r.bad[ci][ii]) c += ' edit';
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summary(iss: number, inf: number, t: Translate): string {
|
||||||
|
return [iss ? `${dayWord(iss, t)} ${t('review.to_check')}` : '', inf ? `${dayWord(inf, t)} ${t('review.to_round')}` : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortSummary(iss: number, inf: number, t: Translate): string {
|
||||||
|
return [iss ? `${iss} ${t('review.to_check')}` : '', inf ? `${inf} ${t('review.to_round')}` : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Day, Employee } from './analysis';
|
||||||
|
|
||||||
|
type Clock = 'FirstIn' | 'FirstOut' | 'SecondIn' | 'SecondOut';
|
||||||
|
|
||||||
|
/** A record as returned by the `load_records` Tauri command. */
|
||||||
|
export interface AttendanceRecord {
|
||||||
|
employee_id: number;
|
||||||
|
clock: Clock;
|
||||||
|
datetime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOCK_INDEX: Record<Clock, number> = {
|
||||||
|
FirstIn: 0,
|
||||||
|
FirstOut: 1,
|
||||||
|
SecondIn: 2,
|
||||||
|
SecondOut: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
function timeOf(datetime: string): string {
|
||||||
|
const d = new Date(datetime);
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups records by employee and day into the Day shape expected by
|
||||||
|
* TimesheetReview. Only employees with at least one record this month are
|
||||||
|
* included — `names` is just used to resolve a display name, not to decide
|
||||||
|
* who shows up (it may hold every employee ever cached, across all months).
|
||||||
|
*/
|
||||||
|
export function buildEmployees(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
records: AttendanceRecord[],
|
||||||
|
names: Record<string, string>
|
||||||
|
): Employee[] {
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
const byEmployee = new Map<number, Day[]>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
let days = byEmployee.get(record.employee_id);
|
||||||
|
if (!days) {
|
||||||
|
days = Array.from({ length: daysInMonth }, () => [[], [], [], []] as Day);
|
||||||
|
byEmployee.set(record.employee_id, days);
|
||||||
|
}
|
||||||
|
const date = new Date(record.datetime);
|
||||||
|
const day = days[date.getDate() - 1];
|
||||||
|
day[CLOCK_INDEX[record.clock]].push(timeOf(record.datetime));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byEmployee.entries()].map(([id, days]) => ({
|
||||||
|
id,
|
||||||
|
name: names[String(id)] ?? String(id),
|
||||||
|
days
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { autofocus } from '$lib/autofocus';
|
||||||
|
import { initials } from '$lib/text';
|
||||||
|
import IconButton from '../IconButton.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
employees,
|
||||||
|
total,
|
||||||
|
query = $bindable(''),
|
||||||
|
onclose,
|
||||||
|
onrename,
|
||||||
|
ondelete
|
||||||
|
}: {
|
||||||
|
employees: { id: string; name: string }[];
|
||||||
|
total: number;
|
||||||
|
query?: string;
|
||||||
|
onclose: () => void;
|
||||||
|
onrename: (id: string, name: string) => void;
|
||||||
|
ondelete: (id: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let editId = $state<string | null>(null);
|
||||||
|
let draft = $state('');
|
||||||
|
let delId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function startEdit(id: string, name: string) {
|
||||||
|
editId = id;
|
||||||
|
draft = name;
|
||||||
|
delId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEdit() {
|
||||||
|
const id = editId;
|
||||||
|
const name = draft.trim();
|
||||||
|
editId = null;
|
||||||
|
if (!id || !name) return;
|
||||||
|
onrename(id, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (editId) editId = null;
|
||||||
|
else if (delId) delId = null;
|
||||||
|
else onclose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div class="drawer-wrap">
|
||||||
|
<button type="button" class="backdrop" aria-label={$_('settings.close_aria')} onclick={onclose}></button>
|
||||||
|
<div class="drawer" role="dialog" aria-modal="true" aria-labelledby="employees-title">
|
||||||
|
<div class="drawer-head">
|
||||||
|
<div>
|
||||||
|
<h2 id="employees-title">{$_('settings.employees_title')}</h2>
|
||||||
|
<span class="drawer-sub">{$_('settings.employees_count', { values: { n: total } })}</span>
|
||||||
|
</div>
|
||||||
|
<IconButton ariaLabel={$_('settings.close_aria')} onclick={onclose}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-tools">
|
||||||
|
<div class="search">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="11" cy="11" r="6.5" /><path d="M20 20l-4-4" /></svg
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder={$_('review.search_employee')}
|
||||||
|
aria-label={$_('review.search_employee')}
|
||||||
|
bind:value={query}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list scroll">
|
||||||
|
{#each employees as emp (emp.id)}
|
||||||
|
<div class="row" class:del={delId === emp.id}>
|
||||||
|
<span class="avatar">{initials(emp.name)}</span>
|
||||||
|
|
||||||
|
{#if editId === emp.id}
|
||||||
|
<input
|
||||||
|
class="name-in"
|
||||||
|
type="text"
|
||||||
|
aria-label={$_('settings.employee_name_aria', { values: { id: emp.id } })}
|
||||||
|
bind:value={draft}
|
||||||
|
use:autofocus
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') saveEdit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton accent ariaLabel={$_('review.save_name')} onclick={saveEdit}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
|
||||||
|
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton ariaLabel={$_('settings.cancel')} onclick={() => (editId = null)}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M6 6l12 12M18 6L6 18" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
{:else if delId === emp.id}
|
||||||
|
<span class="del-text"
|
||||||
|
>{$_('settings.employee_remove_confirm', { values: { name: emp.name } })}</span
|
||||||
|
>
|
||||||
|
<button type="button" class="ghost" onclick={() => (delId = null)}>{$_('settings.cancel')}</button>
|
||||||
|
<button type="button" class="danger" onclick={() => ondelete(emp.id)}>{$_('settings.remove')}</button>
|
||||||
|
{:else}
|
||||||
|
<div class="info">
|
||||||
|
<span class="name">{emp.name}</span>
|
||||||
|
<span class="uid mono">#{emp.id}</span>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel={$_('settings.edit_name_aria', { values: { name: emp.name } })}
|
||||||
|
onclick={() => startEdit(emp.id, emp.name)}
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
danger
|
||||||
|
ariaLabel={$_('settings.remove_name_aria', { values: { name: emp.name } })}
|
||||||
|
onclick={() => {
|
||||||
|
delId = emp.id;
|
||||||
|
editId = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path
|
||||||
|
d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty">{$_('settings.no_employees_found')}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-foot">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg
|
||||||
|
>
|
||||||
|
<span>{$_('settings.employees_local_note')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.drawer-wrap {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
all: unset;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #171717;
|
||||||
|
border-left: 1px solid #2e2e2e;
|
||||||
|
box-shadow: -24px 0 60px rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-head {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-head h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-tools {
|
||||||
|
flex: none;
|
||||||
|
padding: 0 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
height: 44px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #6e6e6e;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus-within {
|
||||||
|
border-color: var(--ox-accent);
|
||||||
|
box-shadow: 0 0 0 4px var(--ox-accent-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #f2f2f2;
|
||||||
|
caret-color: var(--ox-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input::placeholder {
|
||||||
|
color: #5e5e5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 8px 8px 8px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: #1c1c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.del {
|
||||||
|
background: rgba(229, 72, 77, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #262626;
|
||||||
|
color: #c9c9c9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14.5px;
|
||||||
|
color: #f2f2f2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uid {
|
||||||
|
flex: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.del-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #ffb3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-in {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--ox-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--ox-accent-ring);
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 14.5px;
|
||||||
|
color: #ffffff;
|
||||||
|
caret-color: var(--ox-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost,
|
||||||
|
.danger {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
border: 1px solid #333333;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost:hover {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: #e5484d;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost:focus-visible,
|
||||||
|
.danger:focus-visible {
|
||||||
|
outline: 2px solid var(--ox-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 40px 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-foot {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 20px 18px;
|
||||||
|
border-top: 1px solid #262626;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-foot svg {
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
|
||||||
|
let { lang, onchange }: { lang: string; onchange: (lang: string) => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="lang-sec">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h2><label for="ox-lang">{$_('settings.language_title')}</label></h2>
|
||||||
|
<p>{$_('settings.language_desc')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="select-wrap">
|
||||||
|
<select id="ox-lang" class="select" value={lang} onchange={(e) => onchange(e.currentTarget.value)}>
|
||||||
|
<option value="it">Italiano</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" class="select-chev"
|
||||||
|
><path d="M6 9l6 6 6-6" /></svg
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lang-sec {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--ox-font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-head p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 44px;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 0 42px 0 14px;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #161616;
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:hover {
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ox-accent);
|
||||||
|
box-shadow: 0 0 0 4px var(--ox-accent-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select option {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-chev {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
color: #8a8a8a;
|
||||||
|
stroke-width: 2.2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { error, setError, clearError } from '$lib/error';
|
||||||
|
import type { Settings } from '$lib/settings';
|
||||||
|
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
|
||||||
|
import Field from '../Field.svelte';
|
||||||
|
import ErrorBanner from '../ErrorBanner.svelte';
|
||||||
|
import ActionButton from '../ActionButton.svelte';
|
||||||
|
import PillButton from '../PillButton.svelte';
|
||||||
|
import EmployeesDrawer from './EmployeesDrawer.svelte';
|
||||||
|
import { isValidHost } from './logic';
|
||||||
|
|
||||||
|
let { host, port, onsave }: { host: string; port: number; onsave: (host: string, port: number) => void } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let draftHost = $state('');
|
||||||
|
let draftPort = $state(0);
|
||||||
|
let ipError = $state(false);
|
||||||
|
let confirming = $state(false);
|
||||||
|
|
||||||
|
let status = $state<'' | 'testing' | 'ok'>('');
|
||||||
|
|
||||||
|
let fullEmployeeCache = $state<EmployeeCache>({});
|
||||||
|
let employeeCache = $state<EmployeeNames>({});
|
||||||
|
let employeesOpen = $state(false);
|
||||||
|
let employeeQuery = $state('');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
fullEmployeeCache = await invoke<EmployeeCache>('load_employee_cache');
|
||||||
|
employeeCache = fullEmployeeCache[PRIMARY_READER_INDEX] ?? {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const employeesList = $derived(
|
||||||
|
Object.entries(employeeCache)
|
||||||
|
.map(([id, name]) => ({ id, name }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
);
|
||||||
|
const employeeCount = $derived(employeesList.length);
|
||||||
|
const visibleEmployees = $derived(
|
||||||
|
employeesList.filter(
|
||||||
|
(e) =>
|
||||||
|
!employeeQuery.trim() ||
|
||||||
|
e.name.toLowerCase().includes(employeeQuery.trim().toLowerCase()) ||
|
||||||
|
e.id.includes(employeeQuery.trim())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function persistEmployees(next: EmployeeNames) {
|
||||||
|
employeeCache = next;
|
||||||
|
const nextFullCache = { ...fullEmployeeCache, [PRIMARY_READER_INDEX]: next };
|
||||||
|
fullEmployeeCache = nextFullCache;
|
||||||
|
await invoke('save_employee_cache', { cache: nextFullCache });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameEmployee(id: string, name: string) {
|
||||||
|
persistEmployees({ ...employeeCache, [id]: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEmployee(id: string) {
|
||||||
|
const next = { ...employeeCache };
|
||||||
|
delete next[id];
|
||||||
|
persistEmployees(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
draftHost = host;
|
||||||
|
draftPort = port;
|
||||||
|
ipError = false;
|
||||||
|
clearError();
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing = false;
|
||||||
|
ipError = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmEdit() {
|
||||||
|
const v = draftHost.trim();
|
||||||
|
if (!isValidHost(v)) {
|
||||||
|
ipError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextPort = draftPort || 5005;
|
||||||
|
confirming = true;
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
await invoke('test_connection', { host: v, port: nextPort });
|
||||||
|
const settings = await invoke<Settings>('load_settings');
|
||||||
|
await invoke('save_settings', { settings: { ...settings, readers: [{ host: v, port: nextPort }] } });
|
||||||
|
editing = false;
|
||||||
|
status = 'ok';
|
||||||
|
onsave(v, nextPort);
|
||||||
|
} catch (err) {
|
||||||
|
status = '';
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
confirming = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
status = 'testing';
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
await invoke('test_connection', { host, port });
|
||||||
|
status = 'ok';
|
||||||
|
} catch (err) {
|
||||||
|
status = '';
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPortInput(e: Event & { currentTarget: HTMLInputElement }) {
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const next = input.value.replace(/\D/g, '').slice(0, 5);
|
||||||
|
if (input.value !== next) input.value = next;
|
||||||
|
draftPort = Number(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && editing && !confirming) cancelEdit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div class="card" class:active={editing}>
|
||||||
|
{#if !editing}
|
||||||
|
<div class="dev-row">
|
||||||
|
<div class="dev-icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 1.8"
|
||||||
|
><rect x="5" y="3" width="14" height="18" rx="2.5" /><rect x="8" y="6" width="8" height="5" rx="1" /><path
|
||||||
|
d="M9 15h.01M12 15h.01M15 15h.01M9 18h6"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="dev-text">
|
||||||
|
<span class="dev-label">{$_('settings.device_label')}</span>
|
||||||
|
<span class="dev-endpoint mono">{host}:{port}</span>
|
||||||
|
</div>
|
||||||
|
<span class="dev-status">
|
||||||
|
{#if status === 'testing'}
|
||||||
|
<svg class="spin" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
|
||||||
|
><path d="M12 3a9 9 0 1 0 9 9" /></svg
|
||||||
|
>
|
||||||
|
{$_('settings.status_testing')}
|
||||||
|
{:else if status === 'ok'}
|
||||||
|
<span class="dot ok" aria-hidden="true"></span>
|
||||||
|
{$_('settings.status_ok')}
|
||||||
|
{:else}
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
{$_('settings.status_idle')}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="dev-actions">
|
||||||
|
<PillButton onclick={startEdit}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
{$_('settings.edit_address')}
|
||||||
|
</PillButton>
|
||||||
|
<PillButton onclick={test} disabled={status === 'testing'}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01" /></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
{$_('settings.test_connection')}
|
||||||
|
</PillButton>
|
||||||
|
<PillButton onclick={() => (employeesOpen = true)}>
|
||||||
|
{#snippet icon()}
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c.8-3.3 3.3-5 6.5-5s5.7 1.7 6.5 5" /><path
|
||||||
|
d="M16 4.5a3.5 3.5 0 0 1 0 7M18 15.2c1.8.7 3 2.3 3.5 4.8"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
{$_('settings.manage_employees')}
|
||||||
|
<span class="count mono">{employeeCount}</span>
|
||||||
|
</PillButton>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form
|
||||||
|
class="dev-edit"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmEdit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="grid-ip">
|
||||||
|
<div class="stack">
|
||||||
|
<Field
|
||||||
|
id="dev-ip"
|
||||||
|
label={$_('settings.ip_label')}
|
||||||
|
bind:value={draftHost}
|
||||||
|
placeholder="192.168.1.100"
|
||||||
|
err={ipError}
|
||||||
|
oninput={() => (ipError = false)}
|
||||||
|
/>
|
||||||
|
{#if ipError}<span class="field-error">{$_('settings.ip_invalid')}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
id="dev-port"
|
||||||
|
label={$_('settings.port_label')}
|
||||||
|
value={draftPort}
|
||||||
|
maxlength={5}
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="5005"
|
||||||
|
oninput={onPortInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="row-end">
|
||||||
|
<ActionButton disabled={confirming} onclick={cancelEdit}>{$_('settings.cancel')}</ActionButton>
|
||||||
|
<ActionButton primary disabled={confirming} onclick={confirmEdit}>
|
||||||
|
{#snippet icon()}
|
||||||
|
{#if confirming}
|
||||||
|
<svg class="spin" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
|
||||||
|
><path d="M12 3a9 9 0 1 0 9 9" /></svg
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{confirming ? $_('settings.status_testing') : $_('settings.confirm')}
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $error}
|
||||||
|
<ErrorBanner title={$_('errors.title')} message={$error} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if employeesOpen}
|
||||||
|
<EmployeesDrawer
|
||||||
|
employees={visibleEmployees}
|
||||||
|
total={employeeCount}
|
||||||
|
bind:query={employeeQuery}
|
||||||
|
onclose={() => (employeesOpen = false)}
|
||||||
|
onrename={renameEmployee}
|
||||||
|
ondelete={removeEmployee}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.spin {
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid #262626;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #161616;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.active {
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 16px 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: #1f1f1f;
|
||||||
|
color: #bdbdbd;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-label {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-endpoint {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.ok {
|
||||||
|
background: #4cc38a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-status:has(.ok) {
|
||||||
|
color: #4cc38a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px 14px 18px;
|
||||||
|
border-top: 1px solid #222222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #6e6e6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-edit {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 22px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-ip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 120px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-end {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
padding-left: 4px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #ff8a8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.grid-ip {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { fmt } from '$lib/time';
|
||||||
|
import Field from '../Field.svelte';
|
||||||
|
import { roundValid } from './logic';
|
||||||
|
|
||||||
|
let { roundStep, oninput }: { roundStep: string; oninput: (value: string) => void } = $props();
|
||||||
|
|
||||||
|
function onRoundInput(e: Event & { currentTarget: HTMLInputElement }) {
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const next = input.value.replace(/\D/g, '').slice(0, 2);
|
||||||
|
if (input.value !== next) input.value = next;
|
||||||
|
oninput(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const example = $derived.by(() => {
|
||||||
|
if (!roundValid(roundStep)) return '';
|
||||||
|
const step = Number(roundStep);
|
||||||
|
const rounded = Math.round((8 * 60 + 7) / step) * step;
|
||||||
|
return $_('settings.round_example', { values: { from: '08:07', to: fmt(rounded) } });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="round-text">
|
||||||
|
<span class="round-title">{$_('settings.round_title')}</span>
|
||||||
|
<span class="round-desc">{$_('settings.round_desc')} {example}</span>
|
||||||
|
</div>
|
||||||
|
<div class="round-box">
|
||||||
|
<Field
|
||||||
|
id="ox-round"
|
||||||
|
label={$_('settings.round_label')}
|
||||||
|
value={roundStep}
|
||||||
|
maxlength={2}
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="10"
|
||||||
|
suffix={$_('settings.minutes_short')}
|
||||||
|
err={!roundValid(roundStep)}
|
||||||
|
oninput={onRoundInput}
|
||||||
|
/>
|
||||||
|
{#if !roundValid(roundStep)}<span class="field-error">{$_('settings.round_range')}</span>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
border: 1px solid #262626;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #161616;
|
||||||
|
padding: 22px 18px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 240px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-title {
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-box {
|
||||||
|
width: 140px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
padding-left: 4px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #ff8a8f;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { formatTyped, hm } from '$lib/time';
|
||||||
|
import Field from '../Field.svelte';
|
||||||
|
import { SHIFT_ROWS, checkShifts, type ShiftTimeKey, type Shifts } from './logic';
|
||||||
|
|
||||||
|
let { shifts, oninput }: { shifts: Shifts; oninput: (key: ShiftTimeKey, value: string) => void } = $props();
|
||||||
|
|
||||||
|
const check = $derived(checkShifts(shifts));
|
||||||
|
|
||||||
|
function onShiftInput(key: ShiftTimeKey, e: Event & { currentTarget: HTMLInputElement }) {
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const next = formatTyped(input.value, shifts[key], (e as unknown as InputEvent).inputType ?? '');
|
||||||
|
if (input.value !== next) input.value = next;
|
||||||
|
oninput(key, next);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
{#each SHIFT_ROWS as row (row.a)}
|
||||||
|
<div class="shift-row">
|
||||||
|
<span class="shift-title">{$_(row.titleKey)}</span>
|
||||||
|
<Field
|
||||||
|
id="sh-{row.a}"
|
||||||
|
label={$_('settings.shift_in')}
|
||||||
|
value={shifts[row.a]}
|
||||||
|
maxlength={5}
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="--:--"
|
||||||
|
err={!!check.bad[row.a]}
|
||||||
|
oninput={(e) => onShiftInput(row.a, e)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
id="sh-{row.b}"
|
||||||
|
label={$_('settings.shift_out')}
|
||||||
|
value={shifts[row.b]}
|
||||||
|
maxlength={5}
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="--:--"
|
||||||
|
err={!!check.bad[row.b]}
|
||||||
|
oninput={(e) => onShiftInput(row.b, e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
{#if check.errorKey}
|
||||||
|
<span class="summary-err">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
|
||||||
|
><circle cx="12" cy="12" r="9" /><path d="M12 7.5v5.5M12 16.5h.01" /></svg
|
||||||
|
>
|
||||||
|
{$_(check.errorKey)}
|
||||||
|
</span>
|
||||||
|
{:else if check.minutes}
|
||||||
|
<span
|
||||||
|
>{$_('settings.hours_per_day_label')}
|
||||||
|
<b>{hm(check.minutes.out1 - check.minutes.in1 + (check.minutes.out2 - check.minutes.in2))}</b>
|
||||||
|
{$_('settings.per_day')}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
>{$_('settings.break_label')} <b>{shifts.out1}–{shifts.in2}</b>
|
||||||
|
({check.minutes.in2 - check.minutes.out1} {$_('settings.minutes_short')})</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
border: 1px solid #262626;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #161616;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 22px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #c9c9c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #242424;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 24px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary b {
|
||||||
|
font-family: var(--ox-font-mono);
|
||||||
|
font-weight: 400;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-err {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #ff9ea3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.shift-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-title {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { parseT } from '$lib/time';
|
||||||
|
import type { Shifts } from '$lib/shifts';
|
||||||
|
|
||||||
|
export type { Shifts };
|
||||||
|
|
||||||
|
const IPV4 = /^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
|
||||||
|
|
||||||
|
// A DNS label: alphanumeric, hyphens allowed in the middle, 1-63 chars.
|
||||||
|
const LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?';
|
||||||
|
const HOSTNAME = new RegExp(`^${LABEL}(?:\\.${LABEL})*$`);
|
||||||
|
|
||||||
|
/** Whether `v` is a usable time clock address: an IPv4 address, a bare
|
||||||
|
* hostname (e.g. "timbratore") or a FQDN (e.g. "timbratore.lan"). */
|
||||||
|
export const isValidHost = (v: string): boolean => IPV4.test(v) || HOSTNAME.test(v);
|
||||||
|
|
||||||
|
/** The 4 time fields of `Shifts` — everything but `round_step_min`, which isn't a time. */
|
||||||
|
export type ShiftTimeKey = 'in1' | 'out1' | 'in2' | 'out2';
|
||||||
|
|
||||||
|
export const SHIFT_ROWS: { titleKey: string; a: ShiftTimeKey; b: ShiftTimeKey }[] = [
|
||||||
|
{ titleKey: 'settings.shift_1', a: 'in1', b: 'out1' },
|
||||||
|
{ titleKey: 'settings.shift_2', a: 'in2', b: 'out2' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ShiftCheck {
|
||||||
|
minutes: Record<ShiftTimeKey, number> | null;
|
||||||
|
bad: Partial<Record<ShiftTimeKey, true>>;
|
||||||
|
errorKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkShifts(shifts: Shifts): ShiftCheck {
|
||||||
|
const t: Record<ShiftTimeKey, number | null> = {
|
||||||
|
in1: parseT(shifts.in1),
|
||||||
|
out1: parseT(shifts.out1),
|
||||||
|
in2: parseT(shifts.in2),
|
||||||
|
out2: parseT(shifts.out2)
|
||||||
|
};
|
||||||
|
const bad: Partial<Record<ShiftTimeKey, true>> = {};
|
||||||
|
for (const key of Object.keys(t) as ShiftTimeKey[]) {
|
||||||
|
if (t[key] === null) bad[key] = true;
|
||||||
|
}
|
||||||
|
if (Object.keys(bad).length) return { minutes: null, bad, errorKey: 'settings.shift_error_format' };
|
||||||
|
|
||||||
|
const n = t as Record<ShiftTimeKey, number>;
|
||||||
|
if (n.out1 <= n.in1) return { minutes: null, bad: { out1: true }, errorKey: 'settings.shift_error_out1' };
|
||||||
|
if (n.in2 < n.out1) return { minutes: null, bad: { in2: true }, errorKey: 'settings.shift_error_in2' };
|
||||||
|
if (n.out2 <= n.in2) return { minutes: null, bad: { out2: true }, errorKey: 'settings.shift_error_out2' };
|
||||||
|
return { minutes: n, bad: {}, errorKey: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const roundValid = (v: string): boolean => /^\d{1,2}$/.test(v) && Number(v) >= 1 && Number(v) <= 60;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Employee names chosen by the user, by employee_id. */
|
||||||
|
export type EmployeeNames = Record<string, string>;
|
||||||
|
|
||||||
|
/** `EmployeeNames` by reader index, since an employee_id is only unique
|
||||||
|
* within one reader. */
|
||||||
|
export type EmployeeCache = Record<string, EmployeeNames>;
|
||||||
|
|
||||||
|
/** Only the first reader is used today (see `Settings.readers`), so this is
|
||||||
|
* the only bucket of the cache ever read or written. */
|
||||||
|
export const PRIMARY_READER_INDEX = '0';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export const error = writable('');
|
||||||
|
|
||||||
|
export function setError(err: unknown) {
|
||||||
|
error.set(String(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearError() {
|
||||||
|
error.set('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"loading": "Loading...",
|
||||||
|
"onboarding": {
|
||||||
|
"title": "Welcome!",
|
||||||
|
"subtitle": "Connect Oxibadge to your time clock by entering its address and port.",
|
||||||
|
"host_label": "Address",
|
||||||
|
"port_label": "Port",
|
||||||
|
"hint": "You can find the address in the time clock's network settings.",
|
||||||
|
"button": {
|
||||||
|
"idle": "Test connection",
|
||||||
|
"testing": "Connecting...",
|
||||||
|
"retry": "Retry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"confirm_month": {
|
||||||
|
"title": "Confirm the month",
|
||||||
|
"subtitle": "You're about to confirm this month's clock-ins. Tap the month or year to change them.",
|
||||||
|
"change_month_aria": "Month: {month}, change",
|
||||||
|
"change_year_aria": "Year: {year}, change",
|
||||||
|
"close_aria": "Close selection",
|
||||||
|
"choose_month_aria": "Choose the month",
|
||||||
|
"choose_year_aria": "Choose the year",
|
||||||
|
"month_option_aria": "{month} {year}",
|
||||||
|
"button": "Go to clock-ins"
|
||||||
|
},
|
||||||
|
"loading_records": {
|
||||||
|
"title": "Downloading clock-ins...",
|
||||||
|
"description": "Reading <strong>{period}</strong>'s clock-ins from the time clock. This can take a few seconds."
|
||||||
|
},
|
||||||
|
"loading_employees": {
|
||||||
|
"title": "Fetching employees...",
|
||||||
|
"description": "Reading the employee names registered on the time clock. Just a moment."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "Could not reach the time clock",
|
||||||
|
"export_title": "Export failed"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"title": "Clock-in review",
|
||||||
|
"day_word": "{n, plural, one {1 day} other {{n} days}}",
|
||||||
|
"to_check": "to check",
|
||||||
|
"to_round": "to round",
|
||||||
|
"to_check_cap": "To check",
|
||||||
|
"to_round_cap": "To round",
|
||||||
|
"shift_1": "1st shift",
|
||||||
|
"shift_2": "2nd shift",
|
||||||
|
"issue_stacked": "{total} punches all on the {shift}",
|
||||||
|
"issue_invalid_time": "Invalid time (use HH:MM)",
|
||||||
|
"issue_lunch_missing": "Lunch break not punched",
|
||||||
|
"issue_missing_out": "Missing clock-out for the {shift}",
|
||||||
|
"issue_missing_in": "Missing clock-in for the {shift}",
|
||||||
|
"issue_out_before_in": "Clock-out before clock-in ({shift})",
|
||||||
|
"info_unrounded": "Times not rounded to {step} min",
|
||||||
|
"note_empty": "No punches",
|
||||||
|
"note_lunch_added": "Lunch break added",
|
||||||
|
"note_shifts_fixed": "Shifts fixed",
|
||||||
|
"note_edited": "{n, plural, one {1 time edited} other {{n} times edited}}",
|
||||||
|
"note_rounded": "{n, plural, one {1 time rounded} other {{n} times rounded}}",
|
||||||
|
"export_done": "Export completed: {who}",
|
||||||
|
"everyone": "all employees",
|
||||||
|
"only_unrounded": "{days} with unrounded times",
|
||||||
|
"confirm_title_all": "There are {summary}",
|
||||||
|
"confirm_title_one": "{name} has {summary}",
|
||||||
|
"close_menu_aria": "Close menu",
|
||||||
|
"no_records": "No punches found for this month.",
|
||||||
|
"back_to_month": "Back to month selection",
|
||||||
|
"export": "Export",
|
||||||
|
"export_options_aria": "More export options",
|
||||||
|
"all_employees": "All employees",
|
||||||
|
"employee_count": "{n} employees",
|
||||||
|
"all_good": "all good",
|
||||||
|
"all_good_cap": "All good",
|
||||||
|
"only_name": "Only {name}",
|
||||||
|
"employees_nav_aria": "Employees",
|
||||||
|
"search_employee": "Search employee",
|
||||||
|
"days_to_check_aria": "{n} days to check",
|
||||||
|
"days_to_round_aria": "{n} days to round",
|
||||||
|
"no_problem_aria": "No problem",
|
||||||
|
"employee_name_aria": "Employee name",
|
||||||
|
"save_name": "Save name",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"edit_name": "Edit name",
|
||||||
|
"days_worked": "days worked",
|
||||||
|
"total_hours": "total hours",
|
||||||
|
"edited": "edited",
|
||||||
|
"fix_shifts_title": "Sorts and redistributes punches that all ended up on the same shift",
|
||||||
|
"fix_shifts": "Fix shifts ({days})",
|
||||||
|
"fix_shifts_short": "Fix shifts",
|
||||||
|
"add_lunch_title": "Adds the {start}–{end} break on days with only a morning clock-in and an evening clock-out",
|
||||||
|
"add_lunch": "Add lunch break ({days})",
|
||||||
|
"lunch_short": "Lunch",
|
||||||
|
"round_title": "Round to the nearest time",
|
||||||
|
"round_all": "Round to {step} min ({days})",
|
||||||
|
"round_short": "Round",
|
||||||
|
"day_filter_aria": "Day filter",
|
||||||
|
"all_days": "All days",
|
||||||
|
"col_day": "Day",
|
||||||
|
"col_in": "In",
|
||||||
|
"col_out": "Out",
|
||||||
|
"col_hours": "Hours",
|
||||||
|
"col_notes": "Notes",
|
||||||
|
"col_in_1": "1st shift clock-in",
|
||||||
|
"col_out_1": "1st shift clock-out",
|
||||||
|
"col_in_2": "2nd shift clock-in",
|
||||||
|
"col_out_2": "2nd shift clock-out",
|
||||||
|
"cell_aria": "{label}, {col}",
|
||||||
|
"cell_aria_stacked": "{label}, {col} ({index} of {total})",
|
||||||
|
"fix_shifts_day_aria": "Fix the shifts for {label}",
|
||||||
|
"add_lunch_day_aria": "Add the {start}–{end} lunch break to {label}",
|
||||||
|
"round_day_aria": "Round the times for {label}",
|
||||||
|
"no_rows": "No days to check for this employee.",
|
||||||
|
"cancel_export_aria": "Cancel export",
|
||||||
|
"export_warning_issues": "If you export now, these days will end up in the file with missing, unverified, or unrounded times. You can go back and fix them, or export anyway.",
|
||||||
|
"export_warning_unrounded": "The times are complete, but some aren't rounded to {step} minutes. You can round them automatically before exporting, or export them as they are.",
|
||||||
|
"back_to_review": "Back to review",
|
||||||
|
"round_and_export": "Round and export",
|
||||||
|
"export_anyway": "Export anyway",
|
||||||
|
"leave_confirm_title": "Unexported changes",
|
||||||
|
"leave_confirm_message": "If you leave now, the changes made in this review will be lost.",
|
||||||
|
"leave_anyway": "Leave without exporting",
|
||||||
|
"undo_day_aria": "Undo the changes to {label}",
|
||||||
|
"undo_short": "Undo",
|
||||||
|
"undo_all": "Undo changes",
|
||||||
|
"undo_all_title": "Restore this employee's times to what the time clock originally reported",
|
||||||
|
"undo_all_confirm_title": "Undo all changes?",
|
||||||
|
"undo_all_confirm_message": "All edited times for this employee will revert to what the time clock reported. This can't be undone.",
|
||||||
|
"undo_all_confirm": "Undo all changes",
|
||||||
|
"corrections": "Corrections",
|
||||||
|
"add_lunch_action": "Add lunch break",
|
||||||
|
"round_action": "Round to {step} min",
|
||||||
|
"apply_all_corrections": "Apply all corrections",
|
||||||
|
"shift_times": "Shift times",
|
||||||
|
"shift_panel_title": "Shift all the times",
|
||||||
|
"shift_panel_desc": "For when the time clock didn't follow a daylight-saving time change.",
|
||||||
|
"shift_amount_label": "By how much",
|
||||||
|
"shift_minus_aria": "Minus 15 minutes",
|
||||||
|
"shift_plus_aria": "Plus 15 minutes",
|
||||||
|
"shift_minus_hour": "−1 hour",
|
||||||
|
"shift_plus_hour": "+1 hour",
|
||||||
|
"shift_days_label": "Which days",
|
||||||
|
"shift_from": "From",
|
||||||
|
"shift_to": "to",
|
||||||
|
"shift_who_label": "For whom",
|
||||||
|
"shift_scope_all": "Everyone",
|
||||||
|
"shift_preview": "E.g. {from} → <b>{to}</b>",
|
||||||
|
"apply": "Apply"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"device_title": "Time clock",
|
||||||
|
"device_desc": "The time clock Oxibadge downloads punches from.",
|
||||||
|
"device_label": "Address",
|
||||||
|
"status_testing": "Checking…",
|
||||||
|
"status_ok": "Reachable",
|
||||||
|
"status_idle": "Not verified",
|
||||||
|
"edit_address": "Edit address",
|
||||||
|
"test_connection": "Test connection",
|
||||||
|
"ip_label": "Address",
|
||||||
|
"port_label": "Port",
|
||||||
|
"ip_invalid": "Invalid format, e.g. 192.168.1.100 or timbratore.lan",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"shifts_title": "Expected shifts",
|
||||||
|
"shifts_desc": "The standard work schedule. The break between the two shifts is the one used by \"Add lunch break\" in the review.",
|
||||||
|
"shift_1": "1st shift",
|
||||||
|
"shift_2": "2nd shift",
|
||||||
|
"shift_in": "In",
|
||||||
|
"shift_out": "Out",
|
||||||
|
"shift_error_format": "Enter all times in HH:MM format.",
|
||||||
|
"shift_error_out1": "The 1st shift's clock-out must be after its clock-in.",
|
||||||
|
"shift_error_in2": "The 2nd shift must start after the 1st one ends.",
|
||||||
|
"shift_error_out2": "The 2nd shift's clock-out must be after its clock-in.",
|
||||||
|
"hours_per_day_label": "Expected hours",
|
||||||
|
"per_day": "per day",
|
||||||
|
"break_label": "Break",
|
||||||
|
"round_title": "Time rounding",
|
||||||
|
"round_desc": "Times are rounded to the nearest multiple.",
|
||||||
|
"round_example": "E.g. {from} → {to}.",
|
||||||
|
"round_label": "Minutes",
|
||||||
|
"minutes_short": "min",
|
||||||
|
"round_range": "From 1 to 60 minutes",
|
||||||
|
"language_title": "Language",
|
||||||
|
"language_desc": "Oxibadge's interface language.",
|
||||||
|
"save_idle": "Changes are saved automatically",
|
||||||
|
"save_saving": "Saving…",
|
||||||
|
"save_saved": "Changes saved",
|
||||||
|
"save_invalid": "Not saved: fix the error",
|
||||||
|
"save_failed": "Save failed",
|
||||||
|
"employees_title": "Employees",
|
||||||
|
"employees_count": "{n, plural, one {1 employee} other {{n} employees}}",
|
||||||
|
"manage_employees": "Manage employees",
|
||||||
|
"close_aria": "Close",
|
||||||
|
"employee_name_aria": "Name of employee #{id}",
|
||||||
|
"edit_name_aria": "Edit {name}'s name",
|
||||||
|
"remove_name_aria": "Remove {name} from the cache",
|
||||||
|
"employee_remove_confirm": "Remove {name}? You'll be asked for their name again next time.",
|
||||||
|
"remove": "Remove",
|
||||||
|
"no_employees_found": "No employees found.",
|
||||||
|
"employees_local_note": "Edited names only apply within Oxibadge: the time clock is never written to."
|
||||||
|
},
|
||||||
|
"new_employees": {
|
||||||
|
"title": "New employees",
|
||||||
|
"subtitle": "{n, plural, one {The time clock found 1 new employee.} other {The time clock found {n} new employees.}}",
|
||||||
|
"hint": "Fix the names if needed: they only apply within Oxibadge.",
|
||||||
|
"missing_placeholder": "missing name",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"loading": "Caricamento...",
|
||||||
|
"onboarding": {
|
||||||
|
"title": "Benvenuto!",
|
||||||
|
"subtitle": "Collega Oxibadge al tuo timbratore inserendo il suo indirizzo e la porta.",
|
||||||
|
"host_label": "Indirizzo",
|
||||||
|
"port_label": "Porta",
|
||||||
|
"hint": "Trovi l'indirizzo nelle impostazioni di rete del timbratore.",
|
||||||
|
"button": {
|
||||||
|
"idle": "Testa connessione",
|
||||||
|
"testing": "Connessione in corso...",
|
||||||
|
"retry": "Riprova"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"confirm_month": {
|
||||||
|
"title": "Conferma del mese",
|
||||||
|
"subtitle": "Stai per confermare le timbrature di questo mese. Tocca il mese o l'anno per cambiarli.",
|
||||||
|
"change_month_aria": "Mese: {month}, cambia",
|
||||||
|
"change_year_aria": "Anno: {year}, cambia",
|
||||||
|
"close_aria": "Chiudi selezione",
|
||||||
|
"choose_month_aria": "Scegli il mese",
|
||||||
|
"choose_year_aria": "Scegli l'anno",
|
||||||
|
"month_option_aria": "{month} {year}",
|
||||||
|
"button": "Vai alle timbrature"
|
||||||
|
},
|
||||||
|
"loading_records": {
|
||||||
|
"title": "Scarico le timbrature...",
|
||||||
|
"description": "Sto leggendo dal timbratore le timbrature di <strong>{period}</strong>. Può richiedere qualche secondo."
|
||||||
|
},
|
||||||
|
"loading_employees": {
|
||||||
|
"title": "Recupero i dipendenti...",
|
||||||
|
"description": "Sto leggendo i nomi dei dipendenti registrati sul timbratore. Ci vuole solo un attimo."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "Impossibile raggiungere il timbratore",
|
||||||
|
"export_title": "Esportazione non riuscita"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"title": "Revisione timbrature",
|
||||||
|
"day_word": "{n, plural, one {1 giorno} other {{n} giorni}}",
|
||||||
|
"to_check": "da controllare",
|
||||||
|
"to_round": "da arrotondare",
|
||||||
|
"to_check_cap": "Da controllare",
|
||||||
|
"to_round_cap": "Da arrotondare",
|
||||||
|
"shift_1": "1° turno",
|
||||||
|
"shift_2": "2° turno",
|
||||||
|
"issue_stacked": "{total} timbrature tutte sul {shift}",
|
||||||
|
"issue_invalid_time": "Orario non valido (usa HH:MM)",
|
||||||
|
"issue_lunch_missing": "Pausa pranzo non timbrata",
|
||||||
|
"issue_missing_out": "Manca l’uscita del {shift}",
|
||||||
|
"issue_missing_in": "Manca l’entrata del {shift}",
|
||||||
|
"issue_out_before_in": "Uscita prima dell’entrata ({shift})",
|
||||||
|
"info_unrounded": "Orari non arrotondati a {step} min",
|
||||||
|
"note_empty": "Nessuna timbratura",
|
||||||
|
"note_lunch_added": "Pausa pranzo aggiunta",
|
||||||
|
"note_shifts_fixed": "Turni sistemati",
|
||||||
|
"note_edited": "{n, plural, one {1 orario modificato} other {{n} orari modificati}}",
|
||||||
|
"note_rounded": "{n, plural, one {1 orario arrotondato} other {{n} orari arrotondati}}",
|
||||||
|
"export_done": "Esportazione completata: {who}",
|
||||||
|
"everyone": "tutti i dipendenti",
|
||||||
|
"only_unrounded": "{days} con orari non arrotondati",
|
||||||
|
"confirm_title_all": "Ci sono {summary}",
|
||||||
|
"confirm_title_one": "{name} ha {summary}",
|
||||||
|
"close_menu_aria": "Chiudi menu",
|
||||||
|
"no_records": "Nessuna timbratura trovata per questo mese.",
|
||||||
|
"back_to_month": "Torna alla scelta del mese",
|
||||||
|
"export": "Esporta",
|
||||||
|
"export_options_aria": "Altre opzioni di esportazione",
|
||||||
|
"all_employees": "Tutti i dipendenti",
|
||||||
|
"employee_count": "{n} dipendenti",
|
||||||
|
"all_good": "tutto a posto",
|
||||||
|
"all_good_cap": "Tutto a posto",
|
||||||
|
"only_name": "Solo {name}",
|
||||||
|
"employees_nav_aria": "Dipendenti",
|
||||||
|
"search_employee": "Cerca dipendente",
|
||||||
|
"days_to_check_aria": "{n} giorni da controllare",
|
||||||
|
"days_to_round_aria": "{n} giorni da arrotondare",
|
||||||
|
"no_problem_aria": "Nessun problema",
|
||||||
|
"employee_name_aria": "Nome del dipendente",
|
||||||
|
"save_name": "Salva nome",
|
||||||
|
"save": "Salva",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"edit_name": "Modifica nome",
|
||||||
|
"days_worked": "giorni lavorati",
|
||||||
|
"total_hours": "ore totali",
|
||||||
|
"edited": "modificati",
|
||||||
|
"fix_shifts_title": "Riordina in ordine di orario le timbrature finite tutte nello stesso turno",
|
||||||
|
"fix_shifts": "Sistema turni ({days})",
|
||||||
|
"fix_shifts_short": "Sistema turni",
|
||||||
|
"add_lunch_title": "Inserisce la pausa {start}–{end} nei giorni con solo entrata del mattino e uscita della sera",
|
||||||
|
"add_lunch": "Aggiungi pausa pranzo ({days})",
|
||||||
|
"lunch_short": "Pausa",
|
||||||
|
"round_title": "Arrotonda all'orario più vicino",
|
||||||
|
"round_all": "Arrotonda a {step} min ({days})",
|
||||||
|
"round_short": "Arrotonda",
|
||||||
|
"day_filter_aria": "Filtro giorni",
|
||||||
|
"all_days": "Tutti i giorni",
|
||||||
|
"col_day": "Giorno",
|
||||||
|
"col_in": "Entrata",
|
||||||
|
"col_out": "Uscita",
|
||||||
|
"col_hours": "Ore",
|
||||||
|
"col_notes": "Note",
|
||||||
|
"col_in_1": "Entrata 1° turno",
|
||||||
|
"col_out_1": "Uscita 1° turno",
|
||||||
|
"col_in_2": "Entrata 2° turno",
|
||||||
|
"col_out_2": "Uscita 2° turno",
|
||||||
|
"cell_aria": "{label}, {col}",
|
||||||
|
"cell_aria_stacked": "{label}, {col} ({index} di {total})",
|
||||||
|
"fix_shifts_day_aria": "Sistema i turni di {label}",
|
||||||
|
"add_lunch_day_aria": "Aggiungi pausa pranzo {start}–{end} a {label}",
|
||||||
|
"round_day_aria": "Arrotonda gli orari di {label}",
|
||||||
|
"no_rows": "Nessun giorno da controllare per questo dipendente.",
|
||||||
|
"cancel_export_aria": "Annulla esportazione",
|
||||||
|
"export_warning_issues": "Se esporti ora, questi giorni finiranno nel file con orari mancanti, da verificare o non arrotondati. Puoi tornare indietro e sistemarli, oppure esportare comunque.",
|
||||||
|
"export_warning_unrounded": "Gli orari sono completi, ma alcuni non sono arrotondati a {step} minuti. Puoi arrotondarli automaticamente prima di esportare, oppure esportarli così come sono.",
|
||||||
|
"back_to_review": "Torna alla revisione",
|
||||||
|
"round_and_export": "Arrotonda ed esporta",
|
||||||
|
"export_anyway": "Esporta comunque",
|
||||||
|
"leave_confirm_title": "Modifiche non esportate",
|
||||||
|
"leave_confirm_message": "Se esci ora, le modifiche fatte in questa revisione andranno perse.",
|
||||||
|
"leave_anyway": "Esci senza esportare",
|
||||||
|
"undo_day_aria": "Annulla le modifiche di {label}",
|
||||||
|
"undo_short": "Annulla",
|
||||||
|
"undo_all": "Annulla modifiche",
|
||||||
|
"undo_all_title": "Ripristina gli orari di questo dipendente a quelli letti dal timbratore",
|
||||||
|
"undo_all_confirm_title": "Annullare tutte le modifiche?",
|
||||||
|
"undo_all_confirm_message": "Tutti gli orari modificati per questo dipendente torneranno a quelli letti dal timbratore. L'operazione non si può annullare.",
|
||||||
|
"undo_all_confirm": "Annulla tutte le modifiche",
|
||||||
|
"corrections": "Correzioni",
|
||||||
|
"add_lunch_action": "Aggiungi pausa pranzo",
|
||||||
|
"round_action": "Arrotonda a {step} min",
|
||||||
|
"apply_all_corrections": "Applica tutte le correzioni",
|
||||||
|
"shift_times": "Sposta orari",
|
||||||
|
"shift_panel_title": "Sposta tutti gli orari",
|
||||||
|
"shift_panel_desc": "Per quando il timbratore non ha seguito il cambio dell'ora legale.",
|
||||||
|
"shift_amount_label": "Di quanto",
|
||||||
|
"shift_minus_aria": "Meno 15 minuti",
|
||||||
|
"shift_plus_aria": "Più 15 minuti",
|
||||||
|
"shift_minus_hour": "−1 ora",
|
||||||
|
"shift_plus_hour": "+1 ora",
|
||||||
|
"shift_days_label": "Quali giorni",
|
||||||
|
"shift_from": "Dal",
|
||||||
|
"shift_to": "al",
|
||||||
|
"shift_who_label": "Per chi",
|
||||||
|
"shift_scope_all": "Tutti",
|
||||||
|
"shift_preview": "Es. {from} → <b>{to}</b>",
|
||||||
|
"apply": "Applica"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Impostazioni",
|
||||||
|
"device_title": "Timbratore",
|
||||||
|
"device_desc": "Il timbratore da cui Oxibadge scarica le timbrature.",
|
||||||
|
"device_label": "Indirizzo",
|
||||||
|
"status_testing": "Verifica…",
|
||||||
|
"status_ok": "Raggiungibile",
|
||||||
|
"status_idle": "Non verificato",
|
||||||
|
"edit_address": "Modifica indirizzo",
|
||||||
|
"test_connection": "Testa connessione",
|
||||||
|
"ip_label": "Indirizzo",
|
||||||
|
"port_label": "Porta",
|
||||||
|
"ip_invalid": "Formato non valido, es. 192.168.1.100 o timbratore.lan",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"confirm": "Conferma",
|
||||||
|
"shifts_title": "Turni previsti",
|
||||||
|
"shifts_desc": "L'orario di lavoro standard. La pausa tra i due turni è quella usata da \"Aggiungi pausa pranzo\" nella revisione.",
|
||||||
|
"shift_1": "1° turno",
|
||||||
|
"shift_2": "2° turno",
|
||||||
|
"shift_in": "Entrata",
|
||||||
|
"shift_out": "Uscita",
|
||||||
|
"shift_error_format": "Inserisci tutti gli orari nel formato HH:MM.",
|
||||||
|
"shift_error_out1": "L'uscita del 1° turno deve essere dopo l'entrata.",
|
||||||
|
"shift_error_in2": "Il 2° turno deve iniziare dopo la fine del 1°.",
|
||||||
|
"shift_error_out2": "L'uscita del 2° turno deve essere dopo l'entrata.",
|
||||||
|
"hours_per_day_label": "Ore previste",
|
||||||
|
"per_day": "al giorno",
|
||||||
|
"break_label": "Pausa",
|
||||||
|
"round_title": "Arrotondamento orari",
|
||||||
|
"round_desc": "Gli orari vengono arrotondati al multiplo più vicino.",
|
||||||
|
"round_example": "Es. {from} → {to}.",
|
||||||
|
"round_label": "Minuti",
|
||||||
|
"minutes_short": "min",
|
||||||
|
"round_range": "Da 1 a 60 minuti",
|
||||||
|
"language_title": "Lingua",
|
||||||
|
"language_desc": "La lingua dell'interfaccia di Oxibadge.",
|
||||||
|
"save_idle": "Le modifiche vengono salvate automaticamente",
|
||||||
|
"save_saving": "Salvataggio…",
|
||||||
|
"save_saved": "Modifiche salvate",
|
||||||
|
"save_invalid": "Non salvato: correggi l'errore",
|
||||||
|
"save_failed": "Salvataggio non riuscito",
|
||||||
|
"employees_title": "Dipendenti",
|
||||||
|
"employees_count": "{n, plural, one {1 dipendente} other {{n} dipendenti}}",
|
||||||
|
"manage_employees": "Gestisci dipendenti",
|
||||||
|
"close_aria": "Chiudi",
|
||||||
|
"employee_name_aria": "Nome del dipendente #{id}",
|
||||||
|
"edit_name_aria": "Modifica nome di {name}",
|
||||||
|
"remove_name_aria": "Rimuovi {name} dalla cache",
|
||||||
|
"employee_remove_confirm": "Rimuovere {name}? Al prossimo controllo verrà chiesto di nuovo il nome.",
|
||||||
|
"remove": "Rimuovi",
|
||||||
|
"no_employees_found": "Nessun dipendente trovato.",
|
||||||
|
"employees_local_note": "I nomi modificati valgono solo in Oxibadge: il timbratore non viene mai scritto."
|
||||||
|
},
|
||||||
|
"new_employees": {
|
||||||
|
"title": "Nuovi dipendenti",
|
||||||
|
"subtitle": "{n, plural, one {Sul timbratore c'è 1 dipendente nuovo.} other {Sul timbratore ci sono {n} dipendenti nuovi.}}",
|
||||||
|
"hint": "Correggi i nomi se serve: valgono solo in Oxibadge.",
|
||||||
|
"missing_placeholder": "nome mancante",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
function capitalize(value: string): string {
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `month` is 1-12. */
|
||||||
|
export function monthName(loc: string | null | undefined, month: number, format: 'short' | 'long' = 'long') {
|
||||||
|
const formatter = new Intl.DateTimeFormat(loc ?? 'it', { month: format });
|
||||||
|
return capitalize(formatter.format(new Date(2000, month - 1, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `dow` is 0 (Sunday) - 6 (Saturday), matching `Date.getDay()`. */
|
||||||
|
export function weekdayName(loc: string | null | undefined, dow: number, format: 'short' | 'long' = 'short') {
|
||||||
|
const formatter = new Intl.DateTimeFormat(loc ?? 'it', { weekday: format });
|
||||||
|
// 2000-01-02 is a Sunday, so 2000-01-(2+dow) lands on the requested weekday.
|
||||||
|
return capitalize(formatter.format(new Date(2000, 0, 2 + dow)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Shifts } from './shifts';
|
||||||
|
|
||||||
|
export interface Reader {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
/** The configured time clock (only the first one is used today, but this
|
||||||
|
* stays a list to support multiple readers in the future). */
|
||||||
|
readers: Reader[];
|
||||||
|
shifts: Shifts;
|
||||||
|
lang: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface Shifts {
|
||||||
|
in1: string;
|
||||||
|
out1: string;
|
||||||
|
in2: string;
|
||||||
|
out2: string;
|
||||||
|
round_step_min: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function initials(n: string): string {
|
||||||
|
return n
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join('')
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export function parseT(t: string): number | null {
|
||||||
|
const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(t);
|
||||||
|
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmt(m: number): string {
|
||||||
|
return String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hm(m: number): string {
|
||||||
|
return Math.floor(m / 60) + ':' + String(m % 60).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats a time as it's typed, without needing to type ":". */
|
||||||
|
export function formatTyped(raw: string, prev: string, inputType: string): string {
|
||||||
|
const deleting = inputType ? inputType.startsWith('delete') : raw.length < prev.length && prev.startsWith(raw);
|
||||||
|
const digits = raw.replace(/\D/g, '').slice(0, 4);
|
||||||
|
if (!digits) return '';
|
||||||
|
let h: string;
|
||||||
|
let m: string;
|
||||||
|
if (digits[0] >= '3' || (digits[0] === '2' && digits.length > 1 && digits[1] > '3')) {
|
||||||
|
h = '0' + digits[0];
|
||||||
|
m = digits.slice(1, 3);
|
||||||
|
} else {
|
||||||
|
h = digits.slice(0, 2);
|
||||||
|
m = digits.slice(2, 4);
|
||||||
|
}
|
||||||
|
if (h.length < 2) return h;
|
||||||
|
if (!m) return deleting ? h : h + ':';
|
||||||
|
return h + ':' + m;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { locale, waitLocale } from 'svelte-i18n';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { Settings } from '$lib/settings';
|
||||||
|
|
||||||
|
let { children }: { children: Snippet } = $props();
|
||||||
|
|
||||||
|
let ready = $state(false);
|
||||||
|
invoke<Settings>('load_settings')
|
||||||
|
.catch(() => null)
|
||||||
|
.then((settings) => {
|
||||||
|
if (settings?.lang) locale.set(settings.lang);
|
||||||
|
return waitLocale();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
ready = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if ready}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { getLocaleFromNavigator, init, register } from 'svelte-i18n';
|
||||||
|
|
||||||
|
// Tauri doesn't have a Node.js server to do proper SSR
|
||||||
|
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||||
|
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||||
|
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
|
register('it', () => import('$lib/locales/it.json'));
|
||||||
|
register('en', () => import('$lib/locales/en.json'));
|
||||||
|
|
||||||
|
init({
|
||||||
|
fallbackLocale: 'it',
|
||||||
|
initialLocale: getLocaleFromNavigator()
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<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';
|
||||||
|
import LoadingScreen from '$lib/components/LoadingScreen.svelte';
|
||||||
|
import TimesheetReview from '$lib/components/TimesheetReview.svelte';
|
||||||
|
import SettingsButton from '$lib/components/SettingsButton.svelte';
|
||||||
|
import Settings from '$lib/components/Settings.svelte';
|
||||||
|
import NewEmployees from '$lib/components/NewEmployees.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
import { buildEmployees, type AttendanceRecord } from '$lib/components/review/fromRecords';
|
||||||
|
import type { NewEmployee } from '$lib/components/new-employees/logic';
|
||||||
|
import type { Shifts } from '$lib/shifts';
|
||||||
|
import type { Settings as SettingsData } from '$lib/settings';
|
||||||
|
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
|
||||||
|
|
||||||
|
type Step =
|
||||||
|
| 'loading'
|
||||||
|
| 'setup'
|
||||||
|
| 'confirm-month'
|
||||||
|
| 'loading-records'
|
||||||
|
| 'loading-employees'
|
||||||
|
| 'new-employees'
|
||||||
|
| 'review'
|
||||||
|
| 'settings';
|
||||||
|
|
||||||
|
let step = $state<Step>('loading');
|
||||||
|
let selectedYear = $state(0);
|
||||||
|
let selectedMonth = $state(0);
|
||||||
|
let records = $state<AttendanceRecord[]>([]);
|
||||||
|
let fullEmployeeCache = $state<EmployeeCache>({});
|
||||||
|
let employeeCache = $state<EmployeeNames>({});
|
||||||
|
let employeeNames = $state<Record<string, string>>({});
|
||||||
|
let newEmployees = $state<NewEmployee[]>([]);
|
||||||
|
// Placeholder: overwritten by load_settings before the review screen is reachable.
|
||||||
|
let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 });
|
||||||
|
let reviewDirty = $state(false);
|
||||||
|
let pendingLeaveAction = $state<null | (() => void)>(null);
|
||||||
|
|
||||||
|
const period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`);
|
||||||
|
const reviewEmployees = $derived(buildEmployees(selectedYear, selectedMonth, records, employeeNames));
|
||||||
|
|
||||||
|
/** Runs `action` immediately, unless the review screen has unexported
|
||||||
|
* edits, in which case it asks for confirmation first. */
|
||||||
|
function requestLeaveReview(action: () => void) {
|
||||||
|
if (step === 'review' && reviewDirty) {
|
||||||
|
pendingLeaveAction = action;
|
||||||
|
} else {
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmLeaveReview() {
|
||||||
|
const action = pendingLeaveAction;
|
||||||
|
pendingLeaveAction = null;
|
||||||
|
action?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelLeaveReview() {
|
||||||
|
pendingLeaveAction = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const settings = await invoke<SettingsData>('load_settings');
|
||||||
|
shifts = settings.shifts;
|
||||||
|
step = settings.readers[0] ? 'confirm-month' : 'setup';
|
||||||
|
|
||||||
|
// Lives for the whole app, so there's no need to ever unlisten.
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
await appWindow.onCloseRequested((event) => {
|
||||||
|
if (step === 'review' && reviewDirty) {
|
||||||
|
event.preventDefault();
|
||||||
|
requestLeaveReview(() => appWindow.destroy());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
clearError();
|
||||||
|
step = 'settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() {
|
||||||
|
clearError();
|
||||||
|
step = 'confirm-month';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMonth(selection: { year: number; month: number }) {
|
||||||
|
selectedYear = selection.year;
|
||||||
|
selectedMonth = selection.month;
|
||||||
|
step = 'loading-records';
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
records = await invoke('load_records', selection);
|
||||||
|
step = 'loading-employees';
|
||||||
|
|
||||||
|
fullEmployeeCache = await invoke<EmployeeCache>('load_employee_cache');
|
||||||
|
employeeCache = fullEmployeeCache[PRIMARY_READER_INDEX] ?? {};
|
||||||
|
const distinctIds = [...new Set(records.map((r: AttendanceRecord) => r.employee_id))];
|
||||||
|
const knownIds = distinctIds.filter((id) => String(id) in employeeCache);
|
||||||
|
const newIds = distinctIds.filter((id) => !(String(id) in employeeCache));
|
||||||
|
|
||||||
|
if (newIds.length > 0) {
|
||||||
|
const fetched = await invoke<Record<string, string>>('load_employees', {
|
||||||
|
records,
|
||||||
|
known: knownIds
|
||||||
|
});
|
||||||
|
newEmployees = newIds.map((id) => ({ id, name: fetched[String(id)] ?? '' }));
|
||||||
|
step = 'new-employees';
|
||||||
|
} else {
|
||||||
|
employeeNames = employeeCache;
|
||||||
|
step = 'review';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
step = 'confirm-month';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges `updates` into the employee cache (local state + on disk) and returns the merged names. */
|
||||||
|
async function mergeEmployeeNames(updates: Record<string, string>): Promise<Record<string, string>> {
|
||||||
|
const merged = { ...employeeCache, ...updates };
|
||||||
|
employeeCache = merged;
|
||||||
|
employeeNames = merged;
|
||||||
|
const nextFullCache = { ...fullEmployeeCache, [PRIMARY_READER_INDEX]: merged };
|
||||||
|
fullEmployeeCache = nextFullCache;
|
||||||
|
await invoke('save_employee_cache', { cache: nextFullCache });
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onNewEmployeesContinue(list: { id: number; name: string }[]) {
|
||||||
|
const updates = Object.fromEntries(list.map((e) => [String(e.id), e.name]));
|
||||||
|
step = 'review';
|
||||||
|
await mergeEmployeeNames(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'}
|
||||||
|
<TimesheetReview
|
||||||
|
year={selectedYear}
|
||||||
|
month={selectedMonth}
|
||||||
|
employees={reviewEmployees}
|
||||||
|
shift={shifts}
|
||||||
|
onback={() => requestLeaveReview(() => (step = 'confirm-month'))}
|
||||||
|
onexport={onExport}
|
||||||
|
onrename={onEmployeeRenamed}
|
||||||
|
ondirtychange={(dirty) => (reviewDirty = dirty)}
|
||||||
|
/>
|
||||||
|
{:else if step === 'new-employees'}
|
||||||
|
<NewEmployees employees={newEmployees} oncontinue={onNewEmployeesContinue} />
|
||||||
|
{:else if step === 'settings'}
|
||||||
|
<Settings
|
||||||
|
onback={closeSettings}
|
||||||
|
onsettingschange={(s: SettingsData) => {
|
||||||
|
shifts = s.shifts;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Screen>
|
||||||
|
{#if step === 'loading'}
|
||||||
|
<p>{$_('loading')}</p>
|
||||||
|
{:else if step === 'setup'}
|
||||||
|
<Onboarding onconnected={() => (step = 'confirm-month')} />
|
||||||
|
{:else if step === 'confirm-month'}
|
||||||
|
<SettingsButton ariaLabel={$_('settings.title')} onclick={openSettings} />
|
||||||
|
<ConfirmMonth onstart={loadMonth} />
|
||||||
|
{:else if step === 'loading-records'}
|
||||||
|
<LoadingScreen
|
||||||
|
title={$_('loading_records.title')}
|
||||||
|
description={$_('loading_records.description', { values: { period } })}
|
||||||
|
/>
|
||||||
|
{:else if step === 'loading-employees'}
|
||||||
|
<LoadingScreen title={$_('loading_employees.title')} description={$_('loading_employees.description')} />
|
||||||
|
{/if}
|
||||||
|
</Screen>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pendingLeaveAction}
|
||||||
|
<ConfirmDialog
|
||||||
|
fixed
|
||||||
|
title={$_('review.leave_confirm_title')}
|
||||||
|
message={$_('review.leave_confirm_message')}
|
||||||
|
confirmLabel={$_('review.leave_anyway')}
|
||||||
|
cancelLabel={$_('review.cancel')}
|
||||||
|
onconfirm={confirmLeaveReview}
|
||||||
|
oncancel={cancelLeaveReview}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
|
||||||
|
<rect id="Badge" width="856" height="539.8" x="72" y="230.1" fill="#f74c00" rx="34.8"/>
|
||||||
|
<g id="Oxi." fill="#fff">
|
||||||
|
<path id="." d="M734.101 594.267q-7.733 0-12-4-4-4.266-4-12v-4.533q0-7.733 4-11.733 4.267-4.267 12-4.267h3.467q7.733 0 11.733 4.267 4.267 4 4.267 11.733v4.533q0 7.734-4.267 12-4 4-11.733 4z"/>
|
||||||
|
<path id="i" d="M672.254 578.267q0 6.934-4.533 11.734-4.534 4.533-11.467 4.533t-11.467-4.533q-4.533-4.8-4.533-11.734V468.134q0-6.933 4.533-11.467 4.534-4.8 11.467-4.8t11.467 4.8q4.533 4.534 4.533 11.467zm-16.267-143.733q-9.066 0-12.8-2.933-3.733-2.934-3.733-10.4v-5.067q0-7.733 4-10.4 4.267-2.933 12.8-2.933 9.333 0 13.067 2.933t3.733 10.4v5.067q0 7.733-4 10.666-4 2.667-13.067 2.667"/>
|
||||||
|
<path id="x" d="M488.807 451.867q7.733 0 13.066 6.667L590.94 569.2q3.467 4.266 3.467 9.6 0 6.666-5.067 11.466-5.066 4.8-11.2 4.8-7.733 0-13.066-6.933l-89.067-110.667q-3.467-4.266-3.467-9.6 0-6.933 4.8-11.466 5.067-4.534 11.467-4.534m.266 142.934q-5.333 0-10.666-4-5.067-4.267-5.067-10.134 0-5.333 4-10.133l40.8-49.867 16 26.667-32.267 40.8q-5.066 6.667-12.8 6.667m88.8-142.934q6.4 0 10.667 4.534 4.534 4.266 4.534 9.866 0 2.667-.8 5.334t-2.934 5.066l-40.266 49.067-16.8-23.2 32.8-43.467q5.333-7.2 12.8-7.2"/>
|
||||||
|
<path id="O" d="M427.497 501.2q0 20-6.667 37.333t-18.933 30.667q-12 13.066-28.8 20.533-16.534 7.467-36.267 7.467T300.563 589.733t-28.8-20.533q-12-13.334-18.667-30.667-6.666-17.333-6.666-37.333t6.666-37.334 18.667-30.4q12.267-13.333 28.8-20.8 16.533-7.466 36.267-7.466 19.733 0 36.266 7.466 16.8 7.467 28.8 20.8 12.267 13.067 18.934 30.4 6.667 17.334 6.667 37.334m-34.667 0q0-17.867-7.2-32.267-7.2-14.667-19.734-23.2-12.533-8.533-29.066-8.533T307.763 445.733t-19.733 22.933q-6.934 14.4-6.934 32.534 0 17.866 6.934 32.533 7.2 14.4 19.733 22.933 12.533 8.534 29.067 8.534t29.066-8.534 19.734-22.933q7.2-14.667 7.2-32.533"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
// Tauri doesn't have a Node.js server to do proper SSR
|
||||||
|
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||||
|
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||||
|
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||||
|
import adapter from "@sveltejs/adapter-static";
|
||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
fallback: "index.html",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
|
// @ts-expect-error type error without @types/node package
|
||||||
|
import process from "node:process";
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
|
||||||
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
|
//
|
||||||
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
clearScreen: false,
|
||||||
|
// 2. tauri expects a fixed port, fail if that port is not available
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || "127.0.0.1",
|
||||||
|
hmr: host
|
||||||
|
? {
|
||||||
|
protocol: "ws",
|
||||||
|
host,
|
||||||
|
port: 1421,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
watch: {
|
||||||
|
// 3. tell Vite to ignore watching `src-tauri`
|
||||||
|
ignored: ["**/src-tauri/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "r701"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-stream = "0.3.6"
|
||||||
|
chrono = { version = "0.4.38", default-features = false, features = ["clock"] }
|
||||||
|
serde = { version = "1.0.203", default-features = false, features = ["derive"], optional = true }
|
||||||
|
tokio = { version = "1.53.1", default-features = false, features = ["net", "io-util", "sync"] }
|
||||||
|
tokio-stream = { version = "0.1.19", default-features = false }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
serde = ["chrono/serde", "dep:serde"]
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
unwrap_used = "deny"
|
||||||
|
enum_glob_use = { level = "deny", priority = 1 }
|
||||||
|
pedantic = { level = "deny", priority = -1 }
|
||||||
|
nursery = { level = "deny", priority = -1 }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "macros", "net"] }
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# R701 🕰️
|
||||||
|
|
||||||
|
[](https://www.rust-lang.org)
|
||||||
|
[](https://claude.com/product/claude-code)
|
||||||
|
[](https://choosealicense.com/licenses/agpl-3.0)
|
||||||
|
[](https://buymeacoffee.com/nicolabelluti)
|
||||||
|
<br>
|
||||||
|
[](https://git.nicolabelluti.me/nicolabelluti/r701/actions/?workflow=check-format-and-test.yaml)
|
||||||
|
|
||||||
|
</div><br>
|
||||||
|
|
||||||
|
> A reverse-engineered library to communicate with the
|
||||||
|
> [R701](https://ipsattendant.it/rilevatore-presenze-r701/) by [I.P.S.
|
||||||
|
> Informatica](https://ipsinformatica.info/), written in Rust
|
||||||
|
|
||||||
|
If you want to read about this reverse engineering attempt, check out
|
||||||
|
<https://nicolabelluti.me/series/attendance-reader/>.
|
||||||
|
|
||||||
|
## Library Usage
|
||||||
|
|
||||||
|
1. Add the library to you project:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cargo add r701 --git https://git.nicolabelluti.me/nicolabelluti/r701.git
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Use the library in you project:
|
||||||
|
|
||||||
|
This library is async and built on [tokio](https://tokio.rs); add it to your
|
||||||
|
own `[dependencies]` (e.g. `tokio = { version = "1", features =
|
||||||
|
["rt-multi-thread", "macros"] }`) to run the example below.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use r701::R701;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let r701 = R701::connect("127.0.0.1:5005").await.unwrap();
|
||||||
|
let mut record_stream = r701.into_record_iter().await.unwrap();
|
||||||
|
let mut id = 0;
|
||||||
|
|
||||||
|
println!("No\tMchn\tEnNo\t\tName\t\tMode\tIOMd\tDateTime\t");
|
||||||
|
while let Some(record) = record_stream.next().await {
|
||||||
|
let record = record.unwrap();
|
||||||
|
let name = match r701.get_name(record.employee_id).await.unwrap() {
|
||||||
|
Some(Ok(name)) => name,
|
||||||
|
Some(Err(error)) => String::from_utf8_lossy(error.as_bytes()).into_owned(),
|
||||||
|
None => format!("user #{}", record.employee_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:0>6}\t{}\t{:0>9}\t{: <10}\t{}\t{}\t{}",
|
||||||
|
id + 1,
|
||||||
|
1,
|
||||||
|
record.employee_id,
|
||||||
|
name,
|
||||||
|
35,
|
||||||
|
record.clock as u8,
|
||||||
|
record.datetime.format("%Y/%m/%d %H:%M:%S"),
|
||||||
|
);
|
||||||
|
|
||||||
|
id += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//! An async client library for the R701 attendance reader by I.P.S.
|
||||||
|
//! Informatica, reverse-engineered from its TCP protocol.
|
||||||
|
//!
|
||||||
|
//! Connect with [`R701::connect`], then read attendance records with
|
||||||
|
//! [`R701::into_record_iter`] and resolve employee names with
|
||||||
|
//! [`R701::get_name`].
|
||||||
|
|
||||||
|
mod r701;
|
||||||
|
mod record;
|
||||||
|
mod record_iterator;
|
||||||
|
pub use r701::R701;
|
||||||
|
pub use record::{Clock, Record};
|
||||||
|
pub use record_iterator::RecordIterator;
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
use crate::RecordIterator;
|
||||||
|
use std::io::{Error, ErrorKind::InvalidData, Result};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpStream, ToSocketAddrs};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ConnectionState {
|
||||||
|
tcp_stream: TcpStream,
|
||||||
|
sequence_number: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A connection to an R701 attendance reader.
|
||||||
|
///
|
||||||
|
/// Create one with [`R701::connect`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct R701 {
|
||||||
|
state: Mutex<ConnectionState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl R701 {
|
||||||
|
/// Connects to an R701 device at `connection_info` and pings it to
|
||||||
|
/// make sure it's responding correctly.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `Err` if the TCP connection can't be established, or if the
|
||||||
|
/// initial ping doesn't get the expected response.
|
||||||
|
pub async fn connect(connection_info: impl ToSocketAddrs + Send) -> Result<Self> {
|
||||||
|
// Create a new R701 struct
|
||||||
|
let new = Self {
|
||||||
|
state: Mutex::new(ConnectionState {
|
||||||
|
tcp_stream: TcpStream::connect(connection_info).await?,
|
||||||
|
sequence_number: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to ping the endpoint
|
||||||
|
new.ping().await?;
|
||||||
|
Ok(new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps `payload` in the device's request envelope (header and
|
||||||
|
/// sequence number) and sends it, then reads back exactly
|
||||||
|
/// `response_len` bytes of response.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `Err` if writing the request or reading the response fails,
|
||||||
|
/// including if the connection closes before `response_len` bytes have
|
||||||
|
/// been received.
|
||||||
|
// The lock must stay held for the whole write+read round-trip, so that
|
||||||
|
// concurrent callers can't interleave their requests on the wire
|
||||||
|
#[allow(clippy::significant_drop_tightening)]
|
||||||
|
pub async fn request(&self, payload: &[u8; 12], response_len: usize) -> Result<Vec<u8>> {
|
||||||
|
let mut state = self.state.lock().await;
|
||||||
|
|
||||||
|
// Create a blank request
|
||||||
|
let mut request = [0x55, 0xaa, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
|
|
||||||
|
// Insert the payload
|
||||||
|
request[2..14].clone_from_slice(payload);
|
||||||
|
|
||||||
|
// Insert the sequence number
|
||||||
|
request[14..].clone_from_slice(&state.sequence_number.to_le_bytes());
|
||||||
|
state.sequence_number = state.sequence_number.wrapping_add(1);
|
||||||
|
|
||||||
|
// Send the request
|
||||||
|
state.tcp_stream.write_all(&request).await?;
|
||||||
|
|
||||||
|
// Read exactly response_len bytes, looping over multiple reads if
|
||||||
|
// the response arrives split across several TCP packets
|
||||||
|
let mut buffer = vec![0u8; response_len];
|
||||||
|
state.tcp_stream.read_exact(&mut buffer).await?;
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pings the device to check that it's connected and responding.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `Err` if the underlying request fails, or if the device
|
||||||
|
/// responds with anything other than the expected acknowledgement.
|
||||||
|
pub async fn ping(&self) -> Result<()> {
|
||||||
|
// Create a request with a payload of `01 80 00 00 00 00 00 00 00 00 00
|
||||||
|
// 00` and get the response
|
||||||
|
let response = self
|
||||||
|
.request(&[0x01, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 10)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// If the response is not `aa 55 01 01 00 00 00 00 00 00` then return an
|
||||||
|
// error
|
||||||
|
if response != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0] {
|
||||||
|
return Err(Error::new(InvalidData, "Malformed response"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Looks up the name of the employee with the given `id`.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` if no employee with that ID is registered on the
|
||||||
|
/// device. If one is registered but its name isn't valid UTF-8, the
|
||||||
|
/// inner `Err` carries the original bytes (via
|
||||||
|
/// `FromUtf8Error::as_bytes`), letting the caller choose to fail or
|
||||||
|
/// fall back to a lossy rendering.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `Err` if the underlying request fails, or if the response
|
||||||
|
/// has an unexpected header.
|
||||||
|
pub async fn get_name(
|
||||||
|
&self,
|
||||||
|
id: u32,
|
||||||
|
) -> Result<Option<std::result::Result<String, std::string::FromUtf8Error>>> {
|
||||||
|
// Create a request with a payload of `01 c7 xx xx xx xx 00 00 00 00 14
|
||||||
|
// 00`, where `xx xx xx xx` is the employee ID in little endian
|
||||||
|
let mut request = [0x01, 0xc7, 0, 0, 0, 0, 0, 0, 0, 0, 0x14, 0];
|
||||||
|
request[2..6].clone_from_slice(&id.to_le_bytes());
|
||||||
|
|
||||||
|
// Get the response
|
||||||
|
let response = self.request(&request, 34).await?;
|
||||||
|
|
||||||
|
// If the header is `01 00 00 00 00 00 00 00` then the request has
|
||||||
|
// been succesful but the name was not found
|
||||||
|
if response[..12] == [0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa] {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the response header is wrong return an error
|
||||||
|
if response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa] {
|
||||||
|
return Err(Error::new(InvalidData, "Malformed response"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the name as a UTF-8 string and delete the `\0` at the end
|
||||||
|
let name = match String::from_utf8(response[12..22].to_vec()) {
|
||||||
|
Ok(name) => name.trim_end_matches('\0').to_string(),
|
||||||
|
Err(error) => return Ok(Some(Err(error))),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return None if the name is empty, else return the name wrapped into a
|
||||||
|
// Some
|
||||||
|
Ok(Some(name).filter(|name| !name.is_empty()).map(Ok))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total number of attendance records currently stored on
|
||||||
|
/// the device.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `Err` if the underlying request fails, or if the response
|
||||||
|
/// has an unexpected header.
|
||||||
|
pub async fn get_total_record_count(&self) -> Result<u16> {
|
||||||
|
// Create a request with a payload of `01 b4 08 00 00 00 00 00 ff ff 00
|
||||||
|
// 00` and get the response
|
||||||
|
let response = self
|
||||||
|
.request(&[0x01, 0xb4, 0x08, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0], 10)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// If one between the response header or the trailing bytes is wrong
|
||||||
|
// return an error
|
||||||
|
if response[..4] != [0xaa, 0x55, 0x01, 0x01] || response[6..] != [0u8; 4] {
|
||||||
|
return Err(Error::new(InvalidData, "Malformed response"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the payload into a u16 and return it
|
||||||
|
let mut record_count_le = [0u8; 2];
|
||||||
|
record_count_le.clone_from_slice(&response[4..6]);
|
||||||
|
Ok(u16::from_le_bytes(record_count_le))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches one 1024-byte block of raw record data.
|
||||||
|
///
|
||||||
|
/// `sequence_number` selects which block to fetch, except that `0`
|
||||||
|
/// always fetches the first block regardless of `total_records`,
|
||||||
|
/// following the device's own request convention. Used internally by
|
||||||
|
/// [`RecordIterator`] — most callers should use
|
||||||
|
/// [`R701::into_record_iter`] instead.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `Err` if the underlying request fails, or if the response
|
||||||
|
/// has an unexpected header or trailing bytes.
|
||||||
|
pub async fn get_record_bytes(
|
||||||
|
&self,
|
||||||
|
total_records: u16,
|
||||||
|
sequence_number: u16,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
// Create a request with a payload of `01 a4 00 00 00 00 xx xx xx xx 00
|
||||||
|
// 04`, where `xx xx xx xx` changes meaning based on the sequence_number
|
||||||
|
// value
|
||||||
|
let mut request: [u8; 12] = [0x01, 0xa4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x04];
|
||||||
|
|
||||||
|
// If sequence_number is 0 then `xx xx xx xx` is `xx xx 00 00`, where
|
||||||
|
// `xx xx` is total_records in little endian
|
||||||
|
//
|
||||||
|
// Else `xx xx xx xx` is `00 00 xx xx`, where `xx xx` is sequence_number
|
||||||
|
// in little endian
|
||||||
|
if sequence_number == 0 {
|
||||||
|
request[6..8].clone_from_slice(&total_records.to_le_bytes());
|
||||||
|
} else {
|
||||||
|
request[8..10].clone_from_slice(&sequence_number.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the response
|
||||||
|
let response = self.request(&request, 1038).await?;
|
||||||
|
|
||||||
|
// If one between the response header or the last two bits is wrong
|
||||||
|
// return an error
|
||||||
|
if response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|
||||||
|
|| response[1036..] != [0, 0]
|
||||||
|
{
|
||||||
|
return Err(Error::new(InvalidData, "Malformed response"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return only the payload bits as a vector
|
||||||
|
Ok(response[12..1036].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts streaming all attendance records currently stored on the
|
||||||
|
/// device, newest first — fetching the most recent records first is
|
||||||
|
/// faster when you don't need the full history; reverse the order
|
||||||
|
/// yourself if you need chronological output.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `Err` if fetching the total record count or the first block
|
||||||
|
/// of record data fails.
|
||||||
|
pub async fn into_record_iter(&self) -> Result<RecordIterator<'_>> {
|
||||||
|
RecordIterator::from(self).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,37 +2,49 @@ use chrono::{DateTime, Local, TimeZone};
|
|||||||
#[cfg(feature = "serde")]
|
#[cfg(feature = "serde")]
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
/// Which clock an employee punched: entering or leaving, for one of the
|
||||||
|
/// device's two configured time slots.
|
||||||
|
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
||||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
pub enum Clock {
|
pub enum Clock {
|
||||||
#[default]
|
/// Entering during the first time slot
|
||||||
FirstIn,
|
FirstIn,
|
||||||
|
/// Leaving during the first time slot
|
||||||
FirstOut,
|
FirstOut,
|
||||||
|
/// Entering during the second time slot
|
||||||
SecondIn,
|
SecondIn,
|
||||||
|
/// Leaving during the second time slot
|
||||||
SecondOut,
|
SecondOut,
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe impl Send for Clock {}
|
/// A single attendance record read from the device.
|
||||||
unsafe impl Sync for Clock {}
|
///
|
||||||
|
/// Identifies the employee who punched, whether they were entering or
|
||||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
/// leaving, and when.
|
||||||
|
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
||||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
pub struct Record {
|
pub struct Record {
|
||||||
|
/// The employee's numeric ID, as configured on the device
|
||||||
pub employee_id: u32,
|
pub employee_id: u32,
|
||||||
|
/// Whether the employee was entering or leaving
|
||||||
pub clock: Clock,
|
pub clock: Clock,
|
||||||
|
/// When the record was punched
|
||||||
pub datetime: DateTime<Local>,
|
pub datetime: DateTime<Local>,
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe impl Send for Record {}
|
|
||||||
unsafe impl Sync for Record {}
|
|
||||||
|
|
||||||
impl TryFrom<&[u8]> for Record {
|
impl TryFrom<&[u8]> for Record {
|
||||||
type Error = &'static str;
|
type Error = &'static str;
|
||||||
|
|
||||||
|
/// Parses a single 12-byte record as read from the device.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `Err` if `record_bytes` isn't exactly 12 bytes long, or if
|
||||||
|
/// the packed date/time bits don't form a valid calendar date.
|
||||||
fn try_from(record_bytes: &[u8]) -> Result<Self, Self::Error> {
|
fn try_from(record_bytes: &[u8]) -> Result<Self, Self::Error> {
|
||||||
// Return an error if the slice length is less than 12
|
// Return an error if the slice length is less than 12
|
||||||
if record_bytes.len() < 12 {
|
if record_bytes.len() != 12 {
|
||||||
return Err("Slice must be at least of length 12 to be converted into Record");
|
return Err("Slice must be of length 12 to be converted into Record");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the employee ID from the central 4 bytes of the record in
|
// Extract the employee ID from the central 4 bytes of the record in
|
||||||
@@ -48,6 +60,7 @@ impl TryFrom<&[u8]> for Record {
|
|||||||
let datetime = u32::from_le_bytes(datetime);
|
let datetime = u32::from_le_bytes(datetime);
|
||||||
|
|
||||||
// Return a new Record
|
// Return a new Record
|
||||||
|
#[allow(clippy::cast_possible_wrap)]
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
employee_id,
|
employee_id,
|
||||||
clock: match record_bytes[1] >> 6 {
|
clock: match record_bytes[1] >> 6 {
|
||||||
@@ -55,7 +68,7 @@ impl TryFrom<&[u8]> for Record {
|
|||||||
1 => Clock::FirstOut,
|
1 => Clock::FirstOut,
|
||||||
2 => Clock::SecondIn,
|
2 => Clock::SecondIn,
|
||||||
3 => Clock::SecondOut,
|
3 => Clock::SecondOut,
|
||||||
_ => panic!("Math has broken"),
|
_ => unreachable!("Math has broken"),
|
||||||
},
|
},
|
||||||
datetime: Local
|
datetime: Local
|
||||||
.with_ymd_and_hms(
|
.with_ymd_and_hms(
|
||||||
@@ -94,9 +107,9 @@ mod tests {
|
|||||||
datetime: Local
|
datetime: Local
|
||||||
.with_ymd_and_hms(1970, 1, 1, 0, 0, 0)
|
.with_ymd_and_hms(1970, 1, 1, 0, 0, 0)
|
||||||
.single()
|
.single()
|
||||||
.unwrap(),
|
.expect("Datetime is not unique!"),
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -105,8 +118,8 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
record_bytes.try_into(),
|
record_bytes.try_into(),
|
||||||
Err::<Record, &str>("Slice must be at least of length 12 to be converted into Record")
|
Err::<Record, &str>("Slice must be of length 12 to be converted into Record")
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -116,6 +129,6 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
record_bytes.try_into(),
|
record_bytes.try_into(),
|
||||||
Err::<Record, &str>("DateTime conversion error")
|
Err::<Record, &str>("DateTime conversion error")
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
use crate::{Record, R701};
|
||||||
|
use std::io::{Error, ErrorKind::InvalidData, Result};
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
use tokio_stream::Stream;
|
||||||
|
|
||||||
|
/// An async stream of [`Record`]s read from an [`R701`] device.
|
||||||
|
///
|
||||||
|
/// Create one with [`R701::into_record_iter`]. Implements [`Stream`], so
|
||||||
|
/// consume it with [`tokio_stream::StreamExt::next`] (or any other
|
||||||
|
/// `Stream` combinator).
|
||||||
|
pub struct RecordIterator<'a> {
|
||||||
|
inner: Pin<Box<dyn Stream<Item = Result<Record>> + Send + 'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> RecordIterator<'a> {
|
||||||
|
/// Builds a new record stream, immediately fetching the device's total
|
||||||
|
/// record count and the first block of record data.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `Err` if fetching the total record count or the first block
|
||||||
|
/// of record data fails.
|
||||||
|
pub async fn from(r701: &'a R701) -> Result<Self> {
|
||||||
|
// Get the total number of records
|
||||||
|
let total_records = r701.get_total_record_count().await?;
|
||||||
|
|
||||||
|
// Calculate the sequence number on which the last record resides and
|
||||||
|
// the index of the first `ff` byte. The multiplication is done in
|
||||||
|
// u32 to avoid overflowing total_records * 12 (up to 786_420); the
|
||||||
|
// division result (at most 786_420 / 1024 = 768) always fits back
|
||||||
|
// into a u16, so the truncating cast below can never actually lose
|
||||||
|
// data
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
let mut sequence_number = (u32::from(total_records) * 12 / 1024) as u16;
|
||||||
|
let first_useless_byte_index = total_records as usize * 12 % 1024;
|
||||||
|
|
||||||
|
// The endpoint expects the first block of records to be sent first
|
||||||
|
r701.get_record_bytes(total_records, 0).await?;
|
||||||
|
|
||||||
|
// Get the last records and cut out all the trailing `ff` bytes
|
||||||
|
let mut input_buffer = r701
|
||||||
|
.get_record_bytes(total_records, sequence_number)
|
||||||
|
.await?;
|
||||||
|
input_buffer.truncate(first_useless_byte_index);
|
||||||
|
|
||||||
|
let mut record_count = total_records;
|
||||||
|
|
||||||
|
// Build the record stream, yielding an `Err` both on network errors and
|
||||||
|
// on malformed records, so the caller can fail
|
||||||
|
let inner = async_stream::try_stream! {
|
||||||
|
while record_count != 0 {
|
||||||
|
record_count -= 1;
|
||||||
|
|
||||||
|
// If the input buffer is almost empty, make another request to
|
||||||
|
// the endpoint asking for more data
|
||||||
|
if input_buffer.len() < 12 {
|
||||||
|
// If the buffer is almost empty but the sequence number is
|
||||||
|
// already zero, stop iterating
|
||||||
|
if sequence_number == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence_number -= 1;
|
||||||
|
|
||||||
|
// Request new bytes
|
||||||
|
let bytes = r701.get_record_bytes(total_records, sequence_number).await?;
|
||||||
|
|
||||||
|
// Put the bytes at the start of the vector
|
||||||
|
input_buffer.splice(0..0, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield a new Record
|
||||||
|
yield input_buffer
|
||||||
|
.drain(input_buffer.len() - 12..)
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|error: &str| Error::new(InvalidData, error))?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return a new Iterator
|
||||||
|
Ok(Self {
|
||||||
|
inner: Box::pin(inner),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stream for RecordIterator<'_> {
|
||||||
|
type Item = Result<Record>;
|
||||||
|
|
||||||
|
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||||
|
self.inner.as_mut().poll_next(cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//! End-to-end tests exercising R701's public API against the emulated
|
||||||
|
//! device in `mock`.
|
||||||
|
|
||||||
|
mod mock;
|
||||||
|
|
||||||
|
use r701::R701;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connects_and_pings_successfully() {
|
||||||
|
let addr = mock::spawn().await;
|
||||||
|
assert!(R701::connect(addr).await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
async fn gets_employee_name() {
|
||||||
|
let addr = mock::spawn().await;
|
||||||
|
let r701 = R701::connect(addr).await.unwrap();
|
||||||
|
let expected_name = mock::fake_users()[&1].clone();
|
||||||
|
|
||||||
|
match r701.get_name(1).await.unwrap() {
|
||||||
|
Some(Ok(name)) => assert_eq!(name, expected_name),
|
||||||
|
other => panic!("expected Some(Ok({expected_name:?})), got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
async fn gets_none_for_unknown_employee() {
|
||||||
|
let addr = mock::spawn().await;
|
||||||
|
let r701 = R701::connect(addr).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(r701.get_name(999).await.unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[allow(clippy::unwrap_used, clippy::cast_possible_truncation)]
|
||||||
|
async fn gets_total_record_count() {
|
||||||
|
let addr = mock::spawn().await;
|
||||||
|
let r701 = R701::connect(addr).await.unwrap();
|
||||||
|
let expected_count = mock::fake_records().len() as u16;
|
||||||
|
|
||||||
|
assert_eq!(r701.get_total_record_count().await.unwrap(), expected_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
async fn streams_records() {
|
||||||
|
let addr = mock::spawn().await;
|
||||||
|
let r701 = R701::connect(addr).await.unwrap();
|
||||||
|
// The device streams newest first, the reverse of fake_records' order
|
||||||
|
let expected_records: Vec<_> = mock::fake_records().into_iter().rev().collect();
|
||||||
|
|
||||||
|
let mut records = r701.into_record_iter().await.unwrap();
|
||||||
|
for expected in expected_records {
|
||||||
|
let record = records.next().await.unwrap().unwrap();
|
||||||
|
assert_eq!(record, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(records.next().await.is_none());
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//! Emulates an R701 device so `tests/e2e.rs` can exercise the real `R701`
|
||||||
|
//! client against it, grounded in the protocol documented at
|
||||||
|
//! <https://nicolabelluti.me/posts/2024/05/studying-a-communication-protocol/>.
|
||||||
|
|
||||||
|
use chrono::{Datelike, Duration, Local, TimeZone, Timelike};
|
||||||
|
use r701::{Clock, Record};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
/// The employee names the fake device reports
|
||||||
|
pub fn fake_users() -> HashMap<u32, String> {
|
||||||
|
HashMap::from([(1, "alice".to_string()), (2, "bob".to_string())])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attendance records the fake device reports: a full year of classic
|
||||||
|
/// workday punches (09:00 - 13:00, 14:00 - 18:00) for the employees in
|
||||||
|
/// `fake_users()`, staggered by punch time rather than grouped by employee.
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
pub fn fake_records() -> Vec<Record> {
|
||||||
|
let first_day = Local
|
||||||
|
.with_ymd_and_hms(2026, 1, 1, 9, 0, 0)
|
||||||
|
.single()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut employee_ids: Vec<u32> = fake_users().into_keys().collect();
|
||||||
|
employee_ids.sort_unstable();
|
||||||
|
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for day in 0..365 {
|
||||||
|
let morning_in = first_day + Duration::days(day);
|
||||||
|
let morning_out = morning_in + Duration::hours(4);
|
||||||
|
let afternoon_in = morning_out + Duration::hours(1);
|
||||||
|
let afternoon_out = afternoon_in + Duration::hours(4);
|
||||||
|
|
||||||
|
for (clock, datetime) in [
|
||||||
|
(Clock::FirstIn, morning_in),
|
||||||
|
(Clock::FirstOut, morning_out),
|
||||||
|
(Clock::SecondIn, afternoon_in),
|
||||||
|
(Clock::SecondOut, afternoon_out),
|
||||||
|
] {
|
||||||
|
for &employee_id in &employee_ids {
|
||||||
|
records.push(Record {
|
||||||
|
employee_id,
|
||||||
|
clock,
|
||||||
|
datetime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a fake R701 device in the background and returns the address to
|
||||||
|
/// connect to.
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
pub async fn spawn() -> String {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap().to_string();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (mut socket, _) = listener.accept().await.unwrap();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut request = [0u8; 16];
|
||||||
|
let Ok(bytes_read) = socket.read(&mut request).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if bytes_read == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = if bytes_read < 16 {
|
||||||
|
vec![0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0]
|
||||||
|
} else if request[..2] != [0x55, 0xaa] {
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
let payload = &request[2..14];
|
||||||
|
match (payload[0], payload[1]) {
|
||||||
|
(0x01, 0x80) => vec![0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0],
|
||||||
|
(0x01, 0xc7) => get_name_response(payload),
|
||||||
|
(0x01, 0xb4) => get_total_record_count_response(),
|
||||||
|
(0x01, 0xa4) => get_record_bytes_response(payload),
|
||||||
|
_ => vec![0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if socket.write_all(&response).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the 34-byte get-name response: a 12-byte header (whether the
|
||||||
|
/// employee was found), followed by their name (if found) null-padded to
|
||||||
|
/// 10 bytes, followed by 12 don't-care bytes.
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
fn get_name_response(payload: &[u8]) -> Vec<u8> {
|
||||||
|
let employee_id = u32::from_le_bytes(payload[2..6].try_into().unwrap());
|
||||||
|
let mut response = [0u8; 34];
|
||||||
|
|
||||||
|
if let Some(name) = fake_users().get(&employee_id) {
|
||||||
|
response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]);
|
||||||
|
let name_bytes = name.as_bytes();
|
||||||
|
let len = name_bytes.len().min(10);
|
||||||
|
response[12..12 + len].clone_from_slice(&name_bytes[..len]);
|
||||||
|
} else {
|
||||||
|
response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the 10-byte get-total-record-count response.
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
fn get_total_record_count_response() -> Vec<u8> {
|
||||||
|
let count = fake_records().len() as u16;
|
||||||
|
let mut response = [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0];
|
||||||
|
response[4..6].clone_from_slice(&count.to_le_bytes());
|
||||||
|
response.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the 1038-byte get-record-bytes response: a 12-byte header, the
|
||||||
|
/// requested 1024-byte block of records (`ff`-padded past the last real
|
||||||
|
/// one, since records don't align to 1024-byte block boundaries), and 2
|
||||||
|
/// trailing bytes.
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
fn get_record_bytes_response(payload: &[u8]) -> Vec<u8> {
|
||||||
|
let sequence_number = u16::from_le_bytes(payload[8..10].try_into().unwrap());
|
||||||
|
let all_bytes: Vec<u8> = fake_records().iter().flat_map(encode_record).collect();
|
||||||
|
|
||||||
|
let start = sequence_number as usize * 1024;
|
||||||
|
let mut block = [0xffu8; 1024];
|
||||||
|
let available = all_bytes.len().saturating_sub(start).min(1024);
|
||||||
|
block[..available].clone_from_slice(&all_bytes[start..start + available]);
|
||||||
|
|
||||||
|
let mut response = vec![0u8; 1038];
|
||||||
|
response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]);
|
||||||
|
response[12..1036].clone_from_slice(&block);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a [`Record`] back into its 12-byte wire representation (the
|
||||||
|
/// inverse of `Record`'s `TryFrom<&[u8]>`). The "unknown" bytes that
|
||||||
|
/// `Record` doesn't decode are left as 0, since they only need to
|
||||||
|
/// round-trip through our own parsing, not match a real device capture.
|
||||||
|
fn encode_record(record: &Record) -> [u8; 12] {
|
||||||
|
let mut bytes = [0u8; 12];
|
||||||
|
|
||||||
|
let clock_bits: u8 = match record.clock {
|
||||||
|
Clock::FirstIn => 0,
|
||||||
|
Clock::FirstOut => 1,
|
||||||
|
Clock::SecondIn => 2,
|
||||||
|
Clock::SecondOut => 3,
|
||||||
|
};
|
||||||
|
bytes[1] = clock_bits << 6;
|
||||||
|
|
||||||
|
bytes[4..8].copy_from_slice(&record.employee_id.to_le_bytes());
|
||||||
|
|
||||||
|
#[allow(clippy::cast_sign_loss)]
|
||||||
|
let year = record.datetime.year() as u32 & 0x0fff;
|
||||||
|
let month = record.datetime.month() & 0x0f;
|
||||||
|
let day = record.datetime.day() & 0x1f;
|
||||||
|
let hour = record.datetime.hour() & 0x1f;
|
||||||
|
let minute = record.datetime.minute() & 0x3f;
|
||||||
|
let packed = year | (month << 12) | (day << 16) | (hour << 21) | (minute << 26);
|
||||||
|
bytes[8..12].copy_from_slice(&packed.to_le_bytes());
|
||||||
|
|
||||||
|
bytes
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mod r701;
|
|
||||||
mod record;
|
|
||||||
mod record_iterator;
|
|
||||||
pub use r701::R701;
|
|
||||||
pub use record::{Clock, Record};
|
|
||||||
pub use record_iterator::RecordIterator;
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
use crate::RecordIterator;
|
|
||||||
use std::io::{BufRead, BufReader, Error, ErrorKind::InvalidData, Result, Write};
|
|
||||||
use std::net::{TcpStream, ToSocketAddrs};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct R701 {
|
|
||||||
tcp_stream: TcpStream,
|
|
||||||
sequence_number: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl R701 {
|
|
||||||
pub fn connect(connection_info: impl ToSocketAddrs) -> Result<Self> {
|
|
||||||
// Create a new R701 struct
|
|
||||||
let mut new = Self {
|
|
||||||
tcp_stream: TcpStream::connect(connection_info)?,
|
|
||||||
sequence_number: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to ping the endpoint
|
|
||||||
new.ping()?;
|
|
||||||
Ok(new)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn request(&mut self, payload: &[u8; 12]) -> Result<Vec<u8>> {
|
|
||||||
// Create a blank request
|
|
||||||
let mut request = [0x55, 0xaa, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
|
||||||
|
|
||||||
// Insert the payload
|
|
||||||
request[2..14].clone_from_slice(payload);
|
|
||||||
|
|
||||||
// Insert the sequence number
|
|
||||||
request[14..].clone_from_slice(&self.sequence_number.to_le_bytes());
|
|
||||||
self.sequence_number += 1;
|
|
||||||
|
|
||||||
// Send the request
|
|
||||||
self.tcp_stream.write_all(&request)?;
|
|
||||||
|
|
||||||
// Create a buffer and return the response
|
|
||||||
let mut buffer = BufReader::new(&self.tcp_stream);
|
|
||||||
Ok(buffer.fill_buf()?.to_vec())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ping(&mut self) -> Result<()> {
|
|
||||||
// Create a request with a payload of `01 80 00 00 00 00 00 00 00 00 00
|
|
||||||
// 00` and get the response
|
|
||||||
let response = self.request(&[0x01, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])?;
|
|
||||||
|
|
||||||
// If the response is not `aa 55 01 01 00 00 00 00 00 00` then return an
|
|
||||||
// error
|
|
||||||
if response != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0] {
|
|
||||||
return Err(Error::new(InvalidData, "Malformed response"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_name(&mut self, id: u32) -> Result<Option<String>> {
|
|
||||||
// Create a request with a payload of `01 c7 xx xx xx xx 00 00 00 00 14
|
|
||||||
// 00`, where `xx xx xx xx` is the employee ID in little endian
|
|
||||||
let mut request = [0x01, 0xc7, 0, 0, 0, 0, 0, 0, 0, 0, 0x14, 0];
|
|
||||||
request[2..6].clone_from_slice(&id.to_le_bytes());
|
|
||||||
|
|
||||||
// Get the response
|
|
||||||
let response = self.request(&request)?;
|
|
||||||
|
|
||||||
// If the response length is right but the header is `01 00 00 00 00 00
|
|
||||||
// 00 00` then the request is been succesful but the name was not found
|
|
||||||
if response[..12] == [0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|
|
||||||
&& response.len() == 34
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If one between the response length or the response header is wrong
|
|
||||||
// return an error
|
|
||||||
if response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|
|
||||||
|| response.len() != 34
|
|
||||||
{
|
|
||||||
return Err(Error::new(InvalidData, "Malformed response"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the name as a UTF-8 string and delete the `\0` at the end
|
|
||||||
Ok(Some(
|
|
||||||
String::from_utf8_lossy(&response[12..22])
|
|
||||||
.trim_end_matches(char::from(0))
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_total_record_count(&mut self) -> Result<u16> {
|
|
||||||
// Create a request with a payload of `01 b4 08 00 00 00 00 00 ff ff 00
|
|
||||||
// 00` and get the response
|
|
||||||
let response = self.request(&[0x01, 0xb4, 0x08, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0])?;
|
|
||||||
|
|
||||||
// If one between the response length or the response header is wrong
|
|
||||||
// return an error
|
|
||||||
if response[..4] != [0xaa, 0x55, 0x01, 0x01]
|
|
||||||
|| response[6..] != [0u8; 4]
|
|
||||||
|| response.len() != 10
|
|
||||||
{
|
|
||||||
return Err(Error::new(InvalidData, "Malformed response"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert the payload into a u16 and return it
|
|
||||||
let mut record_count_le = [0u8; 2];
|
|
||||||
record_count_le.clone_from_slice(&response[4..6]);
|
|
||||||
Ok(u16::from_le_bytes(record_count_le))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_record_bytes(
|
|
||||||
&mut self,
|
|
||||||
total_records: u16,
|
|
||||||
sequence_number: u16,
|
|
||||||
) -> Result<Vec<u8>> {
|
|
||||||
// Create a request with a payload of `01 a4 00 00 00 00 xx xx xx xx 00
|
|
||||||
// 04`, where `xx xx xx xx` changes meaning based on the sequence_number
|
|
||||||
// value
|
|
||||||
let mut request: [u8; 12] = [0x01, 0xa4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x04];
|
|
||||||
|
|
||||||
// If sequence_number is 0 then `xx xx xx xx` is `xx xx 00 00`, where
|
|
||||||
// `xx xx` is total_records in little endian
|
|
||||||
//
|
|
||||||
// Else `xx xx xx xx` is `00 00 xx xx`, where `xx xx` is sequence_number
|
|
||||||
// in little endian
|
|
||||||
if sequence_number == 0 {
|
|
||||||
request[6..8].clone_from_slice(&total_records.to_le_bytes());
|
|
||||||
} else {
|
|
||||||
request[8..10].clone_from_slice(&sequence_number.to_le_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the response
|
|
||||||
let response = self.request(&request)?;
|
|
||||||
|
|
||||||
// If one between the response length, the response header or the last
|
|
||||||
// two bits is wrong return an error
|
|
||||||
if response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|
|
||||||
|| response[1036..] != [0, 0]
|
|
||||||
|| response.len() != 1038
|
|
||||||
{
|
|
||||||
return Err(Error::new(InvalidData, "Malformed response"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return only the payload bits as a vector
|
|
||||||
Ok(response[12..response.len() - 2].to_vec())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn iter(&mut self) -> Result<RecordIterator> {
|
|
||||||
RecordIterator::from(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
use crate::{Record, R701};
|
|
||||||
use std::io::Result;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct RecordIterator<'a> {
|
|
||||||
r701: &'a mut R701,
|
|
||||||
input_buffer: Vec<u8>,
|
|
||||||
sequence_number: u16,
|
|
||||||
record_count: u16,
|
|
||||||
total_records: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> RecordIterator<'a> {
|
|
||||||
pub fn from(r701: &'a mut R701) -> Result<Self> {
|
|
||||||
// Ping the endpoint
|
|
||||||
r701.ping()?;
|
|
||||||
|
|
||||||
// Get the total number of records
|
|
||||||
let total_records = r701.get_total_record_count()?;
|
|
||||||
|
|
||||||
// Return a new Iterator
|
|
||||||
Ok(Self {
|
|
||||||
r701,
|
|
||||||
input_buffer: Vec::new(),
|
|
||||||
record_count: 0,
|
|
||||||
sequence_number: 0,
|
|
||||||
total_records,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Iterator for RecordIterator<'a> {
|
|
||||||
type Item = Record;
|
|
||||||
|
|
||||||
fn next(&mut self) -> Option<Self::Item> {
|
|
||||||
self.record_count += 1;
|
|
||||||
|
|
||||||
// If we exceeded the total number of records, return None
|
|
||||||
if self.record_count > self.total_records {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the buffer is empty, make another request to the endpoint asking
|
|
||||||
// for more data
|
|
||||||
if self.input_buffer.len() < 12 {
|
|
||||||
let bytes = &mut self
|
|
||||||
.r701
|
|
||||||
.get_record_bytes(self.total_records, self.sequence_number)
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
self.input_buffer.append(bytes);
|
|
||||||
self.sequence_number += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the record bytes are set to `ff ff ff ff ff ff ff ff ff ff ff ff`,
|
|
||||||
// return None
|
|
||||||
if self.input_buffer[..12] == [0xff_u8; 12] {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a new Record
|
|
||||||
self.input_buffer.drain(..12).as_slice().try_into().ok()
|
|
||||||
}
|
|
||||||
}
|
|
||||||