Compare commits
4 Commits
560b3fe44e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e79bbbfcc4 | |||
| 2dd7d33c8b | |||
| 13362528cd | |||
| c1b24206b9 |
Generated
+2
@@ -1196,7 +1196,9 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"che-orm",
|
||||
"che-tauri",
|
||||
"jni",
|
||||
"reqwest",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@@ -27,3 +27,5 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls"] }
|
||||
jni = "0.21"
|
||||
rustls-platform-verifier = "0.6"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import java.util.Properties
|
||||
import groovy.json.JsonSlurper
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
@@ -53,11 +54,19 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
url = uri(rustlsPlatformVerifierMavenDir())
|
||||
metadataSources.artifact()
|
||||
}
|
||||
}
|
||||
|
||||
rust {
|
||||
rootDirRel = "../../../"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("rustls:rustls-platform-verifier:latest.release")
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||
@@ -69,3 +78,27 @@ dependencies {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("ewa_mobile_lib")
|
||||
}
|
||||
|
||||
@JvmStatic external fun initRustlsPlatformVerifier(context: Context)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
initRustlsPlatformVerifier(applicationContext)
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
@@ -16,25 +16,25 @@ impl AppModule for ContractappModule {
|
||||
}
|
||||
|
||||
fn init(&self, ctx: &mut ModuleContext) {
|
||||
ctx.mapped_remote_resource::<models::Contract>(
|
||||
ctx.cached_mapped_remote_resource::<models::Contract>(
|
||||
"contract",
|
||||
"/api/contract/",
|
||||
serializers::contractapp_serializer(),
|
||||
filters::contractapp_filterset(),
|
||||
);
|
||||
ctx.mapped_remote_resource::<models::ContractCategory>(
|
||||
ctx.cached_mapped_remote_resource::<models::ContractCategory>(
|
||||
"contract_category",
|
||||
"/api/cont/category/",
|
||||
serializers::contract_category_serializer(),
|
||||
filters::contract_category_filterset(),
|
||||
);
|
||||
ctx.mapped_remote_resource::<models::Counterparty>(
|
||||
ctx.cached_mapped_remote_resource::<models::Counterparty>(
|
||||
"counterparty",
|
||||
"/api/catalog/company/",
|
||||
serializers::counterparty_serializer(),
|
||||
filters::counterparty_filterset(),
|
||||
);
|
||||
ctx.mapped_remote_resource::<models::ContractApplicationFile>(
|
||||
ctx.cached_mapped_remote_resource::<models::ContractApplicationFile>(
|
||||
"contract_application_file",
|
||||
"/api/cont/appfile/",
|
||||
serializers::contract_application_file_serializer(),
|
||||
|
||||
@@ -52,10 +52,10 @@ static CONTRACT_FIELDS: &[Field] = &[
|
||||
.required(false)
|
||||
.nullable(),
|
||||
Field::related("category_ref", "category_id", &CATEGORY_RELATION),
|
||||
Field::related("counterparty", "company", &COUNTERPARTY_RELATION),
|
||||
Field::related("project", "project", &PROJECT_RELATION),
|
||||
Field::related("frc", "frc", &FRC_RELATION),
|
||||
Field::related("employee", "get_employee", &EMPLOYEE_RELATION),
|
||||
Field::related("counterparty", "counterparty_id", &COUNTERPARTY_RELATION),
|
||||
Field::related("project", "project_id", &PROJECT_RELATION),
|
||||
Field::related("frc", "frc_id", &FRC_RELATION),
|
||||
Field::related("employee", "employee_id", &EMPLOYEE_RELATION),
|
||||
];
|
||||
|
||||
static CONTRACT_CATEGORY_FIELDS: &[Field] = &[
|
||||
|
||||
@@ -16,7 +16,7 @@ impl AppModule for FrcModule {
|
||||
}
|
||||
|
||||
fn init(&self, ctx: &mut ModuleContext) {
|
||||
ctx.mapped_remote_resource::<models::Frc>(
|
||||
ctx.cached_mapped_remote_resource::<models::Frc>(
|
||||
"frc",
|
||||
"/api/frc/frc/",
|
||||
serializers::frc_serializer(),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod frcapp;
|
||||
pub mod internalmemoapp;
|
||||
pub mod personemanagment;
|
||||
pub mod projectapp;
|
||||
pub mod supplyapp;
|
||||
pub mod users;
|
||||
|
||||
use che_tauri::InstalledApps;
|
||||
@@ -12,5 +14,7 @@ pub fn installed_apps() -> InstalledApps {
|
||||
.add(projectapp::module())
|
||||
.add(personemanagment::module())
|
||||
.add(contractapp::module())
|
||||
.add(supplyapp::module())
|
||||
.add(internalmemoapp::module())
|
||||
}
|
||||
pub mod contractapp;
|
||||
|
||||
@@ -13,6 +13,9 @@ static EMPLOYEE_FILTERS: &[Filter] = &[
|
||||
static TASK_FILTERS: &[Filter] = &[
|
||||
Filter::exact("id"),
|
||||
Filter::contains("text"),
|
||||
Filter::remote_only("contract"),
|
||||
Filter::remote_only("bill"),
|
||||
Filter::remote_only("memo"),
|
||||
Filter::remote_only("doer"),
|
||||
Filter::remote_only("author"),
|
||||
Filter::exact("archive"),
|
||||
|
||||
@@ -16,7 +16,7 @@ impl AppModule for TaskModule {
|
||||
}
|
||||
|
||||
fn init(&self, ctx: &mut ModuleContext) {
|
||||
ctx.mapped_remote_resource::<models::Employee>(
|
||||
ctx.cached_mapped_remote_resource::<models::Employee>(
|
||||
"employee",
|
||||
"/api/persone/employee/",
|
||||
serializers::employee_serializer(),
|
||||
@@ -34,8 +34,9 @@ impl AppModule for TaskModule {
|
||||
serializers::task_transfer_serializer(),
|
||||
filters::task_transfer_filterset(),
|
||||
);
|
||||
ctx.resource::<models::Message>(
|
||||
ctx.mapped_remote_resource::<models::Message>(
|
||||
"message",
|
||||
"/api/persone/messages/",
|
||||
serializers::message_serializer(),
|
||||
filters::message_filterset(),
|
||||
);
|
||||
|
||||
@@ -101,6 +101,8 @@ pub struct Message {
|
||||
pub employee_id: i64,
|
||||
|
||||
pub text: String,
|
||||
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Model)]
|
||||
|
||||
@@ -11,92 +11,271 @@ static EMPLOYEE_FIELDS: &[Field] = &[
|
||||
|
||||
static TASK_FIELDS: &[Field] = &[
|
||||
Field::new("id").read_only(),
|
||||
Field::json("project").required(false).nullable(),
|
||||
Field::json("doer").required(false).nullable(),
|
||||
Field::new("doer_name"),
|
||||
Field::json("author").required(false).nullable(),
|
||||
Field::json("memo_full").required(false).nullable(),
|
||||
Field::json("bill_full").required(false).nullable(),
|
||||
Field::json("contract_full").required(false).nullable(),
|
||||
Field::json("project")
|
||||
.ts_type("string")
|
||||
.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(),
|
||||
.nullable()
|
||||
.read_only(),
|
||||
Field::json("outgoing_letter_full")
|
||||
.ts_type("unknown")
|
||||
.required(false)
|
||||
.nullable(),
|
||||
Field::json("entry_letter_full").required(false).nullable(),
|
||||
Field::json("protocolitem_full").required(false).nullable(),
|
||||
Field::json("decree_full").required(false).nullable(),
|
||||
Field::json("delivery_full").required(false).nullable(),
|
||||
Field::new("get_status"),
|
||||
Field::new("get_status_class"),
|
||||
Field::new("get_scan_url").required(false).nullable(),
|
||||
Field::new("get_last_day"),
|
||||
Field::new("frc_icon"),
|
||||
Field::json("uploadfile_set").required(false).nullable(),
|
||||
.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::new("plan_date").required(false).nullable(),
|
||||
Field::new("date"),
|
||||
Field::json("message_set").required(false).nullable(),
|
||||
Field::json("responsible").required(false).nullable(),
|
||||
Field::json("counterparty").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(),
|
||||
Field::new("duration").required(false).nullable(),
|
||||
Field::json("bid_full").required(false).nullable(),
|
||||
.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(),
|
||||
Field::json("transfer").required(false).nullable(),
|
||||
.nullable()
|
||||
.read_only(),
|
||||
Field::json("transfer")
|
||||
.ts_type("unknown")
|
||||
.required(false)
|
||||
.nullable()
|
||||
.read_only(),
|
||||
Field::new("text"),
|
||||
Field::new("status"),
|
||||
Field::new("result"),
|
||||
Field::new("complit_date").required(false).nullable(),
|
||||
Field::new("comment"),
|
||||
Field::new("note"),
|
||||
Field::new("approve"),
|
||||
Field::new("archive"),
|
||||
Field::new("approve_date").required(false).nullable(),
|
||||
Field::new("approve_required"),
|
||||
Field::new("progress_status"),
|
||||
Field::new("start_date").required(false).nullable(),
|
||||
Field::new("end_date").required(false).nullable(),
|
||||
Field::new("order_number").required(false).nullable(),
|
||||
Field::new("priority").required(false).nullable(),
|
||||
Field::new("typ"),
|
||||
Field::json("stage").required(false).nullable(),
|
||||
Field::json("contract").required(false).nullable(),
|
||||
Field::json("questionnair").required(false).nullable(),
|
||||
Field::json("contract_application")
|
||||
Field::new("status").read_only(),
|
||||
Field::new("result").read_only(),
|
||||
Field::new("complit_date")
|
||||
.required(false)
|
||||
.nullable(),
|
||||
Field::json("entry_letter").required(false).nullable(),
|
||||
Field::json("outgoing_letter").required(false).nullable(),
|
||||
Field::json("protocol").required(false).nullable(),
|
||||
Field::json("bill").required(false).nullable(),
|
||||
Field::json("decree").required(false).nullable(),
|
||||
Field::json("court_case").required(false).nullable(),
|
||||
Field::json("bill_register").required(false).nullable(),
|
||||
Field::json("price_agreement").required(false).nullable(),
|
||||
Field::json("bid").required(false).nullable(),
|
||||
Field::json("delivery").required(false).nullable(),
|
||||
Field::json("scheduled_task").required(false).nullable(),
|
||||
Field::json("report").required(false).nullable(),
|
||||
Field::json("memo").required(false).nullable(),
|
||||
Field::json("protocolitem").required(false).nullable(),
|
||||
Field::json("related_note").required(false).nullable(),
|
||||
Field::json("task").required(false).nullable(),
|
||||
.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] = &[
|
||||
Field::new("id").read_only(),
|
||||
Field::new("task_id"),
|
||||
Field::new("employee_id"),
|
||||
Field::new("task").source("task_id"),
|
||||
Field::new("recipient").source("employee_id"),
|
||||
Field::new("text"),
|
||||
Field::new("status"),
|
||||
];
|
||||
|
||||
static TASK_TRANSFER_FIELDS: &[Field] = &[
|
||||
|
||||
@@ -16,7 +16,7 @@ impl AppModule for ProjectModule {
|
||||
}
|
||||
|
||||
fn init(&self, ctx: &mut ModuleContext) {
|
||||
ctx.mapped_remote_resource::<models::Project>(
|
||||
ctx.cached_mapped_remote_resource::<models::Project>(
|
||||
"project",
|
||||
"/api/project/",
|
||||
serializers::project_serializer(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+150
-17
@@ -1,7 +1,10 @@
|
||||
pub mod apps;
|
||||
pub mod sync;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use che_orm::SqliteBackend;
|
||||
use che_tauri::{
|
||||
@@ -9,9 +12,22 @@ use che_tauri::{
|
||||
TauriApi,
|
||||
};
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
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";
|
||||
|
||||
@@ -88,6 +104,14 @@ fn validate_settings(settings: &AppSettings) -> Result<(), ApiError> {
|
||||
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")
|
||||
}
|
||||
@@ -218,7 +242,7 @@ async fn current_employee(api: tauri::State<'_, TauriApi>) -> Result<CurrentEmpl
|
||||
.auth_token()
|
||||
.ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
let response = remote_http_client()
|
||||
.get(format!(
|
||||
"{}/api/persone/employee/who_im/",
|
||||
remote.base_url.trim_end_matches('/')
|
||||
@@ -256,23 +280,76 @@ async fn sync_contracts(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn load_application_file(local_path: String, scan_url: String) -> Result<Vec<u8>, ApiError> {
|
||||
if !local_path.is_empty() {
|
||||
match tokio::fs::metadata(&local_path).await {
|
||||
Ok(metadata) if metadata.len() > 0 => {
|
||||
return tokio::fs::read(&local_path)
|
||||
.await
|
||||
.map_err(|error| ApiError::new("file_error", error.to_string()));
|
||||
}
|
||||
Ok(_) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
async fn load_remote_file(
|
||||
app: tauri::AppHandle,
|
||||
api: tauri::State<'_, TauriApi>,
|
||||
url: String,
|
||||
) -> Result<Vec<u8>, ApiError> {
|
||||
let path = cache_remote_file(&app, &api, &url).await?;
|
||||
tokio::fs::read(path)
|
||||
.await
|
||||
.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", "Файл недоступен"));
|
||||
}
|
||||
|
||||
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() {
|
||||
return Err(ApiError::new(
|
||||
"remote_error",
|
||||
@@ -285,7 +362,61 @@ async fn load_application_file(local_path: String, scan_url: String) -> Result<V
|
||||
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)]
|
||||
@@ -323,7 +454,9 @@ pub fn run() {
|
||||
reset_app_settings,
|
||||
current_employee,
|
||||
sync_contracts,
|
||||
load_application_file
|
||||
load_remote_file,
|
||||
ensure_remote_file,
|
||||
open_remote_file
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+6
-74
@@ -4,6 +4,8 @@ use che_orm::__private::sqlx;
|
||||
use che_tauri::{ApiError, TauriApi};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::remote_http_client;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct SyncContractsResult {
|
||||
pub synced: usize,
|
||||
@@ -158,7 +160,7 @@ struct RemoteEmployee {
|
||||
|
||||
pub async fn sync_contracts(
|
||||
api: &TauriApi,
|
||||
app_data_dir: PathBuf,
|
||||
_app_data_dir: PathBuf,
|
||||
) -> Result<SyncContractsResult, ApiError> {
|
||||
let state = api.state();
|
||||
let token = state
|
||||
@@ -167,7 +169,7 @@ pub async fn sync_contracts(
|
||||
let remote = state
|
||||
.remote_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?;
|
||||
|
||||
let mut next_url = Some(format!(
|
||||
@@ -205,12 +207,9 @@ pub async fn sync_contracts(
|
||||
fetch_contract_detail(&client, &token, &remote.base_url, contract.id).await?;
|
||||
|
||||
for application in &detail.contractapplicationfile_set {
|
||||
let local_path =
|
||||
download_application_file(&client, &token, &app_data_dir, application).await?;
|
||||
upsert_contract_application_file(api, contract.id, application, &local_path)
|
||||
.await?;
|
||||
upsert_contract_application_file(api, contract.id, application, "").await?;
|
||||
applications += 1;
|
||||
if !local_path.is_empty() {
|
||||
if !application.scan.is_empty() {
|
||||
files_downloaded += 1;
|
||||
}
|
||||
}
|
||||
@@ -324,69 +323,6 @@ async fn fetch_contract_detail(
|
||||
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(
|
||||
api: &TauriApi,
|
||||
contract: &RemoteContract,
|
||||
@@ -604,7 +540,3 @@ async fn upsert_contract_application_file(
|
||||
fn database_error(error: sqlx::Error) -> ApiError {
|
||||
ApiError::new("database_error", error.to_string())
|
||||
}
|
||||
|
||||
fn file_error(error: impl std::fmt::Display) -> ApiError {
|
||||
ApiError::new("file_error", error.to_string())
|
||||
}
|
||||
|
||||
+15
-3
@@ -9,8 +9,20 @@ const { logout } = useAuth();
|
||||
|
||||
const activeTab = computed({
|
||||
get() {
|
||||
if (route.path.startsWith("/documents")) {
|
||||
return "/documents";
|
||||
}
|
||||
|
||||
if (route.path.startsWith("/bills")) {
|
||||
return "/documents";
|
||||
}
|
||||
|
||||
if (route.path.startsWith("/contracts")) {
|
||||
return "/contracts";
|
||||
return "/documents";
|
||||
}
|
||||
|
||||
if (route.path.startsWith("/memos")) {
|
||||
return "/documents";
|
||||
}
|
||||
|
||||
if (route.path.startsWith("/settings")) {
|
||||
@@ -61,8 +73,8 @@ async function logoutAndRedirect() {
|
||||
<van-tabbar-item to="/tasks" name="/tasks" icon="todo-list-o"
|
||||
>Задачи</van-tabbar-item
|
||||
>
|
||||
<van-tabbar-item to="/contracts" name="/contracts" icon="orders-o"
|
||||
>Контракты</van-tabbar-item
|
||||
<van-tabbar-item to="/documents" name="/documents" icon="description-o"
|
||||
>Документы</van-tabbar-item
|
||||
>
|
||||
<van-tabbar-item to="/users" name="/users" icon="friends-o"
|
||||
>Пользователи</van-tabbar-item
|
||||
|
||||
+19
-13
@@ -1,25 +1,31 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import { isAuthenticated, useAuth } from "../../shared/auth/useAuth";
|
||||
import { loginRoute } from "../../apps/auth/routes";
|
||||
import { documentsRoute } from "../../apps/documents/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 { usersRoutes } from "../../apps/users/routes";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
redirect: "/tasks",
|
||||
},
|
||||
...tasksRoutes,
|
||||
...usersRoutes,
|
||||
...contractsRoutes,
|
||||
settingsRoute,
|
||||
loginRoute,
|
||||
],
|
||||
});
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
redirect: "/documents",
|
||||
},
|
||||
...tasksRoutes,
|
||||
...usersRoutes,
|
||||
...contractsRoutes,
|
||||
...memosRoutes,
|
||||
...supplyRoutes,
|
||||
documentsRoute,
|
||||
settingsRoute,
|
||||
loginRoute,
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const { restoreToken } = useAuth();
|
||||
@@ -30,6 +36,6 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
|
||||
if (to.path === "/login" && isAuthenticated()) {
|
||||
return "/tasks";
|
||||
return "/documents";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:label="category.name_group || `ID: ${category.id}`"
|
||||
clickable
|
||||
center
|
||||
@click="selectCategory(category.id)"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon v-if="selectedCategoryId === category.id" name="success" color="#1989fa" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<template v-else>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:label="category.name_group || `ID: ${category.id}`"
|
||||
clickable
|
||||
center
|
||||
@click="selectCategory(category.id)"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon v-if="selectedCategoryId === category.id" name="success" color="#1989fa" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
|
||||
<van-empty v-if="categories.length === 0" description="Категории не найдены" />
|
||||
</template>
|
||||
<van-empty v-if="categories.length === 0" description="Категории не найдены" />
|
||||
</template>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entity-popup {
|
||||
min-height: 55vh;
|
||||
padding: 18px 0 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70vh;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
padding: 18px 0 16px;
|
||||
}
|
||||
|
||||
.entity-popup-header {
|
||||
@@ -106,6 +112,12 @@ watch(search, (value) => {
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
.entity-popup-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entity-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -58,40 +58,46 @@ watch(search, (value) => {
|
||||
<van-button size="small" type="primary" plain @click="selectCounterparty(0)">Все</van-button>
|
||||
</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>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="counterparty in counterparties"
|
||||
:key="counterparty.id"
|
||||
:title="counterparty.name"
|
||||
:label="`ID: ${counterparty.id}`"
|
||||
clickable
|
||||
center
|
||||
@click="selectCounterparty(counterparty.id)"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon
|
||||
v-if="selectedCounterpartyId === counterparty.id"
|
||||
name="success"
|
||||
color="#1989fa"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<template v-else>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="counterparty in counterparties"
|
||||
:key="counterparty.id"
|
||||
:title="counterparty.name"
|
||||
:label="`ID: ${counterparty.id}`"
|
||||
clickable
|
||||
center
|
||||
@click="selectCounterparty(counterparty.id)"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon
|
||||
v-if="selectedCounterpartyId === counterparty.id"
|
||||
name="success"
|
||||
color="#1989fa"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
|
||||
<van-empty v-if="counterparties.length === 0" description="Контрагенты не найдены" />
|
||||
</template>
|
||||
<van-empty v-if="counterparties.length === 0" description="Контрагенты не найдены" />
|
||||
</template>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.counterparty-popup {
|
||||
min-height: 55vh;
|
||||
padding: 18px 0 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70vh;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
padding: 18px 0 16px;
|
||||
}
|
||||
|
||||
.counterparty-popup-header {
|
||||
@@ -113,6 +119,12 @@ watch(search, (value) => {
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
.counterparty-popup-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.counterparty-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -53,36 +53,42 @@ watch(search, (value) => {
|
||||
<van-button size="small" type="primary" plain @click="selectFrc(0)">Все</van-button>
|
||||
</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>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="frc in frcs"
|
||||
:key="frc.id"
|
||||
:title="frc.name"
|
||||
:label="`Баланс: ${frc.balance}`"
|
||||
clickable
|
||||
center
|
||||
@click="selectFrc(frc.id)"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon v-if="selectedFrcId === frc.id" name="success" color="#1989fa" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<template v-else>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="frc in frcs"
|
||||
:key="frc.id"
|
||||
:title="frc.name"
|
||||
:label="`Баланс: ${frc.balance}`"
|
||||
clickable
|
||||
center
|
||||
@click="selectFrc(frc.id)"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon v-if="selectedFrcId === frc.id" name="success" color="#1989fa" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
|
||||
<van-empty v-if="frcs.length === 0" description="ФРЦ не найдены" />
|
||||
</template>
|
||||
<van-empty v-if="frcs.length === 0" description="ФРЦ не найдены" />
|
||||
</template>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entity-popup {
|
||||
min-height: 55vh;
|
||||
padding: 18px 0 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70vh;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
padding: 18px 0 16px;
|
||||
}
|
||||
|
||||
.entity-popup-header {
|
||||
@@ -104,6 +110,12 @@ watch(search, (value) => {
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
.entity-popup-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entity-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -7,10 +7,9 @@ import {
|
||||
type PDFDocumentProxy,
|
||||
type RenderTask,
|
||||
} from "pdfjs-dist";
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
localPath: string;
|
||||
scanUrl: string;
|
||||
title: string;
|
||||
}>();
|
||||
@@ -23,7 +22,7 @@ GlobalWorkerOptions.workerSrc = new URL(
|
||||
).toString();
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const pdfDocument = ref<PDFDocumentProxy | null>(null);
|
||||
const pdfDocument = shallowRef<PDFDocumentProxy | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const pageNumber = ref(1);
|
||||
@@ -41,19 +40,17 @@ async function loadDocument() {
|
||||
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
let task: PDFDocumentLoadingTask | null = null;
|
||||
|
||||
try {
|
||||
const bytes = await invoke<number[]>("load_application_file", {
|
||||
localPath: props.localPath,
|
||||
scanUrl: props.scanUrl,
|
||||
const bytes = await invoke<number[]>("load_remote_file", {
|
||||
url: props.scanUrl,
|
||||
});
|
||||
|
||||
if (!bytes.length) {
|
||||
throw new Error("Файл пустой");
|
||||
}
|
||||
|
||||
task = getDocument({ data: new Uint8Array(bytes) });
|
||||
const task = getDocument({ data: new Uint8Array(bytes) });
|
||||
loadingTask = task;
|
||||
|
||||
const document = await task.promise;
|
||||
@@ -66,12 +63,13 @@ async function loadDocument() {
|
||||
pageCount.value = document.numPages;
|
||||
pageNumber.value = 1;
|
||||
scale.value = 1.1;
|
||||
loading.value = false;
|
||||
await nextTick();
|
||||
await renderPage();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось открыть PDF";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (task && loadingTask === task) {
|
||||
if (loadingTask) {
|
||||
loadingTask = null;
|
||||
}
|
||||
}
|
||||
@@ -156,7 +154,7 @@ async function zoomOut() {
|
||||
await renderPage();
|
||||
}
|
||||
|
||||
watch([show, () => props.localPath, () => props.scanUrl], async ([visible]) => {
|
||||
watch([show, () => props.scanUrl], async ([visible]) => {
|
||||
if (visible) {
|
||||
await nextTick();
|
||||
await loadDocument();
|
||||
|
||||
@@ -57,36 +57,42 @@ watch(search, (value) => {
|
||||
<van-button size="small" type="primary" plain @click="selectProject(0)">Все</van-button>
|
||||
</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>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:title="project.short_name || project.name"
|
||||
:label="project.full_name || `ID: ${project.id}`"
|
||||
clickable
|
||||
center
|
||||
@click="selectProject(project.id)"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon v-if="selectedProjectId === project.id" name="success" color="#1989fa" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<template v-else>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:title="project.short_name || project.name"
|
||||
:label="project.full_name || `ID: ${project.id}`"
|
||||
clickable
|
||||
center
|
||||
@click="selectProject(project.id)"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon v-if="selectedProjectId === project.id" name="success" color="#1989fa" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
|
||||
<van-empty v-if="projects.length === 0" description="Проекты не найдены" />
|
||||
</template>
|
||||
<van-empty v-if="projects.length === 0" description="Проекты не найдены" />
|
||||
</template>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entity-popup {
|
||||
min-height: 55vh;
|
||||
padding: 18px 0 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70vh;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
padding: 18px 0 16px;
|
||||
}
|
||||
|
||||
.entity-popup-header {
|
||||
@@ -108,6 +114,12 @@ watch(search, (value) => {
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
.entity-popup-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entity-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<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 { useRoute, useRouter } from "vue-router";
|
||||
import { showToast } from "vant";
|
||||
import { contractApi, contractApplicationFileApi } from "../../../generated/api";
|
||||
import {
|
||||
contractApi,
|
||||
contractApplicationFileApi,
|
||||
} from "../../../generated/api";
|
||||
import type {
|
||||
Contract,
|
||||
ContractApplicationFile,
|
||||
@@ -11,6 +15,7 @@ import type {
|
||||
} from "../../../generated/models";
|
||||
import { useModelApi } from "../../../shared/composables/useModelApi";
|
||||
import PdfPreview from "../components/PdfPreview.vue";
|
||||
import DocumentApprovalTasks from "../../../shared/components/DocumentApprovalTasks.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -106,12 +111,6 @@ const approvalFields = computed(() => {
|
||||
|
||||
return [
|
||||
{ title: "Статус", value: item.status_name },
|
||||
{ title: "Код статуса", value: item.status },
|
||||
{
|
||||
title: "Сотрудник",
|
||||
value: item.employee?.short_name ?? item.employee?.name,
|
||||
},
|
||||
{ title: "Сотрудник ID", value: item.employee_id },
|
||||
{ title: "Комментарий", value: item.comment },
|
||||
];
|
||||
});
|
||||
@@ -173,14 +172,16 @@ function formatMoney(value: unknown) {
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
async function openApplicationFile(localPath: string) {
|
||||
if (!localPath) {
|
||||
async function openApplicationFile(scanUrl: string) {
|
||||
if (!scanUrl) {
|
||||
showToast("Файл не скачан");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await openPath(localPath);
|
||||
await invoke("open_remote_file", {
|
||||
url: scanUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
showToast(errorMessage(err, "Не удалось открыть файл"));
|
||||
}
|
||||
@@ -209,6 +210,10 @@ function openPdfPreview(file: ContractApplicationFile) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -404,6 +409,13 @@ onMounted(() => {
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
|
||||
<DocumentApprovalTasks
|
||||
v-if="contract"
|
||||
:document-id="contract.id"
|
||||
filter-name="contract"
|
||||
title="Согласование договора"
|
||||
/>
|
||||
</div>
|
||||
</van-tab>
|
||||
|
||||
@@ -435,7 +447,7 @@ onMounted(() => {
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!file.local_path"
|
||||
:disabled="!file.scan_url"
|
||||
@click="openPdfPreview(file)"
|
||||
>
|
||||
Просмотр
|
||||
@@ -443,8 +455,8 @@ onMounted(() => {
|
||||
<van-button
|
||||
size="small"
|
||||
plain
|
||||
:disabled="!file.local_path"
|
||||
@click="openApplicationFile(file.local_path)"
|
||||
:disabled="!file.scan_url"
|
||||
@click="openApplicationFile(file.scan_url)"
|
||||
>
|
||||
Открыть
|
||||
</van-button>
|
||||
@@ -507,7 +519,6 @@ onMounted(() => {
|
||||
|
||||
<PdfPreview
|
||||
v-model:show="pdfPreviewVisible"
|
||||
:local-path="pdfPreviewFile?.local_path ?? ''"
|
||||
:scan-url="pdfPreviewFile?.scan_url ?? ''"
|
||||
:title="pdfPreviewTitle"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { showToast } from "vant";
|
||||
@@ -12,6 +11,7 @@ import FrcSelect from "../components/FrcSelect.vue";
|
||||
import ProjectSelect from "../components/ProjectSelect.vue";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
type ContractFilterParams = ContractListParams & { page?: number };
|
||||
const CONTRACT_STATUS_OPTIONS = [
|
||||
{ value: "AN", text: "Аннулирован" },
|
||||
{ value: "IP", text: "В работе" },
|
||||
@@ -32,7 +32,7 @@ const {
|
||||
error,
|
||||
load: loadContracts,
|
||||
} = useModelApi(contractApi, {
|
||||
defaultListParams: { ordering: "-id", limit: PAGE_SIZE, offset: 0 } as ContractListParams,
|
||||
defaultListParams: { ordering: "-id", page: 1 } as ContractFilterParams,
|
||||
loadErrorMessage: "Не удалось загрузить контракты",
|
||||
cleanListParams(params) {
|
||||
params.name__contains = params.name__contains?.trim() || undefined;
|
||||
@@ -42,11 +42,12 @@ const {
|
||||
params.project_id = params.project_id || undefined;
|
||||
params.frc_id = params.frc_id || undefined;
|
||||
params.status = params.status || undefined;
|
||||
params.limit = PAGE_SIZE;
|
||||
params.offset = params.offset ?? 0;
|
||||
params.page = params.page || 1;
|
||||
},
|
||||
});
|
||||
|
||||
const contractFilters = filters as ContractFilterParams;
|
||||
|
||||
const selectedCounterpartyId = computed({
|
||||
get() {
|
||||
return filters.counterparty_id ?? 0;
|
||||
@@ -106,20 +107,13 @@ const hasActiveFilters = computed(() =>
|
||||
|
||||
const currentPage = computed({
|
||||
get() {
|
||||
return Math.floor((filters.offset ?? 0) / PAGE_SIZE) + 1;
|
||||
return contractFilters.page ?? 1;
|
||||
},
|
||||
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 showFilters = ref(false);
|
||||
|
||||
@@ -142,11 +136,11 @@ function resetFilters() {
|
||||
filters.project_id = undefined;
|
||||
filters.frc_id = undefined;
|
||||
filters.status = undefined;
|
||||
filters.offset = 0;
|
||||
contractFilters.page = 1;
|
||||
}
|
||||
|
||||
async function applyFilters() {
|
||||
filters.offset = 0;
|
||||
contractFilters.page = 1;
|
||||
showFilters.value = false;
|
||||
await loadContracts();
|
||||
}
|
||||
@@ -155,15 +149,8 @@ async function syncContracts() {
|
||||
syncing.value = true;
|
||||
|
||||
try {
|
||||
const result = await invoke<SyncContractsResult>("sync_contracts");
|
||||
showToast(
|
||||
`Синхронизировано: ${result.synced}; приложений: ${result.applications}; файлов: ${result.files_downloaded}`,
|
||||
);
|
||||
if (filters.offset) {
|
||||
filters.offset = 0;
|
||||
} else {
|
||||
await loadContracts();
|
||||
}
|
||||
await loadContracts();
|
||||
showToast("Данные обновлены с сервера");
|
||||
} catch (err) {
|
||||
showToast(errorMessage(err, "Не удалось синхронизировать договоры"));
|
||||
} finally {
|
||||
@@ -194,7 +181,9 @@ watch(
|
||||
filters.status,
|
||||
],
|
||||
() => {
|
||||
filters.offset = 0;
|
||||
if ((contractFilters.page ?? 1) !== 1) {
|
||||
contractFilters.page = 1;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@@ -228,7 +217,7 @@ watch(
|
||||
Обновить
|
||||
</van-button>
|
||||
<van-button size="small" type="primary" :loading="syncing" @click="syncContracts">
|
||||
Синхронизация
|
||||
Обновить с сервера
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import DocumentsView from "./views/DocumentsView.vue";
|
||||
|
||||
export const documentsRoute = {
|
||||
path: "/documents",
|
||||
name: "documents",
|
||||
component: DocumentsView,
|
||||
meta: { title: "Документы", requiresAuth: true },
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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(/ /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 { employeeApi } from "../../../generated/api";
|
||||
import type { EmployeeListParams } from "../../../generated/models";
|
||||
import RemoteImage from "../../../shared/components/RemoteImage.vue";
|
||||
import { useModelApi } from "../../../shared/composables/useModelApi";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -58,39 +59,45 @@ watch(search, (value) => {
|
||||
<van-button size="small" type="primary" plain @click="selectEmployee(0)">Не указан</van-button>
|
||||
</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>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
:title="employee.name"
|
||||
:label="`ID: ${employee.id}`"
|
||||
clickable
|
||||
center
|
||||
@click="selectEmployee(employee.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-image class="employee-avatar" round width="36" height="36" :src="employee.avatar_small ?? ''" />
|
||||
</template>
|
||||
<template #right-icon>
|
||||
<van-icon v-if="selectedEmployeeId === employee.id" name="success" color="#1989fa" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<template v-else>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
:title="employee.name"
|
||||
:label="`ID: ${employee.id}`"
|
||||
clickable
|
||||
center
|
||||
@click="selectEmployee(employee.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<RemoteImage class="employee-avatar" round width="36" height="36" :src="employee.avatar_small" />
|
||||
</template>
|
||||
<template #right-icon>
|
||||
<van-icon v-if="selectedEmployeeId === employee.id" name="success" color="#1989fa" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
|
||||
<van-empty v-if="employees.length === 0" description="Сотрудники не найдены" />
|
||||
</template>
|
||||
<van-empty v-if="employees.length === 0" description="Сотрудники не найдены" />
|
||||
</template>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.employee-popup {
|
||||
min-height: 55vh;
|
||||
padding: 18px 0 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70vh;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
padding: 18px 0 16px;
|
||||
}
|
||||
|
||||
.employee-popup-header {
|
||||
@@ -116,6 +123,12 @@ watch(search, (value) => {
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
.employee-popup-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.employee-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from "../../../generated/models";
|
||||
import RemoteImage from "../../../shared/components/RemoteImage.vue";
|
||||
|
||||
type TaskItem = Task;
|
||||
|
||||
@@ -25,7 +26,7 @@ const emit = defineEmits<{
|
||||
@click="emit('open', `/tasks/${props.item.id}`)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-image
|
||||
<RemoteImage
|
||||
class="task-avatar"
|
||||
round
|
||||
width="36"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { showToast } from "vant";
|
||||
import { employeeApi, taskApi } from "../../../generated/api";
|
||||
import type { Employee, TaskCreate, TaskPersonInput } from "../../../generated/models";
|
||||
import type { Employee, TaskCreate } from "../../../generated/models";
|
||||
import { useModelApi } from "../../../shared/composables/useModelApi";
|
||||
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();
|
||||
}
|
||||
|
||||
async function buildPerson(id: number): Promise<TaskPersonInput | null> {
|
||||
async function buildPerson(id: number): Promise<Employee | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const employee = await loadEmployee(id);
|
||||
return employeeToTaskPerson(employee);
|
||||
}
|
||||
|
||||
function employeeToTaskPerson(employee: Employee): TaskPersonInput {
|
||||
return {
|
||||
id: employee.id,
|
||||
name: employee.name,
|
||||
short_name: makeShortName(employee.name),
|
||||
avatar_small: employee.avatar_small,
|
||||
};
|
||||
return employee
|
||||
? {
|
||||
id: employee.id,
|
||||
name: employee.name,
|
||||
short_name: makeShortName(employee.name),
|
||||
avatar_small: employee.avatar_small,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
async function createTask() {
|
||||
@@ -75,7 +73,7 @@ async function createTask() {
|
||||
const payload: TaskCreate = {
|
||||
doer,
|
||||
deadline: deadline.value,
|
||||
text: taskText.value.trim() || undefined,
|
||||
text: taskText.value.trim(),
|
||||
responsible,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
<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 { taskApi } from "../../../generated/api";
|
||||
import type { Task, TaskListParams } from "../../../generated/models";
|
||||
import DocumentApprovalTaskCard from "../../../shared/components/DocumentApprovalTaskCard.vue";
|
||||
import { useModelApi } from "../../../shared/composables/useModelApi";
|
||||
|
||||
interface CurrentEmployee {
|
||||
id: number;
|
||||
name: string;
|
||||
short_name: string;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const taskId = computed(() => Number(route.params.id));
|
||||
const currentEmployeeId = ref<number | null>(null);
|
||||
const activeTab = ref(0);
|
||||
|
||||
const {
|
||||
item: task,
|
||||
loadingItem,
|
||||
error,
|
||||
retrieve: loadTask,
|
||||
} = useModelApi(taskApi, {
|
||||
} = useModelApi<Task, Task, Task, TaskListParams>(taskApi, {
|
||||
retrieveErrorMessage: "Не удалось загрузить задачу",
|
||||
autoLoad: false,
|
||||
autoLoadOnFilterChange: false,
|
||||
});
|
||||
function personName(person: { short_name?: string | null; name?: string | null } | null) {
|
||||
if (!person) {
|
||||
return "не указан";
|
||||
}
|
||||
|
||||
return person.short_name ?? person.name ?? "не указан";
|
||||
async function loadCurrentEmployee() {
|
||||
try {
|
||||
const employee = await invoke<CurrentEmployee>("current_employee");
|
||||
currentEmployeeId.value = employee.id;
|
||||
} catch {
|
||||
currentEmployeeId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function taskTitle() {
|
||||
@@ -39,6 +53,7 @@ function formatBoolean(value: boolean) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadCurrentEmployee();
|
||||
if (Number.isFinite(taskId.value)) {
|
||||
loadTask(taskId.value);
|
||||
}
|
||||
@@ -63,49 +78,49 @@ onMounted(() => {
|
||||
<van-empty v-else-if="!task" description="Задача не найдена" />
|
||||
|
||||
<template v-else>
|
||||
<van-cell-group inset>
|
||||
<van-cell title="ID" :value="task.id" />
|
||||
<van-cell title="Заголовок" :value="taskTitle()" />
|
||||
<van-cell title="Исполнитель" :value="personName(task.doer)" />
|
||||
<van-cell title="Автор" :value="personName(task.author)" />
|
||||
<van-cell title="Ответственный" :value="personName(task.responsible)" />
|
||||
<van-cell title="Статус" :value="task.get_status" />
|
||||
<van-cell title="Срок" :value="formatDate(task.deadline)" />
|
||||
<van-cell title="Плановая дата" :value="formatDate(task.plan_date)" />
|
||||
<van-cell title="Запрос новой даты" :value="formatDate(task.request_new_deadline)" />
|
||||
<van-cell title="Приоритет" :value="task.priority ?? 'не указан'" />
|
||||
<van-cell title="Тип" :value="task.typ || 'не указан'" />
|
||||
<van-cell title="Результат" :value="task.result || 'не указан'" />
|
||||
<van-cell title="Комментарий" :value="task.comment || 'не указан'" />
|
||||
<van-cell title="Примечание" :value="task.note || 'не указано'" />
|
||||
<van-cell title="Согласование" :value="formatBoolean(task.approve)" />
|
||||
<van-cell title="Архив" :value="formatBoolean(task.archive)" />
|
||||
</van-cell-group>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="card messages-card">
|
||||
<div class="list-title">
|
||||
<h2>Диалог</h2>
|
||||
</div>
|
||||
|
||||
<van-empty v-if="!task?.message_set?.length" description="Сообщений пока нет" />
|
||||
|
||||
<div v-else class="message-list">
|
||||
<article v-for="message in task.message_set" :key="message.id" class="message-item">
|
||||
<van-image class="message-avatar" round width="40" height="40" :src="message.author.avatar_small ?? ''" />
|
||||
<div class="message-bubble">
|
||||
<div class="message-author">{{ personName(message.author) }}</div>
|
||||
<div class="message-text">{{ message.text }}</div>
|
||||
<div class="message-meta">
|
||||
{{ message.date }} · {{ message.status }} · {{ personName(message.recipient) }}
|
||||
<van-tabs v-model:active="activeTab" animated>
|
||||
<van-tab title="Диалог">
|
||||
<div class="tab-body">
|
||||
<DocumentApprovalTaskCard
|
||||
:task="task as any"
|
||||
:current-employee-id="currentEmployeeId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</van-tab>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="form-actions stacked-actions">
|
||||
<van-button block round type="primary" plain @click="router.back()">Назад</van-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-body {
|
||||
padding-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { taskApi, taskTransferApi } from "../../../generated/api";
|
||||
import type {
|
||||
Task,
|
||||
TaskCreate,
|
||||
TaskTransferCreate,
|
||||
TaskListParams,
|
||||
TaskTransfer,
|
||||
TaskTransferUpdate,
|
||||
TaskTransferListParams,
|
||||
TaskUpdate,
|
||||
} from "../../../generated/models";
|
||||
@@ -33,6 +35,7 @@ type DocTabKey =
|
||||
| "outgoing_letter";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const mainTab = ref<MainTabKey>("incoming");
|
||||
const docTab = ref<DocTabKey>("all");
|
||||
const employee = ref<CurrentEmployee | null>(null);
|
||||
@@ -76,8 +79,8 @@ const {
|
||||
load: loadTaskTransfers,
|
||||
} = useModelApi<
|
||||
TaskTransfer,
|
||||
Partial<TaskTransfer>,
|
||||
Partial<TaskTransfer>,
|
||||
TaskTransferCreate,
|
||||
TaskTransferUpdate,
|
||||
TaskTransferListParams
|
||||
>(taskTransferApi, {
|
||||
defaultListParams: { ordering: "-id" },
|
||||
@@ -103,6 +106,24 @@ const docTabs = [
|
||||
{ key: "outgoing_letter", label: "Исходящие письма" },
|
||||
] 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 activeLoading = computed(() => taskLoading.value);
|
||||
const showLoading = computed(() => activeLoading.value || refreshing.value);
|
||||
@@ -134,7 +155,7 @@ function buildTaskParams(
|
||||
switch (tab) {
|
||||
case "incoming":
|
||||
return {
|
||||
doer: employeeId,
|
||||
doer: String(employeeId),
|
||||
archive: false,
|
||||
typ: doc === "all" ? undefined : doc,
|
||||
q: "entry",
|
||||
@@ -156,7 +177,7 @@ function buildTaskParams(
|
||||
};
|
||||
case "transfer":
|
||||
return {
|
||||
employee_to: employeeId,
|
||||
employee_to: String(employeeId),
|
||||
status: "A",
|
||||
};
|
||||
default:
|
||||
@@ -222,7 +243,7 @@ async function reloadList() {
|
||||
}
|
||||
|
||||
if (mainTab.value === "transfer") {
|
||||
await loadTaskTransfers({ employee_to: employee.value.id, status: "A" });
|
||||
await loadTaskTransfers({ employee_to: String(employee.value.id), status: "A" });
|
||||
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 () => {
|
||||
await loadCurrentEmployee();
|
||||
await reloadForTabChange({ reloadCounts: true });
|
||||
|
||||
+18
-1
@@ -1,5 +1,9 @@
|
||||
import { createModelApi } from "./api_client";
|
||||
import type {
|
||||
Bill,
|
||||
BillCreate,
|
||||
BillUpdate,
|
||||
BillListParams,
|
||||
Contract,
|
||||
ContractCreate,
|
||||
ContractUpdate,
|
||||
@@ -24,6 +28,14 @@ import type {
|
||||
FrcCreate,
|
||||
FrcUpdate,
|
||||
FrcListParams,
|
||||
Memo,
|
||||
MemoCreate,
|
||||
MemoUpdate,
|
||||
MemoListParams,
|
||||
MemoCategory,
|
||||
MemoCategoryCreate,
|
||||
MemoCategoryUpdate,
|
||||
MemoCategoryListParams,
|
||||
Message,
|
||||
MessageCreate,
|
||||
MessageUpdate,
|
||||
@@ -37,6 +49,8 @@ import type {
|
||||
TaskUpdate,
|
||||
TaskListParams,
|
||||
TaskTransfer,
|
||||
TaskTransferCreate,
|
||||
TaskTransferUpdate,
|
||||
TaskTransferListParams,
|
||||
User,
|
||||
UserCreate,
|
||||
@@ -44,14 +58,17 @@ import type {
|
||||
UserListParams,
|
||||
} from "./models";
|
||||
|
||||
export const billApi = createModelApi<Bill, BillCreate, BillUpdate, BillListParams>("bill");
|
||||
export const contractApi = createModelApi<Contract, ContractCreate, ContractUpdate, ContractListParams>("contract");
|
||||
export const contractApplicationFileApi = createModelApi<ContractApplicationFile, ContractApplicationFileCreate, ContractApplicationFileUpdate, ContractApplicationFileListParams>("contract_application_file");
|
||||
export const contractCategoryApi = createModelApi<ContractCategory, ContractCategoryCreate, ContractCategoryUpdate, ContractCategoryListParams>("contract_category");
|
||||
export const counterpartyApi = createModelApi<Counterparty, CounterpartyCreate, CounterpartyUpdate, CounterpartyListParams>("counterparty");
|
||||
export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee");
|
||||
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 projectApi = createModelApi<Project, ProjectCreate, ProjectUpdate, ProjectListParams>("project");
|
||||
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");
|
||||
|
||||
+336
-214
@@ -1,5 +1,124 @@
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -154,7 +273,7 @@ export interface ContractApplicationFileCreate {
|
||||
absolute_url: string;
|
||||
scan_name: string;
|
||||
scan_url: string;
|
||||
local_path: string;
|
||||
local_path?: string;
|
||||
comment: string;
|
||||
bill_total: number;
|
||||
bill_cost_total: number;
|
||||
@@ -294,23 +413,148 @@ export interface FrcListParams extends ListParams {
|
||||
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 {
|
||||
id: number;
|
||||
task_id: number;
|
||||
employee_id: number;
|
||||
task: number;
|
||||
recipient: number;
|
||||
text: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface MessageCreate {
|
||||
task_id: number;
|
||||
employee_id: number;
|
||||
task: number;
|
||||
recipient: number;
|
||||
text: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface MessageUpdate {
|
||||
task_id?: number;
|
||||
employee_id?: number;
|
||||
task?: number;
|
||||
recipient?: number;
|
||||
text?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface MessageListParams extends ListParams {
|
||||
@@ -319,107 +563,6 @@ export interface MessageListParams extends ListParams {
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -455,37 +598,37 @@ export interface ProjectListParams extends ListParams {
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
project: TaskProject | null;
|
||||
doer: TaskPerson;
|
||||
project: string | null;
|
||||
doer: Employee | null;
|
||||
doer_name: string;
|
||||
author: TaskPerson | null;
|
||||
memo_full: TaskRelatedObject | null;
|
||||
bill_full: TaskRelatedObject | null;
|
||||
contract_full: TaskRelatedObject | null;
|
||||
contract_application_full: TaskRelatedObject | null;
|
||||
outgoing_letter_full: TaskRelatedObject | null;
|
||||
entry_letter_full: TaskRelatedObject | null;
|
||||
protocolitem_full: TaskRelatedObject | null;
|
||||
decree_full: TaskRelatedObject | null;
|
||||
delivery_full: TaskRelatedObject | null;
|
||||
author: Employee | null;
|
||||
memo_full: unknown | null;
|
||||
bill_full: unknown | null;
|
||||
contract_full: unknown | null;
|
||||
contract_application_full: unknown | null;
|
||||
outgoing_letter_full: unknown | null;
|
||||
entry_letter_full: unknown | null;
|
||||
protocolitem_full: unknown | null;
|
||||
decree_full: unknown | null;
|
||||
delivery_full: unknown | null;
|
||||
get_status: string;
|
||||
get_status_class: string;
|
||||
get_scan_url: string | null;
|
||||
get_last_day: string;
|
||||
frc_icon: string;
|
||||
uploadfile_set: unknown[];
|
||||
uploadfile_set: unknown[] | null;
|
||||
deadline: string;
|
||||
request_new_deadline: string | null;
|
||||
plan_date: string | null;
|
||||
date: string;
|
||||
message_set: TaskMessage[];
|
||||
responsible: TaskPerson | null;
|
||||
counterparty: TaskRelatedObject | null;
|
||||
get_deadline_history: unknown[];
|
||||
message_set: unknown[] | null;
|
||||
responsible: Employee | null;
|
||||
counterparty: unknown | null;
|
||||
get_deadline_history: unknown[] | null;
|
||||
duration: number | null;
|
||||
bid_full: TaskRelatedObject | null;
|
||||
price_agreement_full: TaskRelatedObject | null;
|
||||
transfer: unknown;
|
||||
bid_full: unknown | null;
|
||||
price_agreement_full: unknown | null;
|
||||
transfer: unknown | null;
|
||||
text: string;
|
||||
status: string;
|
||||
result: string;
|
||||
@@ -502,108 +645,86 @@ export interface Task {
|
||||
order_number: number | null;
|
||||
priority: number | null;
|
||||
typ: string;
|
||||
stage: unknown;
|
||||
contract: unknown;
|
||||
questionnair: unknown;
|
||||
contract_application: unknown;
|
||||
entry_letter: unknown;
|
||||
outgoing_letter: unknown;
|
||||
protocol: unknown;
|
||||
bill: unknown;
|
||||
decree: unknown;
|
||||
court_case: unknown;
|
||||
bill_register: unknown;
|
||||
price_agreement: unknown;
|
||||
bid: unknown;
|
||||
delivery: unknown;
|
||||
scheduled_task: unknown;
|
||||
report: unknown;
|
||||
memo: unknown;
|
||||
protocolitem: unknown;
|
||||
related_note: unknown;
|
||||
task: unknown;
|
||||
stage: unknown | null;
|
||||
contract: unknown | null;
|
||||
questionnair: unknown | null;
|
||||
contract_application: unknown | null;
|
||||
entry_letter: unknown | null;
|
||||
outgoing_letter: unknown | null;
|
||||
protocol: unknown | null;
|
||||
bill: unknown | null;
|
||||
decree: unknown | null;
|
||||
court_case: unknown | null;
|
||||
bill_register: unknown | null;
|
||||
price_agreement: unknown | null;
|
||||
bid: unknown | null;
|
||||
delivery: unknown | null;
|
||||
scheduled_task: unknown | null;
|
||||
report: unknown | null;
|
||||
memo: unknown | null;
|
||||
protocolitem: unknown | null;
|
||||
related_note: unknown | null;
|
||||
task: unknown | null;
|
||||
}
|
||||
|
||||
export interface TaskCreate {
|
||||
project?: TaskProject | null;
|
||||
doer: TaskPersonInput;
|
||||
get_status?: string;
|
||||
get_status_class?: string;
|
||||
get_scan_url?: string | null;
|
||||
get_last_day?: string;
|
||||
frc_icon?: string;
|
||||
doer?: Employee | null;
|
||||
deadline: string;
|
||||
request_new_deadline?: string | null;
|
||||
plan_date?: string | null;
|
||||
responsible?: TaskPersonInput | null;
|
||||
counterparty?: TaskRelatedObject | null;
|
||||
bid_full?: TaskRelatedObject | null;
|
||||
price_agreement_full?: TaskRelatedObject | null;
|
||||
text?: string;
|
||||
status?: string;
|
||||
result?: string;
|
||||
complit_date?: string | null;
|
||||
comment?: string;
|
||||
note?: string;
|
||||
approve?: boolean;
|
||||
archive?: boolean;
|
||||
approve_date?: string | null;
|
||||
approve_required?: boolean;
|
||||
progress_status?: string;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
order_number?: number | null;
|
||||
priority?: number | null;
|
||||
typ?: string;
|
||||
responsible?: Employee | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TaskUpdate {
|
||||
project?: TaskProject | null;
|
||||
doer?: TaskPersonInput;
|
||||
get_status?: string;
|
||||
get_status_class?: string;
|
||||
get_scan_url?: string | null;
|
||||
get_last_day?: string;
|
||||
frc_icon?: string;
|
||||
doer?: Employee | null;
|
||||
deadline?: string;
|
||||
request_new_deadline?: string | null;
|
||||
plan_date?: string | null;
|
||||
responsible?: TaskPersonInput | null;
|
||||
counterparty?: TaskRelatedObject | null;
|
||||
bid_full?: TaskRelatedObject | null;
|
||||
price_agreement_full?: TaskRelatedObject | null;
|
||||
responsible?: Employee | null;
|
||||
text?: string;
|
||||
status?: string;
|
||||
result?: string;
|
||||
complit_date?: string | null;
|
||||
comment?: string;
|
||||
note?: string;
|
||||
approve?: boolean;
|
||||
archive?: boolean;
|
||||
approve_date?: string | null;
|
||||
approve_required?: boolean;
|
||||
progress_status?: string;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
order_number?: number | null;
|
||||
priority?: number | null;
|
||||
typ?: string;
|
||||
}
|
||||
|
||||
export interface TaskListParams extends ListParams {
|
||||
id?: number;
|
||||
text?: string;
|
||||
text__contains?: string;
|
||||
doer?: number | null;
|
||||
author?: number | null;
|
||||
contract?: string | null;
|
||||
bill?: string | null;
|
||||
memo?: string | null;
|
||||
doer?: string | null;
|
||||
author?: string | null;
|
||||
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;
|
||||
result?: string;
|
||||
doer_name?: string;
|
||||
doer_name__contains?: string;
|
||||
deadline?: string;
|
||||
progress_status?: string;
|
||||
priority?: number | null;
|
||||
typ?: string;
|
||||
}
|
||||
|
||||
@@ -625,3 +746,4 @@ export interface UserListParams extends ListParams {
|
||||
name?: string;
|
||||
name__contains?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user