Compare commits

..

6 Commits

Author SHA1 Message Date
che e79bbbfcc4 fix 2026-07-28 08:53:19 +05:00
che 2dd7d33c8b fix 2026-07-27 15:24:23 +05:00
che 13362528cd fix 2026-07-27 12:17:17 +05:00
che c1b24206b9 fix 2026-07-27 10:51:18 +05:00
che 560b3fe44e fix 2026-07-26 20:30:38 +05:00
che 1441a14b90 fix 2026-07-25 19:18:32 +05:00
66 changed files with 5946 additions and 913 deletions
+243
View File
@@ -0,0 +1,243 @@
# AGENTS.md
## Project Overview
`ewa-mobile` is a Vue 3 + Vite + Vant frontend with a Tauri 2 Rust backend.
The app uses local and remote model resources exposed through `che-tauri`, which is built on top of `che-orm`. Frontend model access should go through the generated TypeScript API in `src/generated`.
## Main Stack
- Vue 3 with `<script setup>` single-file components.
- TypeScript with strict checks enabled.
- Vite for frontend dev/build.
- Vant for mobile UI components.
- Vue Router with hash history.
- Tauri 2 backend in `src-tauri`.
- SQLite local storage through `che-orm`.
- Remote REST integration through `che-tauri` and `reqwest`.
## Important Paths
Frontend:
- `package.json`: npm scripts and frontend dependencies.
- `vite.config.ts`: Vite/Tauri dev server config.
- `tsconfig.json`: strict TypeScript config.
- `src/main.ts`: Vue app bootstrap.
- `src/App.vue`: shell layout, nav bar, tabbar.
- `src/app/router/index.ts`: main router and auth guard.
- `src/apps/auth`: login feature.
- `src/apps/tasks`: task list/detail/create feature.
- `src/apps/contracts`: contract list/detail/PDF feature.
- `src/apps/users`: users feature.
- `src/apps/personnel`: personnel shared UI.
- `src/shared/auth/useAuth.ts`: frontend auth state and token restore.
- `src/shared/composables/useModelApi.ts`: shared model API loading helper.
Generated frontend API:
- `src/generated/api_client.ts`
- `src/generated/api.ts`
- `src/generated/models.ts`
Tauri backend:
- `src-tauri/Cargo.toml`: Rust backend dependencies.
- `src-tauri/tauri.conf.json`: Tauri app config.
- `src-tauri/app.toml`: database and remote API config.
- `src-tauri/src/lib.rs`: Tauri commands and API initialization.
- `src-tauri/src/main.rs`: native entry point.
- `src-tauri/src/bin/manage.rs`: management CLI entry point.
- `src-tauri/src/apps/mod.rs`: installed app list.
- `src-tauri/src/sync.rs`: contract sync and file download logic.
## Installed Backend Apps
Current `che-tauri` app modules:
- `users`
- `frcapp`
- `projectapp`
- `personemanagment`
- `contractapp`
Each app usually contains:
- `models.rs`: `che_orm::Model` structs.
- `serializers.rs`: frontend/remote field mapping.
- `filters.rs`: list filter metadata and remote query mapping.
- `mod.rs`: resource registration.
- `migrations/`: local SQLite migration SQL and `schema.json` when applicable.
## Commands
Install dependencies:
```bash
npm install
```
Frontend development:
```bash
npm run dev
```
Frontend build/typecheck:
```bash
npm run build
```
Tauri development:
```bash
npm run tauri dev
```
Tauri build:
```bash
npm run tauri build
```
Backend checks:
```bash
cd src-tauri
cargo fmt
cargo check
```
Management CLI from `src-tauri`:
```bash
cd src-tauri
cargo run --bin manage -- migrate
cargo run --bin manage -- generate-ts --out ../src/generated
```
Create or update migrations for one backend app:
```bash
cd src-tauri
cargo run --bin manage -- makemigrations <app>
cargo run --bin manage -- migrate <app>
```
## Generated API Rules
- Do not manually edit files under `src/generated` unless explicitly requested.
- Generated API is produced from Rust app/module metadata.
- After changing backend models, serializers, filters, or resources, regenerate TypeScript:
```bash
cd src-tauri
cargo run --bin manage -- generate-ts --out ../src/generated
```
- Frontend code should import model APIs from `src/generated/api`, for example:
```ts
import { taskApi } from "../../../generated/api";
```
- Shared loading/list state should prefer `useModelApi` from `src/shared/composables/useModelApi.ts`.
## Tauri Commands
Commands are registered in `src-tauri/src/lib.rs`.
Important commands:
- `che_api`: generic model API dispatch used by generated API client.
- `auth_login`: remote token login.
- `auth_set_token`: restore saved token into backend state.
- `auth_logout`: clear backend token.
- `auth_status`: backend auth status.
- `current_employee`: fetch current employee from remote API.
- `sync_contracts`: sync contracts and application files into local SQLite.
- `load_application_file`: load a local file or download it from remote URL.
## Adding Or Changing A Resource
For local SQLite-backed resources:
1. Edit `src-tauri/src/apps/<app>/models.rs`.
2. Update `serializers.rs`.
3. Update `filters.rs` if frontend list filtering needs the field.
4. Register or adjust the resource in `<app>/mod.rs`.
5. Run `cargo run --bin manage -- makemigrations <app>` from `src-tauri`.
6. Run `cargo run --bin manage -- migrate <app>` from `src-tauri` if local DB needs updating.
7. Run `cargo run --bin manage -- generate-ts --out ../src/generated`.
8. Run `npm run build` from project root.
For remote-only resources:
- Use `ctx.remote_resource` or `ctx.mapped_remote_resource` in the app module.
- Keep serializer `Field::source(...)` mappings aligned with remote JSON field names.
- Keep filters aligned with backend query params and generated frontend list params.
- Regenerate TS after resource metadata changes.
## Frontend Conventions
- Use Vue 3 `<script setup lang="ts">`.
- Keep TypeScript strict-mode clean: no unused locals or parameters.
- Prefer existing Vant components and the current mobile layout language.
- Preserve current feature grouping under `src/apps/<feature>`.
- Use generated API types from `src/generated/models`.
- Keep auth-sensitive routes guarded with route metadata and the router guard in `src/app/router/index.ts`.
- Do not bypass `useAuth` for login/logout/token restore unless changing auth architecture intentionally.
## Backend Conventions
- Keep Rust formatted with `cargo fmt`.
- Use `che_orm::Model` for model structs.
- Keep model, serializer, filter, resource registration, migration, and generated TS in sync.
- Prefer management CLI for migrations instead of hand-writing migration files.
- Do not edit old migration files unless the task is specifically migration repair.
- Avoid inspecting or modifying `src-tauri/target` and generated Android build artifacts unless the task requires it.
## Config And Local Data
- `src-tauri/app.toml` contains database and remote API settings.
- `src-tauri/src/lib.rs` currently constructs `AppConfig` inline; do not assume `app.toml` is always the runtime source of truth.
- `src-tauri/ewa-mobile.sqlite` is a local SQLite artifact.
- Remote API URLs may be environment-specific and may not be reachable on every machine.
## Validation Checklist
Frontend-only change:
```bash
npm run build
```
Tauri backend-only change:
```bash
cd src-tauri
cargo fmt
cargo check
```
Backend metadata or generated API change:
```bash
cd src-tauri
cargo fmt
cargo check
cargo run --bin manage -- generate-ts --out ../src/generated
cd ..
npm run build
```
Full app confidence check:
```bash
npm run build
cd src-tauri
cargo fmt
cargo check
```
+188 -4
View File
@@ -1,7 +1,191 @@
# Tauri + Vue + TypeScript # ewa-mobile
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. Mobile/desktop EWA client built with Vue 3, Vite, Vant and Tauri 2.
## Recommended IDE Setup The frontend uses generated TypeScript APIs from the Tauri backend. The backend exposes local and remote model resources through `che-tauri`, backed by `che-orm` and SQLite.
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) ## Stack
- Vue 3 with `<script setup>`.
- TypeScript strict mode.
- Vite.
- Vue Router with hash history.
- Vant mobile UI components.
- Tauri 2 Rust backend.
- SQLite via `che-orm`.
- Remote REST access and sync via `che-tauri`/`reqwest`.
## Setup
Install frontend dependencies:
```bash
npm install
```
Run frontend dev server:
```bash
npm run dev
```
Run the Tauri app in development mode:
```bash
npm run tauri dev
```
Build frontend:
```bash
npm run build
```
Build Tauri app:
```bash
npm run tauri build
```
## Backend Commands
Run backend checks:
```bash
cd src-tauri
cargo fmt
cargo check
```
Apply migrations:
```bash
cd src-tauri
cargo run --bin manage -- migrate
```
Generate TypeScript API from Rust metadata:
```bash
cd src-tauri
cargo run --bin manage -- generate-ts --out ../src/generated
```
Create and apply migrations for one app:
```bash
cd src-tauri
cargo run --bin manage -- makemigrations <app>
cargo run --bin manage -- migrate <app>
```
## Project Structure
```text
src/
app/router/ Main Vue Router setup and auth guard
apps/auth/ Login feature
apps/tasks/ Task list/detail/create screens
apps/contracts/ Contract screens and PDF preview
apps/users/ Users screens
apps/personnel/ Personnel shared components
generated/ Generated TypeScript API and models
shared/auth/ Auth token state and restore logic
shared/composables/ Shared model API helpers
src-tauri/
src/lib.rs Tauri commands and API initialization
src/sync.rs Contract sync and file download logic
src/bin/manage.rs Management CLI entry point
src/apps/ che-tauri app modules
```
## Backend App Modules
Installed modules are registered in `src-tauri/src/apps/mod.rs`:
- `users`
- `frcapp`
- `projectapp`
- `personemanagment`
- `contractapp`
Each module usually contains `models.rs`, `serializers.rs`, `filters.rs`, `mod.rs`, and optional `migrations/`.
## Generated API
Generated files live in `src/generated`:
- `api_client.ts`
- `api.ts`
- `models.ts`
Do not edit generated files manually unless there is a specific reason. After changing backend models, serializers, filters, or resource registration, regenerate them:
```bash
cd src-tauri
cargo run --bin manage -- generate-ts --out ../src/generated
```
Frontend code should use generated APIs, for example:
```ts
import { taskApi } from "../../../generated/api";
```
Shared list/detail loading should prefer `src/shared/composables/useModelApi.ts`.
## Tauri Commands
Important commands registered in `src-tauri/src/lib.rs`:
- `che_api`: generic model API dispatch used by generated clients.
- `auth_login`: login against remote API.
- `auth_set_token`: restore saved auth token into backend state.
- `auth_logout`: clear backend token.
- `auth_status`: backend auth status.
- `current_employee`: fetch current employee from remote API.
- `sync_contracts`: sync contracts and application files to local SQLite.
- `load_application_file`: load local file bytes or download from remote URL.
## Configuration
- `src-tauri/app.toml` contains database and remote API settings.
- `src-tauri/src/lib.rs` currently creates `AppConfig` inline, so do not assume `app.toml` is always the runtime source of truth.
- `src-tauri/ewa-mobile.sqlite` is a local SQLite database artifact.
- Remote API URLs are environment-specific.
## Validation
Frontend change:
```bash
npm run build
```
Backend change:
```bash
cd src-tauri
cargo fmt
cargo check
```
Backend metadata or generated API change:
```bash
cd src-tauri
cargo fmt
cargo check
cargo run --bin manage -- generate-ts --out ../src/generated
cd ..
npm run build
```
## Developer Notes
- Keep frontend code TypeScript-strict and avoid unused locals/parameters.
- Keep model, serializer, filter, resource registration, migration and generated TS in sync.
- Prefer the management CLI for migrations.
- Avoid editing generated TypeScript and old migration files by hand.
- Avoid inspecting or modifying `src-tauri/target` and generated Android build artifacts unless needed.
+113 -237
View File
@@ -278,6 +278,29 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.17.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@@ -488,6 +511,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver",
"libc",
"shlex", "shlex",
] ]
@@ -557,7 +582,7 @@ dependencies = [
"async-trait", "async-trait",
"che-orm", "che-orm",
"clap", "clap",
"reqwest 0.12.28", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -617,6 +642,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -658,16 +692,6 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -691,9 +715,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"core-foundation 0.10.1", "core-foundation",
"core-graphics-types", "core-graphics-types",
"foreign-types 0.5.0", "foreign-types",
"libc", "libc",
] ]
@@ -704,7 +728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"core-foundation 0.10.1", "core-foundation",
"libc", "libc",
] ]
@@ -1080,15 +1104,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "endi" name = "endi"
version = "1.1.1" version = "1.1.1"
@@ -1181,8 +1196,9 @@ version = "0.1.0"
dependencies = [ dependencies = [
"che-orm", "che-orm",
"che-tauri", "che-tauri",
"openssl-sys", "jni",
"reqwest 0.12.28", "reqwest",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -1261,15 +1277,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -1277,7 +1284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared 0.3.1", "foreign-types-shared",
] ]
[[package]] [[package]]
@@ -1291,12 +1298,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
@@ -1312,6 +1313,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.33" version = "0.3.33"
@@ -1703,25 +1710,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -1864,7 +1852,6 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -1888,23 +1875,6 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
] ]
[[package]] [[package]]
@@ -1925,11 +1895,9 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -2211,6 +2179,16 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.103" version = "0.3.103"
@@ -2459,23 +2437,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -2797,59 +2758,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "openssl-probe" name = "openssl-probe"
version = "0.2.1" 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]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -3218,6 +3132,7 @@ version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [ dependencies = [
"aws-lc-rs",
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.3.4",
"lru-slab", "lru-slab",
@@ -3411,50 +3326,6 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.4" version = "0.13.4"
@@ -3469,15 +3340,22 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -3557,14 +3435,26 @@ version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [ dependencies = [
"aws-lc-rs",
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.15.0" version = "1.15.0"
@@ -3575,12 +3465,40 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.13" version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [ dependencies = [
"aws-lc-rs",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"untrusted", "untrusted",
@@ -3680,7 +3598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"core-foundation 0.10.1", "core-foundation",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
"security-framework-sys", "security-framework-sys",
@@ -4351,27 +4269,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -4393,7 +4290,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2",
"core-foundation 0.10.1", "core-foundation",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dbus", "dbus",
@@ -4472,7 +4369,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest 0.13.4", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -4836,16 +4733,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.26.4" version = "0.26.4"
@@ -5480,10 +5367,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-root-certs"
version = "1.0.7" version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@@ -5683,17 +5570,6 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
+3 -2
View File
@@ -26,5 +26,6 @@ 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", "rustls-tls"] } reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls"] }
openssl-sys = { version = "0.9", features = ["vendored"] } jni = "0.21"
rustls-platform-verifier = "0.6"
@@ -1,4 +1,5 @@
import java.util.Properties import java.util.Properties
import groovy.json.JsonSlurper
plugins { plugins {
id("com.android.application") id("com.android.application")
@@ -53,11 +54,19 @@ android {
} }
} }
repositories {
maven {
url = uri(rustlsPlatformVerifierMavenDir())
metadataSources.artifact()
}
}
rust { rust {
rootDirRel = "../../../" rootDirRel = "../../../"
} }
dependencies { dependencies {
implementation("rustls:rustls-platform-verifier:latest.release")
implementation("androidx.webkit:webkit:1.14.0") implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1") implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1") implementation("androidx.activity:activity-ktx:1.10.1")
@@ -69,3 +78,27 @@ dependencies {
} }
apply(from = "tauri.build.gradle.kts") apply(from = "tauri.build.gradle.kts")
fun rustlsPlatformVerifierMavenDir(): String {
val manifestPath = providers.exec {
commandLine(
"cargo",
"metadata",
"--format-version",
"1",
"--filter-platform",
"aarch64-linux-android",
"--manifest-path",
rootProject.file("../../Cargo.toml").absolutePath,
)
}.standardOutput.asText.get()
val metadata = JsonSlurper().parseText(manifestPath) as Map<*, *>
val packages = metadata["packages"] as List<*>
val rustlsAndroidPackage = packages
.map { it as Map<*, *> }
.first { it["name"] == "rustls-platform-verifier-android" }
val rustlsManifest = file(rustlsAndroidPackage["manifest_path"] as String)
return File(rustlsManifest.parentFile, "maven").path
}
@@ -1,10 +1,20 @@
package com.che.ewa_mobile package com.che.ewa_mobile
import android.content.Context
import android.os.Bundle import android.os.Bundle
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() { class MainActivity : TauriActivity() {
companion object {
init {
System.loadLibrary("ewa_mobile_lib")
}
@JvmStatic external fun initRustlsPlatformVerifier(context: Context)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
initRustlsPlatformVerifier(applicationContext)
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
} }
+17 -17
View File
@@ -5,9 +5,9 @@ use super::models::{Contract, ContractApplicationFile, ContractCategory, Counter
static CONTRACTAPP_FILTERS: &[Filter] = &[ static CONTRACTAPP_FILTERS: &[Filter] = &[
Filter::exact("id"), Filter::exact("id"),
Filter::exact("name"), Filter::exact("name"),
Filter::contains("name"), Filter::contains("name").remote("name_of_product"),
Filter::exact("number"), Filter::exact("number"),
Filter::contains("number"), Filter::contains("number").remote("number"),
Filter::exact("date"), Filter::exact("date"),
Filter::exact("status"), Filter::exact("status"),
Filter::exact("status_name"), Filter::exact("status_name"),
@@ -16,50 +16,50 @@ 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::exact("category_id").remote("category"),
Filter::contains("counterparty_name"), Filter::contains("counterparty_name"),
Filter::contains("name_of_product"), Filter::contains("name_of_product").remote("name_of_product"),
Filter::exact("counterparty_id"), Filter::exact("counterparty_id").remote("company"),
Filter::exact("project_id"), Filter::exact("project_id").remote("project"),
Filter::exact("frc_id"), Filter::exact("frc_id").remote("frc"),
Filter::exact("employee_id"), Filter::exact("employee_id").remote("employee"),
]; ];
static CONTRACT_CATEGORY_FILTERS: &[Filter] = &[ static CONTRACT_CATEGORY_FILTERS: &[Filter] = &[
Filter::exact("id"), Filter::exact("id"),
Filter::exact("name"), Filter::exact("name"),
Filter::contains("name"), Filter::contains("name").local_only(),
Filter::exact("name_group"), Filter::exact("name_group"),
Filter::contains("name_group"), Filter::contains("name_group").local_only(),
]; ];
static COUNTERPARTY_FILTERS: &[Filter] = &[ static COUNTERPARTY_FILTERS: &[Filter] = &[
Filter::exact("id"), Filter::exact("id"),
Filter::exact("name"), Filter::exact("name"),
Filter::contains("name"), Filter::contains("name").remote("search"),
]; ];
static CONTRACT_APPLICATION_FILE_FILTERS: &[Filter] = &[ static CONTRACT_APPLICATION_FILE_FILTERS: &[Filter] = &[
Filter::exact("id"), Filter::exact("id"),
Filter::exact("contract_id"), Filter::exact("contract_id").remote("contract"),
Filter::exact("name"), Filter::exact("name"),
Filter::contains("name"), Filter::contains("name").remote("search"),
Filter::exact("file_type"), Filter::exact("file_type"),
Filter::exact("status"), Filter::exact("status"),
]; ];
pub fn contractapp_filterset() -> FilterSet<Contract> { pub fn contractapp_filterset() -> FilterSet<Contract> {
FilterSet::new(CONTRACTAPP_FILTERS) FilterSet::new(CONTRACTAPP_FILTERS).remote_ordering("order_by")
} }
pub fn contract_category_filterset() -> FilterSet<ContractCategory> { pub fn contract_category_filterset() -> FilterSet<ContractCategory> {
FilterSet::new(CONTRACT_CATEGORY_FILTERS) FilterSet::new(CONTRACT_CATEGORY_FILTERS).remote_ordering("order_by")
} }
pub fn counterparty_filterset() -> FilterSet<Counterparty> { pub fn counterparty_filterset() -> FilterSet<Counterparty> {
FilterSet::new(COUNTERPARTY_FILTERS) FilterSet::new(COUNTERPARTY_FILTERS).remote_ordering("order_by")
} }
pub fn contract_application_file_filterset() -> FilterSet<ContractApplicationFile> { pub fn contract_application_file_filterset() -> FilterSet<ContractApplicationFile> {
FilterSet::new(CONTRACT_APPLICATION_FILE_FILTERS) FilterSet::new(CONTRACT_APPLICATION_FILE_FILTERS).remote_ordering("order_by")
} }
+8 -4
View File
@@ -16,23 +16,27 @@ impl AppModule for ContractappModule {
} }
fn init(&self, ctx: &mut ModuleContext) { fn init(&self, ctx: &mut ModuleContext) {
ctx.resource::<models::Contract>( ctx.cached_mapped_remote_resource::<models::Contract>(
"contract", "contract",
"/api/contract/",
serializers::contractapp_serializer(), serializers::contractapp_serializer(),
filters::contractapp_filterset(), filters::contractapp_filterset(),
); );
ctx.resource::<models::ContractCategory>( ctx.cached_mapped_remote_resource::<models::ContractCategory>(
"contract_category", "contract_category",
"/api/cont/category/",
serializers::contract_category_serializer(), serializers::contract_category_serializer(),
filters::contract_category_filterset(), filters::contract_category_filterset(),
); );
ctx.resource::<models::Counterparty>( ctx.cached_mapped_remote_resource::<models::Counterparty>(
"counterparty", "counterparty",
"/api/catalog/company/",
serializers::counterparty_serializer(), serializers::counterparty_serializer(),
filters::counterparty_filterset(), filters::counterparty_filterset(),
); );
ctx.resource::<models::ContractApplicationFile>( ctx.cached_mapped_remote_resource::<models::ContractApplicationFile>(
"contract_application_file", "contract_application_file",
"/api/cont/appfile/",
serializers::contract_application_file_serializer(), serializers::contract_application_file_serializer(),
filters::contract_application_file_filterset(), filters::contract_application_file_filterset(),
); );
+34 -18
View File
@@ -12,19 +12,19 @@ static CONTRACT_FIELDS: &[Field] = &[
Field::new("id").read_only(), Field::new("id").read_only(),
Field::new("name"), Field::new("name"),
Field::new("number"), Field::new("number"),
Field::new("absolute_url"), Field::new("absolute_url").source("get_absolute_url"),
Field::new("contract_type"), Field::new("contract_type").source("get_type"),
Field::new("category"), Field::new("category").source("get_category"),
Field::new("category_id").required(false).nullable(), Field::new("category_id").required(false).nullable(),
Field::new("amount_total_display"), Field::new("amount_total_display").source("get_amount_total"),
Field::new("amount_by_ds"), Field::new("amount_by_ds").source("get_amount_by_ds"),
Field::new("comment"), Field::new("comment"),
Field::new("date"), Field::new("date"),
Field::new("status_name"), Field::new("status_name").source("get_status"),
Field::new("status"), Field::new("status"),
Field::new("nds"), Field::new("nds"),
Field::new("name_of_product"), Field::new("name_of_product"),
Field::new("counterparty_name"), Field::new("counterparty_name").source("counterparty"),
Field::new("amount"), Field::new("amount"),
Field::new("month_pay").required(false).nullable(), Field::new("month_pay").required(false).nullable(),
Field::new("avans_pay"), Field::new("avans_pay"),
@@ -35,10 +35,22 @@ static CONTRACT_FIELDS: &[Field] = &[
Field::new("bill_paid_sum").required(false).nullable(), Field::new("bill_paid_sum").required(false).nullable(),
Field::new("income_total"), Field::new("income_total"),
Field::new("arrears"), Field::new("arrears"),
Field::new("counterparty_id").required(false).nullable(), Field::new("counterparty_id")
Field::new("project_id").required(false).nullable(), .source("company")
Field::new("frc_id").required(false).nullable(), .required(false)
Field::new("employee_id").required(false).nullable(), .nullable(),
Field::new("project_id")
.source("project")
.required(false)
.nullable(),
Field::new("frc_id")
.source("frc")
.required(false)
.nullable(),
Field::new("employee_id")
.source("get_employee")
.required(false)
.nullable(),
Field::related("category_ref", "category_id", &CATEGORY_RELATION), 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),
@@ -58,16 +70,16 @@ static CONTRACT_CATEGORY_FIELDS: &[Field] = &[
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(),
Field::new("contract_id"), Field::new("contract_id").source("contract"),
Field::new("name"), Field::new("name"),
Field::new("file_type"), Field::new("file_type"),
Field::new("file_type_display"), Field::new("file_type_display").source("get_file_type_display"),
Field::new("status"), Field::new("status"),
Field::new("status_display"), Field::new("status_display").source("get_status_display"),
Field::new("absolute_url"), Field::new("absolute_url").source("get_absolute_url"),
Field::new("scan_name"), Field::new("scan_name").source("get_scan"),
Field::new("scan_url"), Field::new("scan_url").source("scan"),
Field::new("local_path"), Field::new("local_path").default(empty_string),
Field::new("comment"), Field::new("comment"),
Field::new("bill_total"), Field::new("bill_total"),
Field::new("bill_cost_total"), Field::new("bill_cost_total"),
@@ -83,6 +95,10 @@ static PROJECT_RELATION: RelatedModel<Project> = RelatedModel::new(project_seria
static FRC_RELATION: RelatedModel<Frc> = RelatedModel::new(frc_serializer); static FRC_RELATION: RelatedModel<Frc> = RelatedModel::new(frc_serializer);
static EMPLOYEE_RELATION: RelatedModel<Employee> = RelatedModel::new(employee_serializer); static EMPLOYEE_RELATION: RelatedModel<Employee> = RelatedModel::new(employee_serializer);
fn empty_string() -> serde_json::Value {
serde_json::Value::String(String::new())
}
pub fn contractapp_serializer() -> ModelSerializer<Contract> { pub fn contractapp_serializer() -> ModelSerializer<Contract> {
ModelSerializer::new(CONTRACT_FIELDS) ModelSerializer::new(CONTRACT_FIELDS)
} }
+2 -2
View File
@@ -5,9 +5,9 @@ use super::models::Frc;
static FRC_FILTERS: &[Filter] = &[ static FRC_FILTERS: &[Filter] = &[
Filter::exact("id"), Filter::exact("id"),
Filter::exact("name"), Filter::exact("name"),
Filter::contains("name"), Filter::contains("name").local_only(),
]; ];
pub fn frc_filterset() -> FilterSet<Frc> { pub fn frc_filterset() -> FilterSet<Frc> {
FilterSet::new(FRC_FILTERS) FilterSet::new(FRC_FILTERS).remote_ordering("order_by")
} }
+2 -1
View File
@@ -16,8 +16,9 @@ impl AppModule for FrcModule {
} }
fn init(&self, ctx: &mut ModuleContext) { fn init(&self, ctx: &mut ModuleContext) {
ctx.resource::<models::Frc>( ctx.cached_mapped_remote_resource::<models::Frc>(
"frc", "frc",
"/api/frc/frc/",
serializers::frc_serializer(), serializers::frc_serializer(),
filters::frc_filterset(), filters::frc_filterset(),
); );
+1 -1
View File
@@ -6,7 +6,7 @@ static FRC_FIELDS: &[Field] = &[
Field::new("id").read_only(), Field::new("id").read_only(),
Field::new("name"), Field::new("name"),
Field::new("icon"), Field::new("icon"),
Field::new("balance"), Field::new("balance").source("get_balance"),
]; ];
pub fn frc_serializer() -> ModelSerializer<Frc> { pub fn frc_serializer() -> ModelSerializer<Frc> {
@@ -0,0 +1,31 @@
use che_tauri::{Filter, FilterSet};
use super::models::{Memo, MemoCategory};
static MEMO_FILTERS: &[Filter] = &[
Filter::exact("id"),
Filter::contains("text"),
Filter::exact("date"),
Filter::exact("priority"),
Filter::exact("archive"),
Filter::exact("cancel"),
Filter::exact_source("recipient", "recipient_id").remote("recipient"),
Filter::exact_source("sender", "sender_id").remote("sender"),
Filter::exact_source("category", "category_id").remote("category"),
Filter::exact_source("project", "project_id").remote("project"),
];
static MEMO_CATEGORY_FILTERS: &[Filter] = &[
Filter::exact("id"),
Filter::exact("name"),
Filter::contains("name"),
Filter::exact("code"),
];
pub fn memo_filterset() -> FilterSet<Memo> {
FilterSet::new(MEMO_FILTERS).remote_ordering("order_by")
}
pub fn memo_category_filterset() -> FilterSet<MemoCategory> {
FilterSet::new(MEMO_CATEGORY_FILTERS).remote_ordering("order_by")
}
+32
View File
@@ -0,0 +1,32 @@
pub mod filters;
pub mod models;
pub mod serializers;
use che_tauri::{AppModule, ModuleContext};
pub fn module() -> InternalmemoappModule {
InternalmemoappModule
}
pub struct InternalmemoappModule;
impl AppModule for InternalmemoappModule {
fn name(&self) -> &'static str {
"internalmemoapp"
}
fn init(&self, ctx: &mut ModuleContext) {
ctx.mapped_remote_resource::<models::Memo>(
"memo",
"/api/internalmemo/memo/",
serializers::memo_serializer(),
filters::memo_filterset(),
);
ctx.mapped_remote_resource::<models::MemoCategory>(
"memo_category",
"/api/internalmemo/category/",
serializers::memo_category_serializer(),
filters::memo_category_filterset(),
);
}
}
@@ -0,0 +1,53 @@
use che_orm::Model;
use crate::apps::{personemanagment::models::Employee, projectapp::models::Project};
#[derive(Debug, Clone, Model)]
#[model(table = "internal_memo")]
pub struct Memo {
#[field(primary_key)]
pub id: i64,
#[field(foreign_key = Employee)]
pub recipient_id: Option<i64>,
#[field(foreign_key = Employee)]
pub sender_id: Option<i64>,
#[field(foreign_key = MemoCategory)]
pub category_id: Option<i64>,
#[field(foreign_key = Project)]
pub project_id: Option<i64>,
pub task_set: Option<String>,
pub priority_display: String,
pub absolute_url: String,
pub date: String,
pub my_approve: String,
pub text: String,
pub resalution: String,
pub priority: String,
pub archive: bool,
pub cancel: bool,
pub date_start: Option<String>,
pub date_end: Option<String>,
pub value: f64,
pub archive_s: bool,
pub current_step: i64,
pub subject: Option<String>,
pub employee_acl: Option<String>,
}
#[derive(Debug, Clone, Model)]
#[model(table = "internal_memo_category")]
pub struct MemoCategory {
#[field(primary_key)]
pub id: i64,
pub name: String,
pub template: String,
pub template_text: String,
pub code: String,
pub in_month_limit: bool,
}
@@ -0,0 +1,80 @@
use che_tauri::{Field, ModelSerializer, RelatedModel};
use crate::apps::{
personemanagment::{models::Employee, serializers::employee_serializer},
projectapp::{models::Project, serializers::project_serializer},
};
use super::models::{Memo, MemoCategory};
static MEMO_FIELDS: &[Field] = &[
Field::new("id").read_only(),
Field::new("recipient_id")
.source("recipient")
.required(false)
.nullable(),
Field::new("sender_id")
.source("sender")
.required(false)
.nullable(),
Field::new("category_id")
.source("category")
.required(false)
.nullable(),
Field::new("project_id")
.source("project")
.required(false)
.nullable(),
Field::json("task_set")
.ts_type("unknown[]")
.required(false)
.nullable(),
Field::new("priority_display").source("get_priority_display"),
Field::new("absolute_url").source("get_absolute_url"),
Field::new("date"),
Field::new("my_approve"),
Field::new("text"),
Field::new("resalution"),
Field::new("priority"),
Field::new("archive"),
Field::new("cancel"),
Field::new("date_start").required(false).nullable(),
Field::new("date_end").required(false).nullable(),
Field::new("value"),
Field::new("archive_s"),
Field::new("current_step"),
Field::json("subject")
.ts_type("number[]")
.required(false)
.nullable(),
Field::json("employee_acl")
.ts_type("number[]")
.required(false)
.nullable(),
Field::related("recipient", "recipient_id", &EMPLOYEE_RELATION),
Field::related("sender", "sender_id", &EMPLOYEE_RELATION),
Field::related("category", "category_id", &MEMO_CATEGORY_RELATION),
Field::related("project", "project_id", &PROJECT_RELATION),
];
static MEMO_CATEGORY_FIELDS: &[Field] = &[
Field::new("id").read_only(),
Field::new("name"),
Field::new("template"),
Field::new("template_text"),
Field::new("code"),
Field::new("in_month_limit"),
];
static EMPLOYEE_RELATION: RelatedModel<Employee> = RelatedModel::new(employee_serializer);
static PROJECT_RELATION: RelatedModel<Project> = RelatedModel::new(project_serializer);
static MEMO_CATEGORY_RELATION: RelatedModel<MemoCategory> =
RelatedModel::new(memo_category_serializer);
pub fn memo_serializer() -> ModelSerializer<Memo> {
ModelSerializer::new(MEMO_FIELDS)
}
pub fn memo_category_serializer() -> ModelSerializer<MemoCategory> {
ModelSerializer::new(MEMO_CATEGORY_FIELDS)
}
+4
View File
@@ -1,6 +1,8 @@
pub mod frcapp; pub mod frcapp;
pub mod internalmemoapp;
pub mod personemanagment; pub mod personemanagment;
pub mod projectapp; pub mod projectapp;
pub mod supplyapp;
pub mod users; pub mod users;
use che_tauri::InstalledApps; use che_tauri::InstalledApps;
@@ -12,5 +14,7 @@ pub fn installed_apps() -> InstalledApps {
.add(projectapp::module()) .add(projectapp::module())
.add(personemanagment::module()) .add(personemanagment::module())
.add(contractapp::module()) .add(contractapp::module())
.add(supplyapp::module())
.add(internalmemoapp::module())
} }
pub mod contractapp; pub mod contractapp;
+18 -11
View File
@@ -5,17 +5,24 @@ use super::models::{Employee, Message, Task, TaskTransfer};
static EMPLOYEE_FILTERS: &[Filter] = &[ static EMPLOYEE_FILTERS: &[Filter] = &[
Filter::exact("id"), Filter::exact("id"),
Filter::exact("name"), Filter::exact("name"),
Filter::contains("name"), Filter::contains("name").remote("name"),
Filter::exact("short_name"), Filter::exact("short_name"),
Filter::contains("short_name"), Filter::contains("short_name").remote("name"),
]; ];
static TASK_FILTERS: &[Filter] = &[ static TASK_FILTERS: &[Filter] = &[
Filter::exact("id"), Filter::exact("id"),
Filter::exact("name"), Filter::contains("text"),
Filter::contains("name"), Filter::remote_only("contract"),
Filter::exact("author_id"), Filter::remote_only("bill"),
Filter::exact("responsible_id"), Filter::remote_only("memo"),
Filter::remote_only("doer"),
Filter::remote_only("author"),
Filter::exact("archive"),
Filter::exact("typ"),
Filter::exact("status"),
Filter::remote_only("q"),
Filter::remote_only("incomplete"),
]; ];
static MESSAGE_FILTERS: &[Filter] = &[ static MESSAGE_FILTERS: &[Filter] = &[
@@ -26,18 +33,18 @@ static MESSAGE_FILTERS: &[Filter] = &[
static TASK_TRANSFER_FILTERS: &[Filter] = &[ static TASK_TRANSFER_FILTERS: &[Filter] = &[
Filter::exact("id"), Filter::exact("id"),
Filter::exact("employee_from_id"), Filter::remote_only("employee_from"),
Filter::exact("employee_to_id"), Filter::remote_only("employee_to"),
Filter::exact("status"), Filter::exact("status"),
Filter::exact("typ"), Filter::exact("typ"),
]; ];
pub fn employee_filterset() -> FilterSet<Employee> { pub fn employee_filterset() -> FilterSet<Employee> {
FilterSet::new(EMPLOYEE_FILTERS) FilterSet::new(EMPLOYEE_FILTERS).remote_ordering("order_by")
} }
pub fn task_filterset() -> FilterSet<Task> { pub fn task_filterset() -> FilterSet<Task> {
FilterSet::new(TASK_FILTERS) FilterSet::new(TASK_FILTERS).remote_ordering("order_by")
} }
pub fn message_filterset() -> FilterSet<Message> { pub fn message_filterset() -> FilterSet<Message> {
@@ -45,5 +52,5 @@ pub fn message_filterset() -> FilterSet<Message> {
} }
pub fn task_transfer_filterset() -> FilterSet<TaskTransfer> { pub fn task_transfer_filterset() -> FilterSet<TaskTransfer> {
FilterSet::new(TASK_TRANSFER_FILTERS) FilterSet::new(TASK_TRANSFER_FILTERS).remote_ordering("order_by")
} }
+8 -6
View File
@@ -16,25 +16,27 @@ impl AppModule for TaskModule {
} }
fn init(&self, ctx: &mut ModuleContext) { fn init(&self, ctx: &mut ModuleContext) {
ctx.resource::<models::Employee>( ctx.cached_mapped_remote_resource::<models::Employee>(
"employee", "employee",
"/api/persone/employee/",
serializers::employee_serializer(), serializers::employee_serializer(),
filters::employee_filterset(), filters::employee_filterset(),
); );
ctx.remote_resource::<models::Task>( ctx.mapped_remote_resource::<models::Task>(
"task", "task",
"/api/persone/task", "/api/persone/task/",
serializers::task_serializer(), serializers::task_serializer(),
filters::task_filterset(), filters::task_filterset(),
); );
ctx.remote_resource::<models::TaskTransfer>( ctx.mapped_remote_resource::<models::TaskTransfer>(
"task_transfer", "task_transfer",
"/api/persone/task_transfer", "/api/persone/task_transfer/",
serializers::task_transfer_serializer(), serializers::task_transfer_serializer(),
filters::task_transfer_filterset(), filters::task_transfer_filterset(),
); );
ctx.resource::<models::Message>( ctx.mapped_remote_resource::<models::Message>(
"message", "message",
"/api/persone/messages/",
serializers::message_serializer(), serializers::message_serializer(),
filters::message_filterset(), filters::message_filterset(),
); );
+73 -11
View File
@@ -19,13 +19,73 @@ pub struct Task {
#[field(primary_key)] #[field(primary_key)]
pub id: i64, pub id: i64,
pub name: String, pub project: Option<String>,
pub doer: Option<String>,
#[field(foreign_key = Employee)] pub doer_name: String,
pub author_id: Option<i64>, pub author: Option<String>,
pub memo_full: Option<String>,
#[field(foreign_key = Employee)] pub bill_full: Option<String>,
pub responsible_id: Option<i64>, pub contract_full: Option<String>,
pub contract_application_full: Option<String>,
pub outgoing_letter_full: Option<String>,
pub entry_letter_full: Option<String>,
pub protocolitem_full: Option<String>,
pub decree_full: Option<String>,
pub delivery_full: Option<String>,
pub get_status: String,
pub get_status_class: String,
pub get_scan_url: Option<String>,
pub get_last_day: String,
pub frc_icon: String,
pub uploadfile_set: Option<String>,
pub deadline: String,
pub request_new_deadline: Option<String>,
pub plan_date: Option<String>,
pub date: String,
pub message_set: Option<String>,
pub responsible: Option<String>,
pub counterparty: Option<String>,
pub get_deadline_history: Option<String>,
pub duration: Option<i64>,
pub bid_full: Option<String>,
pub price_agreement_full: Option<String>,
pub transfer: Option<String>,
pub text: String,
pub status: String,
pub result: String,
pub complit_date: Option<String>,
pub comment: String,
pub note: String,
pub approve: bool,
pub archive: bool,
pub approve_date: Option<String>,
pub approve_required: bool,
pub progress_status: String,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub order_number: Option<i64>,
pub priority: Option<i64>,
pub typ: String,
pub stage: Option<String>,
pub contract: Option<String>,
pub questionnair: Option<String>,
pub contract_application: Option<String>,
pub entry_letter: Option<String>,
pub outgoing_letter: Option<String>,
pub protocol: Option<String>,
pub bill: Option<String>,
pub decree: Option<String>,
pub court_case: Option<String>,
pub bill_register: Option<String>,
pub price_agreement: Option<String>,
pub bid: Option<String>,
pub delivery: Option<String>,
pub scheduled_task: Option<String>,
pub report: Option<String>,
pub memo: Option<String>,
pub protocolitem: Option<String>,
pub related_note: Option<String>,
pub task: Option<String>,
} }
#[derive(Debug, Clone, Model)] #[derive(Debug, Clone, Model)]
@@ -41,6 +101,8 @@ pub struct Message {
pub employee_id: i64, pub employee_id: i64,
pub text: String, pub text: String,
pub status: String,
} }
#[derive(Debug, Clone, Model)] #[derive(Debug, Clone, Model)]
@@ -49,14 +111,14 @@ pub struct TaskTransfer {
#[field(primary_key)] #[field(primary_key)]
pub id: i64, pub id: i64,
#[field(foreign_key = Employee)] pub employee_from: Option<String>,
pub employee_from_id: Option<i64>,
#[field(foreign_key = Employee)] pub employee_to: Option<String>,
pub employee_to_id: Option<i64>,
pub date_create: String, pub date_create: String,
pub task: Option<String>,
pub status: String, pub status: String,
pub typ: String, pub typ: String,
@@ -11,26 +11,281 @@ static EMPLOYEE_FIELDS: &[Field] = &[
static TASK_FIELDS: &[Field] = &[ static TASK_FIELDS: &[Field] = &[
Field::new("id").read_only(), Field::new("id").read_only(),
Field::new("name"), Field::json("project")
Field::new("author_id").required(false).nullable(), .ts_type("string")
Field::new("responsible_id").required(false).nullable(), .required(false)
.nullable()
.read_only(),
Field::json("doer")
.ts_type("Employee")
.input_ts_type("Employee")
.required(false)
.nullable(),
Field::new("doer_name").read_only(),
Field::json("author")
.ts_type("Employee")
.required(false)
.nullable()
.read_only(),
Field::json("memo_full")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("bill_full")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("contract_full")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("contract_application_full")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("outgoing_letter_full")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("entry_letter_full")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("protocolitem_full")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("decree_full")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("delivery_full")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::new("get_status").read_only(),
Field::new("get_status_class").read_only(),
Field::new("get_scan_url")
.required(false)
.nullable()
.read_only(),
Field::new("get_last_day").read_only(),
Field::new("frc_icon").read_only(),
Field::json("uploadfile_set")
.ts_type("unknown[]")
.required(false)
.nullable()
.read_only(),
Field::new("deadline"),
Field::new("request_new_deadline")
.required(false)
.nullable()
.read_only(),
Field::new("plan_date")
.required(false)
.nullable()
.read_only(),
Field::new("date").read_only(),
Field::json("message_set")
.ts_type("unknown[]")
.required(false)
.nullable()
.read_only(),
Field::json("responsible")
.ts_type("Employee")
.input_ts_type("Employee")
.required(false)
.nullable(),
Field::json("counterparty")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("get_deadline_history")
.ts_type("unknown[]")
.required(false)
.nullable()
.read_only(),
Field::new("duration")
.required(false)
.nullable()
.read_only(),
Field::json("bid_full")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("price_agreement_full")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("transfer")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::new("text"),
Field::new("status").read_only(),
Field::new("result").read_only(),
Field::new("complit_date")
.required(false)
.nullable()
.read_only(),
Field::new("comment").read_only(),
Field::new("note").read_only(),
Field::new("approve").read_only(),
Field::new("archive").read_only(),
Field::new("approve_date")
.required(false)
.nullable()
.read_only(),
Field::new("approve_required").read_only(),
Field::new("progress_status").read_only(),
Field::new("start_date")
.required(false)
.nullable()
.read_only(),
Field::new("end_date")
.required(false)
.nullable()
.read_only(),
Field::new("order_number")
.required(false)
.nullable()
.read_only(),
Field::new("priority")
.required(false)
.nullable()
.read_only(),
Field::new("typ").read_only(),
Field::json("stage")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("contract")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("questionnair")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("contract_application")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("entry_letter")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("outgoing_letter")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("protocol")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("bill")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("decree")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("court_case")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("bill_register")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("price_agreement")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("bid")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("delivery")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("scheduled_task")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("report")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("memo")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("protocolitem")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("related_note")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
Field::json("task")
.ts_type("unknown")
.required(false)
.nullable()
.read_only(),
]; ];
static MESSAGE_FIELDS: &[Field] = &[ static MESSAGE_FIELDS: &[Field] = &[
Field::new("id").read_only(), Field::new("id").read_only(),
Field::new("task_id"), Field::new("task").source("task_id"),
Field::new("employee_id"), Field::new("recipient").source("employee_id"),
Field::new("text"), Field::new("text"),
Field::new("status"),
]; ];
static TASK_TRANSFER_FIELDS: &[Field] = &[ static TASK_TRANSFER_FIELDS: &[Field] = &[
Field::new("id").read_only(), Field::new("id").read_only(),
Field::new("employee_from_id").required(false).nullable(), Field::json("employee_from").required(false).nullable(),
Field::new("employee_to_id").required(false).nullable(), Field::json("employee_to").required(false).nullable(),
Field::new("date_create").read_only(), Field::new("date_create").read_only(),
Field::json("task").required(false).nullable(),
Field::new("status"), Field::new("status"),
Field::new("typ"), Field::new("typ"),
Field::new("project_id"),
]; ];
pub fn employee_serializer() -> ModelSerializer<Employee> { pub fn employee_serializer() -> ModelSerializer<Employee> {
+3 -3
View File
@@ -5,11 +5,11 @@ use super::models::Project;
static PROJECT_FILTERS: &[Filter] = &[ static PROJECT_FILTERS: &[Filter] = &[
Filter::exact("id"), Filter::exact("id"),
Filter::exact("name"), Filter::exact("name"),
Filter::contains("name"), Filter::contains("name").remote("search"),
Filter::exact("short_name"), Filter::exact("short_name"),
Filter::contains("short_name"), Filter::contains("short_name").remote("search"),
]; ];
pub fn project_filterset() -> FilterSet<Project> { pub fn project_filterset() -> FilterSet<Project> {
FilterSet::new(PROJECT_FILTERS) FilterSet::new(PROJECT_FILTERS).remote_ordering("order_by")
} }
+2 -1
View File
@@ -16,8 +16,9 @@ impl AppModule for ProjectModule {
} }
fn init(&self, ctx: &mut ModuleContext) { fn init(&self, ctx: &mut ModuleContext) {
ctx.resource::<models::Project>( ctx.cached_mapped_remote_resource::<models::Project>(
"project", "project",
"/api/project/",
serializers::project_serializer(), serializers::project_serializer(),
filters::project_filterset(), filters::project_filterset(),
); );
+9 -3
View File
@@ -6,9 +6,15 @@ static PROJECT_FIELDS: &[Field] = &[
Field::new("id").read_only(), Field::new("id").read_only(),
Field::new("name"), Field::new("name"),
Field::new("full_name"), Field::new("full_name"),
Field::new("short_name"), Field::new("short_name").source("get_short_name"),
Field::new("locality_id").required(false).nullable(), Field::new("locality_id")
Field::new("locality_name").required(false).nullable(), .source("locality")
.required(false)
.nullable(),
Field::new("locality_name")
.source("locality.name")
.required(false)
.nullable(),
]; ];
pub fn project_serializer() -> ModelSerializer<Project> { pub fn project_serializer() -> ModelSerializer<Project> {
+26
View File
@@ -0,0 +1,26 @@
use che_tauri::{Filter, FilterSet};
use super::models::Bill;
static BILL_FILTERS: &[Filter] = &[
Filter::exact("id"),
Filter::exact("number"),
Filter::contains("number"),
Filter::exact("text"),
Filter::contains("text"),
Filter::exact("status"),
Filter::exact("status_name"),
Filter::contains("status_name"),
Filter::exact("date"),
Filter::exact("date_due"),
Filter::exact("date_bill"),
Filter::exact("contract_typ"),
Filter::remote_only("counterparty"),
Filter::remote_only("frc"),
Filter::remote_only("project"),
Filter::remote_only("category"),
];
pub fn bill_filterset() -> FilterSet<Bill> {
FilterSet::new(BILL_FILTERS).remote_ordering("order_by")
}
@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS supply_bill (
id INTEGER PRIMARY KEY AUTOINCREMENT,
number TEXT NOT NULL,
text TEXT NOT NULL,
status TEXT NOT NULL,
status_name TEXT NOT NULL,
date TEXT NOT NULL,
date_due TEXT,
date_bill TEXT,
month_of_costs TEXT,
cost REAL NOT NULL,
to_payd REAL NOT NULL,
paid REAL NOT NULL,
nds_cost REAL NOT NULL,
scan TEXT,
comment TEXT NOT NULL,
absolute_url TEXT NOT NULL,
date_pay TEXT,
transaction_date TEXT,
date_applay TEXT,
archive_s BOOLEAN NOT NULL,
pp_maked BOOLEAN NOT NULL,
composit BOOLEAN NOT NULL,
contract_typ TEXT NOT NULL,
frc TEXT,
project TEXT,
counterparty TEXT,
contract TEXT,
responsible TEXT,
author TEXT,
category TEXT,
transferdocument_set TEXT
);
@@ -0,0 +1,350 @@
{
"models": [
{
"table": "supply_bill",
"fields": [
{
"name": "id",
"ty": "integer",
"primary_key": true,
"nullable": false,
"auto": true,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "number",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "text",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "status",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "status_name",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "date",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "date_due",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "date_bill",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "month_of_costs",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "cost",
"ty": "real",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "to_payd",
"ty": "real",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "paid",
"ty": "real",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "nds_cost",
"ty": "real",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "scan",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "comment",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "absolute_url",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "date_pay",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "transaction_date",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "date_applay",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "archive_s",
"ty": "boolean",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "pp_maked",
"ty": "boolean",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "composit",
"ty": "boolean",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "contract_typ",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "frc",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "project",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "counterparty",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "contract",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "responsible",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "author",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "category",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "transferdocument_set",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
}
]
}
]
}
+26
View File
@@ -0,0 +1,26 @@
pub mod filters;
pub mod models;
pub mod serializers;
use che_tauri::{AppModule, ModuleContext};
pub fn module() -> SupplyappModule {
SupplyappModule
}
pub struct SupplyappModule;
impl AppModule for SupplyappModule {
fn name(&self) -> &'static str {
"supplyapp"
}
fn init(&self, ctx: &mut ModuleContext) {
ctx.mapped_remote_resource::<models::Bill>(
"bill",
"/api/supply/bill/",
serializers::bill_serializer(),
filters::bill_filterset(),
);
}
}
+40
View File
@@ -0,0 +1,40 @@
use che_orm::Model;
#[derive(Debug, Clone, Model)]
#[model(table = "supply_bill")]
pub struct Bill {
#[field(primary_key)]
pub id: i64,
pub number: String,
pub text: String,
pub status: String,
pub status_name: String,
pub date: String,
pub date_due: Option<String>,
pub date_bill: Option<String>,
pub month_of_costs: Option<String>,
pub cost: f64,
pub to_payd: f64,
pub paid: f64,
pub nds_cost: f64,
pub scan: Option<String>,
pub comment: String,
pub absolute_url: String,
pub date_pay: Option<String>,
pub transaction_date: Option<String>,
pub date_applay: Option<String>,
pub archive_s: bool,
pub pp_maked: bool,
pub composit: bool,
pub contract_typ: String,
pub frc: Option<String>,
pub project: Option<String>,
pub counterparty: Option<String>,
pub contract: Option<String>,
pub responsible: Option<String>,
pub author: Option<String>,
pub category: Option<String>,
pub transferdocument_set: Option<String>,
}
@@ -0,0 +1,65 @@
use che_tauri::{Field, ModelSerializer};
use super::models::Bill;
static BILL_FIELDS: &[Field] = &[
Field::new("id").read_only(),
Field::new("number"),
Field::new("text"),
Field::new("status"),
Field::new("status_name").source("get_status_display"),
Field::new("date"),
Field::new("date_due").required(false).nullable(),
Field::new("date_bill").required(false).nullable(),
Field::new("month_of_costs").required(false).nullable(),
Field::new("cost"),
Field::new("to_payd"),
Field::new("paid"),
Field::new("nds_cost"),
Field::new("scan").required(false).nullable(),
Field::new("comment"),
Field::new("absolute_url").source("get_absolute_url"),
Field::new("date_pay").required(false).nullable(),
Field::new("transaction_date").required(false).nullable(),
Field::new("date_applay").required(false).nullable(),
Field::new("archive_s"),
Field::new("pp_maked"),
Field::new("composit"),
Field::new("contract_typ"),
Field::json("frc")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("project")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("counterparty")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("contract")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("responsible")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("author")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("category")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("transferdocument_set")
.ts_type("unknown[]")
.required(false)
.nullable(),
];
pub fn bill_serializer() -> ModelSerializer<Bill> {
ModelSerializer::new(BILL_FIELDS)
}
+317 -38
View File
@@ -1,15 +1,143 @@
pub mod apps; pub mod apps;
pub mod sync; pub mod sync;
use std::{
path::{Path, PathBuf},
time::Duration,
};
use che_orm::SqliteBackend; use che_orm::SqliteBackend;
use che_tauri::{ use che_tauri::{
ApiError, ApiRequest, AppConfig, AppState, AuthTokenResponse, DatabaseConfig, RemoteConfig, ApiError, ApiRequest, AppConfig, AppState, AuthTokenResponse, DatabaseConfig, RemoteConfig,
TauriApi, TauriApi,
}; };
use tauri::Manager; use tauri::Manager;
use tauri_plugin_opener::OpenerExt;
use crate::sync::SyncContractsResult; use crate::sync::SyncContractsResult;
#[cfg(target_os = "android")]
#[no_mangle]
pub unsafe extern "system" fn Java_com_che_ewa_1mobile_MainActivity_initRustlsPlatformVerifier(
mut env: jni::JNIEnv,
_class: jni::objects::JClass,
context: jni::objects::JObject,
) {
if let Err(error) = rustls_platform_verifier::android::init_with_env(&mut env, context) {
eprintln!("failed to initialize rustls platform verifier: {error}");
}
}
#[cfg(target_os = "android")]
const REMOTE_BASE_URL: &str = "http://10.0.2.2:8000";
#[cfg(not(target_os = "android"))]
const REMOTE_BASE_URL: &str = "http://127.0.0.1:8000";
const DEFAULT_AUTH_PATH: &str = "/api-token-auth/";
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
struct AppSettings {
remote_base_url: String,
auth_path: String,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
remote_base_url: String::from(REMOTE_BASE_URL),
auth_path: String::from(DEFAULT_AUTH_PATH),
}
}
}
async fn build_api(database_url: String, settings: AppSettings) -> Result<TauriApi, ApiError> {
let config = AppConfig {
database: DatabaseConfig { url: database_url },
remote: Some(settings_to_remote_config(&settings)),
};
let db = SqliteBackend::connect(&config.database.url).await?;
let state = AppState::new(config, db);
TauriApi::new(state)
.install(apps::installed_apps())
.build()
.await
}
fn settings_to_remote_config(settings: &AppSettings) -> RemoteConfig {
RemoteConfig {
base_url: normalize_base_url(&settings.remote_base_url),
auth_path: Some(normalize_auth_path(&settings.auth_path)),
}
}
fn normalize_settings(settings: AppSettings) -> AppSettings {
AppSettings {
remote_base_url: normalize_base_url(&settings.remote_base_url),
auth_path: normalize_auth_path(&settings.auth_path),
}
}
fn normalize_base_url(base_url: &str) -> String {
base_url.trim().trim_end_matches('/').to_string()
}
fn normalize_auth_path(auth_path: &str) -> String {
let clean = auth_path.trim().trim_matches('/');
if clean.is_empty() {
String::from(DEFAULT_AUTH_PATH)
} else {
format!("/{clean}/")
}
}
fn validate_settings(settings: &AppSettings) -> Result<(), ApiError> {
if !(settings.remote_base_url.starts_with("http://")
|| settings.remote_base_url.starts_with("https://"))
{
return Err(ApiError::bad_request(
"Удаленный URL должен начинаться с http:// или https://",
));
}
Ok(())
}
pub(crate) fn remote_http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.connect_timeout(Duration::from_secs(5))
.build()
.expect("failed to build reqwest client")
}
fn settings_path(app_data_dir: &Path) -> PathBuf {
app_data_dir.join("settings.json")
}
async fn load_settings(app_data_dir: &Path) -> Result<AppSettings, ApiError> {
let path = settings_path(app_data_dir);
match tokio::fs::read_to_string(path).await {
Ok(content) => serde_json::from_str::<AppSettings>(&content)
.map(normalize_settings)
.map_err(|error| ApiError::new("settings_error", error.to_string())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(AppSettings::default()),
Err(error) => Err(ApiError::new("settings_error", error.to_string())),
}
}
async fn save_settings(app_data_dir: &Path, settings: &AppSettings) -> Result<(), ApiError> {
tokio::fs::create_dir_all(app_data_dir)
.await
.map_err(|error| ApiError::new("settings_error", error.to_string()))?;
let content = serde_json::to_string_pretty(settings)
.map_err(|error| ApiError::new("settings_error", error.to_string()))?;
tokio::fs::write(settings_path(app_data_dir), content)
.await
.map_err(|error| ApiError::new("settings_error", error.to_string()))
}
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
struct CurrentEmployee { struct CurrentEmployee {
id: i64, id: i64,
@@ -55,9 +183,57 @@ fn auth_status(api: tauri::State<'_, TauriApi>) -> bool {
api.is_authenticated() api.is_authenticated()
} }
#[tauri::command]
async fn get_app_settings(app: tauri::AppHandle) -> Result<AppSettings, ApiError> {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|error| ApiError::new("settings_error", error.to_string()))?;
load_settings(&app_data_dir).await
}
#[tauri::command]
async fn update_app_settings(
app: tauri::AppHandle,
api: tauri::State<'_, TauriApi>,
settings: AppSettings,
) -> Result<AppSettings, ApiError> {
let settings = normalize_settings(settings);
validate_settings(&settings)?;
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|error| ApiError::new("settings_error", error.to_string()))?;
save_settings(&app_data_dir, &settings).await?;
api.state()
.set_remote_config(Some(settings_to_remote_config(&settings)));
api.logout();
Ok(settings)
}
#[tauri::command]
async fn reset_app_settings(
app: tauri::AppHandle,
api: tauri::State<'_, TauriApi>,
) -> Result<AppSettings, ApiError> {
let settings = AppSettings::default();
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|error| ApiError::new("settings_error", error.to_string()))?;
save_settings(&app_data_dir, &settings).await?;
api.state()
.set_remote_config(Some(settings_to_remote_config(&settings)));
api.logout();
Ok(settings)
}
#[tauri::command] #[tauri::command]
async fn current_employee(api: tauri::State<'_, TauriApi>) -> Result<CurrentEmployee, ApiError> { async fn current_employee(api: tauri::State<'_, TauriApi>) -> Result<CurrentEmployee, ApiError> {
let remote = api.state().config.remote.as_ref().ok_or_else(|| { let remote = api.state().remote_config().ok_or_else(|| {
ApiError::bad_request("current_employee requires [remote].base_url config") ApiError::bad_request("current_employee requires [remote].base_url config")
})?; })?;
@@ -66,7 +242,7 @@ async fn current_employee(api: tauri::State<'_, TauriApi>) -> Result<CurrentEmpl
.auth_token() .auth_token()
.ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?; .ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?;
let response = reqwest::Client::new() let response = remote_http_client()
.get(format!( .get(format!(
"{}/api/persone/employee/who_im/", "{}/api/persone/employee/who_im/",
remote.base_url.trim_end_matches('/') remote.base_url.trim_end_matches('/')
@@ -104,23 +280,76 @@ async fn sync_contracts(
} }
#[tauri::command] #[tauri::command]
async fn load_application_file(local_path: String, scan_url: String) -> Result<Vec<u8>, ApiError> { async fn load_remote_file(
if !local_path.is_empty() { app: tauri::AppHandle,
match tokio::fs::metadata(&local_path).await { api: tauri::State<'_, TauriApi>,
Ok(metadata) if metadata.len() > 0 => { url: String,
return tokio::fs::read(&local_path) ) -> Result<Vec<u8>, ApiError> {
.await let path = cache_remote_file(&app, &api, &url).await?;
.map_err(|error| ApiError::new("file_error", error.to_string())); tokio::fs::read(path)
} .await
Ok(_) | Err(_) => {} .map_err(|error| ApiError::new("file_error", error.to_string()))
} }
}
if scan_url.is_empty() { #[tauri::command]
async fn ensure_remote_file(
app: tauri::AppHandle,
api: tauri::State<'_, TauriApi>,
url: String,
) -> Result<String, ApiError> {
let path = cache_remote_file(&app, &api, &url).await?;
Ok(path.to_string_lossy().into_owned())
}
#[tauri::command]
async fn open_remote_file(
app: tauri::AppHandle,
api: tauri::State<'_, TauriApi>,
url: String,
) -> Result<(), ApiError> {
let path = cache_remote_file(&app, &api, &url).await?;
app.opener()
.open_path(path.to_string_lossy().into_owned(), None::<&str>)
.map_err(|error| ApiError::new("file_error", error.to_string()))
}
async fn cache_remote_file(
app: &tauri::AppHandle,
api: &tauri::State<'_, TauriApi>,
url: &str,
) -> Result<PathBuf, ApiError> {
if url.trim().is_empty() {
return Err(ApiError::new("file_error", "Файл недоступен")); return Err(ApiError::new("file_error", "Файл недоступен"));
} }
let response = reqwest::Client::new().get(&scan_url).send().await?; let remote = api.state().remote_config().ok_or_else(|| {
ApiError::bad_request("load_remote_file requires [remote].base_url config")
})?;
let file_url = normalize_remote_file_url(&remote.base_url, url)?;
let relative_path = remote_file_cache_path(file_url.path())?;
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|error| ApiError::new("file_error", error.to_string()))?;
let path = app_data_dir.join("remote_files").join(relative_path);
match tokio::fs::metadata(&path).await {
Ok(metadata) if metadata.len() > 0 => {
return Ok(path);
}
Ok(_) | Err(_) => {}
}
let token = api
.state()
.auth_token()
.ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?;
let response = remote_http_client()
.get(file_url)
.header(reqwest::header::AUTHORIZATION, format!("Token {token}"))
.send()
.await?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(ApiError::new( return Err(ApiError::new(
"remote_error", "remote_error",
@@ -133,7 +362,61 @@ async fn load_application_file(local_path: String, scan_url: String) -> Result<V
return Err(ApiError::new("file_error", "Файл пустой")); return Err(ApiError::new("file_error", "Файл пустой"));
} }
Ok(bytes.to_vec()) if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|error| ApiError::new("file_error", error.to_string()))?;
}
tokio::fs::write(&path, &bytes)
.await
.map_err(|error| ApiError::new("file_error", error.to_string()))?;
Ok(path)
}
fn normalize_remote_file_url(base_url: &str, url: &str) -> Result<reqwest::Url, ApiError> {
if let Ok(parsed) = reqwest::Url::parse(url.trim()) {
return Ok(parsed);
}
let base = reqwest::Url::parse(&format!("{}/", base_url.trim_end_matches('/')))
.map_err(|error| ApiError::new("file_error", error.to_string()))?;
base.join(url.trim_start_matches('/'))
.map_err(|error| ApiError::new("file_error", error.to_string()))
}
fn remote_file_cache_path(url_path: &str) -> Result<PathBuf, ApiError> {
let mut path = PathBuf::new();
for segment in url_path.split('/') {
if segment.is_empty() || segment == "." || segment == ".." {
continue;
}
path.push(sanitize_path_segment(segment));
}
if path.as_os_str().is_empty() {
Err(ApiError::new("file_error", "URL не содержит имя файла"))
} else {
Ok(path)
}
}
fn sanitize_path_segment(segment: &str) -> String {
let sanitized = segment
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => character,
})
.collect::<String>();
if sanitized.is_empty() {
"file".to_string()
} else {
sanitized
}
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -145,29 +428,20 @@ pub fn run() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
} }
let api = tauri::async_runtime::block_on(async {
// 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)
.install(apps::installed_apps())
.build()
.await
})
.expect("failed to initialize che-tauri");
tauri::Builder::default() tauri::Builder::default()
.manage(api)
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.setup(|app| {
let app_data_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&app_data_dir)?;
let database_path = app_data_dir.join("ewa-mobile.sqlite");
let database_url = format!("sqlite://{}?mode=rwc", database_path.to_string_lossy());
let settings = tauri::async_runtime::block_on(load_settings(&app_data_dir))
.map_err(|error| Box::<dyn std::error::Error>::from(error.to_string()))?;
let api = tauri::async_runtime::block_on(build_api(database_url, settings))
.map_err(|error| Box::<dyn std::error::Error>::from(error.to_string()))?;
app.manage(api);
Ok(())
})
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
greet, greet,
che_api, che_api,
@@ -175,9 +449,14 @@ pub fn run() {
auth_set_token, auth_set_token,
auth_logout, auth_logout,
auth_status, auth_status,
get_app_settings,
update_app_settings,
reset_app_settings,
current_employee, current_employee,
sync_contracts, sync_contracts,
load_application_file load_remote_file,
ensure_remote_file,
open_remote_file
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+7 -77
View File
@@ -4,6 +4,8 @@ use che_orm::__private::sqlx;
use che_tauri::{ApiError, TauriApi}; use che_tauri::{ApiError, TauriApi};
use serde::Deserialize; use serde::Deserialize;
use crate::remote_http_client;
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
pub struct SyncContractsResult { pub struct SyncContractsResult {
pub synced: usize, pub synced: usize,
@@ -158,18 +160,16 @@ struct RemoteEmployee {
pub async fn sync_contracts( pub async fn sync_contracts(
api: &TauriApi, api: &TauriApi,
app_data_dir: PathBuf, _app_data_dir: PathBuf,
) -> Result<SyncContractsResult, ApiError> { ) -> Result<SyncContractsResult, ApiError> {
let state = api.state(); let state = api.state();
let token = state let token = state
.auth_token() .auth_token()
.ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?; .ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?;
let remote = state let remote = state
.config .remote_config()
.remote
.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 = remote_http_client();
sync_contract_categories(api, &client, &token, &remote.base_url).await?; sync_contract_categories(api, &client, &token, &remote.base_url).await?;
let mut next_url = Some(format!( let mut next_url = Some(format!(
@@ -207,12 +207,9 @@ pub async fn sync_contracts(
fetch_contract_detail(&client, &token, &remote.base_url, contract.id).await?; 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 = upsert_contract_application_file(api, contract.id, application, "").await?;
download_application_file(&client, &token, &app_data_dir, application).await?;
upsert_contract_application_file(api, contract.id, application, &local_path)
.await?;
applications += 1; applications += 1;
if !local_path.is_empty() { if !application.scan.is_empty() {
files_downloaded += 1; files_downloaded += 1;
} }
} }
@@ -326,69 +323,6 @@ async fn fetch_contract_detail(
Ok(response.json::<RemoteContractDetail>().await?) Ok(response.json::<RemoteContractDetail>().await?)
} }
async fn download_application_file(
client: &reqwest::Client,
token: &str,
app_data_dir: &PathBuf,
application: &RemoteContractApplicationFile,
) -> Result<String, ApiError> {
if application.scan.is_empty() {
return Ok(String::new());
}
let directory = application_download_dir(app_data_dir);
tokio::fs::create_dir_all(&directory)
.await
.map_err(file_error)?;
let file_name = format!(
"{}_{}",
application.id,
sanitize_file_name(&application.scan_name)
);
let path = directory.join(file_name);
let response = client
.get(&application.scan)
.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!("file download failed with {status}: {detail}"),
));
}
let bytes = response.bytes().await?;
tokio::fs::write(&path, bytes).await.map_err(file_error)?;
Ok(path.to_string_lossy().into_owned())
}
fn application_download_dir(app_data_dir: &PathBuf) -> PathBuf {
app_data_dir.join("contract_applications")
}
fn sanitize_file_name(file_name: &str) -> String {
let sanitized = file_name
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => character,
})
.collect::<String>();
if sanitized.is_empty() {
"application_file".to_string()
} else {
sanitized
}
}
async fn upsert_contract_dependencies( async fn upsert_contract_dependencies(
api: &TauriApi, api: &TauriApi,
contract: &RemoteContract, contract: &RemoteContract,
@@ -606,7 +540,3 @@ async fn upsert_contract_application_file(
fn database_error(error: sqlx::Error) -> ApiError { fn database_error(error: sqlx::Error) -> ApiError {
ApiError::new("database_error", error.to_string()) ApiError::new("database_error", error.to_string())
} }
fn file_error(error: impl std::fmt::Display) -> ApiError {
ApiError::new("file_error", error.to_string())
}
+22 -3
View File
@@ -9,8 +9,24 @@ const { logout } = useAuth();
const activeTab = computed({ const activeTab = computed({
get() { get() {
if (route.path.startsWith("/documents")) {
return "/documents";
}
if (route.path.startsWith("/bills")) {
return "/documents";
}
if (route.path.startsWith("/contracts")) { if (route.path.startsWith("/contracts")) {
return "/contracts"; return "/documents";
}
if (route.path.startsWith("/memos")) {
return "/documents";
}
if (route.path.startsWith("/settings")) {
return "/settings";
} }
return route.path.startsWith("/users") ? "/users" : "/tasks"; return route.path.startsWith("/users") ? "/users" : "/tasks";
@@ -57,12 +73,15 @@ async function logoutAndRedirect() {
<van-tabbar-item to="/tasks" name="/tasks" icon="todo-list-o" <van-tabbar-item to="/tasks" name="/tasks" icon="todo-list-o"
>Задачи</van-tabbar-item >Задачи</van-tabbar-item
> >
<van-tabbar-item to="/contracts" name="/contracts" icon="orders-o" <van-tabbar-item to="/documents" name="/documents" icon="description-o"
>Контракты</van-tabbar-item >Документы</van-tabbar-item
> >
<van-tabbar-item to="/users" name="/users" icon="friends-o" <van-tabbar-item to="/users" name="/users" icon="friends-o"
>Пользователи</van-tabbar-item >Пользователи</van-tabbar-item
> >
<van-tabbar-item to="/settings" name="/settings" icon="setting-o"
>Настройки</van-tabbar-item
>
</van-tabbar> </van-tabbar>
</template> </template>
+20 -12
View File
@@ -1,23 +1,31 @@
import { createRouter, createWebHashHistory } from "vue-router"; import { createRouter, createWebHashHistory } from "vue-router";
import { isAuthenticated, useAuth } from "../../shared/auth/useAuth"; import { isAuthenticated, useAuth } from "../../shared/auth/useAuth";
import { loginRoute } from "../../apps/auth/routes"; import { loginRoute } from "../../apps/auth/routes";
import { documentsRoute } from "../../apps/documents/routes";
import { contractsRoutes } from "../../apps/contracts/routes"; import { contractsRoutes } from "../../apps/contracts/routes";
import { memosRoutes } from "../../apps/memos/routes";
import { settingsRoute } from "../../apps/settings/routes";
import { supplyRoutes } from "../../apps/supply/routes";
import { tasksRoutes } from "../../apps/tasks/routes"; import { tasksRoutes } from "../../apps/tasks/routes";
import { usersRoutes } from "../../apps/users/routes"; import { usersRoutes } from "../../apps/users/routes";
export const router = createRouter({ export const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes: [ routes: [
{ {
path: "/", path: "/",
redirect: "/tasks", redirect: "/documents",
}, },
...tasksRoutes, ...tasksRoutes,
...usersRoutes, ...usersRoutes,
...contractsRoutes, ...contractsRoutes,
loginRoute, ...memosRoutes,
], ...supplyRoutes,
}); documentsRoute,
settingsRoute,
loginRoute,
],
});
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
const { restoreToken } = useAuth(); const { restoreToken } = useAuth();
@@ -28,6 +36,6 @@ router.beforeEach(async (to) => {
} }
if (to.path === "/login" && isAuthenticated()) { if (to.path === "/login" && isAuthenticated()) {
return "/tasks"; return "/documents";
} }
}); });
+11
View File
@@ -82,6 +82,17 @@ function errorMessage(err: unknown) {
<van-button block round type="primary" native-type="submit" :loading="loading"> <van-button block round type="primary" native-type="submit" :loading="loading">
Войти Войти
</van-button> </van-button>
<van-button
block
round
plain
type="primary"
native-type="button"
:disabled="loading"
@click="router.push('/settings')"
>
Настройки сервера
</van-button>
</div> </div>
</van-form> </van-form>
</template> </template>
@@ -0,0 +1,475 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { taskApi } from "../../../generated/api";
import type { Employee, Task, TaskListParams } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
import RemoteImage from "../../../shared/components/RemoteImage.vue";
type ApprovalMessage = {
id: number;
author: Employee;
recipient: Employee;
text: string;
date: string;
status: string;
task: number;
};
type ApprovalTask = Omit<Task, "message_set"> & {
message_set?: ApprovalMessage[] | null;
};
const props = defineProps<{
contractId: number;
}>();
const {
items: tasks,
loading,
error,
load,
} = useModelApi(taskApi, {
loadErrorMessage: "Не удалось загрузить поручения",
autoLoad: false,
autoLoadOnFilterChange: false,
});
const expandedTasks = ref<Record<number, boolean>>({});
const hasTasks = computed(() => tasks.value.length > 0);
function taskTitle(task: Task | null | undefined) {
return task?.text?.trim() || task?.result?.trim() || `Задача #${task?.id ?? ""}`;
}
function personName(
person: { short_name?: string | null; name?: string | null } | null,
) {
if (!person) {
return "не указан";
}
return person.short_name ?? person.name ?? "не указан";
}
function personAvatar(person: { avatar_small?: string | null } | null) {
return person?.avatar_small ?? "";
}
function taskMessages(task: Task | null | undefined) {
return (task as ApprovalTask | null | undefined)?.message_set ?? [];
}
function isHistoryExpanded(taskId: number) {
return expandedTasks.value[taskId] ?? false;
}
function toggleHistory(taskId: number) {
expandedTasks.value = {
...expandedTasks.value,
[taskId]: !isHistoryExpanded(taskId),
};
}
function visibleMessages(task: Task | null | undefined) {
const messages = taskMessages(task);
if (isHistoryExpanded(task?.id ?? 0)) {
return messages;
}
return messages.length > 0 ? [messages[messages.length - 1]] : [];
}
function statusType(statusClass: string) {
if (/success|done|complete|green/i.test(statusClass)) {
return "success";
}
if (/warning|pending|wait|yellow|orange/i.test(statusClass)) {
return "warning";
}
if (/danger|error|red|fail/i.test(statusClass)) {
return "danger";
}
return "primary";
}
function statusTone(statusClass: string) {
if (/success|done|complete|green/i.test(statusClass)) {
return "success";
}
if (/warning|pending|wait|yellow|orange/i.test(statusClass)) {
return "warning";
}
if (/danger|error|red|fail/i.test(statusClass)) {
return "danger";
}
return "primary";
}
function loadTasks() {
const params = {
contract: String(props.contractId),
ordering: "-id",
} as TaskListParams;
return load(params);
}
watch(
() => props.contractId,
() => {
void loadTasks();
},
);
onMounted(() => {
void loadTasks();
});
</script>
<template>
<section class="approval-tasks">
<van-notice-bar
v-if="error"
class="approval-tasks__error"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<van-loading v-if="loading" class="approval-tasks__state" type="spinner">
Загрузка поручений...
</van-loading>
<van-empty v-else-if="!hasTasks" description="Поручений пока нет" />
<div v-else class="approval-task-list">
<article
v-for="task in tasks"
:key="task.id"
:class="[
'approval-task-card',
`approval-task-card--${statusTone(task.get_status_class)}`,
]"
>
<div class="approval-task-head">
<div class="approval-task-head__main">
<div class="approval-task-title">{{ taskTitle(task) }}</div>
<div class="approval-task-meta">
ID {{ task.id }} · {{ task.deadline }}
</div>
</div>
<van-tag plain :type="statusType(task.get_status_class)">
{{ task.get_status }}
</van-tag>
</div>
<van-cell-group inset>
<div class="approval-person-list">
<div class="approval-person-item">
<RemoteImage
class="approval-person-avatar"
round
width="32"
height="32"
:src="personAvatar(task.doer)"
/>
<div class="approval-person-body">
<div class="approval-person-role">Исполнитель</div>
<div class="approval-person-name">
{{ personName(task.doer) }}
</div>
</div>
</div>
<div class="approval-person-item">
<RemoteImage
class="approval-person-avatar"
round
width="32"
height="32"
:src="personAvatar(task.author)"
/>
<div class="approval-person-body">
<div class="approval-person-role">Автор</div>
<div class="approval-person-name">
{{ personName(task.author) }}
</div>
</div>
</div>
<div class="approval-person-item">
<RemoteImage
class="approval-person-avatar"
round
width="32"
height="32"
:src="personAvatar(task.responsible)"
/>
<div class="approval-person-body">
<div class="approval-person-role">Ответственный</div>
<div class="approval-person-name">
{{ personName(task.responsible) }}
</div>
</div>
</div>
</div>
</van-cell-group>
<div class="approval-task-messages">
<div class="approval-task-messages__title">Сообщения</div>
<van-empty
v-if="!taskMessages(task).length"
description="Сообщений пока нет"
image-size="64"
/>
<div v-else class="approval-message-list">
<article
v-for="message in visibleMessages(task)"
:key="message.id"
:class="[
'approval-message-item',
!isHistoryExpanded(task.id) &&
message.id === taskMessages(task)[taskMessages(task).length - 1]?.id
? 'approval-message-item--latest'
: '',
]"
@click="
message.id === taskMessages(task)[taskMessages(task).length - 1]?.id &&
!isHistoryExpanded(task.id)
? toggleHistory(task.id)
: undefined
"
>
<RemoteImage
class="approval-message-avatar"
round
width="36"
height="36"
:src="message.author.avatar_small"
/>
<div class="approval-message-body">
<div class="approval-message-head">
<div class="approval-message-author">
{{ personName(message.author) }}
</div>
<div class="approval-message-meta">
{{ message.date }} · {{ message.status }}
</div>
</div>
<div class="approval-message-text">{{ message.text }}</div>
<div class="approval-message-recipient">
Кому: {{ personName(message.recipient) }}
</div>
</div>
</article>
<van-button
v-if="taskMessages(task).length > 1"
class="approval-message-toggle"
size="small"
round
plain
type="primary"
@click.stop="toggleHistory(task.id)"
>
<van-icon
:name="isHistoryExpanded(task.id) ? 'arrow-up' : 'arrow-down'"
/>
</van-button>
</div>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.approval-tasks {
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 0 0;
}
.approval-tasks__state {
display: flex;
justify-content: center;
padding: 24px 0;
}
.approval-task-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.approval-task-card {
overflow: hidden;
border-radius: 16px;
background: #fff;
box-shadow: 0 8px 24px rgba(36, 42, 56, 0.08);
}
.approval-task-card--success {
background: #f0fdf4;
}
.approval-task-card--warning {
background: #fffbeb;
}
.approval-task-card--danger {
background: #fef2f2;
}
.approval-task-card--primary {
background: #eff6ff;
}
.approval-task-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 10px;
}
.approval-task-head__main {
min-width: 0;
}
.approval-task-title {
font-size: 15px;
font-weight: 600;
line-height: 1.35;
word-break: break-word;
}
.approval-task-meta {
margin-top: 4px;
color: #6b7280;
font-size: 12px;
}
.approval-task-messages {
padding: 12px 16px 16px;
}
.approval-person-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 8px 10px;
}
.approval-person-item {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.approval-person-avatar {
flex: none;
}
.approval-person-body {
min-width: 0;
flex: 1;
}
.approval-person-role {
color: #6b7280;
font-size: 10px;
line-height: 1.2;
}
.approval-person-name {
margin-top: 1px;
font-size: 12px;
font-weight: 500;
line-height: 1.35;
word-break: break-word;
}
.approval-task-messages__title {
margin-bottom: 10px;
color: #374151;
font-size: 13px;
font-weight: 600;
}
.approval-message-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.approval-message-item {
display: flex;
gap: 10px;
align-items: flex-start;
}
.approval-message-item--latest {
cursor: pointer;
}
.approval-message-avatar {
flex: none;
margin-top: 2px;
}
.approval-message-body {
min-width: 0;
flex: 1;
}
.approval-message-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.approval-message-author {
font-weight: 600;
font-size: 13px;
}
.approval-message-meta,
.approval-message-recipient {
color: #6b7280;
font-size: 11px;
}
.approval-message-toggle {
width: 28px;
height: 28px;
margin-top: 8px;
padding: 0;
}
.approval-message-text {
margin-top: 4px;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.45;
}
</style>
@@ -55,36 +55,42 @@ watch(search, (value) => {
<van-button size="small" type="primary" plain @click="selectCategory(0)">Все</van-button> <van-button size="small" type="primary" plain @click="selectCategory(0)">Все</van-button>
</div> </div>
<van-search v-model="search" placeholder="Поиск по категории" /> <div class="entity-popup-body">
<van-search v-model="search" placeholder="Поиск по категории" />
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading> <van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
<template v-else> <template v-else>
<van-cell-group inset> <van-cell-group inset>
<van-cell <van-cell
v-for="category in categories" v-for="category in categories"
:key="category.id" :key="category.id"
:title="category.name" :title="category.name"
:label="category.name_group || `ID: ${category.id}`" :label="category.name_group || `ID: ${category.id}`"
clickable clickable
center center
@click="selectCategory(category.id)" @click="selectCategory(category.id)"
> >
<template #right-icon> <template #right-icon>
<van-icon v-if="selectedCategoryId === category.id" name="success" color="#1989fa" /> <van-icon v-if="selectedCategoryId === category.id" name="success" color="#1989fa" />
</template> </template>
</van-cell> </van-cell>
</van-cell-group> </van-cell-group>
<van-empty v-if="categories.length === 0" description="Категории не найдены" /> <van-empty v-if="categories.length === 0" description="Категории не найдены" />
</template> </template>
</div>
</van-popup> </van-popup>
</template> </template>
<style scoped> <style scoped>
.entity-popup { .entity-popup {
min-height: 55vh; display: flex;
padding: 18px 0 28px; flex-direction: column;
height: 70vh;
max-height: 70vh;
overflow: hidden;
padding: 18px 0 16px;
} }
.entity-popup-header { .entity-popup-header {
@@ -106,6 +112,12 @@ watch(search, (value) => {
padding: 36px 0; padding: 36px 0;
} }
.entity-popup-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.entity-select { .entity-select {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -58,40 +58,46 @@ watch(search, (value) => {
<van-button size="small" type="primary" plain @click="selectCounterparty(0)">Все</van-button> <van-button size="small" type="primary" plain @click="selectCounterparty(0)">Все</van-button>
</div> </div>
<van-search v-model="search" placeholder="Поиск по имени" /> <div class="counterparty-popup-body">
<van-search v-model="search" placeholder="Поиск по имени" />
<van-loading v-if="loading" class="counterparty-state" type="spinner">Загрузка...</van-loading> <van-loading v-if="loading" class="counterparty-state" type="spinner">Загрузка...</van-loading>
<template v-else> <template v-else>
<van-cell-group inset> <van-cell-group inset>
<van-cell <van-cell
v-for="counterparty in counterparties" v-for="counterparty in counterparties"
:key="counterparty.id" :key="counterparty.id"
:title="counterparty.name" :title="counterparty.name"
:label="`ID: ${counterparty.id}`" :label="`ID: ${counterparty.id}`"
clickable clickable
center center
@click="selectCounterparty(counterparty.id)" @click="selectCounterparty(counterparty.id)"
> >
<template #right-icon> <template #right-icon>
<van-icon <van-icon
v-if="selectedCounterpartyId === counterparty.id" v-if="selectedCounterpartyId === counterparty.id"
name="success" name="success"
color="#1989fa" color="#1989fa"
/> />
</template> </template>
</van-cell> </van-cell>
</van-cell-group> </van-cell-group>
<van-empty v-if="counterparties.length === 0" description="Контрагенты не найдены" /> <van-empty v-if="counterparties.length === 0" description="Контрагенты не найдены" />
</template> </template>
</div>
</van-popup> </van-popup>
</template> </template>
<style scoped> <style scoped>
.counterparty-popup { .counterparty-popup {
min-height: 55vh; display: flex;
padding: 18px 0 28px; flex-direction: column;
height: 70vh;
max-height: 70vh;
overflow: hidden;
padding: 18px 0 16px;
} }
.counterparty-popup-header { .counterparty-popup-header {
@@ -113,6 +119,12 @@ watch(search, (value) => {
padding: 36px 0; padding: 36px 0;
} }
.counterparty-popup-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.counterparty-select { .counterparty-select {
display: flex; display: flex;
align-items: center; align-items: center;
+34 -22
View File
@@ -53,36 +53,42 @@ watch(search, (value) => {
<van-button size="small" type="primary" plain @click="selectFrc(0)">Все</van-button> <van-button size="small" type="primary" plain @click="selectFrc(0)">Все</van-button>
</div> </div>
<van-search v-model="search" placeholder="Поиск по названию" /> <div class="entity-popup-body">
<van-search v-model="search" placeholder="Поиск по названию" />
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading> <van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
<template v-else> <template v-else>
<van-cell-group inset> <van-cell-group inset>
<van-cell <van-cell
v-for="frc in frcs" v-for="frc in frcs"
:key="frc.id" :key="frc.id"
:title="frc.name" :title="frc.name"
:label="`Баланс: ${frc.balance}`" :label="`Баланс: ${frc.balance}`"
clickable clickable
center center
@click="selectFrc(frc.id)" @click="selectFrc(frc.id)"
> >
<template #right-icon> <template #right-icon>
<van-icon v-if="selectedFrcId === frc.id" name="success" color="#1989fa" /> <van-icon v-if="selectedFrcId === frc.id" name="success" color="#1989fa" />
</template> </template>
</van-cell> </van-cell>
</van-cell-group> </van-cell-group>
<van-empty v-if="frcs.length === 0" description="ФРЦ не найдены" /> <van-empty v-if="frcs.length === 0" description="ФРЦ не найдены" />
</template> </template>
</div>
</van-popup> </van-popup>
</template> </template>
<style scoped> <style scoped>
.entity-popup { .entity-popup {
min-height: 55vh; display: flex;
padding: 18px 0 28px; flex-direction: column;
height: 70vh;
max-height: 70vh;
overflow: hidden;
padding: 18px 0 16px;
} }
.entity-popup-header { .entity-popup-header {
@@ -104,6 +110,12 @@ watch(search, (value) => {
padding: 36px 0; padding: 36px 0;
} }
.entity-popup-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.entity-select { .entity-select {
display: flex; display: flex;
align-items: center; align-items: center;
+9 -11
View File
@@ -7,10 +7,9 @@ import {
type PDFDocumentProxy, type PDFDocumentProxy,
type RenderTask, type RenderTask,
} from "pdfjs-dist"; } from "pdfjs-dist";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"; import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from "vue";
const props = defineProps<{ const props = defineProps<{
localPath: string;
scanUrl: string; scanUrl: string;
title: string; title: string;
}>(); }>();
@@ -23,7 +22,7 @@ GlobalWorkerOptions.workerSrc = new URL(
).toString(); ).toString();
const canvasRef = ref<HTMLCanvasElement | null>(null); const canvasRef = ref<HTMLCanvasElement | null>(null);
const pdfDocument = ref<PDFDocumentProxy | null>(null); const pdfDocument = shallowRef<PDFDocumentProxy | null>(null);
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
const pageNumber = ref(1); const pageNumber = ref(1);
@@ -41,19 +40,17 @@ async function loadDocument() {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
let task: PDFDocumentLoadingTask | null = null;
try { try {
const bytes = await invoke<number[]>("load_application_file", { const bytes = await invoke<number[]>("load_remote_file", {
localPath: props.localPath, url: props.scanUrl,
scanUrl: props.scanUrl,
}); });
if (!bytes.length) { if (!bytes.length) {
throw new Error("Файл пустой"); throw new Error("Файл пустой");
} }
task = getDocument({ data: new Uint8Array(bytes) }); const task = getDocument({ data: new Uint8Array(bytes) });
loadingTask = task; loadingTask = task;
const document = await task.promise; const document = await task.promise;
@@ -66,12 +63,13 @@ async function loadDocument() {
pageCount.value = document.numPages; pageCount.value = document.numPages;
pageNumber.value = 1; pageNumber.value = 1;
scale.value = 1.1; scale.value = 1.1;
loading.value = false;
await nextTick();
await renderPage(); await renderPage();
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось открыть PDF"; error.value = err instanceof Error ? err.message : "Не удалось открыть PDF";
} finally {
loading.value = false; loading.value = false;
if (task && loadingTask === task) { if (loadingTask) {
loadingTask = null; loadingTask = null;
} }
} }
@@ -156,7 +154,7 @@ async function zoomOut() {
await renderPage(); await renderPage();
} }
watch([show, () => props.localPath, () => props.scanUrl], async ([visible]) => { watch([show, () => props.scanUrl], async ([visible]) => {
if (visible) { if (visible) {
await nextTick(); await nextTick();
await loadDocument(); await loadDocument();
+34 -22
View File
@@ -57,36 +57,42 @@ watch(search, (value) => {
<van-button size="small" type="primary" plain @click="selectProject(0)">Все</van-button> <van-button size="small" type="primary" plain @click="selectProject(0)">Все</van-button>
</div> </div>
<van-search v-model="search" placeholder="Поиск по проекту" /> <div class="entity-popup-body">
<van-search v-model="search" placeholder="Поиск по проекту" />
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading> <van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
<template v-else> <template v-else>
<van-cell-group inset> <van-cell-group inset>
<van-cell <van-cell
v-for="project in projects" v-for="project in projects"
:key="project.id" :key="project.id"
:title="project.short_name || project.name" :title="project.short_name || project.name"
:label="project.full_name || `ID: ${project.id}`" :label="project.full_name || `ID: ${project.id}`"
clickable clickable
center center
@click="selectProject(project.id)" @click="selectProject(project.id)"
> >
<template #right-icon> <template #right-icon>
<van-icon v-if="selectedProjectId === project.id" name="success" color="#1989fa" /> <van-icon v-if="selectedProjectId === project.id" name="success" color="#1989fa" />
</template> </template>
</van-cell> </van-cell>
</van-cell-group> </van-cell-group>
<van-empty v-if="projects.length === 0" description="Проекты не найдены" /> <van-empty v-if="projects.length === 0" description="Проекты не найдены" />
</template> </template>
</div>
</van-popup> </van-popup>
</template> </template>
<style scoped> <style scoped>
.entity-popup { .entity-popup {
min-height: 55vh; display: flex;
padding: 18px 0 28px; flex-direction: column;
height: 70vh;
max-height: 70vh;
overflow: hidden;
padding: 18px 0 16px;
} }
.entity-popup-header { .entity-popup-header {
@@ -108,6 +114,12 @@ watch(search, (value) => {
padding: 36px 0; padding: 36px 0;
} }
.entity-popup-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.entity-select { .entity-select {
display: flex; display: flex;
align-items: center; align-items: center;
+26 -15
View File
@@ -1,9 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { openPath, openUrl } from "@tauri-apps/plugin-opener"; import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { showToast } from "vant"; import { showToast } from "vant";
import { contractApi, contractApplicationFileApi } from "../../../generated/api"; import {
contractApi,
contractApplicationFileApi,
} from "../../../generated/api";
import type { import type {
Contract, Contract,
ContractApplicationFile, ContractApplicationFile,
@@ -11,6 +15,7 @@ import type {
} from "../../../generated/models"; } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi"; import { useModelApi } from "../../../shared/composables/useModelApi";
import PdfPreview from "../components/PdfPreview.vue"; import PdfPreview from "../components/PdfPreview.vue";
import DocumentApprovalTasks from "../../../shared/components/DocumentApprovalTasks.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -106,12 +111,6 @@ const approvalFields = computed(() => {
return [ return [
{ title: "Статус", value: item.status_name }, { 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 }, { title: "Комментарий", value: item.comment },
]; ];
}); });
@@ -173,14 +172,16 @@ function formatMoney(value: unknown) {
}).format(amount); }).format(amount);
} }
async function openApplicationFile(localPath: string) { async function openApplicationFile(scanUrl: string) {
if (!localPath) { if (!scanUrl) {
showToast("Файл не скачан"); showToast("Файл не скачан");
return; return;
} }
try { try {
await openPath(localPath); await invoke("open_remote_file", {
url: scanUrl,
});
} catch (err) { } catch (err) {
showToast(errorMessage(err, "Не удалось открыть файл")); showToast(errorMessage(err, "Не удалось открыть файл"));
} }
@@ -209,6 +210,10 @@ function openPdfPreview(file: ContractApplicationFile) {
} }
function errorMessage(err: unknown, fallback: string) { 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) { if (err instanceof Error) {
return err.message; return err.message;
} }
@@ -404,6 +409,13 @@ onMounted(() => {
</template> </template>
</van-cell> </van-cell>
</van-cell-group> </van-cell-group>
<DocumentApprovalTasks
v-if="contract"
:document-id="contract.id"
filter-name="contract"
title="Согласование договора"
/>
</div> </div>
</van-tab> </van-tab>
@@ -435,7 +447,7 @@ onMounted(() => {
size="small" size="small"
type="primary" type="primary"
plain plain
:disabled="!file.local_path" :disabled="!file.scan_url"
@click="openPdfPreview(file)" @click="openPdfPreview(file)"
> >
Просмотр Просмотр
@@ -443,8 +455,8 @@ onMounted(() => {
<van-button <van-button
size="small" size="small"
plain plain
:disabled="!file.local_path" :disabled="!file.scan_url"
@click="openApplicationFile(file.local_path)" @click="openApplicationFile(file.scan_url)"
> >
Открыть Открыть
</van-button> </van-button>
@@ -507,7 +519,6 @@ onMounted(() => {
<PdfPreview <PdfPreview
v-model:show="pdfPreviewVisible" v-model:show="pdfPreviewVisible"
:local-path="pdfPreviewFile?.local_path ?? ''"
:scan-url="pdfPreviewFile?.scan_url ?? ''" :scan-url="pdfPreviewFile?.scan_url ?? ''"
:title="pdfPreviewTitle" :title="pdfPreviewTitle"
/> />
+15 -26
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { showToast } from "vant"; import { showToast } from "vant";
@@ -12,6 +11,7 @@ import FrcSelect from "../components/FrcSelect.vue";
import ProjectSelect from "../components/ProjectSelect.vue"; import ProjectSelect from "../components/ProjectSelect.vue";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
type ContractFilterParams = ContractListParams & { page?: number };
const CONTRACT_STATUS_OPTIONS = [ const CONTRACT_STATUS_OPTIONS = [
{ value: "AN", text: "Аннулирован" }, { value: "AN", text: "Аннулирован" },
{ value: "IP", text: "В работе" }, { value: "IP", text: "В работе" },
@@ -32,7 +32,7 @@ const {
error, error,
load: loadContracts, load: loadContracts,
} = useModelApi(contractApi, { } = useModelApi(contractApi, {
defaultListParams: { ordering: "-id", limit: PAGE_SIZE, offset: 0 } as ContractListParams, defaultListParams: { ordering: "-id", page: 1 } as ContractFilterParams,
loadErrorMessage: "Не удалось загрузить контракты", loadErrorMessage: "Не удалось загрузить контракты",
cleanListParams(params) { cleanListParams(params) {
params.name__contains = params.name__contains?.trim() || undefined; params.name__contains = params.name__contains?.trim() || undefined;
@@ -42,11 +42,12 @@ const {
params.project_id = params.project_id || undefined; params.project_id = params.project_id || undefined;
params.frc_id = params.frc_id || undefined; params.frc_id = params.frc_id || undefined;
params.status = params.status || undefined; params.status = params.status || undefined;
params.limit = PAGE_SIZE; params.page = params.page || 1;
params.offset = params.offset ?? 0;
}, },
}); });
const contractFilters = filters as ContractFilterParams;
const selectedCounterpartyId = computed({ const selectedCounterpartyId = computed({
get() { get() {
return filters.counterparty_id ?? 0; return filters.counterparty_id ?? 0;
@@ -106,20 +107,13 @@ const hasActiveFilters = computed(() =>
const currentPage = computed({ const currentPage = computed({
get() { get() {
return Math.floor((filters.offset ?? 0) / PAGE_SIZE) + 1; return contractFilters.page ?? 1;
}, },
set(page: number) { set(page: number) {
filters.offset = (page - 1) * PAGE_SIZE; contractFilters.page = page;
}, },
}); });
interface SyncContractsResult {
synced: number;
pages: number;
applications: number;
files_downloaded: number;
}
const syncing = ref(false); const syncing = ref(false);
const showFilters = ref(false); const showFilters = ref(false);
@@ -142,11 +136,11 @@ function resetFilters() {
filters.project_id = undefined; filters.project_id = undefined;
filters.frc_id = undefined; filters.frc_id = undefined;
filters.status = undefined; filters.status = undefined;
filters.offset = 0; contractFilters.page = 1;
} }
async function applyFilters() { async function applyFilters() {
filters.offset = 0; contractFilters.page = 1;
showFilters.value = false; showFilters.value = false;
await loadContracts(); await loadContracts();
} }
@@ -155,15 +149,8 @@ async function syncContracts() {
syncing.value = true; syncing.value = true;
try { try {
const result = await invoke<SyncContractsResult>("sync_contracts"); await loadContracts();
showToast( showToast("Данные обновлены с сервера");
`Синхронизировано: ${result.synced}; приложений: ${result.applications}; файлов: ${result.files_downloaded}`,
);
if (filters.offset) {
filters.offset = 0;
} else {
await loadContracts();
}
} catch (err) { } catch (err) {
showToast(errorMessage(err, "Не удалось синхронизировать договоры")); showToast(errorMessage(err, "Не удалось синхронизировать договоры"));
} finally { } finally {
@@ -194,7 +181,9 @@ watch(
filters.status, filters.status,
], ],
() => { () => {
filters.offset = 0; if ((contractFilters.page ?? 1) !== 1) {
contractFilters.page = 1;
}
}, },
); );
</script> </script>
@@ -228,7 +217,7 @@ watch(
Обновить Обновить
</van-button> </van-button>
<van-button size="small" type="primary" :loading="syncing" @click="syncContracts"> <van-button size="small" type="primary" :loading="syncing" @click="syncContracts">
Синхронизация Обновить с сервера
</van-button> </van-button>
</div> </div>
+8
View File
@@ -0,0 +1,8 @@
import DocumentsView from "./views/DocumentsView.vue";
export const documentsRoute = {
path: "/documents",
name: "documents",
component: DocumentsView,
meta: { title: "Документы", requiresAuth: true },
};
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
const router = useRouter();
const tiles = [
{
title: "Договоры",
icon: "orders-o",
to: "/contracts",
},
{
title: "Счета",
icon: "description-o",
to: "/bills",
},
{
title: "Служебные записки",
icon: "records-o",
to: "/memos",
},
{
title: "Входящие письма",
icon: "notes-o",
to: "/tasks?tab=incoming&doc=entry_letter",
},
{
title: "Исходящие письма",
icon: "description-o",
to: "/tasks?tab=outgoing&doc=outgoing_letter",
},
] as const;
function openTile(path: string) {
void router.push(path);
}
</script>
<template>
<section class="page documents-page">
<div class="documents-header">
<h1>Документы</h1>
<p>Быстрый переход к основным разделам</p>
</div>
<van-grid :border="false" :column-num="2" :gutter="12" clickable>
<van-grid-item
v-for="tile in tiles"
:key="tile.title"
class="documents-tile"
@click="openTile(tile.to)"
>
<template #icon>
<div class="documents-tile__icon">
<van-icon :name="tile.icon" />
</div>
</template>
<template #text>
<div class="documents-tile__text">
<span class="documents-tile__title">{{ tile.title }}</span>
</div>
</template>
</van-grid-item>
</van-grid>
</section>
</template>
<style scoped>
.documents-page {
padding: 16px;
}
.documents-header {
margin-bottom: 16px;
}
.documents-header h1 {
margin: 0 0 6px;
font-size: 22px;
line-height: 1.2;
}
.documents-header p {
margin: 0;
color: var(--van-text-color-2);
font-size: 13px;
}
.documents-tile__icon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
margin: 0 auto 10px;
border-radius: 16px;
background: linear-gradient(135deg, #eff6ff, #dbeafe);
color: #2563eb;
font-size: 28px;
}
.documents-tile__text {
display: flex;
justify-content: center;
text-align: center;
}
.documents-tile__title {
font-size: 14px;
font-weight: 600;
}
</style>
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { memoCategoryApi } from "../../../generated/api";
import type { MemoCategoryListParams } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
const PAGE_SIZE = 20;
const selectedCategoryId = defineModel<number>({ required: true });
const search = ref("");
const showSelector = ref(false);
const {
items: categories,
filters,
loading,
} = useModelApi(memoCategoryApi, {
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as MemoCategoryListParams,
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>
<div class="entity-popup-body">
<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.code || `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>
</div>
</van-popup>
</template>
<style scoped>
.entity-popup {
display: flex;
flex-direction: column;
height: 70vh;
max-height: 70vh;
overflow: hidden;
padding: 18px 0 16px;
}
.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-popup-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.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 MemoDetailView from "./views/MemoDetailView.vue";
import MemosView from "./views/MemosView.vue";
export const memosRoutes = [
{
path: "/memos",
name: "memos",
component: MemosView,
meta: { title: "Служебные записки", requiresAuth: true },
},
{
path: "/memos/:id",
name: "memo-detail",
component: MemoDetailView,
meta: { title: "Служебная записка", back: true, requiresAuth: true },
},
];
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { memoApi } from "../../../generated/api";
import DocumentApprovalTasks from "../../../shared/components/DocumentApprovalTasks.vue";
import { useModelApi } from "../../../shared/composables/useModelApi";
const route = useRoute();
const memoId = computed(() => Number(route.params.id));
const activeTab = ref("text");
const {
item: memo,
loadingItem,
error,
retrieve: loadMemo,
} = useModelApi(memoApi, {
retrieveErrorMessage: "Не удалось загрузить служебную записку",
autoLoad: false,
autoLoadOnFilterChange: false,
});
function formatValue(value: unknown) {
if (value === null || value === undefined || value === "") {
return "не указано";
}
return String(value);
}
function money(value: number | null | undefined) {
if (typeof value !== "number") {
return "не указано";
}
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 2,
}).format(value);
}
function taskIds() {
return (memo.value?.task_set ?? [])
.map((task) => (typeof task === "object" && task ? (task as { id?: unknown }).id : undefined))
.filter((id): id is number => typeof id === "number");
}
onMounted(() => {
if (Number.isFinite(memoId.value)) {
loadMemo(memoId.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 memo-detail-card">
<van-loading v-if="loadingItem" class="state" type="spinner">Загрузка...</van-loading>
<van-empty v-else-if="!memo" description="Служебная записка не найдена" />
<template v-else>
<div class="memo-summary">
<div class="memo-summary__title">{{ memo.category?.name || `Служебная записка #${memo.id}` }}</div>
<div class="memo-summary__meta">
{{ memo.date }} · {{ memo.sender?.short_name || 'не указан' }} {{ memo.recipient?.short_name || 'не указан' }}
</div>
<div class="memo-summary__badges">
<van-tag :type="memo.priority === '2' ? 'danger' : 'primary'">{{ memo.priority_display }}</van-tag>
<van-tag v-if="memo.archive" plain>Архив</van-tag>
<van-tag v-if="memo.cancel" type="danger" plain>Отменена</van-tag>
</div>
</div>
<van-tabs v-model:active="activeTab" shrink sticky class="memo-tabs">
<van-tab name="text" title="Текст">
<div class="tab-panel memo-html" v-html="memo.text" />
</van-tab>
<van-tab name="info" title="Информация">
<div class="tab-panel">
<van-cell-group>
<van-cell title="ID" :value="formatValue(memo.id)" />
<van-cell title="Дата" :value="formatValue(memo.date)" />
<van-cell title="Категория" :value="formatValue(memo.category?.name)" />
<van-cell title="Проект" :value="formatValue(memo.project?.name)" />
<van-cell title="Отправитель" :value="formatValue(memo.sender?.name)" />
<van-cell title="Получатель" :value="formatValue(memo.recipient?.name)" />
<van-cell title="Приоритет" :value="memo.priority_display || memo.priority" />
<van-cell title="Сумма" :value="money(memo.value)" />
<van-cell title="Дата начала" :value="formatValue(memo.date_start)" />
<van-cell title="Дата окончания" :value="formatValue(memo.date_end)" />
<van-cell title="Текущий шаг" :value="formatValue(memo.current_step)" />
<van-cell title="Мое согласование" :value="formatValue(memo.my_approve)" />
<van-cell title="Резолюция" :value="formatValue(memo.resalution)" />
</van-cell-group>
</div>
</van-tab>
<van-tab name="tasks" title="Задачи">
<div class="tab-panel">
<van-empty v-if="taskIds().length === 0" description="Задачи не указаны" />
<van-cell-group v-else>
<van-cell v-for="taskId in taskIds()" :key="taskId" title="Задача" :value="`#${taskId}`" />
</van-cell-group>
</div>
</van-tab>
<van-tab name="approval" title="Согласование">
<div class="tab-panel approval-panel">
<DocumentApprovalTasks
:document-id="memo.id"
filter-name="memo"
title="Согласование служебной записки"
/>
</div>
</van-tab>
<van-tab name="access" title="Доступ">
<div class="tab-panel">
<van-cell-group>
<van-cell title="Subject" :value="formatValue(memo.subject?.join(', '))" />
<van-cell title="Employee ACL" :value="formatValue(memo.employee_acl?.join(', '))" />
</van-cell-group>
</div>
</van-tab>
</van-tabs>
</template>
</section>
</template>
<style scoped>
.memo-detail-card {
margin: 12px;
overflow: hidden;
}
.memo-summary {
padding: 16px;
background: linear-gradient(135deg, #eef2ff, #f8fafc);
}
.memo-summary__title {
margin-bottom: 6px;
font-size: 18px;
font-weight: 700;
line-height: 1.25;
}
.memo-summary__meta {
color: var(--van-text-color-2);
font-size: 13px;
}
.memo-summary__badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.tab-panel {
padding: 12px 0;
}
.memo-html {
padding: 16px;
color: var(--van-text-color);
font-size: 14px;
line-height: 1.55;
}
.memo-html :deep(p) {
margin: 0 0 10px;
}
.memo-html :deep(ol),
.memo-html :deep(ul) {
padding-left: 20px;
}
.approval-panel {
padding: 12px;
}
</style>
+364
View File
@@ -0,0 +1,364 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { memoApi } from "../../../generated/api";
import type { Memo, MemoListParams } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
import ProjectSelect from "../../contracts/components/ProjectSelect.vue";
import EmployeeSelect from "../../personnel/components/EmployeeSelect.vue";
import MemoCategorySelect from "../components/MemoCategorySelect.vue";
const PAGE_SIZE = 10;
const PRIORITY_OPTIONS = [
{ text: "Все", value: "" },
{ text: "Обычно", value: "1" },
{ text: "Срочно", value: "2" },
];
type MemoFilterParams = MemoListParams & { page?: number };
type MemoTabName = "send" | "recive" | "by_task";
const router = useRouter();
const activeTab = ref<MemoTabName>("send");
const {
items: memos,
filters,
count,
loading,
error,
load: loadMemos,
} = useModelApi(memoApi, {
defaultListParams: { ordering: "-id", page: 1, q: "send", archive: false } as MemoFilterParams,
loadErrorMessage: "Не удалось загрузить служебные записки",
cleanListParams(params) {
params.text__contains = params.text__contains?.trim() || undefined;
params.date = params.date || undefined;
params.priority = params.priority || undefined;
params.recipient = params.recipient || undefined;
params.sender = params.sender || undefined;
params.category = params.category || undefined;
params.project = params.project || undefined;
params.q = params.q || activeTab.value;
params.archive = false;
params.page = params.page || 1;
},
});
const memoFilters = filters as MemoFilterParams;
const showFilters = ref(false);
const currentPage = computed({
get() {
return memoFilters.page ?? 1;
},
set(page: number) {
memoFilters.page = page;
},
});
const selectedSenderId = computed({
get() {
return filters.sender ?? 0;
},
set(id: number) {
filters.sender = id || undefined;
},
});
const selectedRecipientId = computed({
get() {
return filters.recipient ?? 0;
},
set(id: number) {
filters.recipient = id || undefined;
},
});
const selectedCategoryId = computed({
get() {
return filters.category ?? 0;
},
set(id: number) {
filters.category = id || undefined;
},
});
const selectedProjectId = computed({
get() {
return filters.project ?? 0;
},
set(id: number) {
filters.project = id || undefined;
},
});
const selectedPriority = computed({
get() {
return filters.priority ?? "";
},
set(priority: string) {
filters.priority = priority || undefined;
},
});
const activeFilterCount = computed(
() =>
[
filters.text__contains,
filters.date,
filters.priority,
filters.recipient,
filters.sender,
filters.category,
filters.project,
].filter(Boolean).length,
);
function resetFilters() {
filters.text__contains = undefined;
filters.date = undefined;
filters.priority = undefined;
filters.archive = false;
filters.recipient = undefined;
filters.sender = undefined;
filters.category = undefined;
filters.project = undefined;
memoFilters.page = 1;
}
async function applyFilters() {
memoFilters.page = 1;
showFilters.value = false;
await loadMemos();
}
function stripHtml(html: string) {
return html.replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
}
function memoTitle(memo: Memo) {
return memo.category?.name || `Служебная записка #${memo.id}`;
}
function memoPreview(memo: Memo) {
const preview = stripHtml(memo.text);
return preview.length > 140 ? `${preview.slice(0, 140)}...` : preview;
}
function openMemo(memo: Memo) {
void router.push(`/memos/${memo.id}`);
}
watch(
() => [
filters.text__contains,
filters.date,
filters.priority,
filters.recipient,
filters.sender,
filters.category,
filters.project,
],
() => {
if ((memoFilters.page ?? 1) !== 1) {
memoFilters.page = 1;
}
},
);
watch(activeTab, (tab) => {
memoFilters.q = tab;
memoFilters.archive = false;
memoFilters.page = 1;
});
</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="memos-page">
<van-tabs v-model:active="activeTab" shrink sticky class="memo-scope-tabs">
<van-tab name="send" title="Исходящие" />
<van-tab name="recive" title="Входящие" />
<van-tab name="by_task" title="На согласование" />
</van-tabs>
<van-search v-model="filters.text__contains" placeholder="Поиск по тексту" clearable />
<div class="memo-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="loadMemos()">
Обновить
</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>
<van-field v-model="filters.date" label="Дата" placeholder="29.05.2026" clearable />
<van-field label="Приоритет">
<template #input>
<van-dropdown-menu class="inline-dropdown">
<van-dropdown-item v-model="selectedPriority" :options="PRIORITY_OPTIONS" />
</van-dropdown-menu>
</template>
</van-field>
<van-field label="Категория">
<template #input>
<MemoCategorySelect v-model="selectedCategoryId" />
</template>
</van-field>
<van-field label="Проект">
<template #input>
<ProjectSelect v-model="selectedProjectId" />
</template>
</van-field>
<van-field label="Отправитель">
<template #input>
<EmployeeSelect v-model="selectedSenderId" />
</template>
</van-field>
<van-field label="Получатель">
<template #input>
<EmployeeSelect v-model="selectedRecipientId" />
</template>
</van-field>
</van-cell-group>
<div class="filters-actions">
<van-button block round type="primary" @click="applyFilters">Применить</van-button>
</div>
</div>
</van-popup>
<van-loading v-if="loading" class="state" type="spinner">Загрузка...</van-loading>
<template v-else>
<van-empty v-if="memos.length === 0" description="Служебные записки не найдены" />
<van-cell-group v-else inset>
<van-cell v-for="memo in memos" :key="memo.id" clickable @click="openMemo(memo)">
<template #title>
<div class="memo-card">
<div class="memo-card__top">
<span class="memo-card__title">{{ memoTitle(memo) }}</span>
<van-tag :type="memo.priority === '2' ? 'danger' : 'primary'" plain>
{{ memo.priority_display }}
</van-tag>
</div>
<div class="memo-card__meta">
{{ memo.date }} · {{ memo.sender?.short_name || 'не указан' }} {{ memo.recipient?.short_name || 'не указан' }}
</div>
<div class="memo-card__meta">{{ memo.project?.name || 'Проект не указан' }}</div>
<div class="memo-card__preview">{{ memoPreview(memo) }}</div>
</div>
</template>
</van-cell>
</van-cell-group>
<van-pagination
v-if="count > PAGE_SIZE"
v-model="currentPage"
class="memo-pagination"
:items-per-page="PAGE_SIZE"
:total-items="count"
force-ellipses
@change="loadMemos()"
/>
</template>
</section>
</template>
<style scoped>
.memos-page {
padding-bottom: 12px;
}
.memo-scope-tabs {
background: var(--van-background-2);
}
.memo-actions {
display: flex;
gap: 8px;
padding: 8px 16px 12px;
}
.filters-popup {
max-height: 82vh;
}
.filters-sheet {
padding: 18px 0 20px;
}
.filters-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 12px;
}
.filters-header h2 {
margin: 0;
font-size: 18px;
}
.filters-actions {
padding: 16px 16px 0;
}
.inline-dropdown {
width: 100%;
}
.memo-card {
display: grid;
gap: 6px;
}
.memo-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.memo-card__title {
font-weight: 600;
line-height: 1.3;
}
.memo-card__meta,
.memo-card__preview {
color: var(--van-text-color-2);
font-size: 12px;
line-height: 1.35;
}
.memo-card__preview {
color: var(--van-text-color);
}
.memo-pagination {
margin: 16px;
}
</style>
@@ -2,6 +2,7 @@
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 RemoteImage from "../../../shared/components/RemoteImage.vue";
import { useModelApi } from "../../../shared/composables/useModelApi"; import { useModelApi } from "../../../shared/composables/useModelApi";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -58,39 +59,45 @@ watch(search, (value) => {
<van-button size="small" type="primary" plain @click="selectEmployee(0)">Не указан</van-button> <van-button size="small" type="primary" plain @click="selectEmployee(0)">Не указан</van-button>
</div> </div>
<van-search v-model="search" placeholder="Поиск по имени" /> <div class="employee-popup-body">
<van-search v-model="search" placeholder="Поиск по имени" />
<van-loading v-if="loading" class="employee-state" type="spinner">Загрузка...</van-loading> <van-loading v-if="loading" class="employee-state" type="spinner">Загрузка...</van-loading>
<template v-else> <template v-else>
<van-cell-group inset> <van-cell-group inset>
<van-cell <van-cell
v-for="employee in employees" v-for="employee in employees"
:key="employee.id" :key="employee.id"
:title="employee.name" :title="employee.name"
:label="`ID: ${employee.id}`" :label="`ID: ${employee.id}`"
clickable clickable
center center
@click="selectEmployee(employee.id)" @click="selectEmployee(employee.id)"
> >
<template #icon> <template #icon>
<van-image class="employee-avatar" round width="36" height="36" :src="employee.avatar_small ?? ''" /> <RemoteImage class="employee-avatar" round width="36" height="36" :src="employee.avatar_small" />
</template> </template>
<template #right-icon> <template #right-icon>
<van-icon v-if="selectedEmployeeId === employee.id" name="success" color="#1989fa" /> <van-icon v-if="selectedEmployeeId === employee.id" name="success" color="#1989fa" />
</template> </template>
</van-cell> </van-cell>
</van-cell-group> </van-cell-group>
<van-empty v-if="employees.length === 0" description="Сотрудники не найдены" /> <van-empty v-if="employees.length === 0" description="Сотрудники не найдены" />
</template> </template>
</div>
</van-popup> </van-popup>
</template> </template>
<style scoped> <style scoped>
.employee-popup { .employee-popup {
min-height: 55vh; display: flex;
padding: 18px 0 28px; flex-direction: column;
height: 70vh;
max-height: 70vh;
overflow: hidden;
padding: 18px 0 16px;
} }
.employee-popup-header { .employee-popup-header {
@@ -116,6 +123,12 @@ watch(search, (value) => {
padding: 36px 0; padding: 36px 0;
} }
.employee-popup-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.employee-select { .employee-select {
display: flex; display: flex;
align-items: center; align-items: center;
+8
View File
@@ -0,0 +1,8 @@
import SettingsView from "./views/SettingsView.vue";
export const settingsRoute = {
path: "/settings",
name: "settings",
component: SettingsView,
meta: { title: "Настройки" },
};
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { showToast } from "vant";
import { clearAuthState } from "../../../shared/auth/useAuth";
interface AppSettings {
remote_base_url: string;
auth_path: string;
}
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const error = ref("");
const settings = reactive<AppSettings>({
remote_base_url: "",
auth_path: "/api-token-auth/",
});
onMounted(loadSettings);
async function loadSettings() {
loading.value = true;
error.value = "";
try {
applySettings(await invoke<AppSettings>("get_app_settings"));
} catch (err) {
error.value = errorMessage(err, "Не удалось загрузить настройки");
} finally {
loading.value = false;
}
}
async function saveSettings() {
saving.value = true;
error.value = "";
try {
const saved = await invoke<AppSettings>("update_app_settings", {
settings: {
remote_base_url: settings.remote_base_url,
auth_path: settings.auth_path,
},
});
applySettings(saved);
clearAuthState();
showToast("Настройки сохранены. Войдите заново");
router.replace("/login");
} catch (err) {
error.value = errorMessage(err, "Не удалось сохранить настройки");
} finally {
saving.value = false;
}
}
async function resetSettings() {
saving.value = true;
error.value = "";
try {
applySettings(await invoke<AppSettings>("reset_app_settings"));
clearAuthState();
showToast("Настройки сброшены. Войдите заново");
router.replace("/login");
} catch (err) {
error.value = errorMessage(err, "Не удалось сбросить настройки");
} finally {
saving.value = false;
}
}
function applySettings(nextSettings: AppSettings) {
settings.remote_base_url = nextSettings.remote_base_url;
settings.auth_path = nextSettings.auth_path;
}
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;
}
</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="saveSettings">
<van-loading v-if="loading" class="state" type="spinner">Загрузка...</van-loading>
<template v-else>
<van-field
v-model="settings.remote_base_url"
name="remote_base_url"
label="Удаленный URL"
placeholder="http://10.0.2.2:8000"
clearable
:disabled="saving"
:rules="[{ required: true, message: 'Введите URL сервера' }]"
/>
<van-field
v-model="settings.auth_path"
name="auth_path"
label="Auth path"
placeholder="/api-token-auth/"
clearable
:disabled="saving"
:rules="[{ required: true, message: 'Введите путь авторизации' }]"
/>
<div class="settings-help">
Для Android-эмулятора сервер на хосте обычно доступен как
<code>http://10.0.2.2:8000</code>.
</div>
<div class="form-actions stacked-actions">
<van-button block round type="primary" native-type="submit" :loading="saving">
Сохранить
</van-button>
<van-button
block
round
type="default"
native-type="button"
plain
:disabled="saving"
@click="resetSettings"
>
Сбросить по умолчанию
</van-button>
</div>
</template>
</van-form>
</template>
<style scoped>
.settings-help {
padding: 12px 16px 0;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
</style>
+17
View File
@@ -0,0 +1,17 @@
import BillsView from "./views/BillsView.vue";
import BillDetailView from "./views/BillDetailView.vue";
export const supplyRoutes = [
{
path: "/bills",
name: "bills",
component: BillsView,
meta: { title: "Счета", requiresAuth: true },
},
{
path: "/bills/:id",
name: "bill-detail",
component: BillDetailView,
meta: { title: "Детали счета", back: true, requiresAuth: true },
},
];
+283
View File
@@ -0,0 +1,283 @@
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { showToast } from "vant";
import { billApi } from "../../../generated/api";
import { useModelApi } from "../../../shared/composables/useModelApi";
import DocumentApprovalTasks from "../../../shared/components/DocumentApprovalTasks.vue";
const route = useRoute();
const billId = computed(() => Number(route.params.id));
const activeTab = ref("info");
const {
item: bill,
loadingItem,
error,
retrieve: loadBill,
} = useModelApi(billApi, {
retrieveErrorMessage: "Не удалось загрузить счет",
autoLoad: false,
autoLoadOnFilterChange: false,
});
type BillRelated = Record<string, unknown>;
function parseJson(value: unknown): unknown {
if (typeof value === "string") {
if (!value.trim()) {
return null;
}
try {
return JSON.parse(value) as unknown;
} catch {
return null;
}
}
return value;
}
function parseObject(value: unknown): BillRelated | null {
const parsed = parseJson(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
return parsed as BillRelated;
}
function objectLabel(value: unknown) {
const record = parseObject(value);
if (!record) {
return "не указано";
}
const candidates = ["name", "short_name", "number", "text", "full_name", "get_status_display"];
for (const key of candidates) {
const candidate = record[key];
if (typeof candidate === "string" && candidate.trim()) {
return candidate;
}
}
return "не указано";
}
function money(value: number | null | undefined) {
if (typeof value !== "number") {
return "не указано";
}
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 2,
}).format(value);
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === "") {
return "не указано";
}
return String(value);
}
async function openScan() {
if (!bill.value?.scan) {
showToast("У счета нет файла");
return;
}
await invoke("open_remote_file", { url: bill.value.scan });
}
function relatedDocuments() {
const raw = parseJson(bill.value?.transferdocument_set);
if (!Array.isArray(raw)) {
return [];
}
return raw;
}
onMounted(() => {
if (Number.isFinite(billId.value)) {
loadBill(billId.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="!bill" description="Счет не найден" />
<template v-else>
<div class="bill-summary">
<div class="bill-summary__title">{{ bill.text || bill.number || `Счет #${bill.id}` }}</div>
<div class="bill-summary__meta">
{{ bill.number || 'не указан' }} · {{ bill.date_bill || bill.date }}
</div>
<div class="bill-summary__badges">
<van-tag type="primary">{{ bill.status_name || bill.status }}</van-tag>
<van-tag plain>{{ bill.contract_typ || 'не указан' }}</van-tag>
</div>
<van-cell-group inset>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Контрагент</div>
<div class="detail-value">{{ objectLabel(bill.counterparty) }}</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Проект</div>
<div class="detail-value">{{ objectLabel(bill.project) }}</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Договор</div>
<div class="detail-value">{{ objectLabel(bill.contract) }}</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">{{ money(bill.cost) }}</div>
</div>
</template>
</van-cell>
</van-cell-group>
</div>
<van-tabs v-model:active="activeTab" shrink sticky class="bill-tabs">
<van-tab name="info" title="Информация">
<div class="tab-panel">
<van-cell-group>
<van-cell title="Номер" :value="formatValue(bill.number)" />
<van-cell title="Описание" :value="formatValue(bill.text)" />
<van-cell title="Дата счета" :value="formatValue(bill.date_bill)" />
<van-cell title="Дата" :value="formatValue(bill.date)" />
<van-cell title="Срок оплаты" :value="formatValue(bill.date_due)" />
<van-cell title="Статус" :value="bill.status_name || bill.status" />
<van-cell title="Тип" :value="formatValue(bill.contract_typ)" />
<van-cell title="Комментарий" :value="formatValue(bill.comment)" />
</van-cell-group>
</div>
</van-tab>
<van-tab name="finance" title="Финансы">
<div class="tab-panel">
<van-cell-group>
<van-cell title="Сумма" :value="money(bill.cost)" />
<van-cell title="Оплачено" :value="money(bill.paid)" />
<van-cell title="К оплате" :value="money(bill.to_payd)" />
<van-cell title="НДС" :value="money(bill.nds_cost)" />
<van-cell title="Реестр" :value="bill.pp_maked ? 'Да' : 'Нет'" />
<van-cell title="Архив" :value="bill.archive_s ? 'Да' : 'Нет'" />
</van-cell-group>
</div>
</van-tab>
<van-tab name="relations" title="Связи">
<div class="tab-panel">
<van-cell-group>
<van-cell title="Контрагент" :value="objectLabel(bill.counterparty)" />
<van-cell title="Проект" :value="objectLabel(bill.project)" />
<van-cell title="Договор" :value="objectLabel(bill.contract)" />
<van-cell title="ФРЦ" :value="objectLabel(bill.frc)" />
<van-cell title="Ответственный" :value="objectLabel(bill.responsible)" />
<van-cell title="Автор" :value="objectLabel(bill.author)" />
<van-cell title="Категория" :value="objectLabel(bill.category)" />
</van-cell-group>
<van-cell-group v-if="relatedDocuments().length" title="Документы">
<van-cell v-for="(doc, index) in relatedDocuments()" :key="String((doc as Record<string, unknown>).id ?? index)">
<template #title>
<div class="detail-cell">
<div class="detail-label">{{ String((doc as Record<string, unknown>).number ?? 'Документ') }}</div>
<div class="detail-value">
{{ String((doc as Record<string, unknown>).date ?? '') }}
{{ (doc as Record<string, unknown>).cost ? `· ${formatValue((doc as Record<string, unknown>).cost)}` : '' }}
</div>
</div>
</template>
</van-cell>
</van-cell-group>
</div>
</van-tab>
<van-tab name="approval" title="Согласование">
<div class="tab-panel">
<DocumentApprovalTasks
v-if="bill"
:document-id="bill.id"
filter-name="bill"
title="Согласование счета"
/>
</div>
</van-tab>
<van-tab name="file" title="Файл">
<div class="tab-panel">
<van-empty v-if="!bill.scan" description="Файл не прикреплен" />
<div v-else class="form-actions stacked-actions">
<van-button block round type="primary" plain @click="openScan">
Открыть файл
</van-button>
</div>
</div>
</van-tab>
</van-tabs>
</template>
</section>
</template>
<style scoped>
.bill-summary {
padding: 16px 16px 10px;
}
.bill-summary__title {
font-size: 22px;
font-weight: 700;
line-height: 1.25;
}
.bill-summary__meta {
margin-top: 4px;
color: var(--van-text-color-2);
font-size: 13px;
}
.bill-summary__badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
</style>
+492
View File
@@ -0,0 +1,492 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { showToast } from "vant";
import { billApi } from "../../../generated/api";
import type { Bill, BillCreate, BillListParams, BillUpdate } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
import CounterpartySelect from "../../contracts/components/CounterpartySelect.vue";
import ContractCategorySelect from "../../contracts/components/ContractCategorySelect.vue";
import FrcSelect from "../../contracts/components/FrcSelect.vue";
import ProjectSelect from "../../contracts/components/ProjectSelect.vue";
type BillFilterParams = Omit<BillListParams, "counterparty" | "frc" | "project" | "category"> & {
page?: number;
counterparty?: number;
frc?: number;
project?: number;
category?: number;
};
const PAGE_SIZE = 10;
const router = useRouter();
const {
items: bills,
filters,
count,
loading,
error,
load: loadBills,
} = useModelApi<Bill, BillCreate, BillUpdate, BillListParams>(billApi, {
defaultListParams: { ordering: "-id", page: 1 } as BillListParams,
loadErrorMessage: "Не удалось загрузить счета",
cleanListParams(params) {
params.page = params.page || 1;
params.text__contains = typeof params.text__contains === "string" ? params.text__contains.trim() || undefined : undefined;
params.number__contains = typeof params.number__contains === "string" ? params.number__contains.trim() || undefined : undefined;
params.status = params.status || undefined;
params.status_name__contains = typeof params.status_name__contains === "string" ? params.status_name__contains.trim() || undefined : undefined;
params.contract_typ = params.contract_typ || undefined;
params.date = params.date || undefined;
params.date_due = params.date_due || undefined;
params.date_bill = params.date_bill || undefined;
},
});
const billFilters = filters as unknown as BillFilterParams;
const billTextContains = computed<string>({
get() {
return typeof filters.text__contains === "string" ? filters.text__contains : "";
},
set(value) {
filters.text__contains = value || undefined;
},
});
const billNumberContains = computed<string>({
get() {
return typeof filters.number__contains === "string" ? filters.number__contains : "";
},
set(value) {
filters.number__contains = value || undefined;
},
});
const billStatus = computed<string>({
get() {
return typeof filters.status === "string" ? filters.status : "";
},
set(value) {
filters.status = value || undefined;
},
});
const billStatusNameContains = computed<string>({
get() {
return typeof filters.status_name__contains === "string" ? filters.status_name__contains : "";
},
set(value) {
filters.status_name__contains = value || undefined;
},
});
const billContractTyp = computed<string>({
get() {
return typeof filters.contract_typ === "string" ? filters.contract_typ : "";
},
set(value) {
filters.contract_typ = value || undefined;
},
});
const billDate = computed<string>({
get() {
return typeof filters.date === "string" ? filters.date : "";
},
set(value) {
filters.date = value || undefined;
},
});
const selectedDateBill = computed<string>({
get() {
return typeof filters.date_bill === "string" ? filters.date_bill : "";
},
set(value) {
filters.date_bill = value || undefined;
},
});
const selectedDateDue = computed<string>({
get() {
return typeof filters.date_due === "string" ? filters.date_due : "";
},
set(value) {
filters.date_due = value || undefined;
},
});
const showFilters = ref(false);
const syncing = ref(false);
const hasActiveFilters = computed(() =>
Boolean(
filters.text__contains ||
filters.number__contains ||
filters.status ||
filters.status_name__contains ||
filters.contract_typ ||
filters.date ||
filters.date_due ||
filters.date_bill ||
billFilters.counterparty ||
billFilters.project ||
billFilters.frc ||
billFilters.category,
),
);
const activeFilterCount = computed(
() =>
[
filters.text__contains,
filters.number__contains,
filters.status,
filters.status_name__contains,
filters.contract_typ,
filters.date,
filters.date_due,
filters.date_bill,
billFilters.counterparty,
billFilters.project,
billFilters.frc,
billFilters.category,
].filter(Boolean).length,
);
const currentPage = computed({
get() {
return billFilters.page ?? 1;
},
set(page: number) {
billFilters.page = page;
},
});
const selectedCounterpartyId = computed({
get() {
return billFilters.counterparty ?? 0;
},
set(id: number) {
billFilters.counterparty = id || undefined;
},
});
const selectedProjectId = computed({
get() {
return billFilters.project ?? 0;
},
set(id: number) {
billFilters.project = id || undefined;
},
});
const selectedFrcId = computed({
get() {
return billFilters.frc ?? 0;
},
set(id: number) {
billFilters.frc = id || undefined;
},
});
const selectedCategoryId = computed({
get() {
return billFilters.category ?? 0;
},
set(id: number) {
billFilters.category = id || undefined;
},
});
function parseObject(value: unknown): Record<string, unknown> | null {
if (typeof value === "string") {
if (!value.trim()) {
return null;
}
try {
return parseObject(JSON.parse(value) as unknown);
} catch {
return null;
}
}
if (!value || typeof value !== "object") {
return null;
}
return value as Record<string, unknown>;
}
function objectLabel(value: unknown) {
const record = parseObject(value);
if (!record) {
return "";
}
const candidates = ["name", "short_name", "number", "text", "full_name", "get_status_display"];
for (const key of candidates) {
const candidate = record[key];
if (typeof candidate === "string" && candidate.trim()) {
return candidate;
}
}
return "";
}
function resetFilters() {
filters.text__contains = undefined;
filters.number__contains = undefined;
filters.status = undefined;
filters.status_name__contains = undefined;
filters.contract_typ = undefined;
filters.date = undefined;
filters.date_due = undefined;
filters.date_bill = undefined;
billFilters.counterparty = undefined;
billFilters.project = undefined;
billFilters.frc = undefined;
billFilters.category = undefined;
billFilters.page = 1;
}
async function applyFilters() {
billFilters.page = 1;
showFilters.value = false;
await loadBills();
}
async function syncBills() {
syncing.value = true;
try {
await loadBills();
showToast("Данные обновлены с сервера");
} 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.text__contains,
filters.number__contains,
filters.status,
filters.status_name__contains,
filters.contract_typ,
filters.date,
filters.date_due,
filters.date_bill,
billFilters.counterparty,
billFilters.project,
billFilters.frc,
billFilters.category,
],
() => {
billFilters.page = 1;
},
);
</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="bills-page">
<van-search v-model="billTextContains" placeholder="Поиск по описанию" clearable />
<div class="bill-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="loadBills()">
Обновить
</van-button>
<van-button size="small" type="primary" :loading="syncing" @click="syncBills">
Обновить с сервера
</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="bill-filters">
<van-field
v-model="billNumberContains"
label="Номер"
placeholder="Номер счета"
clearable
/>
<van-field
v-model="billStatus"
label="Статус"
placeholder="Код статуса"
clearable
/>
<van-field
v-model="billStatusNameContains"
label="Статус текстом"
placeholder="Название статуса"
clearable
/>
<van-field
v-model="billContractTyp"
label="Тип"
placeholder="contract_typ"
clearable
/>
<van-field label="Контрагент">
<template #input>
<CounterpartySelect v-model="selectedCounterpartyId" />
</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>
<ContractCategorySelect v-model="selectedCategoryId" />
</template>
</van-field>
<van-field v-model="selectedDateBill" label="Дата счета" type="date" clearable />
<van-field v-model="selectedDateDue" label="Срок оплаты" type="date" clearable />
<van-field v-model="billDate" label="Создан" type="date" clearable />
</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 && bills.length === 0" class="state" type="spinner">
Загрузка...
</van-loading>
<van-empty
v-else-if="bills.length === 0"
:description="hasActiveFilters ? 'Счета не найдены' : 'Счетов пока нет'"
/>
<template v-else>
<van-cell-group>
<van-cell
v-for="bill in bills"
:key="bill.id"
:title="bill.text || bill.number || `Счет #${bill.id}`"
:label="`№ ${bill.number || 'не указан'} · ${objectLabel(bill.counterparty) || 'не указан'}`"
center
is-link
@click="router.push(`/bills/${bill.id}`)"
>
<template #right-icon>
<van-tag :type="bill.status === '5' ? 'success' : bill.status === '3' ? 'warning' : 'primary'">
{{ bill.status_name || bill.status }}
</van-tag>
</template>
</van-cell>
</van-cell-group>
<van-pagination
v-model="currentPage"
class="bill-pagination"
:total-items="count"
:items-per-page="PAGE_SIZE"
prev-text="Назад"
next-text="Вперед"
mode="simple"
/>
</template>
</section>
</template>
<style scoped>
.bills-page {
padding-bottom: 12px;
}
.bill-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;
}
.bill-filters {
margin-bottom: 8px;
}
.filters-actions {
padding: 4px 12px 0;
}
.bill-pagination {
margin: 10px 12px 0;
}
</style>
+2 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from "../../../generated/models"; import type { Task } from "../../../generated/models";
import RemoteImage from "../../../shared/components/RemoteImage.vue";
type TaskItem = Task; type TaskItem = Task;
@@ -25,7 +26,7 @@ const emit = defineEmits<{
@click="emit('open', `/tasks/${props.item.id}`)" @click="emit('open', `/tasks/${props.item.id}`)"
> >
<template #icon> <template #icon>
<van-image <RemoteImage
class="task-avatar" class="task-avatar"
round round
width="36" width="36"
+11 -13
View File
@@ -3,7 +3,7 @@ import { computed, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { showToast } from "vant"; import { showToast } from "vant";
import { employeeApi, taskApi } from "../../../generated/api"; import { employeeApi, taskApi } from "../../../generated/api";
import type { Employee, TaskCreate, TaskPersonInput } from "../../../generated/models"; import type { Employee, TaskCreate } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi"; import { useModelApi } from "../../../shared/composables/useModelApi";
import EmployeeSelect from "../../personnel/components/EmployeeSelect.vue"; import EmployeeSelect from "../../personnel/components/EmployeeSelect.vue";
@@ -40,22 +40,20 @@ function makeShortName(name: string) {
return `${parts[0]} ${parts.slice(1).map((part) => `${part[0]?.toUpperCase()}.`).join(" ")}`.trim(); return `${parts[0]} ${parts.slice(1).map((part) => `${part[0]?.toUpperCase()}.`).join(" ")}`.trim();
} }
async function buildPerson(id: number): Promise<TaskPersonInput | null> { async function buildPerson(id: number): Promise<Employee | null> {
if (!id) { if (!id) {
return null; return null;
} }
const employee = await loadEmployee(id); const employee = await loadEmployee(id);
return employeeToTaskPerson(employee); return employee
} ? {
id: employee.id,
function employeeToTaskPerson(employee: Employee): TaskPersonInput { name: employee.name,
return { short_name: makeShortName(employee.name),
id: employee.id, avatar_small: employee.avatar_small,
name: employee.name, }
short_name: makeShortName(employee.name), : null;
avatar_small: employee.avatar_small,
};
} }
async function createTask() { async function createTask() {
@@ -75,7 +73,7 @@ async function createTask() {
const payload: TaskCreate = { const payload: TaskCreate = {
doer, doer,
deadline: deadline.value, deadline: deadline.value,
text: taskText.value.trim() || undefined, text: taskText.value.trim(),
responsible, responsible,
}; };
+61 -46
View File
@@ -1,29 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from "vue"; import { computed, onMounted, ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { taskApi } from "../../../generated/api"; import { taskApi } from "../../../generated/api";
import type { Task, TaskListParams } from "../../../generated/models";
import DocumentApprovalTaskCard from "../../../shared/components/DocumentApprovalTaskCard.vue";
import { useModelApi } from "../../../shared/composables/useModelApi"; import { useModelApi } from "../../../shared/composables/useModelApi";
interface CurrentEmployee {
id: number;
name: string;
short_name: string;
avatar?: string | null;
}
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const taskId = computed(() => Number(route.params.id)); const taskId = computed(() => Number(route.params.id));
const currentEmployeeId = ref<number | null>(null);
const activeTab = ref(0);
const { const {
item: task, item: task,
loadingItem, loadingItem,
error, error,
retrieve: loadTask, retrieve: loadTask,
} = useModelApi(taskApi, { } = useModelApi<Task, Task, Task, TaskListParams>(taskApi, {
retrieveErrorMessage: "Не удалось загрузить задачу", retrieveErrorMessage: "Не удалось загрузить задачу",
autoLoad: false, autoLoad: false,
autoLoadOnFilterChange: false, autoLoadOnFilterChange: false,
}); });
function personName(person: { short_name?: string | null; name?: string | null } | null) {
if (!person) {
return "не указан";
}
return person.short_name ?? person.name ?? "не указан"; async function loadCurrentEmployee() {
try {
const employee = await invoke<CurrentEmployee>("current_employee");
currentEmployeeId.value = employee.id;
} catch {
currentEmployeeId.value = null;
}
} }
function taskTitle() { function taskTitle() {
@@ -39,6 +53,7 @@ function formatBoolean(value: boolean) {
} }
onMounted(() => { onMounted(() => {
void loadCurrentEmployee();
if (Number.isFinite(taskId.value)) { if (Number.isFinite(taskId.value)) {
loadTask(taskId.value); loadTask(taskId.value);
} }
@@ -63,49 +78,49 @@ onMounted(() => {
<van-empty v-else-if="!task" description="Задача не найдена" /> <van-empty v-else-if="!task" description="Задача не найдена" />
<template v-else> <template v-else>
<van-cell-group inset> <van-tabs v-model:active="activeTab" animated>
<van-cell title="ID" :value="task.id" /> <van-tab title="Диалог">
<van-cell title="Заголовок" :value="taskTitle()" /> <div class="tab-body">
<van-cell title="Исполнитель" :value="personName(task.doer)" /> <DocumentApprovalTaskCard
<van-cell title="Автор" :value="personName(task.author)" /> :task="task as any"
<van-cell title="Ответственный" :value="personName(task.responsible)" /> :current-employee-id="currentEmployeeId"
<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>
</div> </van-tab>
</article>
</div> <van-tab title="Подробная информация">
<div class="tab-body">
<van-cell-group inset>
<van-cell title="ID" :value="task.id" />
<van-cell title="Заголовок" :value="taskTitle()" />
<van-cell title="Исполнитель" :value="task.doer?.short_name ?? task.doer?.name ?? 'не указан'" />
<van-cell title="Автор" :value="task.author?.short_name ?? task.author?.name ?? 'не указан'" />
<van-cell title="Ответственный" :value="task.responsible?.short_name ?? task.responsible?.name ?? 'не указан'" />
<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>
</div>
</van-tab>
</van-tabs>
</template>
</section> </section>
<div class="form-actions stacked-actions"> <div class="form-actions stacked-actions">
<van-button block round type="primary" plain @click="router.back()">Назад</van-button> <van-button block round type="primary" plain @click="router.back()">Назад</van-button>
</div> </div>
</template> </template>
<style scoped>
.tab-body {
padding-top: 12px;
}
</style>
+44 -6
View File
@@ -1,13 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { computed, nextTick, onMounted, ref, watch } from "vue"; import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { taskApi, taskTransferApi } from "../../../generated/api"; import { taskApi, taskTransferApi } from "../../../generated/api";
import type { import type {
Task, Task,
TaskCreate, TaskCreate,
TaskTransferCreate,
TaskListParams, TaskListParams,
TaskTransfer, TaskTransfer,
TaskTransferUpdate,
TaskTransferListParams, TaskTransferListParams,
TaskUpdate, TaskUpdate,
} from "../../../generated/models"; } from "../../../generated/models";
@@ -33,6 +35,7 @@ type DocTabKey =
| "outgoing_letter"; | "outgoing_letter";
const router = useRouter(); const router = useRouter();
const route = useRoute();
const mainTab = ref<MainTabKey>("incoming"); const mainTab = ref<MainTabKey>("incoming");
const docTab = ref<DocTabKey>("all"); const docTab = ref<DocTabKey>("all");
const employee = ref<CurrentEmployee | null>(null); const employee = ref<CurrentEmployee | null>(null);
@@ -76,8 +79,8 @@ const {
load: loadTaskTransfers, load: loadTaskTransfers,
} = useModelApi< } = useModelApi<
TaskTransfer, TaskTransfer,
Partial<TaskTransfer>, TaskTransferCreate,
Partial<TaskTransfer>, TaskTransferUpdate,
TaskTransferListParams TaskTransferListParams
>(taskTransferApi, { >(taskTransferApi, {
defaultListParams: { ordering: "-id" }, defaultListParams: { ordering: "-id" },
@@ -103,6 +106,24 @@ const docTabs = [
{ key: "outgoing_letter", label: "Исходящие письма" }, { key: "outgoing_letter", label: "Исходящие письма" },
] as const; ] as const;
function normalizeMainTab(value: unknown): MainTabKey {
return value === "transfer" || value === "review" || value === "outgoing"
? value
: "incoming";
}
function normalizeDocTab(value: unknown): DocTabKey {
return value === "simple" ||
value === "memo" ||
value === "bill" ||
value === "contract" ||
value === "contract_application" ||
value === "entry_letter" ||
value === "outgoing_letter"
? value
: "all";
}
const visibleItems = computed(() => tasks.value); const visibleItems = computed(() => tasks.value);
const activeLoading = computed(() => taskLoading.value); const activeLoading = computed(() => taskLoading.value);
const showLoading = computed(() => activeLoading.value || refreshing.value); const showLoading = computed(() => activeLoading.value || refreshing.value);
@@ -134,7 +155,7 @@ function buildTaskParams(
switch (tab) { switch (tab) {
case "incoming": case "incoming":
return { return {
doer: employeeId, doer: String(employeeId),
archive: false, archive: false,
typ: doc === "all" ? undefined : doc, typ: doc === "all" ? undefined : doc,
q: "entry", q: "entry",
@@ -156,7 +177,7 @@ function buildTaskParams(
}; };
case "transfer": case "transfer":
return { return {
employee_to: employeeId, employee_to: String(employeeId),
status: "A", status: "A",
}; };
default: default:
@@ -222,7 +243,7 @@ async function reloadList() {
} }
if (mainTab.value === "transfer") { if (mainTab.value === "transfer") {
await loadTaskTransfers({ employee_to: employee.value.id, status: "A" }); await loadTaskTransfers({ employee_to: String(employee.value.id), status: "A" });
return; return;
} }
@@ -346,6 +367,23 @@ watch(docTab, () => {
} }
}); });
watch(
() => [route.query.tab, route.query.doc],
() => {
const nextMainTab = normalizeMainTab(route.query.tab);
const nextDocTab = nextMainTab === "transfer" ? "all" : normalizeDocTab(route.query.doc);
if (mainTab.value !== nextMainTab) {
mainTab.value = nextMainTab;
}
if (docTab.value !== nextDocTab) {
docTab.value = nextDocTab;
}
},
{ immediate: true },
);
onMounted(async () => { onMounted(async () => {
await loadCurrentEmployee(); await loadCurrentEmployee();
await reloadForTabChange({ reloadCounts: true }); await reloadForTabChange({ reloadCounts: true });
+18 -1
View File
@@ -1,5 +1,9 @@
import { createModelApi } from "./api_client"; import { createModelApi } from "./api_client";
import type { import type {
Bill,
BillCreate,
BillUpdate,
BillListParams,
Contract, Contract,
ContractCreate, ContractCreate,
ContractUpdate, ContractUpdate,
@@ -24,6 +28,14 @@ import type {
FrcCreate, FrcCreate,
FrcUpdate, FrcUpdate,
FrcListParams, FrcListParams,
Memo,
MemoCreate,
MemoUpdate,
MemoListParams,
MemoCategory,
MemoCategoryCreate,
MemoCategoryUpdate,
MemoCategoryListParams,
Message, Message,
MessageCreate, MessageCreate,
MessageUpdate, MessageUpdate,
@@ -37,6 +49,8 @@ import type {
TaskUpdate, TaskUpdate,
TaskListParams, TaskListParams,
TaskTransfer, TaskTransfer,
TaskTransferCreate,
TaskTransferUpdate,
TaskTransferListParams, TaskTransferListParams,
User, User,
UserCreate, UserCreate,
@@ -44,14 +58,17 @@ import type {
UserListParams, UserListParams,
} from "./models"; } from "./models";
export const billApi = createModelApi<Bill, BillCreate, BillUpdate, BillListParams>("bill");
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 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 memoApi = createModelApi<Memo, MemoCreate, MemoUpdate, MemoListParams>("memo");
export const memoCategoryApi = createModelApi<MemoCategory, MemoCategoryCreate, MemoCategoryUpdate, MemoCategoryListParams>("memo_category");
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 taskTransferApi = createModelApi<TaskTransfer, TaskTransferCreate, TaskTransferUpdate, TaskTransferListParams>("task_transfer");
export const userApi = createModelApi<User, UserCreate, UserUpdate, UserListParams>("users"); export const userApi = createModelApi<User, UserCreate, UserUpdate, UserListParams>("users");
+336 -214
View File
@@ -1,5 +1,124 @@
import type { ListParams } from "./api_client"; import type { ListParams } from "./api_client";
export interface Bill {
id: number;
number: string;
text: string;
status: string;
status_name: string;
date: string;
date_due: string | null;
date_bill: string | null;
month_of_costs: string | null;
cost: number;
to_payd: number;
paid: number;
nds_cost: number;
scan: string | null;
comment: string;
absolute_url: string;
date_pay: string | null;
transaction_date: string | null;
date_applay: string | null;
archive_s: boolean;
pp_maked: boolean;
composit: boolean;
contract_typ: string;
frc: unknown | null;
project: unknown | null;
counterparty: unknown | null;
contract: unknown | null;
responsible: unknown | null;
author: unknown | null;
category: unknown | null;
transferdocument_set: unknown[] | null;
}
export interface BillCreate {
number: string;
text: string;
status: string;
status_name: string;
date: string;
date_due?: string | null;
date_bill?: string | null;
month_of_costs?: string | null;
cost: number;
to_payd: number;
paid: number;
nds_cost: number;
scan?: string | null;
comment: string;
absolute_url: string;
date_pay?: string | null;
transaction_date?: string | null;
date_applay?: string | null;
archive_s: boolean;
pp_maked: boolean;
composit: boolean;
contract_typ: string;
frc?: unknown | null;
project?: unknown | null;
counterparty?: unknown | null;
contract?: unknown | null;
responsible?: unknown | null;
author?: unknown | null;
category?: unknown | null;
transferdocument_set?: unknown[] | null;
}
export interface BillUpdate {
number?: string;
text?: string;
status?: string;
status_name?: string;
date?: string;
date_due?: string | null;
date_bill?: string | null;
month_of_costs?: string | null;
cost?: number;
to_payd?: number;
paid?: number;
nds_cost?: number;
scan?: string | null;
comment?: string;
absolute_url?: string;
date_pay?: string | null;
transaction_date?: string | null;
date_applay?: string | null;
archive_s?: boolean;
pp_maked?: boolean;
composit?: boolean;
contract_typ?: string;
frc?: unknown | null;
project?: unknown | null;
counterparty?: unknown | null;
contract?: unknown | null;
responsible?: unknown | null;
author?: unknown | null;
category?: unknown | null;
transferdocument_set?: unknown[] | null;
}
export interface BillListParams extends ListParams {
id?: number;
number?: string;
number__contains?: string;
text?: string;
text__contains?: string;
status?: string;
status_name?: string;
status_name__contains?: string;
date?: string;
date_due?: string | null;
date_bill?: string | null;
contract_typ?: string;
counterparty?: string | null;
frc?: string | null;
project?: string | null;
category?: string | null;
}
export interface Contract { export interface Contract {
id: number; id: number;
name: string; name: string;
@@ -154,7 +273,7 @@ export interface ContractApplicationFileCreate {
absolute_url: string; absolute_url: string;
scan_name: string; scan_name: string;
scan_url: string; scan_url: string;
local_path: string; local_path?: string;
comment: string; comment: string;
bill_total: number; bill_total: number;
bill_cost_total: number; bill_cost_total: number;
@@ -294,23 +413,148 @@ export interface FrcListParams extends ListParams {
name__contains?: string; name__contains?: string;
} }
export interface Memo {
id: number;
recipient_id: number | null;
sender_id: number | null;
category_id: number | null;
project_id: number | null;
task_set: unknown[] | null;
priority_display: string;
absolute_url: string;
date: string;
my_approve: string;
text: string;
resalution: string;
priority: string;
archive: boolean;
cancel: boolean;
date_start: string | null;
date_end: string | null;
value: number;
archive_s: boolean;
current_step: number;
subject: number[] | null;
employee_acl: number[] | null;
recipient: Employee | null;
sender: Employee | null;
category: MemoCategory | null;
project: Project | null;
}
export interface MemoCreate {
recipient_id?: number | null;
sender_id?: number | null;
category_id?: number | null;
project_id?: number | null;
task_set?: unknown[] | null;
priority_display: string;
absolute_url: string;
date: string;
my_approve: string;
text: string;
resalution: string;
priority: string;
archive: boolean;
cancel: boolean;
date_start?: string | null;
date_end?: string | null;
value: number;
archive_s: boolean;
current_step: number;
subject?: number[] | null;
employee_acl?: number[] | null;
}
export interface MemoUpdate {
recipient_id?: number | null;
sender_id?: number | null;
category_id?: number | null;
project_id?: number | null;
task_set?: unknown[] | null;
priority_display?: string;
absolute_url?: string;
date?: string;
my_approve?: string;
text?: string;
resalution?: string;
priority?: string;
archive?: boolean;
cancel?: boolean;
date_start?: string | null;
date_end?: string | null;
value?: number;
archive_s?: boolean;
current_step?: number;
subject?: number[] | null;
employee_acl?: number[] | null;
}
export interface MemoListParams extends ListParams {
id?: number;
text__contains?: string;
date?: string;
priority?: string;
archive?: boolean;
cancel?: boolean;
recipient?: number | null;
sender?: number | null;
category?: number | null;
project?: number | null;
}
export interface MemoCategory {
id: number;
name: string;
template: string;
template_text: string;
code: string;
in_month_limit: boolean;
}
export interface MemoCategoryCreate {
name: string;
template: string;
template_text: string;
code: string;
in_month_limit: boolean;
}
export interface MemoCategoryUpdate {
name?: string;
template?: string;
template_text?: string;
code?: string;
in_month_limit?: boolean;
}
export interface MemoCategoryListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
code?: string;
}
export interface Message { export interface Message {
id: number; id: number;
task_id: number; task: number;
employee_id: number; recipient: number;
text: string; text: string;
status: string;
} }
export interface MessageCreate { export interface MessageCreate {
task_id: number; task: number;
employee_id: number; recipient: number;
text: string; text: string;
status: string;
} }
export interface MessageUpdate { export interface MessageUpdate {
task_id?: number; task?: number;
employee_id?: number; recipient?: number;
text?: string; text?: string;
status?: string;
} }
export interface MessageListParams extends ListParams { export interface MessageListParams extends ListParams {
@@ -319,107 +563,6 @@ 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;
@@ -455,37 +598,37 @@ export interface ProjectListParams extends ListParams {
export interface Task { export interface Task {
id: number; id: number;
project: TaskProject | null; project: string | null;
doer: TaskPerson; doer: Employee | null;
doer_name: string; doer_name: string;
author: TaskPerson | null; author: Employee | null;
memo_full: TaskRelatedObject | null; memo_full: unknown | null;
bill_full: TaskRelatedObject | null; bill_full: unknown | null;
contract_full: TaskRelatedObject | null; contract_full: unknown | null;
contract_application_full: TaskRelatedObject | null; contract_application_full: unknown | null;
outgoing_letter_full: TaskRelatedObject | null; outgoing_letter_full: unknown | null;
entry_letter_full: TaskRelatedObject | null; entry_letter_full: unknown | null;
protocolitem_full: TaskRelatedObject | null; protocolitem_full: unknown | null;
decree_full: TaskRelatedObject | null; decree_full: unknown | null;
delivery_full: TaskRelatedObject | null; delivery_full: unknown | null;
get_status: string; get_status: string;
get_status_class: string; get_status_class: string;
get_scan_url: string | null; get_scan_url: string | null;
get_last_day: string; get_last_day: string;
frc_icon: string; frc_icon: string;
uploadfile_set: unknown[]; uploadfile_set: unknown[] | null;
deadline: string; deadline: string;
request_new_deadline: string | null; request_new_deadline: string | null;
plan_date: string | null; plan_date: string | null;
date: string; date: string;
message_set: TaskMessage[]; message_set: unknown[] | null;
responsible: TaskPerson | null; responsible: Employee | null;
counterparty: TaskRelatedObject | null; counterparty: unknown | null;
get_deadline_history: unknown[]; get_deadline_history: unknown[] | null;
duration: number | null; duration: number | null;
bid_full: TaskRelatedObject | null; bid_full: unknown | null;
price_agreement_full: TaskRelatedObject | null; price_agreement_full: unknown | null;
transfer: unknown; transfer: unknown | null;
text: string; text: string;
status: string; status: string;
result: string; result: string;
@@ -502,108 +645,86 @@ export interface Task {
order_number: number | null; order_number: number | null;
priority: number | null; priority: number | null;
typ: string; typ: string;
stage: unknown; stage: unknown | null;
contract: unknown; contract: unknown | null;
questionnair: unknown; questionnair: unknown | null;
contract_application: unknown; contract_application: unknown | null;
entry_letter: unknown; entry_letter: unknown | null;
outgoing_letter: unknown; outgoing_letter: unknown | null;
protocol: unknown; protocol: unknown | null;
bill: unknown; bill: unknown | null;
decree: unknown; decree: unknown | null;
court_case: unknown; court_case: unknown | null;
bill_register: unknown; bill_register: unknown | null;
price_agreement: unknown; price_agreement: unknown | null;
bid: unknown; bid: unknown | null;
delivery: unknown; delivery: unknown | null;
scheduled_task: unknown; scheduled_task: unknown | null;
report: unknown; report: unknown | null;
memo: unknown; memo: unknown | null;
protocolitem: unknown; protocolitem: unknown | null;
related_note: unknown; related_note: unknown | null;
task: unknown; task: unknown | null;
} }
export interface TaskCreate { export interface TaskCreate {
project?: TaskProject | null; doer?: Employee | null;
doer: TaskPersonInput;
get_status?: string;
get_status_class?: string;
get_scan_url?: string | null;
get_last_day?: string;
frc_icon?: string;
deadline: string; deadline: string;
request_new_deadline?: string | null; responsible?: Employee | null;
plan_date?: string | null; text: string;
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 {
project?: TaskProject | null; doer?: Employee | null;
doer?: TaskPersonInput;
get_status?: string;
get_status_class?: string;
get_scan_url?: string | null;
get_last_day?: string;
frc_icon?: string;
deadline?: string; deadline?: string;
request_new_deadline?: string | null; responsible?: Employee | null;
plan_date?: string | null;
responsible?: TaskPersonInput | null;
counterparty?: TaskRelatedObject | null;
bid_full?: TaskRelatedObject | null;
price_agreement_full?: TaskRelatedObject | null;
text?: string; 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;
text?: string;
text__contains?: string; text__contains?: string;
doer?: number | null; contract?: string | null;
author?: number | null; bill?: string | null;
memo?: string | null;
doer?: string | null;
author?: string | null;
archive?: boolean; archive?: boolean;
typ?: string;
status?: string;
}
export interface TaskTransfer {
id: number;
employee_from: string | null;
employee_to: string | null;
date_create: string;
task: string | null;
status: string;
typ: string;
}
export interface TaskTransferCreate {
employee_from?: string | null;
employee_to?: string | null;
task?: string | null;
status: string;
typ: string;
}
export interface TaskTransferUpdate {
employee_from?: string | null;
employee_to?: string | null;
task?: string | null;
status?: string;
typ?: string;
}
export interface TaskTransferListParams extends ListParams {
id?: number;
employee_from?: string | null;
employee_to?: string | null;
status?: string; status?: string;
result?: string;
doer_name?: string;
doer_name__contains?: string;
deadline?: string;
progress_status?: string;
priority?: number | null;
typ?: string; typ?: string;
} }
@@ -625,3 +746,4 @@ export interface UserListParams extends ListParams {
name?: string; name?: string;
name__contains?: string; name__contains?: string;
} }
+8 -3
View File
@@ -18,9 +18,7 @@ export function useAuth() {
} }
async function logout() { async function logout() {
authToken.value = ""; clearAuthState();
localStorage.removeItem(TOKEN_STORAGE_KEY);
restored = false;
await invoke("auth_logout"); await invoke("auth_logout");
} }
@@ -36,12 +34,19 @@ export function useAuth() {
return { return {
authToken, authToken,
isAuthenticated, isAuthenticated,
clearAuthState,
login, login,
logout, logout,
restoreToken, restoreToken,
}; };
} }
export function clearAuthState() {
authToken.value = "";
localStorage.removeItem(TOKEN_STORAGE_KEY);
restored = false;
}
export function isAuthenticated() { export function isAuthenticated() {
return Boolean(authToken.value); return Boolean(authToken.value);
} }
@@ -0,0 +1,573 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { showToast } from "vant";
import { messageApi } from "../../generated/api";
import type { Employee, MessageCreate, Task } from "../../generated/models";
import RemoteImage from "./RemoteImage.vue";
type ApprovalMessage = {
id: number;
author: Employee;
recipient: Employee;
text: string;
date: string;
status: string;
task: number;
};
type ApprovalTask = Omit<Task, "message_set"> & {
message_set?: ApprovalMessage[] | null;
};
type ReplyMode = "success" | "failure";
const props = defineProps<{
task: ApprovalTask;
currentEmployeeId: number | null;
}>();
const emit = defineEmits<{
updated: [];
}>();
const executorSuccessReplies = ["Согласовано", "Ознакомлен", "Выполнено"];
const executorFailureReplies = [
"Прошу дать объяснение",
"Обосновать цену",
"На доработку",
"Отказ",
];
const expanded = ref(false);
const sendingActionKey = ref("");
const activeReplyForm = ref<ReplyMode | undefined>();
const replyText = ref("");
const isTaskDoer = computed(() =>
Boolean(props.currentEmployeeId && props.task.doer?.id === props.currentEmployeeId),
);
const isTaskAuthor = computed(() =>
Boolean(props.currentEmployeeId && props.task.author?.id === props.currentEmployeeId),
);
const hasActionForm = computed(() => isTaskDoer.value && activeReplyForm.value);
function statusType(statusClass: string) {
if (/success|done|complete|green/i.test(statusClass)) {
return "success";
}
if (/warning|pending|wait|yellow|orange/i.test(statusClass)) {
return "warning";
}
if (/danger|error|red|fail/i.test(statusClass)) {
return "danger";
}
return "primary";
}
function statusTone(statusClass: string) {
if (/success|done|complete|green/i.test(statusClass)) {
return "success";
}
if (/warning|pending|wait|yellow|orange/i.test(statusClass)) {
return "warning";
}
if (/danger|error|red|fail/i.test(statusClass)) {
return "danger";
}
return "primary";
}
function actionKey(status: string) {
return `${props.task.id}:${status}`;
}
function taskRecipientId() {
if (isTaskDoer.value) {
return props.task.author?.id ?? null;
}
if (isTaskAuthor.value) {
return props.task.doer?.id ?? null;
}
return null;
}
function taskMessages() {
return props.task.message_set ?? [];
}
function visibleMessages() {
const messages = taskMessages();
if (expanded.value) {
return messages;
}
return messages.length > 0 ? [messages[messages.length - 1]] : [];
}
function toggleHistory() {
expanded.value = !expanded.value;
}
function openReplyForm(mode: ReplyMode) {
activeReplyForm.value = activeReplyForm.value === mode ? undefined : mode;
if (!replyText.value) {
replyText.value = "";
}
}
function closeReplyForm() {
activeReplyForm.value = undefined;
replyText.value = "";
}
function quickReplies(mode: ReplyMode) {
return mode === "success" ? executorSuccessReplies : executorFailureReplies;
}
function employeeName(employee: Employee | null | undefined) {
return employee?.name || employee?.short_name || "не указан";
}
function showEmployeeName(employee: Employee | null | undefined) {
showToast(employeeName(employee));
}
async function sendMessage(status: string, text: string) {
const key = actionKey(status);
if (sendingActionKey.value) {
return;
}
const trimmedText = text.trim();
if (!trimmedText) {
showToast("Введите сообщение");
return;
}
const recipientId = taskRecipientId();
if (!recipientId) {
showToast("Не удалось определить получателя сообщения");
return;
}
sendingActionKey.value = key;
try {
const payload: MessageCreate = {
task: props.task.id,
recipient: recipientId,
text: trimmedText,
status,
};
await messageApi.create(payload);
showToast("Сообщение отправлено");
closeReplyForm();
emit("updated");
} catch (error) {
showToast(
error instanceof Error ? error.message : "Не удалось отправить сообщение",
);
} finally {
sendingActionKey.value = "";
}
}
</script>
<template>
<article
:class="['approval-task-card', `approval-task-card--${statusTone(task.get_status_class)}`]"
>
<div class="approval-task-head">
<div class="approval-task-head__main">
<div class="approval-task-title-row">
<RemoteImage
class="approval-person-avatar approval-person-avatar--author"
round
width="30"
height="30"
:src="task.author?.avatar_small ?? ''"
:fallback-text="employeeName(task.author)"
@click.stop="showEmployeeName(task.author)"
/>
<div class="approval-task-title">
{{ task.text?.trim() || task.result?.trim() || `Задача #${task.id}` }}
</div>
</div>
<div class="approval-task-meta">ID {{ task.id }} · {{ task.deadline }}</div>
</div>
<div class="approval-task-status">
<van-tag plain :type="statusType(task.get_status_class)">
{{ task.get_status }}
</van-tag>
<div class="approval-status-avatars">
<RemoteImage
class="approval-person-avatar"
round
width="28"
height="28"
:src="task.doer?.avatar_small ?? ''"
:fallback-text="employeeName(task.doer)"
@click.stop="showEmployeeName(task.doer)"
/>
<RemoteImage
class="approval-person-avatar"
round
width="28"
height="28"
:src="task.responsible?.avatar_small ?? ''"
:fallback-text="employeeName(task.responsible)"
@click.stop="showEmployeeName(task.responsible)"
/>
</div>
</div>
</div>
<div class="approval-task-messages">
<div class="approval-task-messages__title">Сообщения</div>
<van-empty v-if="!taskMessages().length" description="Сообщений пока нет" image-size="64" />
<div v-else class="approval-message-list">
<article
v-for="message in visibleMessages()"
:key="message.id"
:class="[
'approval-message-item',
!expanded && message.id === taskMessages()[taskMessages().length - 1]?.id
? 'approval-message-item--latest'
: '',
]"
@click="
message.id === taskMessages()[taskMessages().length - 1]?.id && !expanded
? toggleHistory()
: undefined
"
>
<RemoteImage
class="approval-message-avatar"
round
width="36"
height="36"
:src="message.author.avatar_small"
/>
<div class="approval-message-body">
<div class="approval-message-head">
<div class="approval-message-author">
{{ message.author.short_name ?? message.author.name ?? 'не указан' }}
</div>
<div class="approval-message-meta">{{ message.date }} · {{ message.status }}</div>
</div>
<div class="approval-message-text">{{ message.text }}</div>
<div class="approval-message-recipient">
Кому: {{ message.recipient.short_name ?? message.recipient.name ?? 'не указан' }}
</div>
</div>
</article>
<van-button
v-if="taskMessages().length > 1"
class="approval-message-toggle"
size="small"
round
plain
type="primary"
@click.stop="toggleHistory"
>
<van-icon :name="expanded ? 'arrow-up' : 'arrow-down'" />
</van-button>
</div>
</div>
<div v-if="isTaskDoer" class="approval-task-actions">
<van-button
size="small"
round
plain
type="success"
:loading="sendingActionKey === `${task.id}:S`"
:disabled="Boolean(sendingActionKey) && sendingActionKey !== `${task.id}:S`"
@click="openReplyForm('success')"
>
Успех
</van-button>
<van-button
size="small"
round
plain
type="danger"
:loading="sendingActionKey === `${task.id}:F`"
:disabled="Boolean(sendingActionKey) && sendingActionKey !== `${task.id}:F`"
@click="openReplyForm('failure')"
>
Отказ
</van-button>
</div>
<div v-else-if="isTaskAuthor" class="approval-task-actions">
<van-button
size="small"
round
plain
type="warning"
:loading="sendingActionKey === `${task.id}:R`"
:disabled="Boolean(sendingActionKey) && sendingActionKey !== `${task.id}:R`"
@click="sendMessage('R', 'На повторное рассмотрение')"
>
На повторное рассмотрение
</van-button>
<van-button
size="small"
round
plain
type="success"
:loading="sendingActionKey === `${task.id}:S`"
:disabled="Boolean(sendingActionKey) && sendingActionKey !== `${task.id}:S`"
@click="sendMessage('S', 'Одобрить')"
>
Одобрить
</van-button>
</div>
<div v-if="hasActionForm" class="approval-reply-box">
<div class="approval-reply-title">
{{ activeReplyForm === 'success' ? 'Успех' : 'Отказ' }}
</div>
<div class="approval-reply-quick">
<van-button
v-for="reply in quickReplies((activeReplyForm ?? 'success') as ReplyMode)"
:key="reply"
size="small"
round
plain
type="primary"
@click="activeReplyForm === 'success' ? sendMessage('S', reply) : sendMessage('F', reply)"
>
{{ reply }}
</van-button>
</div>
<van-field
v-model="replyText"
rows="3"
autosize
type="textarea"
placeholder="Введите сообщение"
maxlength="500"
show-word-limit
/>
<div class="approval-reply-actions">
<van-button plain size="small" round @click="closeReplyForm()">
Отмена
</van-button>
<van-button
size="small"
round
:type="activeReplyForm === 'success' ? 'success' : 'danger'"
:loading="sendingActionKey === `${task.id}:${activeReplyForm === 'success' ? 'S' : 'F'}`"
@click="activeReplyForm === 'success' ? sendMessage('S', replyText) : sendMessage('F', replyText)"
>
Отправить
</van-button>
</div>
</div>
</article>
</template>
<style scoped>
.approval-task-card {
overflow: hidden;
border-radius: 16px;
background: #fff;
box-shadow: 0 8px 24px rgba(36, 42, 56, 0.08);
}
.approval-task-card--success {
background: #f0fdf4;
}
.approval-task-card--warning {
background: #fffbeb;
}
.approval-task-card--danger {
background: #fef2f2;
}
.approval-task-card--primary {
background: #eff6ff;
}
.approval-task-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 10px;
}
.approval-task-head__main {
min-width: 0;
}
.approval-task-title-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.approval-task-title {
min-width: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.35;
word-break: break-word;
}
.approval-task-status {
display: flex;
flex: none;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.approval-status-avatars {
display: flex;
align-items: center;
gap: 6px;
}
.approval-task-meta {
margin-top: 4px;
color: #6b7280;
font-size: 12px;
}
.approval-task-messages {
padding: 12px 16px 16px;
}
.approval-person-avatar {
flex: none;
cursor: pointer;
}
.approval-person-avatar--author {
margin-top: 1px;
}
.approval-task-messages__title {
margin-bottom: 10px;
color: #374151;
font-size: 13px;
font-weight: 600;
}
.approval-message-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.approval-message-item {
display: flex;
gap: 10px;
align-items: flex-start;
}
.approval-message-item--latest {
cursor: pointer;
}
.approval-message-avatar {
flex: none;
margin-top: 2px;
}
.approval-message-body {
min-width: 0;
flex: 1;
}
.approval-message-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.approval-message-author {
font-weight: 600;
font-size: 13px;
}
.approval-message-meta,
.approval-message-recipient {
color: #6b7280;
font-size: 11px;
}
.approval-message-toggle {
width: 28px;
height: 28px;
margin-top: 8px;
padding: 0;
}
.approval-message-text {
margin-top: 4px;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.45;
}
.approval-task-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 16px 16px;
}
.approval-reply-box {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0 16px 16px;
}
.approval-reply-title {
color: #374151;
font-size: 13px;
font-weight: 600;
}
.approval-reply-quick {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.approval-reply-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { taskApi } from "../../generated/api";
import type { TaskListParams } from "../../generated/models";
import { useModelApi } from "../composables/useModelApi";
import DocumentApprovalTaskCard from "./DocumentApprovalTaskCard.vue";
interface CurrentEmployee {
id: number;
name: string;
short_name: string;
avatar?: string | null;
}
type DocumentFilterName = "contract" | "bill" | "memo";
const props = withDefaults(
defineProps<{
documentId: number;
filterName: DocumentFilterName;
title?: string;
}>(),
{
title: "Согласование",
},
);
const {
items: tasks,
loading,
error,
load,
} = useModelApi(taskApi, {
loadErrorMessage: "Не удалось загрузить поручения",
autoLoad: false,
autoLoadOnFilterChange: false,
});
const currentEmployeeId = ref<number | null>(null);
const hasTasks = computed(() => tasks.value.length > 0);
const heading = computed(() => props.title);
async function loadCurrentEmployee() {
try {
const employee = await invoke<CurrentEmployee>("current_employee");
currentEmployeeId.value = employee.id;
} catch {
currentEmployeeId.value = null;
}
}
function loadTasks() {
const params = {
[props.filterName]: String(props.documentId),
ordering: "-id",
} as TaskListParams;
return load(params);
}
watch(
() => props.documentId,
() => {
void loadTasks();
},
);
onMounted(() => {
void loadCurrentEmployee();
void loadTasks();
});
</script>
<template>
<section class="approval-tasks">
<van-notice-bar
v-if="error"
class="approval-tasks__error"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<van-loading v-if="loading" class="approval-tasks__state" type="spinner">
{{ heading }}...
</van-loading>
<van-empty v-else-if="!hasTasks" description="Поручений пока нет" />
<div v-else class="approval-task-list">
<DocumentApprovalTaskCard
v-for="task in tasks"
:key="task.id"
:task="task as any"
:current-employee-id="currentEmployeeId"
@updated="loadTasks"
/>
</div>
</section>
</template>
<style scoped>
.approval-tasks {
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 0 0;
}
.approval-tasks__state {
display: flex;
justify-content: center;
padding: 24px 0;
}
.approval-task-list {
display: flex;
flex-direction: column;
gap: 12px;
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useRemoteFileUrl } from "../composables/useRemoteFileUrl";
const props = defineProps<{
src?: string | null;
fallbackText?: string | null;
}>();
const sourceUrl = computed(() => props.src);
const { objectUrl } = useRemoteFileUrl(sourceUrl);
const loadFailed = ref(false);
const initials = computed(() => {
const text = (props.fallbackText ?? "").trim();
if (!text || text.toLocaleLowerCase("ru-RU") === "не указан") {
return "-";
}
const words = text
.split(/\s+/)
.filter(Boolean);
if (words.length === 0) {
return "-";
}
return words
.slice(0, 2)
.map((word) => word[0]?.toLocaleUpperCase("ru-RU") ?? "")
.join("");
});
const showFallback = computed(() => !objectUrl.value || loadFailed.value);
watch(objectUrl, () => {
loadFailed.value = false;
});
</script>
<template>
<div v-if="showFallback" v-bind="$attrs" class="remote-image-fallback">
{{ initials }}
</div>
<van-image v-else v-bind="$attrs" :src="objectUrl" @error="loadFailed = true" />
</template>
<style scoped>
.remote-image-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
color: #1d4ed8;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
</style>
@@ -0,0 +1,57 @@
import { invoke } from "@tauri-apps/api/core";
import { onBeforeUnmount, ref, watch, type Ref } from "vue";
export function useRemoteFileUrl(sourceUrl: Ref<string | null | undefined>) {
const objectUrl = ref("");
const loading = ref(false);
const error = ref("");
let loadId = 0;
async function load(url: string | null | undefined) {
const currentLoadId = ++loadId;
revokeObjectUrl();
error.value = "";
if (!url) {
loading.value = false;
return;
}
loading.value = true;
try {
const bytes = await invoke<number[]>("load_remote_file", { url });
if (currentLoadId !== loadId) {
return;
}
objectUrl.value = URL.createObjectURL(new Blob([new Uint8Array(bytes)]));
} catch (err) {
if (currentLoadId !== loadId) {
return;
}
error.value = err instanceof Error ? err.message : "Не удалось загрузить файл";
} finally {
if (currentLoadId === loadId) {
loading.value = false;
}
}
}
function revokeObjectUrl() {
if (objectUrl.value) {
URL.revokeObjectURL(objectUrl.value);
objectUrl.value = "";
}
}
watch(sourceUrl, (url) => void load(url), { immediate: true });
onBeforeUnmount(revokeObjectUrl);
return {
objectUrl,
loading,
error,
};
}