Compare commits

...

7 Commits

Author SHA1 Message Date
che 2032725563 fix 2026-07-25 15:20:15 +05:00
che c564608983 fix 2026-07-25 14:49:14 +05:00
che 6e56cfc9cd fix 2026-07-25 13:51:28 +05:00
che 6e64b97150 fix 2026-07-25 13:48:34 +05:00
che 249a4b455e fix 2026-07-25 13:18:07 +05:00
che 0544d41b54 fix 2026-07-25 11:15:39 +05:00
che 860ba21dd0 fix 2026-07-25 10:31:32 +05:00
89 changed files with 4658 additions and 1008 deletions
+286
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"pdfjs-dist": "^5.6.205",
"vant": "^4.10.0", "vant": "^4.10.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
@@ -516,6 +517,271 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2", "version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
@@ -1551,6 +1817,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1558,6 +1831,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdfjs-dist": {
"version": "5.6.205",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.96",
"node-readable-to-web-readable-stream": "^0.4.2"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+1
View File
@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"pdfjs-dist": "^5.6.205",
"vant": "^4.10.0", "vant": "^4.10.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
+145 -8
View File
@@ -524,6 +524,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "che-orm" name = "che-orm"
version = "0.1.0" version = "0.1.0"
@@ -1175,6 +1181,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"che-orm", "che-orm",
"che-tauri", "che-tauri",
"openssl-sys",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
@@ -1517,8 +1524,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1528,9 +1537,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1877,6 +1888,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -2357,6 +2369,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.38.0" version = "0.38.0"
@@ -2499,7 +2517,7 @@ dependencies = [
"num-integer", "num-integer",
"num-iter", "num-iter",
"num-traits", "num-traits",
"rand", "rand 0.8.7",
"smallvec", "smallvec",
"zeroize", "zeroize",
] ]
@@ -2810,6 +2828,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-src"
version = "300.5.5+3.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.117" version = "0.9.117"
@@ -2818,6 +2845,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
"openssl-src",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]
@@ -3164,6 +3192,61 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.19",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.5",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.19",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.47"
@@ -3192,8 +3275,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -3203,7 +3296,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -3215,6 +3318,15 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -3323,6 +3435,8 @@ dependencies = [
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
@@ -3330,6 +3444,7 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-rustls",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
@@ -3337,6 +3452,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -3400,7 +3516,7 @@ dependencies = [
"num-traits", "num-traits",
"pkcs1", "pkcs1",
"pkcs8", "pkcs8",
"rand_core", "rand_core 0.6.4",
"signature", "signature",
"spki", "spki",
"subtle", "subtle",
@@ -3442,6 +3558,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
@@ -3454,6 +3571,7 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -3822,7 +3940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest",
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@@ -4043,7 +4161,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"rand", "rand 0.8.7",
"rsa", "rsa",
"serde", "serde",
"sha1", "sha1",
@@ -4081,7 +4199,7 @@ dependencies = [
"md-5", "md-5",
"memchr", "memchr",
"once_cell", "once_cell",
"rand", "rand 0.8.7",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -5295,6 +5413,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.5" version = "0.2.5"
@@ -5351,6 +5479,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
+2 -1
View File
@@ -26,4 +26,5 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
openssl-sys = { version = "0.9", features = ["vendored"] }
+1 -1
View File
@@ -2,5 +2,5 @@
url = "sqlite://ewa-mobile.sqlite?mode=rwc" url = "sqlite://ewa-mobile.sqlite?mode=rwc"
[remote] [remote]
base_url = "http://127.0.0.1:8000" base_url = "http://192.168.0.166:8000"
auth_path = "/api-token-auth/" auth_path = "/api-token-auth/"
+2 -1
View File
@@ -5,6 +5,7 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default" "opener:default",
"opener:allow-open-path"
] ]
} }
+12
View File
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
+20
View File
@@ -0,0 +1,20 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
keystore.properties
/.tauri
/tauri.settings.gradle
+6
View File
@@ -0,0 +1,6 @@
/src/main/**/generated
/src/main/jniLibs/**/*.so
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties
@@ -0,0 +1,71 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 36
namespace = "com.che.ewa_mobile"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "com.che.ewa_mobile"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.ewa_mobile"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,11 @@
package com.che.ewa_mobile
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ewa_mobile" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">"ewa-mobile"</string>
<string name="main_activity_title">"ewa-mobile"</string>
</resources>
@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ewa_mobile" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+22
View File
@@ -0,0 +1,22 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}
@@ -0,0 +1,23 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}
@@ -0,0 +1,68 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """npm""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
// Try different Windows-specific extensions
val fallbacks = listOf(
"$executable.exe",
"$executable.cmd",
"$executable.bat",
)
var lastException: Exception = e
for (fallback in fallbacks) {
try {
runTauriCli(fallback)
return
} catch (fallbackException: Exception) {
lastException = fallbackException
}
}
throw lastException
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("run", "--", "tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}
@@ -0,0 +1,85 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
Binary file not shown.
@@ -0,0 +1,6 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+3
View File
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
+14 -1
View File
@@ -1,6 +1,6 @@
use che_tauri::{Filter, FilterSet}; use che_tauri::{Filter, FilterSet};
use super::models::{Contract, ContractApplicationFile, Counterparty}; use super::models::{Contract, ContractApplicationFile, ContractCategory, Counterparty};
static CONTRACTAPP_FILTERS: &[Filter] = &[ static CONTRACTAPP_FILTERS: &[Filter] = &[
Filter::exact("id"), Filter::exact("id"),
@@ -16,6 +16,7 @@ static CONTRACTAPP_FILTERS: &[Filter] = &[
Filter::contains("contract_type"), Filter::contains("contract_type"),
Filter::exact("category"), Filter::exact("category"),
Filter::contains("category"), Filter::contains("category"),
Filter::exact("category_id"),
Filter::contains("counterparty_name"), Filter::contains("counterparty_name"),
Filter::contains("name_of_product"), Filter::contains("name_of_product"),
Filter::exact("counterparty_id"), Filter::exact("counterparty_id"),
@@ -24,6 +25,14 @@ static CONTRACTAPP_FILTERS: &[Filter] = &[
Filter::exact("employee_id"), Filter::exact("employee_id"),
]; ];
static CONTRACT_CATEGORY_FILTERS: &[Filter] = &[
Filter::exact("id"),
Filter::exact("name"),
Filter::contains("name"),
Filter::exact("name_group"),
Filter::contains("name_group"),
];
static COUNTERPARTY_FILTERS: &[Filter] = &[ static COUNTERPARTY_FILTERS: &[Filter] = &[
Filter::exact("id"), Filter::exact("id"),
Filter::exact("name"), Filter::exact("name"),
@@ -43,6 +52,10 @@ pub fn contractapp_filterset() -> FilterSet<Contract> {
FilterSet::new(CONTRACTAPP_FILTERS) FilterSet::new(CONTRACTAPP_FILTERS)
} }
pub fn contract_category_filterset() -> FilterSet<ContractCategory> {
FilterSet::new(CONTRACT_CATEGORY_FILTERS)
}
pub fn counterparty_filterset() -> FilterSet<Counterparty> { pub fn counterparty_filterset() -> FilterSet<Counterparty> {
FilterSet::new(COUNTERPARTY_FILTERS) FilterSet::new(COUNTERPARTY_FILTERS)
} }
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS contract_category (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
name_group TEXT NOT NULL,
template TEXT,
is_questionnair BOOLEAN NOT NULL,
parent INTEGER,
secure_group INTEGER
);
ALTER TABLE contractapp ADD COLUMN category_id INTEGER REFERENCES contract_category(id);
@@ -173,6 +173,88 @@
} }
] ]
}, },
{
"table": "contract_category",
"fields": [
{
"name": "id",
"ty": "integer",
"primary_key": true,
"nullable": false,
"auto": true,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "name",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "name_group",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "template",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "is_questionnair",
"ty": "boolean",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "parent",
"ty": "integer",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "secure_group",
"ty": "integer",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
}
]
},
{ {
"table": "contractapp", "table": "contractapp",
"fields": [ "fields": [
@@ -242,6 +324,20 @@
"default": null, "default": null,
"foreign_key": null "foreign_key": null
}, },
{
"name": "category_id",
"ty": "integer",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": {
"table": "contract_category",
"column": "id"
}
},
{ {
"name": "amount_total_display", "name": "amount_total_display",
"ty": "text", "ty": "text",
+5
View File
@@ -21,6 +21,11 @@ impl AppModule for ContractappModule {
serializers::contractapp_serializer(), serializers::contractapp_serializer(),
filters::contractapp_filterset(), filters::contractapp_filterset(),
); );
ctx.resource::<models::ContractCategory>(
"contract_category",
serializers::contract_category_serializer(),
filters::contract_category_filterset(),
);
ctx.resource::<models::Counterparty>( ctx.resource::<models::Counterparty>(
"counterparty", "counterparty",
serializers::counterparty_serializer(), serializers::counterparty_serializer(),
+18
View File
@@ -15,6 +15,10 @@ pub struct Contract {
pub absolute_url: String, pub absolute_url: String,
pub contract_type: String, pub contract_type: String,
pub category: String, pub category: String,
#[field(foreign_key = ContractCategory)]
pub category_id: Option<i64>,
pub amount_total_display: String, pub amount_total_display: String,
pub amount_by_ds: String, pub amount_by_ds: String,
pub comment: String, pub comment: String,
@@ -48,6 +52,20 @@ pub struct Contract {
pub employee_id: Option<i64>, pub employee_id: Option<i64>,
} }
#[derive(Debug, Clone, Model)]
#[model(table = "contract_category")]
pub struct ContractCategory {
#[field(primary_key)]
pub id: i64,
pub name: String,
pub name_group: String,
pub template: Option<String>,
pub is_questionnair: bool,
pub parent: Option<i64>,
pub secure_group: Option<i64>,
}
#[derive(Debug, Clone, Model)] #[derive(Debug, Clone, Model)]
#[model(table = "counterparty")] #[model(table = "counterparty")]
pub struct Counterparty { pub struct Counterparty {
+18 -1
View File
@@ -6,7 +6,7 @@ use crate::apps::{
projectapp::{models::Project, serializers::project_serializer}, projectapp::{models::Project, serializers::project_serializer},
}; };
use super::models::{Contract, ContractApplicationFile, Counterparty}; use super::models::{Contract, ContractApplicationFile, ContractCategory, Counterparty};
static CONTRACT_FIELDS: &[Field] = &[ static CONTRACT_FIELDS: &[Field] = &[
Field::new("id").read_only(), Field::new("id").read_only(),
@@ -15,6 +15,7 @@ static CONTRACT_FIELDS: &[Field] = &[
Field::new("absolute_url"), Field::new("absolute_url"),
Field::new("contract_type"), Field::new("contract_type"),
Field::new("category"), Field::new("category"),
Field::new("category_id").required(false).nullable(),
Field::new("amount_total_display"), Field::new("amount_total_display"),
Field::new("amount_by_ds"), Field::new("amount_by_ds"),
Field::new("comment"), Field::new("comment"),
@@ -38,12 +39,22 @@ static CONTRACT_FIELDS: &[Field] = &[
Field::new("project_id").required(false).nullable(), Field::new("project_id").required(false).nullable(),
Field::new("frc_id").required(false).nullable(), Field::new("frc_id").required(false).nullable(),
Field::new("employee_id").required(false).nullable(), Field::new("employee_id").required(false).nullable(),
Field::related("category_ref", "category_id", &CATEGORY_RELATION),
Field::related("counterparty", "counterparty_id", &COUNTERPARTY_RELATION), Field::related("counterparty", "counterparty_id", &COUNTERPARTY_RELATION),
Field::related("project", "project_id", &PROJECT_RELATION), Field::related("project", "project_id", &PROJECT_RELATION),
Field::related("frc", "frc_id", &FRC_RELATION), Field::related("frc", "frc_id", &FRC_RELATION),
Field::related("employee", "employee_id", &EMPLOYEE_RELATION), Field::related("employee", "employee_id", &EMPLOYEE_RELATION),
]; ];
static CONTRACT_CATEGORY_FIELDS: &[Field] = &[
Field::new("id").read_only(),
Field::new("name"),
Field::new("name_group"),
Field::new("template").required(false).nullable(),
Field::new("is_questionnair"),
Field::new("parent").required(false).nullable(),
Field::new("secure_group").required(false).nullable(),
];
static COUNTERPARTY_FIELDS: &[Field] = &[Field::new("id").read_only(), Field::new("name")]; static COUNTERPARTY_FIELDS: &[Field] = &[Field::new("id").read_only(), Field::new("name")];
static CONTRACT_APPLICATION_FILE_FIELDS: &[Field] = &[ static CONTRACT_APPLICATION_FILE_FIELDS: &[Field] = &[
Field::new("id").read_only(), Field::new("id").read_only(),
@@ -65,6 +76,8 @@ static CONTRACT_APPLICATION_FILE_FIELDS: &[Field] = &[
]; ];
static COUNTERPARTY_RELATION: RelatedModel<Counterparty> = static COUNTERPARTY_RELATION: RelatedModel<Counterparty> =
RelatedModel::new(counterparty_serializer); RelatedModel::new(counterparty_serializer);
static CATEGORY_RELATION: RelatedModel<ContractCategory> =
RelatedModel::new(contract_category_serializer);
static CONTRACT_RELATION: RelatedModel<Contract> = RelatedModel::new(contractapp_serializer); static CONTRACT_RELATION: RelatedModel<Contract> = RelatedModel::new(contractapp_serializer);
static PROJECT_RELATION: RelatedModel<Project> = RelatedModel::new(project_serializer); static PROJECT_RELATION: RelatedModel<Project> = RelatedModel::new(project_serializer);
static FRC_RELATION: RelatedModel<Frc> = RelatedModel::new(frc_serializer); static FRC_RELATION: RelatedModel<Frc> = RelatedModel::new(frc_serializer);
@@ -74,6 +87,10 @@ pub fn contractapp_serializer() -> ModelSerializer<Contract> {
ModelSerializer::new(CONTRACT_FIELDS) ModelSerializer::new(CONTRACT_FIELDS)
} }
pub fn contract_category_serializer() -> ModelSerializer<ContractCategory> {
ModelSerializer::new(CONTRACT_CATEGORY_FIELDS)
}
pub fn counterparty_serializer() -> ModelSerializer<Counterparty> { pub fn counterparty_serializer() -> ModelSerializer<Counterparty> {
ModelSerializer::new(COUNTERPARTY_FIELDS) ModelSerializer::new(COUNTERPARTY_FIELDS)
} }
+13 -1
View File
@@ -1,6 +1,6 @@
use che_tauri::{Filter, FilterSet}; use che_tauri::{Filter, FilterSet};
use super::models::{Employee, Message, Task}; use super::models::{Employee, Message, Task, TaskTransfer};
static EMPLOYEE_FILTERS: &[Filter] = &[ static EMPLOYEE_FILTERS: &[Filter] = &[
Filter::exact("id"), Filter::exact("id"),
@@ -24,6 +24,14 @@ static MESSAGE_FILTERS: &[Filter] = &[
Filter::exact("employee_id"), Filter::exact("employee_id"),
]; ];
static TASK_TRANSFER_FILTERS: &[Filter] = &[
Filter::exact("id"),
Filter::exact("employee_from_id"),
Filter::exact("employee_to_id"),
Filter::exact("status"),
Filter::exact("typ"),
];
pub fn employee_filterset() -> FilterSet<Employee> { pub fn employee_filterset() -> FilterSet<Employee> {
FilterSet::new(EMPLOYEE_FILTERS) FilterSet::new(EMPLOYEE_FILTERS)
} }
@@ -35,3 +43,7 @@ pub fn task_filterset() -> FilterSet<Task> {
pub fn message_filterset() -> FilterSet<Message> { pub fn message_filterset() -> FilterSet<Message> {
FilterSet::new(MESSAGE_FILTERS) FilterSet::new(MESSAGE_FILTERS)
} }
pub fn task_transfer_filterset() -> FilterSet<TaskTransfer> {
FilterSet::new(TASK_TRANSFER_FILTERS)
}
+8 -1
View File
@@ -21,11 +21,18 @@ impl AppModule for TaskModule {
serializers::employee_serializer(), serializers::employee_serializer(),
filters::employee_filterset(), filters::employee_filterset(),
); );
ctx.resource::<models::Task>( ctx.remote_resource::<models::Task>(
"task", "task",
"/api/persone/task",
serializers::task_serializer(), serializers::task_serializer(),
filters::task_filterset(), filters::task_filterset(),
); );
ctx.remote_resource::<models::TaskTransfer>(
"task_transfer",
"/api/persone/task_transfer",
serializers::task_transfer_serializer(),
filters::task_transfer_filterset(),
);
ctx.resource::<models::Message>( ctx.resource::<models::Message>(
"message", "message",
serializers::message_serializer(), serializers::message_serializer(),
@@ -42,3 +42,22 @@ pub struct Message {
pub text: String, pub text: String,
} }
#[derive(Debug, Clone, Model)]
#[model(table = "task_transfer")]
pub struct TaskTransfer {
#[field(primary_key)]
pub id: i64,
#[field(foreign_key = Employee)]
pub employee_from_id: Option<i64>,
#[field(foreign_key = Employee)]
pub employee_to_id: Option<i64>,
pub date_create: String,
pub status: String,
pub typ: String,
}
@@ -1,6 +1,6 @@
use che_tauri::{Field, ModelSerializer}; use che_tauri::{Field, ModelSerializer};
use super::models::{Employee, Message, Task}; use super::models::{Employee, Message, Task, TaskTransfer};
static EMPLOYEE_FIELDS: &[Field] = &[ static EMPLOYEE_FIELDS: &[Field] = &[
Field::new("id").read_only(), Field::new("id").read_only(),
@@ -23,6 +23,16 @@ static MESSAGE_FIELDS: &[Field] = &[
Field::new("text"), Field::new("text"),
]; ];
static TASK_TRANSFER_FIELDS: &[Field] = &[
Field::new("id").read_only(),
Field::new("employee_from_id").required(false).nullable(),
Field::new("employee_to_id").required(false).nullable(),
Field::new("date_create").read_only(),
Field::new("status"),
Field::new("typ"),
Field::new("project_id"),
];
pub fn employee_serializer() -> ModelSerializer<Employee> { pub fn employee_serializer() -> ModelSerializer<Employee> {
ModelSerializer::new(EMPLOYEE_FIELDS) ModelSerializer::new(EMPLOYEE_FIELDS)
} }
@@ -34,3 +44,7 @@ pub fn task_serializer() -> ModelSerializer<Task> {
pub fn message_serializer() -> ModelSerializer<Message> { pub fn message_serializer() -> ModelSerializer<Message> {
ModelSerializer::new(MESSAGE_FIELDS) ModelSerializer::new(MESSAGE_FIELDS)
} }
pub fn task_transfer_serializer() -> ModelSerializer<TaskTransfer> {
ModelSerializer::new(TASK_TRANSFER_FIELDS)
}
+97 -3
View File
@@ -1,11 +1,23 @@
pub mod apps; pub mod apps;
pub mod sync; pub mod sync;
use che_tauri::{ApiError, ApiRequest, AppState, AuthTokenResponse, TauriApi}; use che_orm::SqliteBackend;
use che_tauri::{
ApiError, ApiRequest, AppConfig, AppState, AuthTokenResponse, DatabaseConfig, RemoteConfig,
TauriApi,
};
use tauri::Manager; use tauri::Manager;
use crate::sync::SyncContractsResult; use crate::sync::SyncContractsResult;
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct CurrentEmployee {
id: i64,
name: String,
short_name: String,
avatar: Option<String>,
}
#[tauri::command] #[tauri::command]
fn greet(name: &str) -> String { fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name) format!("Hello, {}! You've been greeted from Rust!", name)
@@ -43,6 +55,42 @@ fn auth_status(api: tauri::State<'_, TauriApi>) -> bool {
api.is_authenticated() api.is_authenticated()
} }
#[tauri::command]
async fn current_employee(api: tauri::State<'_, TauriApi>) -> Result<CurrentEmployee, ApiError> {
let remote = api.state().config.remote.as_ref().ok_or_else(|| {
ApiError::bad_request("current_employee requires [remote].base_url config")
})?;
let token = api
.state()
.auth_token()
.ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?;
let response = reqwest::Client::new()
.get(format!(
"{}/api/persone/employee/who_im/",
remote.base_url.trim_end_matches('/')
))
.header(reqwest::header::AUTHORIZATION, format!("Token {token}"))
.send()
.await
.map_err(|error| ApiError::new("remote_error", error.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let detail = response.text().await.unwrap_or_default();
return Err(ApiError::new(
"remote_error",
format!("who_im request failed with {status}: {detail}"),
));
}
response
.json::<CurrentEmployee>()
.await
.map_err(|error| ApiError::new("remote_error", error.to_string()))
}
#[tauri::command] #[tauri::command]
async fn sync_contracts( async fn sync_contracts(
app: tauri::AppHandle, app: tauri::AppHandle,
@@ -55,6 +103,39 @@ async fn sync_contracts(
sync::sync_contracts(&api, app_data_dir).await sync::sync_contracts(&api, app_data_dir).await
} }
#[tauri::command]
async fn load_application_file(local_path: String, scan_url: String) -> Result<Vec<u8>, ApiError> {
if !local_path.is_empty() {
match tokio::fs::metadata(&local_path).await {
Ok(metadata) if metadata.len() > 0 => {
return tokio::fs::read(&local_path)
.await
.map_err(|error| ApiError::new("file_error", error.to_string()));
}
Ok(_) | Err(_) => {}
}
}
if scan_url.is_empty() {
return Err(ApiError::new("file_error", "Файл недоступен"));
}
let response = reqwest::Client::new().get(&scan_url).send().await?;
if !response.status().is_success() {
return Err(ApiError::new(
"remote_error",
format!("file download failed with {}", response.status()),
));
}
let bytes = response.bytes().await?;
if bytes.is_empty() {
return Err(ApiError::new("file_error", "Файл пустой"));
}
Ok(bytes.to_vec())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -65,7 +146,18 @@ pub fn run() {
} }
let api = tauri::async_runtime::block_on(async { let api = tauri::async_runtime::block_on(async {
let state = AppState::from_config_file("app.toml").await?; // let state = AppState::from_config_file("app.toml").await?;
let config = AppConfig {
database: DatabaseConfig {
url: String::from("sqlite://ewa-mobile.sqlite?mode=rwc"),
},
remote: Some(RemoteConfig {
base_url: String::from("https://ewa-corp.ru/"),
auth_path: Some(String::from(" /api-token-auth/")),
}),
};
let db = SqliteBackend::connect(&config.database.url).await?;
let state = AppState::new(config, db);
TauriApi::new(state) TauriApi::new(state)
.install(apps::installed_apps()) .install(apps::installed_apps())
.build() .build()
@@ -83,7 +175,9 @@ pub fn run() {
auth_set_token, auth_set_token,
auth_logout, auth_logout,
auth_status, auth_status,
sync_contracts current_employee,
sync_contracts,
load_application_file
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+102 -5
View File
@@ -18,6 +18,12 @@ struct ContractPage {
results: Vec<RemoteContract>, results: Vec<RemoteContract>,
} }
#[derive(Debug, Deserialize)]
struct ContractCategoryPage {
links: PageLinks,
results: Vec<RemoteContractCategory>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct PageLinks { struct PageLinks {
next: Option<String>, next: Option<String>,
@@ -34,6 +40,8 @@ struct RemoteContract {
contract_type: String, contract_type: String,
#[serde(rename = "get_category")] #[serde(rename = "get_category")]
category: String, category: String,
#[serde(default)]
category_id: Option<i64>,
#[serde(rename = "get_amount_total")] #[serde(rename = "get_amount_total")]
amount_total_display: String, amount_total_display: String,
#[serde(rename = "get_amount_by_ds")] #[serde(rename = "get_amount_by_ds")]
@@ -62,6 +70,17 @@ struct RemoteContract {
get_employee: Option<RemoteEmployee>, get_employee: Option<RemoteEmployee>,
} }
#[derive(Debug, Deserialize)]
struct RemoteContractCategory {
id: i64,
name: String,
name_group: String,
template: Option<String>,
is_questionnair: bool,
parent: Option<i64>,
secure_group: Option<i64>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct RemoteBillCost { struct RemoteBillCost {
#[serde(rename = "cost__sum")] #[serde(rename = "cost__sum")]
@@ -151,6 +170,8 @@ pub async fn sync_contracts(
.as_ref() .as_ref()
.ok_or_else(|| ApiError::bad_request("sync requires [remote].base_url config"))?; .ok_or_else(|| ApiError::bad_request("sync requires [remote].base_url config"))?;
let client = reqwest::Client::new(); let client = reqwest::Client::new();
sync_contract_categories(api, &client, &token, &remote.base_url).await?;
let mut next_url = Some(format!( let mut next_url = Some(format!(
"{}/api/contract/?page_size=100", "{}/api/contract/?page_size=100",
remote.base_url.trim_end_matches('/') remote.base_url.trim_end_matches('/')
@@ -182,12 +203,14 @@ pub async fn sync_contracts(
for contract in &page.results { for contract in &page.results {
upsert_contract_dependencies(api, contract).await?; upsert_contract_dependencies(api, contract).await?;
upsert_contract(api, contract).await?; upsert_contract(api, contract).await?;
let detail = fetch_contract_detail(&client, &token, &remote.base_url, contract.id).await?; let detail =
fetch_contract_detail(&client, &token, &remote.base_url, contract.id).await?;
for application in &detail.contractapplicationfile_set { for application in &detail.contractapplicationfile_set {
let local_path = let local_path =
download_application_file(&client, &token, &app_data_dir, application).await?; download_application_file(&client, &token, &app_data_dir, application).await?;
upsert_contract_application_file(api, contract.id, application, &local_path).await?; upsert_contract_application_file(api, contract.id, application, &local_path)
.await?;
applications += 1; applications += 1;
if !local_path.is_empty() { if !local_path.is_empty() {
files_downloaded += 1; files_downloaded += 1;
@@ -208,6 +231,74 @@ pub async fn sync_contracts(
}) })
} }
async fn sync_contract_categories(
api: &TauriApi,
client: &reqwest::Client,
token: &str,
base_url: &str,
) -> Result<(), ApiError> {
let mut next_url = Some(format!(
"{}/api/cont/category/?page_size=100",
base_url.trim_end_matches('/')
));
while let Some(url) = next_url {
let response = client
.get(url)
.header(reqwest::header::AUTHORIZATION, format!("Token {token}"))
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let detail = response.text().await.unwrap_or_default();
return Err(ApiError::new(
"remote_error",
format!("remote category request failed with {status}: {detail}"),
));
}
let page = response.json::<ContractCategoryPage>().await?;
for category in &page.results {
upsert_contract_category(api, category).await?;
}
next_url = page.links.next;
}
Ok(())
}
async fn upsert_contract_category(
api: &TauriApi,
category: &RemoteContractCategory,
) -> Result<(), ApiError> {
sqlx::query(
"INSERT INTO contract_category (
id, name, name_group, template, is_questionnair, parent, secure_group
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
name_group = excluded.name_group,
template = excluded.template,
is_questionnair = excluded.is_questionnair,
parent = excluded.parent,
secure_group = excluded.secure_group",
)
.bind(category.id)
.bind(&category.name)
.bind(&category.name_group)
.bind(category.template.as_deref())
.bind(category.is_questionnair)
.bind(category.parent)
.bind(category.secure_group)
.execute(api.state().db().pool())
.await
.map_err(database_error)?;
Ok(())
}
async fn fetch_contract_detail( async fn fetch_contract_detail(
client: &reqwest::Client, client: &reqwest::Client,
token: &str, token: &str,
@@ -250,7 +341,11 @@ async fn download_application_file(
.await .await
.map_err(file_error)?; .map_err(file_error)?;
let file_name = format!("{}_{}", application.id, sanitize_file_name(&application.scan_name)); let file_name = format!(
"{}_{}",
application.id,
sanitize_file_name(&application.scan_name)
);
let path = directory.join(file_name); let path = directory.join(file_name);
let response = client let response = client
@@ -379,7 +474,7 @@ async fn upsert_contract_dependencies(
async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<(), ApiError> { async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<(), ApiError> {
sqlx::query( sqlx::query(
"INSERT INTO contractapp ( "INSERT INTO contractapp (
id, name, number, absolute_url, contract_type, category, amount_total_display, id, name, number, absolute_url, contract_type, category, category_id, amount_total_display,
amount_by_ds, comment, date, status_name, status, nds, name_of_product, amount_by_ds, comment, date, status_name, status, nds, name_of_product,
counterparty_name, amount, month_pay, avans_pay, amount_total, estimate_nds_cost, counterparty_name, amount, month_pay, avans_pay, amount_total, estimate_nds_cost,
estimate_nds_cert, bill_cost_sum, bill_paid_sum, income_total, arrears, estimate_nds_cert, bill_cost_sum, bill_paid_sum, income_total, arrears,
@@ -387,7 +482,7 @@ async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<()
) )
VALUES ( VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29 ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30
) )
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
@@ -395,6 +490,7 @@ async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<()
absolute_url = excluded.absolute_url, absolute_url = excluded.absolute_url,
contract_type = excluded.contract_type, contract_type = excluded.contract_type,
category = excluded.category, category = excluded.category,
category_id = excluded.category_id,
amount_total_display = excluded.amount_total_display, amount_total_display = excluded.amount_total_display,
amount_by_ds = excluded.amount_by_ds, amount_by_ds = excluded.amount_by_ds,
comment = excluded.comment, comment = excluded.comment,
@@ -425,6 +521,7 @@ async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<()
.bind(&contract.absolute_url) .bind(&contract.absolute_url)
.bind(&contract.contract_type) .bind(&contract.contract_type)
.bind(&contract.category) .bind(&contract.category)
.bind(contract.category_id)
.bind(&contract.amount_total_display) .bind(&contract.amount_total_display)
.bind(&contract.amount_by_ds) .bind(&contract.amount_by_ds)
.bind(&contract.comment) .bind(&contract.comment)
+18 -113
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useAuth } from "./composables/useAuth"; import { useAuth } from "./shared/auth/useAuth";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -47,118 +47,23 @@ async function logoutAndRedirect() {
<router-view /> <router-view />
</main> </main>
<van-tabbar v-if="showShellNavigation" v-model="activeTab" route placeholder safe-area-inset-bottom> <van-tabbar
<van-tabbar-item to="/tasks" name="/tasks" icon="todo-list-o">Задачи</van-tabbar-item> v-if="showShellNavigation"
<van-tabbar-item to="/contracts" name="/contracts" icon="orders-o">Контракты</van-tabbar-item> v-model="activeTab"
<van-tabbar-item to="/users" name="/users" icon="friends-o">Пользователи</van-tabbar-item> route
placeholder
safe-area-inset-bottom
>
<van-tabbar-item to="/tasks" name="/tasks" icon="todo-list-o"
>Задачи</van-tabbar-item
>
<van-tabbar-item to="/contracts" name="/contracts" icon="orders-o"
>Контракты</van-tabbar-item
>
<van-tabbar-item to="/users" name="/users" icon="friends-o"
>Пользователи</van-tabbar-item
>
</van-tabbar> </van-tabbar>
</template> </template>
<style> <style></style>
:root {
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: #172033;
background: #f7f8fa;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: #f7f8fa;
}
.page {
box-sizing: border-box;
width: min(760px, 100%);
margin: 0 auto;
padding: 24px 14px 40px;
}
.notice {
margin-bottom: 12px;
border-radius: 14px;
}
.card {
overflow: hidden;
margin-bottom: 14px;
border-radius: 18px;
background: #fff;
box-shadow: 0 12px 40px rgba(36, 42, 56, 0.08);
}
.form-actions {
padding: 14px 16px 16px;
}
.list-card {
padding: 14px 0 16px;
}
.list-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px 12px;
}
.list-actions {
display: flex;
align-items: center;
gap: 8px;
}
.stacked-actions {
display: grid;
gap: 10px;
}
h2 {
margin: 0;
font-size: 1.1rem;
}
.state {
justify-content: center;
padding: 34px 0;
}
.detail-card {
padding: 14px 0 16px;
}
.detail-actions {
padding: 16px 16px 0;
}
.employee-popup {
box-sizing: border-box;
max-height: 78vh;
padding: 16px 0 22px;
}
.employee-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px 12px;
}
.employee-avatar {
margin-right: 10px;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
import { createRouter, createWebHashHistory } from "vue-router";
import { isAuthenticated, useAuth } from "../../shared/auth/useAuth";
import { loginRoute } from "../../apps/auth/routes";
import { contractsRoutes } from "../../apps/contracts/routes";
import { tasksRoutes } from "../../apps/tasks/routes";
import { usersRoutes } from "../../apps/users/routes";
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: "/",
redirect: "/tasks",
},
...tasksRoutes,
...usersRoutes,
...contractsRoutes,
loginRoute,
],
});
router.beforeEach(async (to) => {
const { restoreToken } = useAuth();
await restoreToken();
if (to.meta.requiresAuth && !isAuthenticated()) {
return { path: "/login", query: { redirect: to.fullPath } };
}
if (to.path === "/login" && isAuthenticated()) {
return "/tasks";
}
});
+8
View File
@@ -0,0 +1,8 @@
import LoginView from "./views/LoginView.vue";
export const loginRoute = {
path: "/login",
name: "login",
component: LoginView,
meta: { title: "Вход" },
};
@@ -2,7 +2,7 @@
import { ref } from "vue"; import { ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { showToast } from "vant"; import { showToast } from "vant";
import { useAuth } from "../composables/useAuth"; import { useAuth } from "../../../shared/auth/useAuth";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { contractCategoryApi } from "../../../generated/api";
import type { ContractCategoryListParams } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
const PAGE_SIZE = 30;
const selectedCategoryId = defineModel<number>({ required: true });
const search = ref("");
const showSelector = ref(false);
const {
items: categories,
filters,
loading,
} = useModelApi(contractCategoryApi, {
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as ContractCategoryListParams,
loadErrorMessage: "Не удалось загрузить категории",
cleanListParams(params) {
params.name__contains = params.name__contains?.trim() || undefined;
},
});
const selectedCategory = computed(() =>
categories.value.find((category) => category.id === selectedCategoryId.value),
);
const selectedCategoryName = computed(() => selectedCategory.value?.name ?? "все");
function openSelector() {
search.value = "";
showSelector.value = true;
}
function selectCategory(id: number) {
selectedCategoryId.value = id;
showSelector.value = false;
}
watch(search, (value) => {
filters.name__contains = value.trim();
filters.offset = 0;
});
</script>
<template>
<button class="entity-select" type="button" @click="openSelector">
<span>{{ selectedCategoryName }}</span>
<van-icon name="arrow" />
</button>
<van-popup v-model:show="showSelector" round position="bottom" class="entity-popup">
<div class="entity-popup-header">
<h2>Выберите категорию</h2>
<van-button size="small" type="primary" plain @click="selectCategory(0)">Все</van-button>
</div>
<van-search v-model="search" placeholder="Поиск по категории" />
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
<template v-else>
<van-cell-group inset>
<van-cell
v-for="category in categories"
:key="category.id"
:title="category.name"
:label="category.name_group || `ID: ${category.id}`"
clickable
center
@click="selectCategory(category.id)"
>
<template #right-icon>
<van-icon v-if="selectedCategoryId === category.id" name="success" color="#1989fa" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="categories.length === 0" description="Категории не найдены" />
</template>
</van-popup>
</template>
<style scoped>
.entity-popup {
min-height: 55vh;
padding: 18px 0 28px;
}
.entity-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px 10px;
}
.entity-popup-header h2 {
margin: 0;
font-size: 18px;
}
.entity-state {
display: flex;
justify-content: center;
padding: 36px 0;
}
.entity-select {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
width: 100%;
border: 0;
padding: 0;
color: #323233;
background: transparent;
font: inherit;
line-height: 24px;
text-align: right;
}
.entity-select .van-icon {
color: #969799;
font-size: 16px;
}
</style>
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { counterpartyApi } from "../generated/api"; import { counterpartyApi } from "../../../generated/api";
import type { CounterpartyListParams } from "../generated/models"; import type { CounterpartyListParams } from "../../../generated/models";
import { useModelApi } from "../composables/useModelApi"; import { useModelApi } from "../../../shared/composables/useModelApi";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { frcApi } from "../../../generated/api";
import type { FrcListParams } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
const PAGE_SIZE = 20;
const selectedFrcId = defineModel<number>({ required: true });
const search = ref("");
const showSelector = ref(false);
const {
items: frcs,
filters,
loading,
} = useModelApi(frcApi, {
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as FrcListParams,
loadErrorMessage: "Не удалось загрузить ФРЦ",
cleanListParams(params) {
params.name__contains = params.name__contains?.trim() || undefined;
},
});
const selectedFrc = computed(() => frcs.value.find((frc) => frc.id === selectedFrcId.value));
const selectedFrcName = computed(() => selectedFrc.value?.name ?? "все");
function openSelector() {
search.value = "";
showSelector.value = true;
}
function selectFrc(id: number) {
selectedFrcId.value = id;
showSelector.value = false;
}
watch(search, (value) => {
filters.name__contains = value.trim();
filters.offset = 0;
});
</script>
<template>
<button class="entity-select" type="button" @click="openSelector">
<span>{{ selectedFrcName }}</span>
<van-icon name="arrow" />
</button>
<van-popup v-model:show="showSelector" round position="bottom" class="entity-popup">
<div class="entity-popup-header">
<h2>Выберите ФРЦ</h2>
<van-button size="small" type="primary" plain @click="selectFrc(0)">Все</van-button>
</div>
<van-search v-model="search" placeholder="Поиск по названию" />
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
<template v-else>
<van-cell-group inset>
<van-cell
v-for="frc in frcs"
:key="frc.id"
:title="frc.name"
:label="`Баланс: ${frc.balance}`"
clickable
center
@click="selectFrc(frc.id)"
>
<template #right-icon>
<van-icon v-if="selectedFrcId === frc.id" name="success" color="#1989fa" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="frcs.length === 0" description="ФРЦ не найдены" />
</template>
</van-popup>
</template>
<style scoped>
.entity-popup {
min-height: 55vh;
padding: 18px 0 28px;
}
.entity-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px 10px;
}
.entity-popup-header h2 {
margin: 0;
font-size: 18px;
}
.entity-state {
display: flex;
justify-content: center;
padding: 36px 0;
}
.entity-select {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
width: 100%;
border: 0;
padding: 0;
color: #323233;
background: transparent;
font: inherit;
line-height: 24px;
text-align: right;
}
.entity-select .van-icon {
color: #969799;
font-size: 16px;
}
</style>
@@ -0,0 +1,264 @@
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import {
GlobalWorkerOptions,
getDocument,
type PDFDocumentLoadingTask,
type PDFDocumentProxy,
type RenderTask,
} from "pdfjs-dist";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
const props = defineProps<{
localPath: string;
scanUrl: string;
title: string;
}>();
const show = defineModel<boolean>("show", { required: true });
GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
const canvasRef = ref<HTMLCanvasElement | null>(null);
const pdfDocument = ref<PDFDocumentProxy | null>(null);
const loading = ref(false);
const error = ref("");
const pageNumber = ref(1);
const pageCount = ref(0);
const scale = ref(1.1);
let loadingTask: PDFDocumentLoadingTask | null = null;
let renderTask: RenderTask | null = null;
const pageInfo = computed(() => `${pageNumber.value} / ${pageCount.value}`);
const canGoBack = computed(() => pageNumber.value > 1);
const canGoForward = computed(() => pageNumber.value < pageCount.value);
async function loadDocument() {
resetDocument();
loading.value = true;
error.value = "";
let task: PDFDocumentLoadingTask | null = null;
try {
const bytes = await invoke<number[]>("load_application_file", {
localPath: props.localPath,
scanUrl: props.scanUrl,
});
if (!bytes.length) {
throw new Error("Файл пустой");
}
task = getDocument({ data: new Uint8Array(bytes) });
loadingTask = task;
const document = await task.promise;
if (loadingTask !== task) {
await document.destroy();
return;
}
pdfDocument.value = document;
pageCount.value = document.numPages;
pageNumber.value = 1;
scale.value = 1.1;
await renderPage();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось открыть PDF";
} finally {
loading.value = false;
if (task && loadingTask === task) {
loadingTask = null;
}
}
}
async function renderPage() {
const document = pdfDocument.value;
const canvas = canvasRef.value;
if (!document || !canvas) {
return;
}
renderTask?.cancel();
const page = await document.getPage(pageNumber.value);
const viewport = page.getViewport({ scale: scale.value });
const context = canvas.getContext("2d");
if (!context) {
error.value = "Не удалось создать canvas context";
return;
}
const devicePixelRatio = window.devicePixelRatio || 1;
canvas.width = Math.floor(viewport.width * devicePixelRatio);
canvas.height = Math.floor(viewport.height * devicePixelRatio);
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
renderTask = page.render({ canvas: canvas, canvasContext: context, viewport });
try {
await renderTask.promise;
} catch (err) {
if (!(err instanceof Error) || err.name !== "RenderingCancelledException") {
error.value = err instanceof Error ? err.message : "Не удалось отрисовать PDF";
}
}
}
function resetDocument() {
renderTask?.cancel();
renderTask = null;
loadingTask?.destroy().catch(() => undefined);
loadingTask = null;
pdfDocument.value?.destroy().catch(() => undefined);
pdfDocument.value = null;
pageCount.value = 0;
pageNumber.value = 1;
loading.value = false;
error.value = "";
}
async function goBack() {
if (canGoBack.value) {
pageNumber.value -= 1;
await nextTick();
await renderPage();
}
}
async function goForward() {
if (canGoForward.value) {
pageNumber.value += 1;
await nextTick();
await renderPage();
}
}
async function zoomIn() {
scale.value = Math.min(scale.value + 0.2, 3);
await nextTick();
await renderPage();
}
async function zoomOut() {
scale.value = Math.max(scale.value - 0.2, 0.5);
await nextTick();
await renderPage();
}
watch([show, () => props.localPath, () => props.scanUrl], async ([visible]) => {
if (visible) {
await nextTick();
await loadDocument();
} else {
resetDocument();
}
});
onBeforeUnmount(() => {
resetDocument();
});
</script>
<template>
<van-popup v-model:show="show" round position="bottom" class="pdf-preview">
<div class="pdf-preview__header">
<div class="pdf-preview__title">{{ title }}</div>
<van-button size="small" plain @click="show = false">Закрыть</van-button>
</div>
<div class="pdf-preview__toolbar">
<van-button size="small" plain :disabled="!canGoBack" @click="goBack">
Назад
</van-button>
<div class="pdf-preview__page-info">{{ pageInfo }}</div>
<van-button size="small" plain :disabled="!canGoForward" @click="goForward">
Вперед
</van-button>
<van-button size="small" plain @click="zoomOut">-</van-button>
<van-button size="small" plain @click="zoomIn">+</van-button>
</div>
<div class="pdf-preview__body">
<van-loading v-if="loading">Загрузка PDF...</van-loading>
<van-empty v-else-if="error" :description="error" />
<div v-else class="pdf-preview__canvas-wrap">
<canvas ref="canvasRef" class="pdf-preview__canvas" />
</div>
</div>
</van-popup>
</template>
<style scoped>
.pdf-preview {
display: flex;
flex-direction: column;
height: 92vh;
background: #f5f7fb;
}
.pdf-preview__header,
.pdf-preview__toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fff;
}
.pdf-preview__header {
justify-content: space-between;
border-bottom: 1px solid #eef0f4;
}
.pdf-preview__title {
min-width: 0;
font-size: 16px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pdf-preview__toolbar {
justify-content: center;
flex-wrap: wrap;
border-bottom: 1px solid #eef0f4;
}
.pdf-preview__page-info {
min-width: 72px;
text-align: center;
color: #636b74;
font-size: 13px;
}
.pdf-preview__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
}
.pdf-preview__canvas-wrap {
display: flex;
justify-content: center;
}
.pdf-preview__canvas {
max-width: 100%;
height: auto;
background: #fff;
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.16);
}
</style>
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { projectApi } from "../../../generated/api";
import type { ProjectListParams } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
const PAGE_SIZE = 20;
const selectedProjectId = defineModel<number>({ required: true });
const search = ref("");
const showSelector = ref(false);
const {
items: projects,
filters,
loading,
} = useModelApi(projectApi, {
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as ProjectListParams,
loadErrorMessage: "Не удалось загрузить проекты",
cleanListParams(params) {
params.name__contains = params.name__contains?.trim() || undefined;
},
});
const selectedProject = computed(() =>
projects.value.find((project) => project.id === selectedProjectId.value),
);
const selectedProjectName = computed(
() => selectedProject.value?.short_name || selectedProject.value?.name || "все",
);
function openSelector() {
search.value = "";
showSelector.value = true;
}
function selectProject(id: number) {
selectedProjectId.value = id;
showSelector.value = false;
}
watch(search, (value) => {
filters.name__contains = value.trim();
filters.offset = 0;
});
</script>
<template>
<button class="entity-select" type="button" @click="openSelector">
<span>{{ selectedProjectName }}</span>
<van-icon name="arrow" />
</button>
<van-popup v-model:show="showSelector" round position="bottom" class="entity-popup">
<div class="entity-popup-header">
<h2>Выберите проект</h2>
<van-button size="small" type="primary" plain @click="selectProject(0)">Все</van-button>
</div>
<van-search v-model="search" placeholder="Поиск по проекту" />
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
<template v-else>
<van-cell-group inset>
<van-cell
v-for="project in projects"
:key="project.id"
:title="project.short_name || project.name"
:label="project.full_name || `ID: ${project.id}`"
clickable
center
@click="selectProject(project.id)"
>
<template #right-icon>
<van-icon v-if="selectedProjectId === project.id" name="success" color="#1989fa" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="projects.length === 0" description="Проекты не найдены" />
</template>
</van-popup>
</template>
<style scoped>
.entity-popup {
min-height: 55vh;
padding: 18px 0 28px;
}
.entity-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px 10px;
}
.entity-popup-header h2 {
margin: 0;
font-size: 18px;
}
.entity-state {
display: flex;
justify-content: center;
padding: 36px 0;
}
.entity-select {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
width: 100%;
border: 0;
padding: 0;
color: #323233;
background: transparent;
font: inherit;
line-height: 24px;
text-align: right;
}
.entity-select .van-icon {
color: #969799;
font-size: 16px;
}
</style>
+17
View File
@@ -0,0 +1,17 @@
import ContractDetailView from "./views/ContractDetailView.vue";
import ContractsView from "./views/ContractsView.vue";
export const contractsRoutes = [
{
path: "/contracts",
name: "contracts",
component: ContractsView,
meta: { title: "Контракты", requiresAuth: true },
},
{
path: "/contracts/:id",
name: "contract-detail",
component: ContractDetailView,
meta: { title: "Детали контракта", back: true, requiresAuth: true },
},
];
@@ -0,0 +1,604 @@
<script setup lang="ts">
import { openPath, openUrl } from "@tauri-apps/plugin-opener";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast } from "vant";
import { contractApi, contractApplicationFileApi } from "../../../generated/api";
import type {
Contract,
ContractApplicationFile,
ContractApplicationFileListParams,
} from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
import PdfPreview from "../components/PdfPreview.vue";
const route = useRoute();
const router = useRouter();
const contractId = computed(() => Number(route.params.id));
const activeTab = ref("info");
const {
item: contract,
loadingItem,
error,
retrieve: loadContract,
} = useModelApi(contractApi, {
retrieveErrorMessage: "Не удалось загрузить контракт",
autoLoad: false,
autoLoadOnFilterChange: false,
});
const {
items: applicationFiles,
loading: loadingApplicationFiles,
error: applicationFilesError,
load: loadApplicationFiles,
} = useModelApi(contractApplicationFileApi, {
defaultListParams: { ordering: "id" } as ContractApplicationFileListParams,
loadErrorMessage: "Не удалось загрузить приложения",
autoLoad: false,
autoLoadOnFilterChange: false,
});
const viewError = computed(() => error.value || applicationFilesError.value);
type DetailField = {
title: string;
key: keyof Contract;
};
type MoneyField = DetailField & {
money?: boolean;
};
const mainFields: DetailField[] = [
{ title: "Номер", key: "number" },
{ title: "Дата", key: "date" },
{ title: "Тип", key: "contract_type" },
{ title: "Категория", key: "category" },
{ title: "Статус", key: "status_name" },
{ title: "НДС", key: "nds" },
{ title: "Продукт", key: "name_of_product" },
{ title: "Комментарий", key: "comment" },
];
const moneyFields: MoneyField[] = [
{ title: "Платеж в месяц", key: "month_pay", money: true },
{ title: "Аванс", key: "avans_pay" },
{ title: "Сумма", key: "amount", money: true },
{ title: "Сумма итого", key: "amount_total", money: true },
{ title: "Сумма итого строкой", key: "amount_total_display" },
{ title: "Сумма по ДС", key: "amount_by_ds" },
{ title: "Смета НДС стоимость", key: "estimate_nds_cost", money: true },
{ title: "Смета НДС сертификат", key: "estimate_nds_cert", money: true },
{ title: "Счета сумма", key: "bill_cost_sum", money: true },
{ title: "Счета оплачено", key: "bill_paid_sum", money: true },
{ title: "Доход всего", key: "income_total", money: true },
{ title: "Задолженность", key: "arrears", money: true },
];
const relationFields = computed(() => {
const item = contract.value;
if (!item) {
return [];
}
return [
{
title: "Контрагент",
value: item.counterparty?.name ?? item.counterparty_name,
},
{ title: "Проект", value: item.project?.short_name ?? item.project?.name },
{ title: "ФРЦ", value: item.frc?.name },
{
title: "Сотрудник",
value: item.employee?.short_name ?? item.employee?.name,
},
];
});
const approvalFields = computed(() => {
const item = contract.value;
if (!item) {
return [];
}
return [
{ title: "Статус", value: item.status_name },
{ title: "Код статуса", value: item.status },
{
title: "Сотрудник",
value: item.employee?.short_name ?? item.employee?.name,
},
{ title: "Сотрудник ID", value: item.employee_id },
{ title: "Комментарий", value: item.comment },
];
});
const templateUrl = computed(
() => contract.value?.category_ref?.template ?? "",
);
const pdfPreviewVisible = ref(false);
const pdfPreviewFile = ref<ContractApplicationFile | null>(null);
const pdfPreviewTitle = computed(
() => pdfPreviewFile.value?.name || pdfPreviewFile.value?.scan_name || "PDF",
);
const summaryStatus = computed(
() => contract.value?.status_name ?? "не указано",
);
const summaryCategory = computed(
() =>
contract.value?.category_ref?.name ??
contract.value?.category ??
"не указано",
);
const summaryCounterparty = computed(
() =>
contract.value?.counterparty?.name ??
contract.value?.counterparty_name ??
"не указано",
);
const summaryAmount = computed(() => formatMoney(contract.value?.amount_total));
function displayValue(value: unknown) {
return formatValue(value);
}
function displayMoneyValue(value: unknown) {
return formatMoney(value);
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === "") {
return "не указано";
}
return String(value);
}
function formatMoney(value: unknown) {
if (value === null || value === undefined || value === "") {
return "не указано";
}
const amount = Number(value);
if (!Number.isFinite(amount)) {
return String(value);
}
return new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
}).format(amount);
}
async function openApplicationFile(localPath: string) {
if (!localPath) {
showToast("Файл не скачан");
return;
}
try {
await openPath(localPath);
} catch (err) {
showToast(errorMessage(err, "Не удалось открыть файл"));
}
}
async function openTemplateForm() {
if (!templateUrl.value) {
showToast("Типовая форма не указана");
return;
}
try {
await openUrl(templateUrl.value);
} catch (err) {
showToast(errorMessage(err, "Не удалось открыть типовую форму"));
}
}
function isPdfFile(file: ContractApplicationFile) {
return `${file.scan_name} ${file.name}`.toLowerCase().includes(".pdf");
}
function openPdfPreview(file: ContractApplicationFile) {
pdfPreviewFile.value = file;
pdfPreviewVisible.value = true;
}
function errorMessage(err: unknown, fallback: string) {
if (err instanceof Error) {
return err.message;
}
return fallback;
}
onMounted(() => {
if (Number.isFinite(contractId.value)) {
loadContract(contractId.value);
loadApplicationFiles({
ordering: "id",
contract_id: contractId.value,
} as ContractApplicationFileListParams);
}
});
</script>
<template>
<van-notice-bar
v-if="viewError"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="viewError"
/>
<section class="">
<van-loading v-if="loadingItem" class="state" type="spinner"
>Загрузка...</van-loading
>
<van-empty v-else-if="!contract" description="Контракт не найден" />
<template v-else>
<div class="contract-summary">
<div class="contract-summary__title">{{ contract.name }}</div>
<div class="contract-summary__meta">
{{ contract.number }} · {{ contract.date }}
</div>
<div class="contract-summary__badges">
<van-tag type="primary">{{ summaryStatus }}</van-tag>
<van-tag plain>{{ summaryCategory }}</van-tag>
</div>
<van-cell-group inset>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">ЦФО</div>
<div class="detail-value">
{{ contract.frc?.name ?? "не указано" }}
</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Объект</div>
<div class="detail-value">
{{
contract.project?.short_name ??
contract.project?.name ??
"не указано"
}}
</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Предмет договора</div>
<div class="detail-value detail-value--money">
{{ contract.name_of_product }}
</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Контрагент</div>
<div class="detail-value">{{ summaryCounterparty }}</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Сумма итого</div>
<div class="detail-value detail-value--money">
{{ summaryAmount }}
</div>
</div>
</template>
</van-cell>
</van-cell-group>
</div>
<van-tabs v-model:active="activeTab" shrink sticky class="contract-tabs">
<van-tab name="info" title="Информация">
<div class="tab-panel">
<van-cell-group>
<van-cell v-for="field in mainFields" :key="field.key">
<template #title>
<div class="detail-cell">
<div class="detail-label">{{ field.title }}</div>
<div class="detail-value">
{{ displayValue(contract?.[field.key]) }}
</div>
</div>
</template>
</van-cell>
</van-cell-group>
<van-cell-group title="Финансы">
<van-cell v-for="field in moneyFields" :key="field.key">
<template #title>
<div class="detail-cell">
<div class="detail-label">{{ field.title }}</div>
<div class="detail-value detail-value--money">
{{ displayMoneyValue(contract?.[field.key]) }}
</div>
</div>
</template>
</van-cell>
</van-cell-group>
<van-cell-group title="Связи">
<van-cell v-for="field in relationFields" :key="field.title">
<template #title>
<div class="detail-cell">
<div class="detail-label">{{ field.title }}</div>
<div class="detail-value">
{{ formatValue(field.value) }}
</div>
</div>
</template>
</van-cell>
</van-cell-group>
<van-cell-group title="Техническое">
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">ID</div>
<div class="detail-value">
{{ formatValue(contract.id) }}
</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Код статуса</div>
<div class="detail-value">
{{ formatValue(contract.status) }}
</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">URL</div>
<div class="detail-value">
{{ formatValue(contract.absolute_url) }}
</div>
</div>
</template>
</van-cell>
</van-cell-group>
</div>
</van-tab>
<van-tab name="approval" title="Согласование">
<div class="tab-panel">
<van-cell-group>
<van-cell v-for="field in approvalFields" :key="field.title">
<template #title>
<div class="detail-cell">
<div class="detail-label">{{ field.title }}</div>
<div class="detail-value">
{{ formatValue(field.value) }}
</div>
</div>
</template>
</van-cell>
</van-cell-group>
</div>
</van-tab>
<van-tab name="applications" title="Приложения">
<div class="tab-panel">
<van-cell-group>
<van-cell
v-if="loadingApplicationFiles"
title="Загрузка приложений..."
/>
<van-cell
v-else-if="applicationFiles.length === 0"
title="Приложений нет"
/>
<van-cell v-for="file in applicationFiles" v-else :key="file.id">
<template #title>
<div class="detail-cell">
<div class="detail-label">{{ file.name }}</div>
<div class="detail-value detail-value--muted">
{{ file.file_type_display }} · {{ file.status_display }} ·
{{ file.scan_name }}
</div>
</div>
</template>
<template #value>
<div class="file-actions">
<van-button
v-if="isPdfFile(file)"
size="small"
type="primary"
plain
:disabled="!file.local_path"
@click="openPdfPreview(file)"
>
Просмотр
</van-button>
<van-button
size="small"
plain
:disabled="!file.local_path"
@click="openApplicationFile(file.local_path)"
>
Открыть
</van-button>
</div>
</template>
</van-cell>
</van-cell-group>
</div>
</van-tab>
<van-tab name="works" title="Работы">
<div class="tab-panel">
<van-empty description="Работы по договору пока не загружены" />
</div>
</van-tab>
<van-tab name="archive" title="Архив">
<div class="tab-panel">
<van-empty description="Архивные материалы пока не загружены" />
</div>
</van-tab>
<van-tab name="templates" title="Типовые формы">
<div class="tab-panel">
<van-cell-group>
<van-cell
:title="contract.category_ref?.name ?? contract.category"
:label="contract.category_ref?.name_group"
>
<template #title>
<div class="detail-cell">
<div class="detail-label">
{{ contract.category_ref?.name ?? contract.category }}
</div>
<div class="detail-value detail-value--muted">
{{ contract.category_ref?.name_group ?? "Типовая форма" }}
</div>
</div>
</template>
<template #value>
<van-button
size="small"
type="primary"
plain
:disabled="!templateUrl"
@click="openTemplateForm"
>
Открыть
</van-button>
</template>
</van-cell>
</van-cell-group>
<van-empty
v-if="!templateUrl"
description="Типовая форма не указана"
/>
</div>
</van-tab>
</van-tabs>
<PdfPreview
v-model:show="pdfPreviewVisible"
:local-path="pdfPreviewFile?.local_path ?? ''"
:scan-url="pdfPreviewFile?.scan_url ?? ''"
:title="pdfPreviewTitle"
/>
<div class="detail-actions">
<van-button block round type="primary" plain @click="router.back()"
>Назад</van-button
>
</div>
</template>
</section>
</template>
<style scoped>
.contract-summary {
margin-bottom: 12px;
padding: 12px 0 0;
overflow: hidden;
border-radius: 16px;
background: #fff;
box-shadow: 0 8px 24px rgba(36, 42, 56, 0.08);
}
.contract-summary__title {
padding: 0 16px;
font-size: 18px;
font-weight: 600;
line-height: 1.3;
}
.contract-summary__meta {
padding: 4px 16px 0;
color: #969799;
font-size: 13px;
}
.contract-summary__badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px 16px 12px;
}
.detail-cell {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.detail-label {
color: #636b74;
font-size: 12px;
line-height: 1.2;
}
.detail-value {
color: #111827;
font-size: 15px;
font-weight: 500;
line-height: 1.35;
white-space: normal;
word-break: break-word;
}
.detail-value--muted {
color: #374151;
font-size: 14px;
font-weight: 400;
}
.file-actions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.detail-value--money {
font-variant-numeric: tabular-nums;
}
.contract-tabs {
margin-top: 4px;
}
.contract-tabs :deep(.van-tabs__wrap) {
box-shadow: 0 1px 0 #ebedf0;
}
.tab-panel {
padding-top: 8px;
}
</style>
+392
View File
@@ -0,0 +1,392 @@
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { showToast } from "vant";
import { contractApi } from "../../../generated/api";
import type { ContractListParams } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
import ContractCategorySelect from "../components/ContractCategorySelect.vue";
import CounterpartySelect from "../components/CounterpartySelect.vue";
import FrcSelect from "../components/FrcSelect.vue";
import ProjectSelect from "../components/ProjectSelect.vue";
const PAGE_SIZE = 10;
const CONTRACT_STATUS_OPTIONS = [
{ value: "AN", text: "Аннулирован" },
{ value: "IP", text: "В работе" },
{ value: "OW", text: "На доработке" },
{ value: "OS", text: "На подписи" },
{ value: "OK", text: "Окончен" },
{ value: "WA", text: "Проект" },
{ value: "TE", text: "Расторгнут" },
{ value: "AU", text: "Согласован" },
];
const router = useRouter();
const {
items: contracts,
filters,
count,
loading,
error,
load: loadContracts,
} = useModelApi(contractApi, {
defaultListParams: { ordering: "-id", limit: PAGE_SIZE, offset: 0 } as ContractListParams,
loadErrorMessage: "Не удалось загрузить контракты",
cleanListParams(params) {
params.name__contains = params.name__contains?.trim() || undefined;
params.number__contains = params.number__contains?.trim() || undefined;
params.counterparty_id = params.counterparty_id || undefined;
params.category_id = params.category_id || undefined;
params.project_id = params.project_id || undefined;
params.frc_id = params.frc_id || undefined;
params.status = params.status || undefined;
params.limit = PAGE_SIZE;
params.offset = params.offset ?? 0;
},
});
const selectedCounterpartyId = computed({
get() {
return filters.counterparty_id ?? 0;
},
set(id: number) {
filters.counterparty_id = id || undefined;
},
});
const selectedCategoryId = computed({
get() {
return filters.category_id ?? 0;
},
set(id: number) {
filters.category_id = id || undefined;
},
});
const selectedProjectId = computed({
get() {
return filters.project_id ?? 0;
},
set(id: number) {
filters.project_id = id || undefined;
},
});
const selectedFrcId = computed({
get() {
return filters.frc_id ?? 0;
},
set(id: number) {
filters.frc_id = id || undefined;
},
});
const selectedStatus = computed({
get() {
return filters.status ?? "";
},
set(status: string) {
filters.status = status || undefined;
},
});
const hasActiveFilters = computed(() =>
Boolean(
filters.name__contains ||
filters.number__contains ||
filters.counterparty_id ||
filters.category_id ||
filters.project_id ||
filters.frc_id ||
filters.status,
),
);
const currentPage = computed({
get() {
return Math.floor((filters.offset ?? 0) / PAGE_SIZE) + 1;
},
set(page: number) {
filters.offset = (page - 1) * PAGE_SIZE;
},
});
interface SyncContractsResult {
synced: number;
pages: number;
applications: number;
files_downloaded: number;
}
const syncing = ref(false);
const showFilters = ref(false);
const activeFilterCount = computed(
() =>
[
filters.number__contains,
filters.counterparty_id,
filters.category_id,
filters.project_id,
filters.frc_id,
filters.status,
].filter(Boolean).length,
);
function resetFilters() {
filters.number__contains = undefined;
filters.counterparty_id = undefined;
filters.category_id = undefined;
filters.project_id = undefined;
filters.frc_id = undefined;
filters.status = undefined;
filters.offset = 0;
}
async function applyFilters() {
filters.offset = 0;
showFilters.value = false;
await loadContracts();
}
async function syncContracts() {
syncing.value = true;
try {
const result = await invoke<SyncContractsResult>("sync_contracts");
showToast(
`Синхронизировано: ${result.synced}; приложений: ${result.applications}; файлов: ${result.files_downloaded}`,
);
if (filters.offset) {
filters.offset = 0;
} else {
await loadContracts();
}
} catch (err) {
showToast(errorMessage(err, "Не удалось синхронизировать договоры"));
} finally {
syncing.value = false;
}
}
function errorMessage(err: unknown, fallback: string) {
if (typeof err === "object" && err && "detail" in err) {
return String((err as { detail: unknown }).detail);
}
if (err instanceof Error) {
return err.message;
}
return fallback;
}
watch(
() => [
filters.name__contains,
filters.number__contains,
filters.counterparty_id,
filters.category_id,
filters.project_id,
filters.frc_id,
filters.status,
],
() => {
filters.offset = 0;
},
);
</script>
<template>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<section class="contracts-page">
<van-search
v-model="filters.name__contains"
placeholder="Поиск по названию"
clearable
/>
<div class="contract-actions">
<van-badge :content="activeFilterCount || undefined">
<van-button size="small" plain type="primary" icon="filter-o" @click="showFilters = true">
Фильтры
</van-button>
</van-badge>
<van-button size="small" plain type="primary" :loading="loading" @click="loadContracts()">
Обновить
</van-button>
<van-button size="small" type="primary" :loading="syncing" @click="syncContracts">
Синхронизация
</van-button>
</div>
<van-popup v-model:show="showFilters" round position="bottom" class="filters-popup">
<div class="filters-sheet">
<div class="filters-header">
<h2>Фильтры</h2>
<van-button size="small" plain type="primary" @click="resetFilters">
Сбросить
</van-button>
</div>
<van-cell-group class="contract-filters">
<van-field
v-model="filters.number__contains"
label="Номер"
placeholder="Номер договора"
clearable
/>
<van-field label="Контрагент">
<template #input>
<CounterpartySelect v-model="selectedCounterpartyId" />
</template>
</van-field>
<van-field label="Категория">
<template #input>
<ContractCategorySelect v-model="selectedCategoryId" />
</template>
</van-field>
<van-field label="Проект">
<template #input>
<ProjectSelect v-model="selectedProjectId" />
</template>
</van-field>
<van-field label="ФРЦ">
<template #input>
<FrcSelect v-model="selectedFrcId" />
</template>
</van-field>
<van-field label="Статус">
<template #input>
<select v-model="selectedStatus" class="native-select">
<option value="">Все</option>
<option
v-for="status in CONTRACT_STATUS_OPTIONS"
:key="status.value"
:value="status.value"
>
{{ status.text }}
</option>
</select>
</template>
</van-field>
</van-cell-group>
<div class="filters-actions">
<van-button block type="primary" @click="applyFilters">
Применить
</van-button>
</div>
</div>
</van-popup>
<van-loading
v-if="loading && contracts.length === 0"
class="state"
type="spinner"
>
Загрузка...
</van-loading>
<van-empty
v-else-if="contracts.length === 0"
:description="
hasActiveFilters ? 'Договоры не найдены' : 'Контрактов пока нет'
"
/>
<template v-else>
<van-cell-group>
<van-cell
v-for="contract in contracts"
:key="contract.id"
:title="contract.name"
:label="`№ ${contract.number} · ${contract.counterparty?.name ?? 'не указан'}`"
center
is-link
@click="router.push(`/contracts/${contract.id}`)"
/>
</van-cell-group>
<van-pagination
v-model="currentPage"
class="contract-pagination"
:total-items="count"
:items-per-page="PAGE_SIZE"
mode="simple"
/>
</template>
</section>
</template>
<style scoped>
.contract-filters {
margin-bottom: 8px;
}
.contracts-page {
padding-bottom: 12px;
}
.contract-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 12px 10px;
overflow-x: auto;
}
.filters-popup {
max-height: 85vh;
}
.filters-sheet {
padding: 14px 0 18px;
}
.filters-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px 8px;
}
.filters-header h2 {
margin: 0;
font-size: 18px;
}
.filters-actions {
padding: 4px 12px 0;
}
.contract-pagination {
margin: 10px 12px 0;
}
.native-select {
width: 100%;
border: 0;
padding: 0;
color: #323233;
background: transparent;
font: inherit;
line-height: 24px;
text-align: right;
outline: none;
}
</style>
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { employeeApi } from "../generated/api"; import { employeeApi } from "../../../generated/api";
import type { EmployeeListParams } from "../generated/models"; import type { EmployeeListParams } from "../../../generated/models";
import { useModelApi } from "../composables/useModelApi"; import { useModelApi } from "../../../shared/composables/useModelApi";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -0,0 +1,93 @@
<script setup lang="ts">
import type { Task } from "../../../generated/models";
type TaskItem = Task;
const props = defineProps<{
item: TaskItem;
}>();
const emit = defineEmits<{
open: [path: string];
}>();
</script>
<template>
<van-cell
:title="
props.item.text?.trim() ||
props.item.result?.trim() ||
`Задача #${props.item.id}`
"
center
is-link
:class="`task-row task-row-${/success|done|complete|green/i.test(props.item.get_status_class || 'status-default') ? 'success' : /warning|pending|wait|yellow|orange/i.test(props.item.get_status_class || 'status-default') ? 'warning' : /danger|error|red|fail/i.test(props.item.get_status_class || 'status-default') ? 'danger' : 'primary'}`"
@click="emit('open', `/tasks/${props.item.id}`)"
>
<template #icon>
<van-image
class="task-avatar"
round
width="36"
height="36"
:src="
'employee_from' in props.item
? ''
: (props.item.author?.avatar_small ?? '')
"
/>
</template>
<template #label>
<div>
Автор:
{{ props.item.author?.short_name }}
</div>
<div>объект строительства: {{ props.item.project }}</div>
</template>
<template #right-icon>
<van-tag
plain
:type="
/success|done|complete|green/i.test(props.item.get_status_class)
? 'success'
: /warning|pending|wait|yellow|orange/i.test(
props.item.get_status_class,
)
? 'warning'
: /danger|error|red|fail/i.test(props.item.get_status_class)
? 'danger'
: 'primary'
"
>
{{ props.item.deadline }}
</van-tag>
</template>
</van-cell>
</template>
<style scoped>
.task-avatar {
margin-right: 10px;
}
:deep(.van-cell.task-row-success) {
background: #f0fdf4;
}
:deep(.van-cell.task-row-warning) {
background: #fffbeb;
}
:deep(.van-cell.task-row-danger) {
background: #fef2f2;
}
:deep(.van-cell.task-row-primary) {
background: #eff6ff;
}
:deep(.van-cell.task-row-default) {
background: #f8fafc;
}
</style>
+24
View File
@@ -0,0 +1,24 @@
import TaskCreateView from "./views/TaskCreateView.vue";
import TaskDetailView from "./views/TaskDetailView.vue";
import TasksView from "./views/TasksView.vue";
export const tasksRoutes = [
{
path: "/tasks",
name: "tasks",
component: TasksView,
meta: { title: "Задачи", requiresAuth: true },
},
{
path: "/tasks/new",
name: "task-create",
component: TaskCreateView,
meta: { title: "Новая задача", back: true, requiresAuth: true },
},
{
path: "/tasks/:id",
name: "task-detail",
component: TaskDetailView,
meta: { title: "Детали задачи", back: true, requiresAuth: true },
},
];
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { showToast } from "vant";
import { employeeApi, taskApi } from "../../../generated/api";
import type { Employee, TaskCreate, TaskPersonInput } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
import EmployeeSelect from "../../personnel/components/EmployeeSelect.vue";
const router = useRouter();
const deadline = ref("");
const taskText = ref("");
const selectedDoerId = ref(0);
const selectedResponsibleId = ref(0);
const { creating, error, create: createTaskApi } = useModelApi(taskApi, {
createErrorMessage: "Не удалось создать задачу",
autoLoad: false,
autoLoadOnFilterChange: false,
});
const { retrieve: loadEmployee } = useModelApi(employeeApi, {
autoLoad: false,
autoLoadOnFilterChange: false,
});
const canSubmit = computed(() => Boolean(deadline.value.trim()) && Boolean(selectedDoerId.value));
function makeShortName(name: string) {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "";
}
if (parts.length === 1) {
return parts[0];
}
return `${parts[0]} ${parts.slice(1).map((part) => `${part[0]?.toUpperCase()}.`).join(" ")}`.trim();
}
async function buildPerson(id: number): Promise<TaskPersonInput | null> {
if (!id) {
return null;
}
const employee = await loadEmployee(id);
return employeeToTaskPerson(employee);
}
function employeeToTaskPerson(employee: Employee): TaskPersonInput {
return {
id: employee.id,
name: employee.name,
short_name: makeShortName(employee.name),
avatar_small: employee.avatar_small,
};
}
async function createTask() {
if (!canSubmit.value) {
showToast("Выберите исполнителя и срок");
return;
}
const doer = await buildPerson(selectedDoerId.value);
if (!doer) {
showToast("Не удалось загрузить исполнителя");
return;
}
const responsible = await buildPerson(selectedResponsibleId.value);
const payload: TaskCreate = {
doer,
deadline: deadline.value,
text: taskText.value.trim() || undefined,
responsible,
};
const task = await createTaskApi(payload);
if (task) {
showToast("Задача создана");
router.push(`/tasks/${task.id}`);
}
}
</script>
<template>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<van-form class="card" @submit="createTask">
<van-field label="Исполнитель" required>
<template #input>
<EmployeeSelect v-model="selectedDoerId" />
</template>
</van-field>
<van-field label="Ответственный">
<template #input>
<EmployeeSelect v-model="selectedResponsibleId" />
</template>
</van-field>
<van-field
v-model="deadline"
name="deadline"
label="Срок"
type="date"
required
clearable
:disabled="creating"
/>
<van-field
v-model="taskText"
name="text"
label="Текст"
type="textarea"
rows="3"
autosize
clearable
placeholder="Описание задачи"
:disabled="creating"
/>
<div class="form-actions stacked-actions">
<van-button
block
round
type="primary"
native-type="submit"
:loading="creating"
:disabled="!canSubmit"
>
Создать задачу
</van-button>
<van-button block round plain type="default" @click="router.back()">Отмена</van-button>
</div>
</van-form>
</template>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { taskApi } from "../../../generated/api";
import { useModelApi } from "../../../shared/composables/useModelApi";
const route = useRoute();
const router = useRouter();
const taskId = computed(() => Number(route.params.id));
const {
item: task,
loadingItem,
error,
retrieve: loadTask,
} = useModelApi(taskApi, {
retrieveErrorMessage: "Не удалось загрузить задачу",
autoLoad: false,
autoLoadOnFilterChange: false,
});
function personName(person: { short_name?: string | null; name?: string | null } | null) {
if (!person) {
return "не указан";
}
return person.short_name ?? person.name ?? "не указан";
}
function taskTitle() {
return task.value?.text?.trim() || task.value?.result?.trim() || `Задача #${taskId.value}`;
}
function formatDate(value: string | null | undefined) {
return value || "не указано";
}
function formatBoolean(value: boolean) {
return value ? "Да" : "Нет";
}
onMounted(() => {
if (Number.isFinite(taskId.value)) {
loadTask(taskId.value);
}
});
</script>
<template>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<section class="card detail-card">
<van-loading v-if="loadingItem" class="state" type="spinner">Загрузка...</van-loading>
<van-empty v-else-if="!task" description="Задача не найдена" />
<template v-else>
<van-cell-group inset>
<van-cell title="ID" :value="task.id" />
<van-cell title="Заголовок" :value="taskTitle()" />
<van-cell title="Исполнитель" :value="personName(task.doer)" />
<van-cell title="Автор" :value="personName(task.author)" />
<van-cell title="Ответственный" :value="personName(task.responsible)" />
<van-cell title="Статус" :value="task.get_status" />
<van-cell title="Срок" :value="formatDate(task.deadline)" />
<van-cell title="Плановая дата" :value="formatDate(task.plan_date)" />
<van-cell title="Запрос новой даты" :value="formatDate(task.request_new_deadline)" />
<van-cell title="Приоритет" :value="task.priority ?? 'не указан'" />
<van-cell title="Тип" :value="task.typ || 'не указан'" />
<van-cell title="Результат" :value="task.result || 'не указан'" />
<van-cell title="Комментарий" :value="task.comment || 'не указан'" />
<van-cell title="Примечание" :value="task.note || 'не указано'" />
<van-cell title="Согласование" :value="formatBoolean(task.approve)" />
<van-cell title="Архив" :value="formatBoolean(task.archive)" />
</van-cell-group>
</template>
</section>
<section class="card messages-card">
<div class="list-title">
<h2>Диалог</h2>
</div>
<van-empty v-if="!task?.message_set?.length" description="Сообщений пока нет" />
<div v-else class="message-list">
<article v-for="message in task.message_set" :key="message.id" class="message-item">
<van-image class="message-avatar" round width="40" height="40" :src="message.author.avatar_small ?? ''" />
<div class="message-bubble">
<div class="message-author">{{ personName(message.author) }}</div>
<div class="message-text">{{ message.text }}</div>
<div class="message-meta">
{{ message.date }} · {{ message.status }} · {{ personName(message.recipient) }}
</div>
</div>
</article>
</div>
</section>
<div class="form-actions stacked-actions">
<van-button block round type="primary" plain @click="router.back()">Назад</van-button>
</div>
</template>
+461
View File
@@ -0,0 +1,461 @@
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { taskApi, taskTransferApi } from "../../../generated/api";
import type {
Task,
TaskCreate,
TaskListParams,
TaskTransfer,
TaskTransferListParams,
TaskUpdate,
} from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
import TaskListItem from "../components/TaskListItem.vue";
type CurrentEmployee = {
id: number;
name: string;
short_name: string;
avatar?: string | null;
};
type MainTabKey = "incoming" | "transfer" | "review" | "outgoing";
type DocTabKey =
| "all"
| "simple"
| "memo"
| "bill"
| "contract"
| "contract_application"
| "entry_letter"
| "outgoing_letter";
const router = useRouter();
const mainTab = ref<MainTabKey>("incoming");
const docTab = ref<DocTabKey>("all");
const employee = ref<CurrentEmployee | null>(null);
const employeeError = ref("");
const employeeLoading = ref(false);
const refreshing = ref(false);
const switchingList = ref(false);
let suppressDocTabReload = false;
const mainTabCounts = ref<Record<MainTabKey, number>>({
incoming: 0,
transfer: 0,
review: 0,
outgoing: 0,
});
const docTabCounts = ref<Record<DocTabKey, number>>({
all: 0,
simple: 0,
memo: 0,
bill: 0,
contract: 0,
contract_application: 0,
entry_letter: 0,
outgoing_letter: 0,
});
const {
items: tasks,
loading: taskLoading,
error: taskError,
load: loadTasks,
} = useModelApi<Task, TaskCreate, TaskUpdate, TaskListParams>(taskApi, {
defaultListParams: { ordering: "-id" },
autoLoad: false,
loadErrorMessage: "Не удалось загрузить задачи",
});
const {
// items: taskTransfers,
// loading: taskTransferLoading,
error: taskTransferError,
load: loadTaskTransfers,
} = useModelApi<
TaskTransfer,
Partial<TaskTransfer>,
Partial<TaskTransfer>,
TaskTransferListParams
>(taskTransferApi, {
defaultListParams: { ordering: "-id" },
autoLoad: false,
loadErrorMessage: "Не удалось загрузить переносы срока",
});
const mainTabs = [
{ key: "incoming", label: "Входящие", icon: "todo-list-o" },
{ key: "transfer", label: "Перенос срока", icon: "underway-o" },
{ key: "review", label: "Для проверки", icon: "eye-o" },
{ key: "outgoing", label: "Исходящие", icon: "send-gift-o" },
] as const;
const docTabs = [
{ key: "all", label: "Все" },
{ key: "simple", label: "Простые" },
{ key: "memo", label: "Служебные" },
{ key: "bill", label: "Счета" },
{ key: "contract", label: "Договоры" },
{ key: "contract_application", label: "Приложения" },
{ key: "entry_letter", label: "Входящие письма" },
{ key: "outgoing_letter", label: "Исходящие письма" },
] as const;
const visibleItems = computed(() => tasks.value);
const activeLoading = computed(() => taskLoading.value);
const showLoading = computed(() => activeLoading.value || refreshing.value);
const showEmployeeLoading = computed(
() =>
employeeLoading.value &&
visibleItems.value.length === 0 &&
!switchingList.value,
);
const activeError = computed(() =>
mainTab.value === "transfer" ? taskTransferError.value : taskError.value,
);
const showDocTabs = computed(() => mainTab.value !== "transfer");
const visibleDocTabs = computed(() =>
showDocTabs.value
? docTabs.filter((tab) => (docTabCounts.value[tab.key] ?? 0) > 0)
: [],
);
function badgeValue(count: number) {
return count > 0 ? count : undefined;
}
function buildTaskParams(
tab: MainTabKey,
doc: DocTabKey,
employeeId: number,
): TaskListParams | TaskTransferListParams | null {
switch (tab) {
case "incoming":
return {
doer: employeeId,
archive: false,
typ: doc === "all" ? undefined : doc,
q: "entry",
incomplete: true,
};
case "review":
return {
q: "to_approve",
incomplete: true,
archive: false,
typ: doc === "all" ? undefined : doc,
};
case "outgoing":
return {
q: "outgoing",
archive: false,
incomplete: true,
typ: doc === "all" ? undefined : doc,
};
case "transfer":
return {
employee_to: employeeId,
status: "A",
};
default:
return null;
}
}
function taskParams(): TaskListParams | TaskTransferListParams | null {
if (!employee.value) {
return null;
}
return buildTaskParams(mainTab.value, docTab.value, employee.value.id);
}
function taskCountParams(
tab: MainTabKey,
): TaskListParams | TaskTransferListParams | null {
if (!employee.value) {
return null;
}
return buildTaskParams(tab, "all", employee.value.id);
}
function docCountParams(doc: DocTabKey): TaskListParams | null {
if (!employee.value || mainTab.value === "transfer") {
return null;
}
return buildTaskParams(
mainTab.value,
doc,
employee.value.id,
) as TaskListParams;
}
async function loadCurrentEmployee() {
employeeLoading.value = true;
employeeError.value = "";
try {
employee.value = await invoke<CurrentEmployee>("current_employee");
} catch (error) {
employee.value = null;
employeeError.value =
error instanceof Error
? error.message
: "Не удалось получить текущего сотрудника";
} finally {
employeeLoading.value = false;
}
}
async function reloadList() {
if (!employee.value) {
return;
}
const params = taskParams();
if (!params) {
return;
}
if (mainTab.value === "transfer") {
await loadTaskTransfers({ employee_to: employee.value.id, status: "A" });
return;
}
await loadTasks(params as TaskListParams);
}
async function countTaskItems(
params: TaskListParams | TaskTransferListParams | null,
) {
if (!params) {
return 0;
}
if ("employee_to" in params) {
const response = await taskTransferApi.list({ ...params, limit: 1 });
return response.count;
}
const response = await taskApi.list({ ...params, limit: 1 });
return response.count;
}
async function reloadCounts() {
if (!employee.value) {
return;
}
const mainCounts = await Promise.allSettled(
mainTabs.map(
async (tab) =>
[tab.key, await countTaskItems(taskCountParams(tab.key))] as const,
),
);
mainTabCounts.value = {
...mainTabCounts.value,
...Object.fromEntries(
mainCounts.flatMap((result) =>
result.status === "fulfilled" ? [result.value] : [],
),
),
} as Record<MainTabKey, number>;
if (mainTab.value === "transfer") {
docTabCounts.value = {
all: 0,
simple: 0,
memo: 0,
bill: 0,
contract: 0,
contract_application: 0,
entry_letter: 0,
outgoing_letter: 0,
};
return;
}
const docCounts = await Promise.allSettled(
docTabs.map(
async (tab) =>
[tab.key, await countTaskItems(docCountParams(tab.key))] as const,
),
);
docTabCounts.value = {
...docTabCounts.value,
...Object.fromEntries(
docCounts.flatMap((result) =>
result.status === "fulfilled" ? [result.value] : [],
),
),
} as Record<DocTabKey, number>;
const nextDocTab = visibleDocTabs.value[0]?.key ?? "all";
if (docTab.value !== nextDocTab) {
suppressDocTabReload = true;
docTab.value = nextDocTab;
await nextTick();
suppressDocTabReload = false;
}
}
async function reloadForTabChange(options: { reloadCounts: boolean }) {
switchingList.value = true;
try {
if (options.reloadCounts) {
await reloadCounts();
}
await reloadList();
} finally {
switchingList.value = false;
}
}
async function onRefresh() {
refreshing.value = true;
try {
await reloadCounts();
await reloadList();
} finally {
refreshing.value = false;
}
}
watch(mainTab, () => {
if (mainTab.value === "transfer") {
suppressDocTabReload = true;
docTab.value = "all";
void nextTick(() => {
suppressDocTabReload = false;
});
}
void reloadForTabChange({ reloadCounts: true });
});
watch(docTab, () => {
if (showDocTabs.value && !suppressDocTabReload) {
void reloadForTabChange({ reloadCounts: false });
}
});
onMounted(async () => {
await loadCurrentEmployee();
await reloadForTabChange({ reloadCounts: true });
});
</script>
<template>
<van-notice-bar
v-if="employeeError || activeError"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="employeeError || activeError"
/>
<section class="card list-card">
<van-tabbar
v-model="mainTab"
:fixed="false"
:border="false"
class="task-tabbar"
>
<van-tabbar-item
v-for="tab in mainTabs"
:key="tab.key"
:name="tab.key"
:icon="tab.icon"
:badge="badgeValue(mainTabCounts[tab.key])"
>
{{ tab.label }}
</van-tabbar-item>
</van-tabbar>
<van-tabs
v-if="showDocTabs && visibleDocTabs.length > 0"
v-model:active="docTab"
shrink
class="tabs tabs-secondary"
>
<van-tab
v-for="tab in visibleDocTabs"
:key="tab.key"
:name="tab.key"
:title="tab.label"
:badge="badgeValue(docTabCounts[tab.key])"
/>
</van-tabs>
<van-pull-refresh
v-if="!switchingList"
v-model="refreshing"
class="pull-refresh"
@refresh="onRefresh"
>
<van-list
:loading="showLoading"
:finished="false"
loading-text="Загрузка..."
:immediate-check="false"
>
<van-loading v-if="showEmployeeLoading" class="state" type="spinner">
Загрузка...
</van-loading>
<van-empty
v-else-if="visibleItems.length === 0"
description="Записей пока нет"
/>
<van-cell-group v-else inset>
<TaskListItem
v-for="item in visibleItems"
:key="item.id"
:item="item"
@open="router.push($event)"
/>
</van-cell-group>
</van-list>
</van-pull-refresh>
<van-loading v-else class="state" type="spinner">Загрузка...</van-loading>
</section>
<van-floating-bubble icon="plus" @click="router.push('/tasks/new')" />
</template>
<style scoped>
.list-card {
display: flex;
flex-direction: column;
}
.task-tabbar {
position: sticky;
top: 0;
z-index: 2;
margin: -12px -12px 8px;
border-radius: 0;
}
.pull-refresh {
min-height: calc(100vh - 220px);
}
@media (max-width: 768px) {
.pull-refresh {
min-height: calc(100vh - 200px);
}
}
</style>
+10
View File
@@ -0,0 +1,10 @@
import UsersView from "./views/UsersView.vue";
export const usersRoutes = [
{
path: "/users",
name: "users",
component: UsersView,
meta: { title: "Пользователи", requiresAuth: true },
},
];
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { userApi } from "../generated/api"; import { userApi } from "../../../generated/api";
import { useModelApi } from "../composables/useModelApi"; import { useModelApi } from "../../../shared/composables/useModelApi";
const { const {
items: users, items: users,
+8
View File
@@ -8,6 +8,10 @@ import type {
ContractApplicationFileCreate, ContractApplicationFileCreate,
ContractApplicationFileUpdate, ContractApplicationFileUpdate,
ContractApplicationFileListParams, ContractApplicationFileListParams,
ContractCategory,
ContractCategoryCreate,
ContractCategoryUpdate,
ContractCategoryListParams,
Counterparty, Counterparty,
CounterpartyCreate, CounterpartyCreate,
CounterpartyUpdate, CounterpartyUpdate,
@@ -32,6 +36,8 @@ import type {
TaskCreate, TaskCreate,
TaskUpdate, TaskUpdate,
TaskListParams, TaskListParams,
TaskTransfer,
TaskTransferListParams,
User, User,
UserCreate, UserCreate,
UserUpdate, UserUpdate,
@@ -40,10 +46,12 @@ import type {
export const contractApi = createModelApi<Contract, ContractCreate, ContractUpdate, ContractListParams>("contract"); export const contractApi = createModelApi<Contract, ContractCreate, ContractUpdate, ContractListParams>("contract");
export const contractApplicationFileApi = createModelApi<ContractApplicationFile, ContractApplicationFileCreate, ContractApplicationFileUpdate, ContractApplicationFileListParams>("contract_application_file"); export const contractApplicationFileApi = createModelApi<ContractApplicationFile, ContractApplicationFileCreate, ContractApplicationFileUpdate, ContractApplicationFileListParams>("contract_application_file");
export const contractCategoryApi = createModelApi<ContractCategory, ContractCategoryCreate, ContractCategoryUpdate, ContractCategoryListParams>("contract_category");
export const counterpartyApi = createModelApi<Counterparty, CounterpartyCreate, CounterpartyUpdate, CounterpartyListParams>("counterparty"); export const counterpartyApi = createModelApi<Counterparty, CounterpartyCreate, CounterpartyUpdate, CounterpartyListParams>("counterparty");
export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee"); export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee");
export const frcApi = createModelApi<Frc, FrcCreate, FrcUpdate, FrcListParams>("frc"); export const frcApi = createModelApi<Frc, FrcCreate, FrcUpdate, FrcListParams>("frc");
export const messageApi = createModelApi<Message, MessageCreate, MessageUpdate, MessageListParams>("message"); export const messageApi = createModelApi<Message, MessageCreate, MessageUpdate, MessageListParams>("message");
export const projectApi = createModelApi<Project, ProjectCreate, ProjectUpdate, ProjectListParams>("project"); export const projectApi = createModelApi<Project, ProjectCreate, ProjectUpdate, ProjectListParams>("project");
export const taskApi = createModelApi<Task, TaskCreate, TaskUpdate, TaskListParams>("task"); export const taskApi = createModelApi<Task, TaskCreate, TaskUpdate, TaskListParams>("task");
export const taskTransferApi = createModelApi<TaskTransfer, Partial<TaskTransfer>, Partial<TaskTransfer>, TaskTransferListParams>("task_transfer");
export const userApi = createModelApi<User, UserCreate, UserUpdate, UserListParams>("users"); export const userApi = createModelApi<User, UserCreate, UserUpdate, UserListParams>("users");
+282 -14
View File
@@ -7,6 +7,7 @@ export interface Contract {
absolute_url: string; absolute_url: string;
contract_type: string; contract_type: string;
category: string; category: string;
category_id: number | null;
amount_total_display: string; amount_total_display: string;
amount_by_ds: string; amount_by_ds: string;
comment: string; comment: string;
@@ -30,6 +31,7 @@ export interface Contract {
project_id: number | null; project_id: number | null;
frc_id: number | null; frc_id: number | null;
employee_id: number | null; employee_id: number | null;
category_ref: ContractCategory | null;
counterparty: Counterparty | null; counterparty: Counterparty | null;
project: Project | null; project: Project | null;
frc: Frc | null; frc: Frc | null;
@@ -42,6 +44,7 @@ export interface ContractCreate {
absolute_url: string; absolute_url: string;
contract_type: string; contract_type: string;
category: string; category: string;
category_id?: number | null;
amount_total_display: string; amount_total_display: string;
amount_by_ds: string; amount_by_ds: string;
comment: string; comment: string;
@@ -73,6 +76,7 @@ export interface ContractUpdate {
absolute_url?: string; absolute_url?: string;
contract_type?: string; contract_type?: string;
category?: string; category?: string;
category_id?: number | null;
amount_total_display?: string; amount_total_display?: string;
amount_by_ds?: string; amount_by_ds?: string;
comment?: string; comment?: string;
@@ -112,6 +116,7 @@ export interface ContractListParams extends ListParams {
contract_type__contains?: string; contract_type__contains?: string;
category?: string; category?: string;
category__contains?: string; category__contains?: string;
category_id?: number | null;
counterparty_name__contains?: string; counterparty_name__contains?: string;
name_of_product__contains?: string; name_of_product__contains?: string;
counterparty_id?: number | null; counterparty_id?: number | null;
@@ -182,6 +187,42 @@ export interface ContractApplicationFileListParams extends ListParams {
status?: string; status?: string;
} }
export interface ContractCategory {
id: number;
name: string;
name_group: string;
template: string | null;
is_questionnair: boolean;
parent: number | null;
secure_group: number | null;
}
export interface ContractCategoryCreate {
name: string;
name_group: string;
template?: string | null;
is_questionnair: boolean;
parent?: number | null;
secure_group?: number | null;
}
export interface ContractCategoryUpdate {
name?: string;
name_group?: string;
template?: string | null;
is_questionnair?: boolean;
parent?: number | null;
secure_group?: number | null;
}
export interface ContractCategoryListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
name_group?: string;
name_group__contains?: string;
}
export interface Counterparty { export interface Counterparty {
id: number; id: number;
name: string; name: string;
@@ -278,6 +319,107 @@ export interface MessageListParams extends ListParams {
employee_id?: number; employee_id?: number;
} }
export interface TaskTransferPerson {
id: number;
name: string;
short_name: string;
first_position: string;
get_main_position: string;
departament_name: string;
company: Record<string, unknown> | null;
}
export interface TaskTransferProject {
id: number;
name: string;
short_name: string;
tender: string;
get_absolute_url: string;
}
export interface TaskTransferTask {
id: number;
text: string;
author: TaskTransferPerson;
doer: TaskTransferPerson;
deadline: string;
get_last_day: string;
status: string;
project: TaskTransferProject | null;
}
export interface TaskTransfer {
id: number;
employee_from: TaskTransferPerson;
employee_to: TaskTransferPerson;
date_create: string;
task: TaskTransferTask;
status: string;
typ: string;
}
export interface TaskTransferListParams extends ListParams {
id?: number;
employee_from?: number;
employee_to?: number;
status?: string;
typ?: string;
date_create?: string;
}
export interface TaskPosition {
id: number;
frc_name: string;
name: string;
frc: number;
}
export interface TaskPerson {
id: number;
name: string;
short_name: string;
avatar: string | null;
avatar_small: string | null;
first_position: string;
company: Record<string, unknown> | null;
position: TaskPosition[];
}
export interface TaskPersonInput {
id?: number;
name: string;
short_name: string;
avatar?: string | null;
avatar_small?: string | null;
first_position?: string;
company?: Record<string, unknown> | null;
position?: TaskPosition[];
}
export interface TaskProject {
id: number;
name: string;
short_name: string;
tender: string;
get_absolute_url: string;
}
export interface TaskMessage {
id: number;
author: TaskPerson;
text: string;
request_new_deadline: string | null;
recipient: TaskPerson;
date: string;
status: string;
task: number;
}
export interface TaskRelatedObject {
id: number;
[key: string]: unknown;
}
export interface Project { export interface Project {
id: number; id: number;
name: string; name: string;
@@ -313,29 +455,156 @@ export interface ProjectListParams extends ListParams {
export interface Task { export interface Task {
id: number; id: number;
name: string; project: TaskProject | null;
author_id: number | null; doer: TaskPerson;
responsible_id: number | null; doer_name: string;
author: TaskPerson | null;
memo_full: TaskRelatedObject | null;
bill_full: TaskRelatedObject | null;
contract_full: TaskRelatedObject | null;
contract_application_full: TaskRelatedObject | null;
outgoing_letter_full: TaskRelatedObject | null;
entry_letter_full: TaskRelatedObject | null;
protocolitem_full: TaskRelatedObject | null;
decree_full: TaskRelatedObject | null;
delivery_full: TaskRelatedObject | null;
get_status: string;
get_status_class: string;
get_scan_url: string | null;
get_last_day: string;
frc_icon: string;
uploadfile_set: unknown[];
deadline: string;
request_new_deadline: string | null;
plan_date: string | null;
date: string;
message_set: TaskMessage[];
responsible: TaskPerson | null;
counterparty: TaskRelatedObject | null;
get_deadline_history: unknown[];
duration: number | null;
bid_full: TaskRelatedObject | null;
price_agreement_full: TaskRelatedObject | null;
transfer: unknown;
text: string;
status: string;
result: string;
complit_date: string | null;
comment: string;
note: string;
approve: boolean;
archive: boolean;
approve_date: string | null;
approve_required: boolean;
progress_status: string;
start_date: string | null;
end_date: string | null;
order_number: number | null;
priority: number | null;
typ: string;
stage: unknown;
contract: unknown;
questionnair: unknown;
contract_application: unknown;
entry_letter: unknown;
outgoing_letter: unknown;
protocol: unknown;
bill: unknown;
decree: unknown;
court_case: unknown;
bill_register: unknown;
price_agreement: unknown;
bid: unknown;
delivery: unknown;
scheduled_task: unknown;
report: unknown;
memo: unknown;
protocolitem: unknown;
related_note: unknown;
task: unknown;
} }
export interface TaskCreate { export interface TaskCreate {
name: string; project?: TaskProject | null;
author_id?: number | null; doer: TaskPersonInput;
responsible_id?: number | null; get_status?: string;
get_status_class?: string;
get_scan_url?: string | null;
get_last_day?: string;
frc_icon?: string;
deadline: string;
request_new_deadline?: string | null;
plan_date?: string | null;
responsible?: TaskPersonInput | null;
counterparty?: TaskRelatedObject | null;
bid_full?: TaskRelatedObject | null;
price_agreement_full?: TaskRelatedObject | null;
text?: string;
status?: string;
result?: string;
complit_date?: string | null;
comment?: string;
note?: string;
approve?: boolean;
archive?: boolean;
approve_date?: string | null;
approve_required?: boolean;
progress_status?: string;
start_date?: string | null;
end_date?: string | null;
order_number?: number | null;
priority?: number | null;
typ?: string;
} }
export interface TaskUpdate { export interface TaskUpdate {
name?: string; project?: TaskProject | null;
author_id?: number | null; doer?: TaskPersonInput;
responsible_id?: number | null; get_status?: string;
get_status_class?: string;
get_scan_url?: string | null;
get_last_day?: string;
frc_icon?: string;
deadline?: string;
request_new_deadline?: string | null;
plan_date?: string | null;
responsible?: TaskPersonInput | null;
counterparty?: TaskRelatedObject | null;
bid_full?: TaskRelatedObject | null;
price_agreement_full?: TaskRelatedObject | null;
text?: string;
status?: string;
result?: string;
complit_date?: string | null;
comment?: string;
note?: string;
approve?: boolean;
archive?: boolean;
approve_date?: string | null;
approve_required?: boolean;
progress_status?: string;
start_date?: string | null;
end_date?: string | null;
order_number?: number | null;
priority?: number | null;
typ?: string;
} }
export interface TaskListParams extends ListParams { export interface TaskListParams extends ListParams {
id?: number; id?: number;
name?: string; text?: string;
name__contains?: string; text__contains?: string;
author_id?: number | null; doer?: number | null;
responsible_id?: number | null; author?: number | null;
archive?: boolean;
status?: string;
result?: string;
doer_name?: string;
doer_name__contains?: string;
deadline?: string;
progress_status?: string;
priority?: number | null;
typ?: string;
} }
export interface User { export interface User {
@@ -356,4 +625,3 @@ export interface UserListParams extends ListParams {
name?: string; name?: string;
name__contains?: string; name__contains?: string;
} }
+1 -1
View File
@@ -2,6 +2,6 @@ import { createApp } from "vue";
import Vant from "vant"; import Vant from "vant";
import "vant/lib/index.css"; import "vant/lib/index.css";
import App from "./App.vue"; import App from "./App.vue";
import { router } from "./router"; import { router } from "./app/router";
createApp(App).use(router).use(Vant).mount("#app"); createApp(App).use(router).use(Vant).mount("#app");
+1 -74
View File
@@ -1,74 +1 @@
import { createRouter, createWebHashHistory } from "vue-router"; export { router } from "../app/router";
import { isAuthenticated, useAuth } from "../composables/useAuth";
import ContractDetailView from "../views/ContractDetailView.vue";
import ContractsView from "../views/ContractsView.vue";
import LoginView from "../views/LoginView.vue";
import TaskCreateView from "../views/TaskCreateView.vue";
import TaskDetailView from "../views/TaskDetailView.vue";
import TasksView from "../views/TasksView.vue";
import UsersView from "../views/UsersView.vue";
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: "/",
redirect: "/tasks",
},
{
path: "/tasks",
name: "tasks",
component: TasksView,
meta: { title: "Задачи", requiresAuth: true },
},
{
path: "/tasks/new",
name: "task-create",
component: TaskCreateView,
meta: { title: "Новая задача", back: true, requiresAuth: true },
},
{
path: "/tasks/:id",
name: "task-detail",
component: TaskDetailView,
meta: { title: "Детали задачи", back: true, requiresAuth: true },
},
{
path: "/users",
name: "users",
component: UsersView,
meta: { title: "Пользователи", requiresAuth: true },
},
{
path: "/contracts",
name: "contracts",
component: ContractsView,
meta: { title: "Контракты", requiresAuth: true },
},
{
path: "/contracts/:id",
name: "contract-detail",
component: ContractDetailView,
meta: { title: "Детали контракта", back: true, requiresAuth: true },
},
{
path: "/login",
name: "login",
component: LoginView,
meta: { title: "Вход" },
},
],
});
router.beforeEach(async (to) => {
const { restoreToken } = useAuth();
await restoreToken();
if (to.meta.requiresAuth && !isAuthenticated()) {
return { path: "/login", query: { redirect: to.fullPath } };
}
if (to.path === "/login" && isAuthenticated()) {
return "/tasks";
}
});
@@ -1,6 +1,6 @@
import { onMounted, reactive, ref, shallowRef, watch } from "vue"; import { onMounted, reactive, ref, shallowRef, watch } from "vue";
import { showToast } from "vant"; import { showToast } from "vant";
import type { BaseEntity, ListParams, ModelApi } from "../generated/api_client"; import type { BaseEntity, ListParams, ModelApi } from "../../generated/api_client";
interface UseModelApiOptions<Params> { interface UseModelApiOptions<Params> {
defaultListParams?: Params; defaultListParams?: Params;
-211
View File
@@ -1,211 +0,0 @@
<script setup lang="ts">
import { openPath } from "@tauri-apps/plugin-opener";
import { computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast } from "vant";
import { contractApi, contractApplicationFileApi } from "../generated/api";
import type { Contract, ContractApplicationFileListParams } from "../generated/models";
import { useModelApi } from "../composables/useModelApi";
const route = useRoute();
const router = useRouter();
const contractId = computed(() => Number(route.params.id));
const {
item: contract,
loadingItem,
error,
retrieve: loadContract,
} = useModelApi(contractApi, {
retrieveErrorMessage: "Не удалось загрузить контракт",
autoLoad: false,
autoLoadOnFilterChange: false,
});
const {
items: applicationFiles,
loading: loadingApplicationFiles,
error: applicationFilesError,
load: loadApplicationFiles,
} = useModelApi(contractApplicationFileApi, {
defaultListParams: { ordering: "id" } as ContractApplicationFileListParams,
loadErrorMessage: "Не удалось загрузить приложения",
autoLoad: false,
autoLoadOnFilterChange: false,
});
const viewError = computed(() => error.value || applicationFilesError.value);
type DetailField = {
title: string;
key: keyof Contract;
};
const mainFields: DetailField[] = [
{ title: "ID", key: "id" },
{ title: "Название", key: "name" },
{ title: "Номер", key: "number" },
{ title: "Дата", key: "date" },
{ title: "Тип", key: "contract_type" },
{ title: "Категория", key: "category" },
{ title: "Статус", key: "status_name" },
{ title: "Код статуса", key: "status" },
{ title: "НДС", key: "nds" },
{ title: "Продукт", key: "name_of_product" },
{ title: "Комментарий", key: "comment" },
{ title: "URL", key: "absolute_url" },
];
const moneyFields: DetailField[] = [
{ title: "Сумма", key: "amount" },
{ title: "Сумма итого", key: "amount_total" },
{ title: "Сумма итого строкой", key: "amount_total_display" },
{ title: "Сумма по ДС", key: "amount_by_ds" },
{ title: "Платеж в месяц", key: "month_pay" },
{ title: "Аванс", key: "avans_pay" },
{ title: "Смета НДС стоимость", key: "estimate_nds_cost" },
{ title: "Смета НДС сертификат", key: "estimate_nds_cert" },
{ title: "Счета сумма", key: "bill_cost_sum" },
{ title: "Счета оплачено", key: "bill_paid_sum" },
{ title: "Доход всего", key: "income_total" },
{ title: "Задолженность", key: "arrears" },
];
const relationFields = computed(() => {
const item = contract.value;
if (!item) {
return [];
}
return [
{ title: "Контрагент", value: item.counterparty?.name ?? item.counterparty_name },
{ title: "Контрагент ID", value: item.counterparty_id },
{ title: "Проект", value: item.project?.short_name ?? item.project?.name },
{ title: "Проект ID", value: item.project_id },
{ title: "ФРЦ", value: item.frc?.name },
{ title: "ФРЦ ID", value: item.frc_id },
{ title: "Баланс ФРЦ", value: item.frc?.balance },
{ title: "Сотрудник", value: item.employee?.short_name ?? item.employee?.name },
{ title: "Сотрудник ID", value: item.employee_id },
];
});
function fieldValue(field: DetailField) {
return formatValue(contract.value?.[field.key]);
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === "") {
return "не указано";
}
return String(value);
}
async function openApplicationFile(localPath: string) {
if (!localPath) {
showToast("Файл не скачан");
return;
}
try {
await openPath(localPath);
} catch (err) {
showToast(errorMessage(err, "Не удалось открыть файл"));
}
}
function errorMessage(err: unknown, fallback: string) {
if (err instanceof Error) {
return err.message;
}
return fallback;
}
onMounted(() => {
if (Number.isFinite(contractId.value)) {
loadContract(contractId.value);
loadApplicationFiles({
ordering: "id",
contract_id: contractId.value,
} as ContractApplicationFileListParams);
}
});
</script>
<template>
<van-notice-bar
v-if="viewError"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="viewError"
/>
<section class="card detail-card">
<van-loading v-if="loadingItem" class="state" type="spinner">Загрузка...</van-loading>
<van-empty v-else-if="!contract" description="Контракт не найден" />
<template v-else>
<van-cell-group inset>
<van-cell
v-for="field in mainFields"
:key="field.key"
:title="field.title"
:value="fieldValue(field)"
/>
</van-cell-group>
<van-cell-group inset title="Суммы">
<van-cell
v-for="field in moneyFields"
:key="field.key"
:title="field.title"
:value="fieldValue(field)"
/>
</van-cell-group>
<van-cell-group inset title="Связи">
<van-cell
v-for="field in relationFields"
:key="field.title"
:title="field.title"
:value="formatValue(field.value)"
/>
</van-cell-group>
<van-cell-group inset title="Приложения">
<van-cell v-if="loadingApplicationFiles" title="Загрузка приложений..." />
<van-cell v-else-if="applicationFiles.length === 0" title="Приложений нет" />
<van-cell
v-for="file in applicationFiles"
v-else
:key="file.id"
:title="file.name"
:label="`${file.file_type_display} · ${file.status_display} · ${file.scan_name}`"
>
<template #value>
<van-button
size="small"
type="primary"
plain
:disabled="!file.local_path"
@click="openApplicationFile(file.local_path)"
>
Открыть
</van-button>
</template>
</van-cell>
</van-cell-group>
<div class="detail-actions">
<van-button block round type="primary" plain @click="router.back()">Назад</van-button>
</div>
</template>
</section>
</template>
-204
View File
@@ -1,204 +0,0 @@
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { showToast } from "vant";
import { contractApi } from "../generated/api";
import type { ContractListParams } from "../generated/models";
import { useModelApi } from "../composables/useModelApi";
import CounterpartySelect from "../components/CounterpartySelect.vue";
const PAGE_SIZE = 10;
const router = useRouter();
const {
items: contracts,
filters,
count,
loading,
error,
load: loadContracts,
} = useModelApi(contractApi, {
defaultListParams: { ordering: "-id", limit: PAGE_SIZE, offset: 0 } as ContractListParams,
loadErrorMessage: "Не удалось загрузить контракты",
cleanListParams(params) {
params.name__contains = params.name__contains?.trim() || undefined;
params.number__contains = params.number__contains?.trim() || undefined;
params.counterparty_id = params.counterparty_id || undefined;
params.limit = PAGE_SIZE;
params.offset = params.offset ?? 0;
},
});
const selectedCounterpartyId = computed({
get() {
return filters.counterparty_id ?? 0;
},
set(id: number) {
filters.counterparty_id = id || undefined;
},
});
const hasActiveFilters = computed(() =>
Boolean(
filters.name__contains ||
filters.number__contains ||
filters.counterparty_id,
),
);
const currentPage = computed({
get() {
return Math.floor((filters.offset ?? 0) / PAGE_SIZE) + 1;
},
set(page: number) {
filters.offset = (page - 1) * PAGE_SIZE;
},
});
interface SyncContractsResult {
synced: number;
pages: number;
applications: number;
files_downloaded: number;
}
const syncing = ref(false);
async function syncContracts() {
syncing.value = true;
try {
const result = await invoke<SyncContractsResult>("sync_contracts");
showToast(
`Синхронизировано: ${result.synced}; приложений: ${result.applications}; файлов: ${result.files_downloaded}`,
);
if (filters.offset) {
filters.offset = 0;
} else {
await loadContracts();
}
} catch (err) {
showToast(errorMessage(err, "Не удалось синхронизировать договоры"));
} finally {
syncing.value = false;
}
}
function errorMessage(err: unknown, fallback: string) {
if (typeof err === "object" && err && "detail" in err) {
return String((err as { detail: unknown }).detail);
}
if (err instanceof Error) {
return err.message;
}
return fallback;
}
watch(
() => [filters.name__contains, filters.number__contains, filters.counterparty_id],
() => {
filters.offset = 0;
},
);
</script>
<template>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<section class="card list-card">
<van-search
v-model="filters.name__contains"
placeholder="Поиск по названию"
clearable
/>
<van-cell-group inset class="contract-filters">
<van-field
v-model="filters.number__contains"
label="Номер"
placeholder="Номер договора"
clearable
/>
<van-field label="Контрагент">
<template #input>
<CounterpartySelect v-model="selectedCounterpartyId" />
</template>
</van-field>
</van-cell-group>
<div class="list-title">
<h2>Список</h2>
<div class="list-actions">
<van-button size="small" plain type="primary" :loading="loading" @click="loadContracts()">
Обновить
</van-button>
<van-button size="small" type="primary" :loading="syncing" @click="syncContracts">
Синхронизация
</van-button>
</div>
</div>
<van-loading
v-if="loading && contracts.length === 0"
class="state"
type="spinner"
>
Загрузка...
</van-loading>
<van-empty
v-else-if="contracts.length === 0"
:description="
hasActiveFilters ? 'Договоры не найдены' : 'Контрактов пока нет'
"
/>
<template v-else>
<van-cell-group inset>
<van-cell
v-for="contract in contracts"
:key="contract.id"
:title="contract.name"
:label="`№ ${contract.number} · ${contract.counterparty?.name ?? 'не указан'}`"
center
is-link
@click="router.push(`/contracts/${contract.id}`)"
>
<template #right-icon>
<van-tag plain type="primary">Contract</van-tag>
</template>
</van-cell>
</van-cell-group>
<van-pagination
v-model="currentPage"
class="contract-pagination"
:total-items="count"
:items-per-page="PAGE_SIZE"
mode="simple"
/>
</template>
</section>
</template>
<style scoped>
.contract-filters {
margin-bottom: 12px;
}
.contract-pagination {
margin: 14px 16px 0;
}
</style>
-91
View File
@@ -1,91 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { showToast } from "vant";
import { taskApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
import EmployeeSelect from "../components/EmployeeSelect.vue";
const router = useRouter();
const newTaskName = ref("");
const selectedAuthorId = ref(0);
const selectedResponsibleId = ref(0);
const {
creating,
error,
create: createTaskApi,
} = useModelApi(taskApi, {
createErrorMessage: "Не удалось создать задачу",
autoLoad: false,
autoLoadOnFilterChange: false,
});
async function createTask() {
const name = newTaskName.value.trim();
if (!name) {
showToast("Введите название задачи");
return;
}
const task = await createTaskApi({
name,
author_id: selectedAuthorId.value || null,
responsible_id: selectedResponsibleId.value || null,
});
if (task) {
showToast("Задача создана");
router.push("/tasks");
}
}
</script>
<template>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<van-form class="card" @submit="createTask">
<van-field
v-model="newTaskName"
name="name"
label="Название"
placeholder="Например: Купить молоко"
clearable
:disabled="creating"
/>
<van-field label="Автор">
<template #input>
<EmployeeSelect v-model="selectedAuthorId" />
</template>
</van-field>
<van-field label="Ответственный">
<template #input>
<EmployeeSelect v-model="selectedResponsibleId" />
</template>
</van-field>
<div class="form-actions stacked-actions">
<van-button
block
round
type="primary"
native-type="submit"
:loading="creating"
:disabled="!newTaskName.trim()"
>
Создать задачу
</van-button>
<van-button block round plain type="default" @click="router.back()">Отмена</van-button>
</div>
</van-form>
</template>
-188
View File
@@ -1,188 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast } from "vant";
import { employeeApi, messageApi, taskApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
import EmployeeSelect from "../components/EmployeeSelect.vue";
const route = useRoute();
const router = useRouter();
const taskId = computed(() => Number(route.params.id));
const messageText = ref("");
const selectedEmployeeId = ref(0);
const {
item: task,
loadingItem,
error,
retrieve: loadTask,
} = useModelApi(taskApi, {
retrieveErrorMessage: "Не удалось загрузить задачу",
autoLoad: false,
autoLoadOnFilterChange: false,
});
const {
items: messages,
loading: loadingMessages,
creating: sendingMessage,
error: messageError,
load: loadMessages,
create: createMessageApi,
} = useModelApi(messageApi, {
loadErrorMessage: "Не удалось загрузить сообщения",
createErrorMessage: "Не удалось отправить сообщение",
autoLoad: false,
autoLoadOnFilterChange: false,
});
const { items: employees } = useModelApi(employeeApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить сотрудников",
});
async function sendMessage() {
if (!Number.isFinite(taskId.value)) {
showToast("Некорректный ID задачи");
return;
}
const text = messageText.value.trim();
if (!text) {
showToast("Введите сообщение");
return;
}
if (!selectedEmployeeId.value) {
showToast("Выберите сотрудника");
return;
}
const message = await createMessageApi({
task_id: taskId.value,
employee_id: selectedEmployeeId.value,
text,
});
if (message) {
messageText.value = "";
await loadMessages({ task_id: taskId.value });
showToast("Сообщение отправлено");
}
}
function employeeName(id: number) {
return employees.value.find((employee) => employee.id === id)?.name ?? `#${id}`;
}
function employeeAvatar(id: number) {
return employees.value.find((employee) => employee.id === id)?.avatar_small ?? "";
}
onMounted(() => {
if (Number.isFinite(taskId.value)) {
loadTask(taskId.value);
loadMessages({ task_id: taskId.value });
}
});
</script>
<template>
<van-notice-bar
v-if="error || messageError"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error || messageError"
/>
<section class="card detail-card">
<van-loading v-if="loadingItem" class="state" type="spinner">Загрузка...</van-loading>
<van-empty v-else-if="!task" description="Задача не найдена" />
<template v-else>
<van-cell-group inset>
<van-cell title="ID" :value="task.id" />
<van-cell title="Название" :value="task.name" />
<van-cell title="Автор" :value="task.author_id ?? 'не указан'" />
<van-cell title="Ответственный" :value="task.responsible_id ?? 'не указан'" />
</van-cell-group>
</template>
</section>
<section class="card messages-card">
<div class="list-title">
<h2>Диалог</h2>
<van-button
size="small"
plain
type="primary"
:loading="loadingMessages"
@click="loadMessages({ task_id: taskId })"
>
Обновить
</van-button>
</div>
<van-loading v-if="loadingMessages && messages.length === 0" class="state" type="spinner">
Загрузка сообщений...
</van-loading>
<van-empty v-else-if="messages.length === 0" description="Сообщений пока нет" />
<div v-else class="message-list">
<article v-for="message in messages" :key="message.id" class="message-item">
<van-image
class="message-avatar"
round
width="40"
height="40"
:src="employeeAvatar(message.employee_id)"
/>
<div class="message-bubble">
<div class="message-author">{{ employeeName(message.employee_id) }}</div>
<div class="message-text">{{ message.text }}</div>
</div>
</article>
</div>
</section>
<van-form class="card" @submit="sendMessage">
<van-field
name="employee_id"
label="Отправитель"
placeholder="Выберите сотрудника"
>
<template #input>
<EmployeeSelect v-model="selectedEmployeeId" />
</template>
</van-field>
<van-field
v-model="messageText"
name="text"
label="Сообщение"
type="textarea"
rows="2"
autosize
clearable
placeholder="Напишите сообщение"
:disabled="sendingMessage"
/>
<div class="form-actions stacked-actions">
<van-button
block
round
type="primary"
native-type="submit"
:loading="sendingMessage"
:disabled="!Number.isFinite(taskId) || !messageText.trim() || !selectedEmployeeId"
>
Отправить сообщение
</van-button>
<van-button block round type="primary" plain @click="router.back()">Назад</van-button>
</div>
</van-form>
</template>
-78
View File
@@ -1,78 +0,0 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import { employeeApi, taskApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
const router = useRouter();
const {
items: tasks,
loading,
error,
load: loadTasks,
} = useModelApi(taskApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить задачи",
});
const { items: employees } = useModelApi(employeeApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить сотрудников",
});
function employeeName(id: number | null) {
if (id === null || id === 0) {
return "не указан";
}
return employees.value.find((employee) => employee.id === id)?.name ?? `#${id}`;
}
</script>
<template>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<section class="card list-card">
<div class="list-title">
<h2>Список</h2>
<div class="list-actions">
<van-button size="small" plain type="primary" :loading="loading" @click="loadTasks()">
Обновить
</van-button>
<van-button size="small" round type="primary" icon="plus" @click="router.push('/tasks/new')" />
</div>
</div>
<van-loading v-if="loading && tasks.length === 0" class="state" type="spinner">
Загрузка...
</van-loading>
<van-empty v-else-if="tasks.length === 0" description="Задач пока нет" />
<van-cell-group v-else inset>
<van-cell
v-for="task in tasks"
:key="task.id"
:title="task.name"
center
is-link
@click="router.push(`/tasks/${task.id}`)"
>
<template #label>
ID: {{ task.id }} · Автор: {{ employeeName(task.author_id) }} · Ответственный:
{{ employeeName(task.responsible_id) }}
</template>
<template #right-icon>
<van-tag plain type="primary">Task</van-tag>
</template>
</van-cell>
</van-cell-group>
</section>
</template>