fix
This commit is contained in:
Generated
+2
@@ -1196,7 +1196,9 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"che-orm",
|
"che-orm",
|
||||||
"che-tauri",
|
"che-tauri",
|
||||||
|
"jni",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|||||||
@@ -27,3 +27,5 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls"] }
|
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 java.util.Properties
|
||||||
|
import groovy.json.JsonSlurper
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
@@ -53,11 +54,19 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url = uri(rustlsPlatformVerifierMavenDir())
|
||||||
|
metadataSources.artifact()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rust {
|
rust {
|
||||||
rootDirRel = "../../../"
|
rootDirRel = "../../../"
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("rustls:rustls-platform-verifier:latest.release")
|
||||||
implementation("androidx.webkit:webkit:1.14.0")
|
implementation("androidx.webkit:webkit:1.14.0")
|
||||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
@@ -69,3 +78,27 @@ dependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply(from = "tauri.build.gradle.kts")
|
apply(from = "tauri.build.gradle.kts")
|
||||||
|
|
||||||
|
fun rustlsPlatformVerifierMavenDir(): String {
|
||||||
|
val manifestPath = providers.exec {
|
||||||
|
commandLine(
|
||||||
|
"cargo",
|
||||||
|
"metadata",
|
||||||
|
"--format-version",
|
||||||
|
"1",
|
||||||
|
"--filter-platform",
|
||||||
|
"aarch64-linux-android",
|
||||||
|
"--manifest-path",
|
||||||
|
rootProject.file("../../Cargo.toml").absolutePath,
|
||||||
|
)
|
||||||
|
}.standardOutput.asText.get()
|
||||||
|
|
||||||
|
val metadata = JsonSlurper().parseText(manifestPath) as Map<*, *>
|
||||||
|
val packages = metadata["packages"] as List<*>
|
||||||
|
val rustlsAndroidPackage = packages
|
||||||
|
.map { it as Map<*, *> }
|
||||||
|
.first { it["name"] == "rustls-platform-verifier-android" }
|
||||||
|
val rustlsManifest = file(rustlsAndroidPackage["manifest_path"] as String)
|
||||||
|
|
||||||
|
return File(rustlsManifest.parentFile, "maven").path
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
package com.che.ewa_mobile
|
package com.che.ewa_mobile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
class MainActivity : TauriActivity() {
|
||||||
|
companion object {
|
||||||
|
init {
|
||||||
|
System.loadLibrary("ewa_mobile_lib")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic external fun initRustlsPlatformVerifier(context: Context)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
initRustlsPlatformVerifier(applicationContext)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
pub mod frcapp;
|
pub mod frcapp;
|
||||||
|
pub mod internalmemoapp;
|
||||||
pub mod personemanagment;
|
pub mod personemanagment;
|
||||||
pub mod projectapp;
|
pub mod projectapp;
|
||||||
pub mod supplyapp;
|
pub mod supplyapp;
|
||||||
@@ -14,5 +15,6 @@ pub fn installed_apps() -> InstalledApps {
|
|||||||
.add(personemanagment::module())
|
.add(personemanagment::module())
|
||||||
.add(contractapp::module())
|
.add(contractapp::module())
|
||||||
.add(supplyapp::module())
|
.add(supplyapp::module())
|
||||||
|
.add(internalmemoapp::module())
|
||||||
}
|
}
|
||||||
pub mod contractapp;
|
pub mod contractapp;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ static TASK_FILTERS: &[Filter] = &[
|
|||||||
Filter::contains("text"),
|
Filter::contains("text"),
|
||||||
Filter::remote_only("contract"),
|
Filter::remote_only("contract"),
|
||||||
Filter::remote_only("bill"),
|
Filter::remote_only("bill"),
|
||||||
|
Filter::remote_only("memo"),
|
||||||
Filter::remote_only("doer"),
|
Filter::remote_only("doer"),
|
||||||
Filter::remote_only("author"),
|
Filter::remote_only("author"),
|
||||||
Filter::exact("archive"),
|
Filter::exact("archive"),
|
||||||
|
|||||||
+26
-3
@@ -1,7 +1,10 @@
|
|||||||
pub mod apps;
|
pub mod apps;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::{
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use che_orm::SqliteBackend;
|
use che_orm::SqliteBackend;
|
||||||
use che_tauri::{
|
use che_tauri::{
|
||||||
@@ -13,6 +16,18 @@ use tauri_plugin_opener::OpenerExt;
|
|||||||
|
|
||||||
use crate::sync::SyncContractsResult;
|
use crate::sync::SyncContractsResult;
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "system" fn Java_com_che_ewa_1mobile_MainActivity_initRustlsPlatformVerifier(
|
||||||
|
mut env: jni::JNIEnv,
|
||||||
|
_class: jni::objects::JClass,
|
||||||
|
context: jni::objects::JObject,
|
||||||
|
) {
|
||||||
|
if let Err(error) = rustls_platform_verifier::android::init_with_env(&mut env, context) {
|
||||||
|
eprintln!("failed to initialize rustls platform verifier: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
const REMOTE_BASE_URL: &str = "http://10.0.2.2:8000";
|
const REMOTE_BASE_URL: &str = "http://10.0.2.2:8000";
|
||||||
|
|
||||||
@@ -89,6 +104,14 @@ fn validate_settings(settings: &AppSettings) -> Result<(), ApiError> {
|
|||||||
Ok(())
|
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 {
|
fn settings_path(app_data_dir: &Path) -> PathBuf {
|
||||||
app_data_dir.join("settings.json")
|
app_data_dir.join("settings.json")
|
||||||
}
|
}
|
||||||
@@ -219,7 +242,7 @@ async fn current_employee(api: tauri::State<'_, TauriApi>) -> Result<CurrentEmpl
|
|||||||
.auth_token()
|
.auth_token()
|
||||||
.ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?;
|
.ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?;
|
||||||
|
|
||||||
let response = reqwest::Client::new()
|
let response = remote_http_client()
|
||||||
.get(format!(
|
.get(format!(
|
||||||
"{}/api/persone/employee/who_im/",
|
"{}/api/persone/employee/who_im/",
|
||||||
remote.base_url.trim_end_matches('/')
|
remote.base_url.trim_end_matches('/')
|
||||||
@@ -322,7 +345,7 @@ async fn cache_remote_file(
|
|||||||
.auth_token()
|
.auth_token()
|
||||||
.ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?;
|
.ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?;
|
||||||
|
|
||||||
let response = reqwest::Client::new()
|
let response = remote_http_client()
|
||||||
.get(file_url)
|
.get(file_url)
|
||||||
.header(reqwest::header::AUTHORIZATION, format!("Token {token}"))
|
.header(reqwest::header::AUTHORIZATION, format!("Token {token}"))
|
||||||
.send()
|
.send()
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use che_orm::__private::sqlx;
|
|||||||
use che_tauri::{ApiError, TauriApi};
|
use che_tauri::{ApiError, TauriApi};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::remote_http_client;
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
pub struct SyncContractsResult {
|
pub struct SyncContractsResult {
|
||||||
pub synced: usize,
|
pub synced: usize,
|
||||||
@@ -167,7 +169,7 @@ pub async fn sync_contracts(
|
|||||||
let remote = state
|
let remote = state
|
||||||
.remote_config()
|
.remote_config()
|
||||||
.ok_or_else(|| ApiError::bad_request("sync requires [remote].base_url config"))?;
|
.ok_or_else(|| ApiError::bad_request("sync requires [remote].base_url config"))?;
|
||||||
let client = reqwest::Client::new();
|
let client = remote_http_client();
|
||||||
sync_contract_categories(api, &client, &token, &remote.base_url).await?;
|
sync_contract_categories(api, &client, &token, &remote.base_url).await?;
|
||||||
|
|
||||||
let mut next_url = Some(format!(
|
let mut next_url = Some(format!(
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ const activeTab = computed({
|
|||||||
return "/documents";
|
return "/documents";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route.path.startsWith("/memos")) {
|
||||||
|
return "/documents";
|
||||||
|
}
|
||||||
|
|
||||||
if (route.path.startsWith("/settings")) {
|
if (route.path.startsWith("/settings")) {
|
||||||
return "/settings";
|
return "/settings";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { isAuthenticated, useAuth } from "../../shared/auth/useAuth";
|
|||||||
import { loginRoute } from "../../apps/auth/routes";
|
import { loginRoute } from "../../apps/auth/routes";
|
||||||
import { documentsRoute } from "../../apps/documents/routes";
|
import { documentsRoute } from "../../apps/documents/routes";
|
||||||
import { contractsRoutes } from "../../apps/contracts/routes";
|
import { contractsRoutes } from "../../apps/contracts/routes";
|
||||||
|
import { memosRoutes } from "../../apps/memos/routes";
|
||||||
import { settingsRoute } from "../../apps/settings/routes";
|
import { settingsRoute } from "../../apps/settings/routes";
|
||||||
import { supplyRoutes } from "../../apps/supply/routes";
|
import { supplyRoutes } from "../../apps/supply/routes";
|
||||||
import { tasksRoutes } from "../../apps/tasks/routes";
|
import { tasksRoutes } from "../../apps/tasks/routes";
|
||||||
@@ -18,6 +19,7 @@ export const router = createRouter({
|
|||||||
...tasksRoutes,
|
...tasksRoutes,
|
||||||
...usersRoutes,
|
...usersRoutes,
|
||||||
...contractsRoutes,
|
...contractsRoutes,
|
||||||
|
...memosRoutes,
|
||||||
...supplyRoutes,
|
...supplyRoutes,
|
||||||
documentsRoute,
|
documentsRoute,
|
||||||
settingsRoute,
|
settingsRoute,
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ const tiles = [
|
|||||||
icon: "description-o",
|
icon: "description-o",
|
||||||
to: "/bills",
|
to: "/bills",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Служебные записки",
|
||||||
|
icon: "records-o",
|
||||||
|
to: "/memos",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Входящие письма",
|
title: "Входящие письма",
|
||||||
icon: "notes-o",
|
icon: "notes-o",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,45 +1,43 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { taskApi } from "../../../generated/api";
|
import { taskApi } from "../../../generated/api";
|
||||||
import type { Employee, Task } from "../../../generated/models";
|
import type { Task, TaskListParams } from "../../../generated/models";
|
||||||
import RemoteImage from "../../../shared/components/RemoteImage.vue";
|
import DocumentApprovalTaskCard from "../../../shared/components/DocumentApprovalTaskCard.vue";
|
||||||
import { useModelApi } from "../../../shared/composables/useModelApi";
|
import { useModelApi } from "../../../shared/composables/useModelApi";
|
||||||
|
|
||||||
type TaskMessage = {
|
interface CurrentEmployee {
|
||||||
id: number;
|
id: number;
|
||||||
author: Employee;
|
name: string;
|
||||||
recipient: Employee;
|
short_name: string;
|
||||||
text: string;
|
avatar?: string | null;
|
||||||
date: string;
|
}
|
||||||
status: string;
|
|
||||||
task: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TaskDetail = Omit<Task, "message_set"> & {
|
|
||||||
message_set?: TaskMessage[] | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const taskId = computed(() => Number(route.params.id));
|
const taskId = computed(() => Number(route.params.id));
|
||||||
|
const currentEmployeeId = ref<number | null>(null);
|
||||||
|
const activeTab = ref(0);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
item: task,
|
item: task,
|
||||||
loadingItem,
|
loadingItem,
|
||||||
error,
|
error,
|
||||||
retrieve: loadTask,
|
retrieve: loadTask,
|
||||||
} = useModelApi(taskApi, {
|
} = useModelApi<Task, Task, Task, TaskListParams>(taskApi, {
|
||||||
retrieveErrorMessage: "Не удалось загрузить задачу",
|
retrieveErrorMessage: "Не удалось загрузить задачу",
|
||||||
autoLoad: false,
|
autoLoad: false,
|
||||||
autoLoadOnFilterChange: false,
|
autoLoadOnFilterChange: false,
|
||||||
});
|
});
|
||||||
function personName(person: { short_name?: string | null; name?: string | null } | null) {
|
|
||||||
if (!person) {
|
|
||||||
return "не указан";
|
|
||||||
}
|
|
||||||
|
|
||||||
return person.short_name ?? person.name ?? "не указан";
|
async function loadCurrentEmployee() {
|
||||||
|
try {
|
||||||
|
const employee = await invoke<CurrentEmployee>("current_employee");
|
||||||
|
currentEmployeeId.value = employee.id;
|
||||||
|
} catch {
|
||||||
|
currentEmployeeId.value = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskTitle() {
|
function taskTitle() {
|
||||||
@@ -54,11 +52,8 @@ function formatBoolean(value: boolean) {
|
|||||||
return value ? "Да" : "Нет";
|
return value ? "Да" : "Нет";
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageList(taskItem: Task | null | undefined) {
|
|
||||||
return (taskItem as TaskDetail | null | undefined)?.message_set ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
void loadCurrentEmployee();
|
||||||
if (Number.isFinite(taskId.value)) {
|
if (Number.isFinite(taskId.value)) {
|
||||||
loadTask(taskId.value);
|
loadTask(taskId.value);
|
||||||
}
|
}
|
||||||
@@ -83,49 +78,49 @@ onMounted(() => {
|
|||||||
<van-empty v-else-if="!task" description="Задача не найдена" />
|
<van-empty v-else-if="!task" description="Задача не найдена" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<van-cell-group inset>
|
<van-tabs v-model:active="activeTab" animated>
|
||||||
<van-cell title="ID" :value="task.id" />
|
<van-tab title="Диалог">
|
||||||
<van-cell title="Заголовок" :value="taskTitle()" />
|
<div class="tab-body">
|
||||||
<van-cell title="Исполнитель" :value="personName(task.doer)" />
|
<DocumentApprovalTaskCard
|
||||||
<van-cell title="Автор" :value="personName(task.author)" />
|
:task="task as any"
|
||||||
<van-cell title="Ответственный" :value="personName(task.responsible)" />
|
:current-employee-id="currentEmployeeId"
|
||||||
<van-cell title="Статус" :value="task.get_status" />
|
/>
|
||||||
<van-cell title="Срок" :value="formatDate(task.deadline)" />
|
|
||||||
<van-cell title="Плановая дата" :value="formatDate(task.plan_date)" />
|
|
||||||
<van-cell title="Запрос новой даты" :value="formatDate(task.request_new_deadline)" />
|
|
||||||
<van-cell title="Приоритет" :value="task.priority ?? 'не указан'" />
|
|
||||||
<van-cell title="Тип" :value="task.typ || 'не указан'" />
|
|
||||||
<van-cell title="Результат" :value="task.result || 'не указан'" />
|
|
||||||
<van-cell title="Комментарий" :value="task.comment || 'не указан'" />
|
|
||||||
<van-cell title="Примечание" :value="task.note || 'не указано'" />
|
|
||||||
<van-cell title="Согласование" :value="formatBoolean(task.approve)" />
|
|
||||||
<van-cell title="Архив" :value="formatBoolean(task.archive)" />
|
|
||||||
</van-cell-group>
|
|
||||||
</template>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card messages-card">
|
|
||||||
<div class="list-title">
|
|
||||||
<h2>Диалог</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<van-empty v-if="!messageList(task).length" description="Сообщений пока нет" />
|
|
||||||
|
|
||||||
<div v-else class="message-list">
|
|
||||||
<article v-for="message in messageList(task)" :key="message.id" class="message-item">
|
|
||||||
<RemoteImage class="message-avatar" round width="40" height="40" :src="message.author.avatar_small" />
|
|
||||||
<div class="message-bubble">
|
|
||||||
<div class="message-author">{{ personName(message.author) }}</div>
|
|
||||||
<div class="message-text">{{ message.text }}</div>
|
|
||||||
<div class="message-meta">
|
|
||||||
{{ message.date }} · {{ message.status }} · {{ personName(message.recipient) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</van-tab>
|
||||||
</article>
|
|
||||||
</div>
|
<van-tab title="Подробная информация">
|
||||||
|
<div class="tab-body">
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell title="ID" :value="task.id" />
|
||||||
|
<van-cell title="Заголовок" :value="taskTitle()" />
|
||||||
|
<van-cell title="Исполнитель" :value="task.doer?.short_name ?? task.doer?.name ?? 'не указан'" />
|
||||||
|
<van-cell title="Автор" :value="task.author?.short_name ?? task.author?.name ?? 'не указан'" />
|
||||||
|
<van-cell title="Ответственный" :value="task.responsible?.short_name ?? task.responsible?.name ?? 'не указан'" />
|
||||||
|
<van-cell title="Статус" :value="task.get_status" />
|
||||||
|
<van-cell title="Срок" :value="formatDate(task.deadline)" />
|
||||||
|
<van-cell title="Плановая дата" :value="formatDate(task.plan_date)" />
|
||||||
|
<van-cell title="Запрос новой даты" :value="formatDate(task.request_new_deadline)" />
|
||||||
|
<van-cell title="Приоритет" :value="task.priority ?? 'не указан'" />
|
||||||
|
<van-cell title="Тип" :value="task.typ || 'не указан'" />
|
||||||
|
<van-cell title="Результат" :value="task.result || 'не указан'" />
|
||||||
|
<van-cell title="Комментарий" :value="task.comment || 'не указан'" />
|
||||||
|
<van-cell title="Примечание" :value="task.note || 'не указано'" />
|
||||||
|
<van-cell title="Согласование" :value="formatBoolean(task.approve)" />
|
||||||
|
<van-cell title="Архив" :value="formatBoolean(task.archive)" />
|
||||||
|
</van-cell-group>
|
||||||
|
</div>
|
||||||
|
</van-tab>
|
||||||
|
</van-tabs>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="form-actions stacked-actions">
|
<div class="form-actions stacked-actions">
|
||||||
<van-button block round type="primary" plain @click="router.back()">Назад</van-button>
|
<van-button block round type="primary" plain @click="router.back()">Назад</van-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tab-body {
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ import type {
|
|||||||
FrcCreate,
|
FrcCreate,
|
||||||
FrcUpdate,
|
FrcUpdate,
|
||||||
FrcListParams,
|
FrcListParams,
|
||||||
|
Memo,
|
||||||
|
MemoCreate,
|
||||||
|
MemoUpdate,
|
||||||
|
MemoListParams,
|
||||||
|
MemoCategory,
|
||||||
|
MemoCategoryCreate,
|
||||||
|
MemoCategoryUpdate,
|
||||||
|
MemoCategoryListParams,
|
||||||
Message,
|
Message,
|
||||||
MessageCreate,
|
MessageCreate,
|
||||||
MessageUpdate,
|
MessageUpdate,
|
||||||
@@ -57,6 +65,8 @@ export const contractCategoryApi = createModelApi<ContractCategory, ContractCate
|
|||||||
export const counterpartyApi = createModelApi<Counterparty, CounterpartyCreate, CounterpartyUpdate, CounterpartyListParams>("counterparty");
|
export const counterpartyApi = createModelApi<Counterparty, CounterpartyCreate, CounterpartyUpdate, CounterpartyListParams>("counterparty");
|
||||||
export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee");
|
export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee");
|
||||||
export const frcApi = createModelApi<Frc, FrcCreate, FrcUpdate, FrcListParams>("frc");
|
export const frcApi = createModelApi<Frc, FrcCreate, FrcUpdate, FrcListParams>("frc");
|
||||||
|
export const memoApi = createModelApi<Memo, MemoCreate, MemoUpdate, MemoListParams>("memo");
|
||||||
|
export const memoCategoryApi = createModelApi<MemoCategory, MemoCategoryCreate, MemoCategoryUpdate, MemoCategoryListParams>("memo_category");
|
||||||
export const messageApi = createModelApi<Message, MessageCreate, MessageUpdate, MessageListParams>("message");
|
export const messageApi = createModelApi<Message, MessageCreate, MessageUpdate, MessageListParams>("message");
|
||||||
export const projectApi = createModelApi<Project, ProjectCreate, ProjectUpdate, ProjectListParams>("project");
|
export const projectApi = createModelApi<Project, ProjectCreate, ProjectUpdate, ProjectListParams>("project");
|
||||||
export const taskApi = createModelApi<Task, TaskCreate, TaskUpdate, TaskListParams>("task");
|
export const taskApi = createModelApi<Task, TaskCreate, TaskUpdate, TaskListParams>("task");
|
||||||
|
|||||||
@@ -413,6 +413,128 @@ export interface FrcListParams extends ListParams {
|
|||||||
name__contains?: string;
|
name__contains?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Memo {
|
||||||
|
id: number;
|
||||||
|
recipient_id: number | null;
|
||||||
|
sender_id: number | null;
|
||||||
|
category_id: number | null;
|
||||||
|
project_id: number | null;
|
||||||
|
task_set: unknown[] | null;
|
||||||
|
priority_display: string;
|
||||||
|
absolute_url: string;
|
||||||
|
date: string;
|
||||||
|
my_approve: string;
|
||||||
|
text: string;
|
||||||
|
resalution: string;
|
||||||
|
priority: string;
|
||||||
|
archive: boolean;
|
||||||
|
cancel: boolean;
|
||||||
|
date_start: string | null;
|
||||||
|
date_end: string | null;
|
||||||
|
value: number;
|
||||||
|
archive_s: boolean;
|
||||||
|
current_step: number;
|
||||||
|
subject: number[] | null;
|
||||||
|
employee_acl: number[] | null;
|
||||||
|
recipient: Employee | null;
|
||||||
|
sender: Employee | null;
|
||||||
|
category: MemoCategory | null;
|
||||||
|
project: Project | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoCreate {
|
||||||
|
recipient_id?: number | null;
|
||||||
|
sender_id?: number | null;
|
||||||
|
category_id?: number | null;
|
||||||
|
project_id?: number | null;
|
||||||
|
task_set?: unknown[] | null;
|
||||||
|
priority_display: string;
|
||||||
|
absolute_url: string;
|
||||||
|
date: string;
|
||||||
|
my_approve: string;
|
||||||
|
text: string;
|
||||||
|
resalution: string;
|
||||||
|
priority: string;
|
||||||
|
archive: boolean;
|
||||||
|
cancel: boolean;
|
||||||
|
date_start?: string | null;
|
||||||
|
date_end?: string | null;
|
||||||
|
value: number;
|
||||||
|
archive_s: boolean;
|
||||||
|
current_step: number;
|
||||||
|
subject?: number[] | null;
|
||||||
|
employee_acl?: number[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoUpdate {
|
||||||
|
recipient_id?: number | null;
|
||||||
|
sender_id?: number | null;
|
||||||
|
category_id?: number | null;
|
||||||
|
project_id?: number | null;
|
||||||
|
task_set?: unknown[] | null;
|
||||||
|
priority_display?: string;
|
||||||
|
absolute_url?: string;
|
||||||
|
date?: string;
|
||||||
|
my_approve?: string;
|
||||||
|
text?: string;
|
||||||
|
resalution?: string;
|
||||||
|
priority?: string;
|
||||||
|
archive?: boolean;
|
||||||
|
cancel?: boolean;
|
||||||
|
date_start?: string | null;
|
||||||
|
date_end?: string | null;
|
||||||
|
value?: number;
|
||||||
|
archive_s?: boolean;
|
||||||
|
current_step?: number;
|
||||||
|
subject?: number[] | null;
|
||||||
|
employee_acl?: number[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoListParams extends ListParams {
|
||||||
|
id?: number;
|
||||||
|
text__contains?: string;
|
||||||
|
date?: string;
|
||||||
|
priority?: string;
|
||||||
|
archive?: boolean;
|
||||||
|
cancel?: boolean;
|
||||||
|
recipient?: number | null;
|
||||||
|
sender?: number | null;
|
||||||
|
category?: number | null;
|
||||||
|
project?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
template: string;
|
||||||
|
template_text: string;
|
||||||
|
code: string;
|
||||||
|
in_month_limit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoCategoryCreate {
|
||||||
|
name: string;
|
||||||
|
template: string;
|
||||||
|
template_text: string;
|
||||||
|
code: string;
|
||||||
|
in_month_limit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoCategoryUpdate {
|
||||||
|
name?: string;
|
||||||
|
template?: string;
|
||||||
|
template_text?: string;
|
||||||
|
code?: string;
|
||||||
|
in_month_limit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoCategoryListParams extends ListParams {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
name__contains?: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
task: number;
|
task: number;
|
||||||
@@ -564,6 +686,7 @@ export interface TaskListParams extends ListParams {
|
|||||||
text__contains?: string;
|
text__contains?: string;
|
||||||
contract?: string | null;
|
contract?: string | null;
|
||||||
bill?: string | null;
|
bill?: string | null;
|
||||||
|
memo?: string | null;
|
||||||
doer?: string | null;
|
doer?: string | null;
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
archive?: boolean;
|
archive?: boolean;
|
||||||
|
|||||||
@@ -135,6 +135,14 @@ function quickReplies(mode: ReplyMode) {
|
|||||||
return mode === "success" ? executorSuccessReplies : executorFailureReplies;
|
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) {
|
async function sendMessage(status: string, text: string) {
|
||||||
const key = actionKey(status);
|
const key = actionKey(status);
|
||||||
if (sendingActionKey.value) {
|
if (sendingActionKey.value) {
|
||||||
@@ -184,67 +192,48 @@ async function sendMessage(status: string, text: string) {
|
|||||||
>
|
>
|
||||||
<div class="approval-task-head">
|
<div class="approval-task-head">
|
||||||
<div class="approval-task-head__main">
|
<div class="approval-task-head__main">
|
||||||
<div class="approval-task-title">
|
<div class="approval-task-title-row">
|
||||||
{{ task.text?.trim() || task.result?.trim() || `Задача #${task.id}` }}
|
<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>
|
||||||
<div class="approval-task-meta">ID {{ task.id }} · {{ task.deadline }}</div>
|
<div class="approval-task-meta">ID {{ task.id }} · {{ task.deadline }}</div>
|
||||||
</div>
|
</div>
|
||||||
<van-tag plain :type="statusType(task.get_status_class)">
|
<div class="approval-task-status">
|
||||||
{{ task.get_status }}
|
<van-tag plain :type="statusType(task.get_status_class)">
|
||||||
</van-tag>
|
{{ task.get_status }}
|
||||||
</div>
|
</van-tag>
|
||||||
|
<div class="approval-status-avatars">
|
||||||
<van-cell-group inset>
|
|
||||||
<div class="approval-person-list">
|
|
||||||
<div class="approval-person-item">
|
|
||||||
<RemoteImage
|
<RemoteImage
|
||||||
class="approval-person-avatar"
|
class="approval-person-avatar"
|
||||||
round
|
round
|
||||||
width="32"
|
width="28"
|
||||||
height="32"
|
height="28"
|
||||||
:src="task.doer?.avatar_small ?? ''"
|
:src="task.doer?.avatar_small ?? ''"
|
||||||
|
:fallback-text="employeeName(task.doer)"
|
||||||
|
@click.stop="showEmployeeName(task.doer)"
|
||||||
/>
|
/>
|
||||||
<div class="approval-person-body">
|
|
||||||
<div class="approval-person-role">Исполнитель</div>
|
|
||||||
<div class="approval-person-name">
|
|
||||||
{{ task.doer?.short_name ?? task.doer?.name ?? 'не указан' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="approval-person-item">
|
|
||||||
<RemoteImage
|
<RemoteImage
|
||||||
class="approval-person-avatar"
|
class="approval-person-avatar"
|
||||||
round
|
round
|
||||||
width="32"
|
width="28"
|
||||||
height="32"
|
height="28"
|
||||||
:src="task.author?.avatar_small ?? ''"
|
|
||||||
/>
|
|
||||||
<div class="approval-person-body">
|
|
||||||
<div class="approval-person-role">Автор</div>
|
|
||||||
<div class="approval-person-name">
|
|
||||||
{{ task.author?.short_name ?? task.author?.name ?? 'не указан' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="approval-person-item">
|
|
||||||
<RemoteImage
|
|
||||||
class="approval-person-avatar"
|
|
||||||
round
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
:src="task.responsible?.avatar_small ?? ''"
|
:src="task.responsible?.avatar_small ?? ''"
|
||||||
|
:fallback-text="employeeName(task.responsible)"
|
||||||
|
@click.stop="showEmployeeName(task.responsible)"
|
||||||
/>
|
/>
|
||||||
<div class="approval-person-body">
|
|
||||||
<div class="approval-person-role">Ответственный</div>
|
|
||||||
<div class="approval-person-name">
|
|
||||||
{{ task.responsible?.short_name ?? task.responsible?.name ?? 'не указан' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</van-cell-group>
|
</div>
|
||||||
|
|
||||||
<div class="approval-task-messages">
|
<div class="approval-task-messages">
|
||||||
<div class="approval-task-messages__title">Сообщения</div>
|
<div class="approval-task-messages__title">Сообщения</div>
|
||||||
@@ -436,13 +425,34 @@ async function sendMessage(status: string, text: string) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.approval-task-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.approval-task-title {
|
.approval-task-title {
|
||||||
|
min-width: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
word-break: break-word;
|
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 {
|
.approval-task-meta {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
@@ -453,41 +463,13 @@ async function sendMessage(status: string, text: string) {
|
|||||||
padding: 12px 16px 16px;
|
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 {
|
.approval-person-avatar {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.approval-person-body {
|
.approval-person-avatar--author {
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.approval-person-role {
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.approval-person-name {
|
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.35;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.approval-task-messages__title {
|
.approval-task-messages__title {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface CurrentEmployee {
|
|||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DocumentFilterName = "contract" | "bill";
|
type DocumentFilterName = "contract" | "bill" | "memo";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
@@ -1,15 +1,61 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { useRemoteFileUrl } from "../composables/useRemoteFileUrl";
|
import { useRemoteFileUrl } from "../composables/useRemoteFileUrl";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
|
fallbackText?: string | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const sourceUrl = computed(() => props.src);
|
const sourceUrl = computed(() => props.src);
|
||||||
const { objectUrl } = useRemoteFileUrl(sourceUrl);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<van-image v-bind="$attrs" :src="objectUrl" />
|
<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>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user