This commit is contained in:
che
2026-07-27 15:24:23 +05:00
parent 13362528cd
commit 2dd7d33c8b
29 changed files with 2796 additions and 353 deletions
@@ -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] = &[
+2
View File
@@ -1,6 +1,7 @@
pub mod frcapp;
pub mod personemanagment;
pub mod projectapp;
pub mod supplyapp;
pub mod users;
use che_tauri::InstalledApps;
@@ -12,5 +13,6 @@ pub fn installed_apps() -> InstalledApps {
.add(projectapp::module())
.add(personemanagment::module())
.add(contractapp::module())
.add(supplyapp::module())
}
pub mod contractapp;
@@ -14,6 +14,7 @@ static TASK_FILTERS: &[Filter] = &[
Filter::exact("id"),
Filter::contains("text"),
Filter::remote_only("contract"),
Filter::remote_only("bill"),
Filter::remote_only("doer"),
Filter::remote_only("author"),
Filter::exact("archive"),
+2 -1
View File
@@ -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] = &[
+26
View File
@@ -0,0 +1,26 @@
use che_tauri::{Filter, FilterSet};
use super::models::Bill;
static BILL_FILTERS: &[Filter] = &[
Filter::exact("id"),
Filter::exact("number"),
Filter::contains("number"),
Filter::exact("text"),
Filter::contains("text"),
Filter::exact("status"),
Filter::exact("status_name"),
Filter::contains("status_name"),
Filter::exact("date"),
Filter::exact("date_due"),
Filter::exact("date_bill"),
Filter::exact("contract_typ"),
Filter::remote_only("counterparty"),
Filter::remote_only("frc"),
Filter::remote_only("project"),
Filter::remote_only("category"),
];
pub fn bill_filterset() -> FilterSet<Bill> {
FilterSet::new(BILL_FILTERS).remote_ordering("order_by")
}
@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS supply_bill (
id INTEGER PRIMARY KEY AUTOINCREMENT,
number TEXT NOT NULL,
text TEXT NOT NULL,
status TEXT NOT NULL,
status_name TEXT NOT NULL,
date TEXT NOT NULL,
date_due TEXT,
date_bill TEXT,
month_of_costs TEXT,
cost REAL NOT NULL,
to_payd REAL NOT NULL,
paid REAL NOT NULL,
nds_cost REAL NOT NULL,
scan TEXT,
comment TEXT NOT NULL,
absolute_url TEXT NOT NULL,
date_pay TEXT,
transaction_date TEXT,
date_applay TEXT,
archive_s BOOLEAN NOT NULL,
pp_maked BOOLEAN NOT NULL,
composit BOOLEAN NOT NULL,
contract_typ TEXT NOT NULL,
frc TEXT,
project TEXT,
counterparty TEXT,
contract TEXT,
responsible TEXT,
author TEXT,
category TEXT,
transferdocument_set TEXT
);
@@ -0,0 +1,350 @@
{
"models": [
{
"table": "supply_bill",
"fields": [
{
"name": "id",
"ty": "integer",
"primary_key": true,
"nullable": false,
"auto": true,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "number",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "text",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "status",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "status_name",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "date",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "date_due",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "date_bill",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "month_of_costs",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "cost",
"ty": "real",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "to_payd",
"ty": "real",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "paid",
"ty": "real",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "nds_cost",
"ty": "real",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "scan",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "comment",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "absolute_url",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "date_pay",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "transaction_date",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "date_applay",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "archive_s",
"ty": "boolean",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "pp_maked",
"ty": "boolean",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "composit",
"ty": "boolean",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "contract_typ",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "frc",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "project",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "counterparty",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "contract",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "responsible",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "author",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "category",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "transferdocument_set",
"ty": "text",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
}
]
}
]
}
+26
View File
@@ -0,0 +1,26 @@
pub mod filters;
pub mod models;
pub mod serializers;
use che_tauri::{AppModule, ModuleContext};
pub fn module() -> SupplyappModule {
SupplyappModule
}
pub struct SupplyappModule;
impl AppModule for SupplyappModule {
fn name(&self) -> &'static str {
"supplyapp"
}
fn init(&self, ctx: &mut ModuleContext) {
ctx.mapped_remote_resource::<models::Bill>(
"bill",
"/api/supply/bill/",
serializers::bill_serializer(),
filters::bill_filterset(),
);
}
}
+40
View File
@@ -0,0 +1,40 @@
use che_orm::Model;
#[derive(Debug, Clone, Model)]
#[model(table = "supply_bill")]
pub struct Bill {
#[field(primary_key)]
pub id: i64,
pub number: String,
pub text: String,
pub status: String,
pub status_name: String,
pub date: String,
pub date_due: Option<String>,
pub date_bill: Option<String>,
pub month_of_costs: Option<String>,
pub cost: f64,
pub to_payd: f64,
pub paid: f64,
pub nds_cost: f64,
pub scan: Option<String>,
pub comment: String,
pub absolute_url: String,
pub date_pay: Option<String>,
pub transaction_date: Option<String>,
pub date_applay: Option<String>,
pub archive_s: bool,
pub pp_maked: bool,
pub composit: bool,
pub contract_typ: String,
pub frc: Option<String>,
pub project: Option<String>,
pub counterparty: Option<String>,
pub contract: Option<String>,
pub responsible: Option<String>,
pub author: Option<String>,
pub category: Option<String>,
pub transferdocument_set: Option<String>,
}
@@ -0,0 +1,65 @@
use che_tauri::{Field, ModelSerializer};
use super::models::Bill;
static BILL_FIELDS: &[Field] = &[
Field::new("id").read_only(),
Field::new("number"),
Field::new("text"),
Field::new("status"),
Field::new("status_name").source("get_status_display"),
Field::new("date"),
Field::new("date_due").required(false).nullable(),
Field::new("date_bill").required(false).nullable(),
Field::new("month_of_costs").required(false).nullable(),
Field::new("cost"),
Field::new("to_payd"),
Field::new("paid"),
Field::new("nds_cost"),
Field::new("scan").required(false).nullable(),
Field::new("comment"),
Field::new("absolute_url").source("get_absolute_url"),
Field::new("date_pay").required(false).nullable(),
Field::new("transaction_date").required(false).nullable(),
Field::new("date_applay").required(false).nullable(),
Field::new("archive_s"),
Field::new("pp_maked"),
Field::new("composit"),
Field::new("contract_typ"),
Field::json("frc")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("project")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("counterparty")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("contract")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("responsible")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("author")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("category")
.ts_type("unknown")
.required(false)
.nullable(),
Field::json("transferdocument_set")
.ts_type("unknown[]")
.required(false)
.nullable(),
];
pub fn bill_serializer() -> ModelSerializer<Bill> {
ModelSerializer::new(BILL_FIELDS)
}
+11 -3
View File
@@ -9,8 +9,16 @@ 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("/settings")) {
@@ -61,8 +69,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
+17 -13
View File
@@ -1,25 +1,29 @@
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 { 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,
...supplyRoutes,
documentsRoute,
settingsRoute,
loginRoute,
],
});
router.beforeEach(async (to) => {
const { restoreToken } = useAuth();
@@ -30,6 +34,6 @@ router.beforeEach(async (to) => {
}
if (to.path === "/login" && isAuthenticated()) {
return "/tasks";
return "/documents";
}
});
@@ -1,10 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { taskApi } from "../../../generated/api";
import type { Task, TaskListParams } from "../../../generated/models";
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;
}>();
@@ -24,8 +38,8 @@ const expandedTasks = ref<Record<number, boolean>>({});
const hasTasks = computed(() => tasks.value.length > 0);
function taskTitle(task: Task) {
return task.text?.trim() || task.result?.trim() || `Задача #${task.id}`;
function taskTitle(task: Task | null | undefined) {
return task?.text?.trim() || task?.result?.trim() || `Задача #${task?.id ?? ""}`;
}
function personName(
@@ -42,6 +56,10 @@ 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;
}
@@ -53,10 +71,10 @@ function toggleHistory(taskId: number) {
};
}
function visibleMessages(task: Task) {
const messages = task.message_set ?? [];
function visibleMessages(task: Task | null | undefined) {
const messages = taskMessages(task);
if (isHistoryExpanded(task.id)) {
if (isHistoryExpanded(task?.id ?? 0)) {
return messages;
}
@@ -97,7 +115,7 @@ function statusTone(statusClass: string) {
function loadTasks() {
const params = {
contract: props.contractId,
contract: String(props.contractId),
ordering: "-id",
} as TaskListParams;
@@ -212,7 +230,7 @@ onMounted(() => {
<div class="approval-task-messages__title">Сообщения</div>
<van-empty
v-if="!task.message_set?.length"
v-if="!taskMessages(task).length"
description="Сообщений пока нет"
image-size="64"
/>
@@ -224,14 +242,12 @@ onMounted(() => {
:class="[
'approval-message-item',
!isHistoryExpanded(task.id) &&
message.id ===
task.message_set?.[task.message_set.length - 1]?.id
message.id === taskMessages(task)[taskMessages(task).length - 1]?.id
? 'approval-message-item--latest'
: '',
]"
@click="
message.id ===
task.message_set?.[task.message_set.length - 1]?.id &&
message.id === taskMessages(task)[taskMessages(task).length - 1]?.id &&
!isHistoryExpanded(task.id)
? toggleHistory(task.id)
: undefined
@@ -262,7 +278,7 @@ onMounted(() => {
</article>
<van-button
v-if="(task.message_set?.length ?? 0) > 1"
v-if="taskMessages(task).length > 1"
class="approval-message-toggle"
size="small"
round
@@ -15,7 +15,7 @@ import type {
} from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
import PdfPreview from "../components/PdfPreview.vue";
import ContractApprovalTasks from "../components/ContractApprovalTasks.vue";
import DocumentApprovalTasks from "../../../shared/components/DocumentApprovalTasks.vue";
const route = useRoute();
const router = useRouter();
@@ -410,7 +410,12 @@ onMounted(() => {
</van-cell>
</van-cell-group>
<ContractApprovalTasks v-if="contract" :contract-id="contract.id" />
<DocumentApprovalTasks
v-if="contract"
:document-id="contract.id"
filter-name="contract"
title="Согласование договора"
/>
</div>
</van-tab>
+13 -14
View File
@@ -11,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: "В работе" },
@@ -31,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;
@@ -41,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;
@@ -105,10 +107,10 @@ 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;
},
});
@@ -134,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();
}
@@ -147,12 +149,7 @@ async function syncContracts() {
syncing.value = true;
try {
await loadContracts({
...filters,
limit: PAGE_SIZE,
offset: filters.offset ?? 0,
force_remote: true,
} as ContractListParams);
await loadContracts();
showToast("Данные обновлены с сервера");
} catch (err) {
showToast(errorMessage(err, "Не удалось синхронизировать договоры"));
@@ -184,7 +181,9 @@ watch(
filters.status,
],
() => {
filters.offset = 0;
if ((contractFilters.page ?? 1) !== 1) {
contractFilters.page = 1;
}
},
);
</script>
+8
View File
@@ -0,0 +1,8 @@
import DocumentsView from "./views/DocumentsView.vue";
export const documentsRoute = {
path: "/documents",
name: "documents",
component: DocumentsView,
meta: { title: "Документы", requiresAuth: true },
};
+108
View File
@@ -0,0 +1,108 @@
<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: "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>
+17
View File
@@ -0,0 +1,17 @@
import BillsView from "./views/BillsView.vue";
import BillDetailView from "./views/BillDetailView.vue";
export const supplyRoutes = [
{
path: "/bills",
name: "bills",
component: BillsView,
meta: { title: "Счета", requiresAuth: true },
},
{
path: "/bills/:id",
name: "bill-detail",
component: BillDetailView,
meta: { title: "Детали счета", back: true, requiresAuth: true },
},
];
+283
View File
@@ -0,0 +1,283 @@
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { showToast } from "vant";
import { billApi } from "../../../generated/api";
import { useModelApi } from "../../../shared/composables/useModelApi";
import DocumentApprovalTasks from "../../../shared/components/DocumentApprovalTasks.vue";
const route = useRoute();
const billId = computed(() => Number(route.params.id));
const activeTab = ref("info");
const {
item: bill,
loadingItem,
error,
retrieve: loadBill,
} = useModelApi(billApi, {
retrieveErrorMessage: "Не удалось загрузить счет",
autoLoad: false,
autoLoadOnFilterChange: false,
});
type BillRelated = Record<string, unknown>;
function parseJson(value: unknown): unknown {
if (typeof value === "string") {
if (!value.trim()) {
return null;
}
try {
return JSON.parse(value) as unknown;
} catch {
return null;
}
}
return value;
}
function parseObject(value: unknown): BillRelated | null {
const parsed = parseJson(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
return parsed as BillRelated;
}
function objectLabel(value: unknown) {
const record = parseObject(value);
if (!record) {
return "не указано";
}
const candidates = ["name", "short_name", "number", "text", "full_name", "get_status_display"];
for (const key of candidates) {
const candidate = record[key];
if (typeof candidate === "string" && candidate.trim()) {
return candidate;
}
}
return "не указано";
}
function money(value: number | null | undefined) {
if (typeof value !== "number") {
return "не указано";
}
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 2,
}).format(value);
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === "") {
return "не указано";
}
return String(value);
}
async function openScan() {
if (!bill.value?.scan) {
showToast("У счета нет файла");
return;
}
await invoke("open_remote_file", { url: bill.value.scan });
}
function relatedDocuments() {
const raw = parseJson(bill.value?.transferdocument_set);
if (!Array.isArray(raw)) {
return [];
}
return raw;
}
onMounted(() => {
if (Number.isFinite(billId.value)) {
loadBill(billId.value);
}
});
</script>
<template>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<section class="card detail-card">
<van-loading v-if="loadingItem" class="state" type="spinner">Загрузка...</van-loading>
<van-empty v-else-if="!bill" description="Счет не найден" />
<template v-else>
<div class="bill-summary">
<div class="bill-summary__title">{{ bill.text || bill.number || `Счет #${bill.id}` }}</div>
<div class="bill-summary__meta">
{{ bill.number || 'не указан' }} · {{ bill.date_bill || bill.date }}
</div>
<div class="bill-summary__badges">
<van-tag type="primary">{{ bill.status_name || bill.status }}</van-tag>
<van-tag plain>{{ bill.contract_typ || 'не указан' }}</van-tag>
</div>
<van-cell-group inset>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Контрагент</div>
<div class="detail-value">{{ objectLabel(bill.counterparty) }}</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Проект</div>
<div class="detail-value">{{ objectLabel(bill.project) }}</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Договор</div>
<div class="detail-value">{{ objectLabel(bill.contract) }}</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Сумма</div>
<div class="detail-value detail-value--money">{{ money(bill.cost) }}</div>
</div>
</template>
</van-cell>
</van-cell-group>
</div>
<van-tabs v-model:active="activeTab" shrink sticky class="bill-tabs">
<van-tab name="info" title="Информация">
<div class="tab-panel">
<van-cell-group>
<van-cell title="Номер" :value="formatValue(bill.number)" />
<van-cell title="Описание" :value="formatValue(bill.text)" />
<van-cell title="Дата счета" :value="formatValue(bill.date_bill)" />
<van-cell title="Дата" :value="formatValue(bill.date)" />
<van-cell title="Срок оплаты" :value="formatValue(bill.date_due)" />
<van-cell title="Статус" :value="bill.status_name || bill.status" />
<van-cell title="Тип" :value="formatValue(bill.contract_typ)" />
<van-cell title="Комментарий" :value="formatValue(bill.comment)" />
</van-cell-group>
</div>
</van-tab>
<van-tab name="finance" title="Финансы">
<div class="tab-panel">
<van-cell-group>
<van-cell title="Сумма" :value="money(bill.cost)" />
<van-cell title="Оплачено" :value="money(bill.paid)" />
<van-cell title="К оплате" :value="money(bill.to_payd)" />
<van-cell title="НДС" :value="money(bill.nds_cost)" />
<van-cell title="Реестр" :value="bill.pp_maked ? 'Да' : 'Нет'" />
<van-cell title="Архив" :value="bill.archive_s ? 'Да' : 'Нет'" />
</van-cell-group>
</div>
</van-tab>
<van-tab name="relations" title="Связи">
<div class="tab-panel">
<van-cell-group>
<van-cell title="Контрагент" :value="objectLabel(bill.counterparty)" />
<van-cell title="Проект" :value="objectLabel(bill.project)" />
<van-cell title="Договор" :value="objectLabel(bill.contract)" />
<van-cell title="ФРЦ" :value="objectLabel(bill.frc)" />
<van-cell title="Ответственный" :value="objectLabel(bill.responsible)" />
<van-cell title="Автор" :value="objectLabel(bill.author)" />
<van-cell title="Категория" :value="objectLabel(bill.category)" />
</van-cell-group>
<van-cell-group v-if="relatedDocuments().length" title="Документы">
<van-cell v-for="(doc, index) in relatedDocuments()" :key="String((doc as Record<string, unknown>).id ?? index)">
<template #title>
<div class="detail-cell">
<div class="detail-label">{{ String((doc as Record<string, unknown>).number ?? 'Документ') }}</div>
<div class="detail-value">
{{ String((doc as Record<string, unknown>).date ?? '') }}
{{ (doc as Record<string, unknown>).cost ? `· ${formatValue((doc as Record<string, unknown>).cost)}` : '' }}
</div>
</div>
</template>
</van-cell>
</van-cell-group>
</div>
</van-tab>
<van-tab name="approval" title="Согласование">
<div class="tab-panel">
<DocumentApprovalTasks
v-if="bill"
:document-id="bill.id"
filter-name="bill"
title="Согласование счета"
/>
</div>
</van-tab>
<van-tab name="file" title="Файл">
<div class="tab-panel">
<van-empty v-if="!bill.scan" description="Файл не прикреплен" />
<div v-else class="form-actions stacked-actions">
<van-button block round type="primary" plain @click="openScan">
Открыть файл
</van-button>
</div>
</div>
</van-tab>
</van-tabs>
</template>
</section>
</template>
<style scoped>
.bill-summary {
padding: 16px 16px 10px;
}
.bill-summary__title {
font-size: 22px;
font-weight: 700;
line-height: 1.25;
}
.bill-summary__meta {
margin-top: 4px;
color: var(--van-text-color-2);
font-size: 13px;
}
.bill-summary__badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
</style>
+492
View File
@@ -0,0 +1,492 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { showToast } from "vant";
import { billApi } from "../../../generated/api";
import type { Bill, BillCreate, BillListParams, BillUpdate } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
import CounterpartySelect from "../../contracts/components/CounterpartySelect.vue";
import ContractCategorySelect from "../../contracts/components/ContractCategorySelect.vue";
import FrcSelect from "../../contracts/components/FrcSelect.vue";
import ProjectSelect from "../../contracts/components/ProjectSelect.vue";
type BillFilterParams = Omit<BillListParams, "counterparty" | "frc" | "project" | "category"> & {
page?: number;
counterparty?: number;
frc?: number;
project?: number;
category?: number;
};
const PAGE_SIZE = 10;
const router = useRouter();
const {
items: bills,
filters,
count,
loading,
error,
load: loadBills,
} = useModelApi<Bill, BillCreate, BillUpdate, BillListParams>(billApi, {
defaultListParams: { ordering: "-id", page: 1 } as BillListParams,
loadErrorMessage: "Не удалось загрузить счета",
cleanListParams(params) {
params.page = params.page || 1;
params.text__contains = typeof params.text__contains === "string" ? params.text__contains.trim() || undefined : undefined;
params.number__contains = typeof params.number__contains === "string" ? params.number__contains.trim() || undefined : undefined;
params.status = params.status || undefined;
params.status_name__contains = typeof params.status_name__contains === "string" ? params.status_name__contains.trim() || undefined : undefined;
params.contract_typ = params.contract_typ || undefined;
params.date = params.date || undefined;
params.date_due = params.date_due || undefined;
params.date_bill = params.date_bill || undefined;
},
});
const billFilters = filters as unknown as BillFilterParams;
const billTextContains = computed<string>({
get() {
return typeof filters.text__contains === "string" ? filters.text__contains : "";
},
set(value) {
filters.text__contains = value || undefined;
},
});
const billNumberContains = computed<string>({
get() {
return typeof filters.number__contains === "string" ? filters.number__contains : "";
},
set(value) {
filters.number__contains = value || undefined;
},
});
const billStatus = computed<string>({
get() {
return typeof filters.status === "string" ? filters.status : "";
},
set(value) {
filters.status = value || undefined;
},
});
const billStatusNameContains = computed<string>({
get() {
return typeof filters.status_name__contains === "string" ? filters.status_name__contains : "";
},
set(value) {
filters.status_name__contains = value || undefined;
},
});
const billContractTyp = computed<string>({
get() {
return typeof filters.contract_typ === "string" ? filters.contract_typ : "";
},
set(value) {
filters.contract_typ = value || undefined;
},
});
const billDate = computed<string>({
get() {
return typeof filters.date === "string" ? filters.date : "";
},
set(value) {
filters.date = value || undefined;
},
});
const selectedDateBill = computed<string>({
get() {
return typeof filters.date_bill === "string" ? filters.date_bill : "";
},
set(value) {
filters.date_bill = value || undefined;
},
});
const selectedDateDue = computed<string>({
get() {
return typeof filters.date_due === "string" ? filters.date_due : "";
},
set(value) {
filters.date_due = value || undefined;
},
});
const showFilters = ref(false);
const syncing = ref(false);
const hasActiveFilters = computed(() =>
Boolean(
filters.text__contains ||
filters.number__contains ||
filters.status ||
filters.status_name__contains ||
filters.contract_typ ||
filters.date ||
filters.date_due ||
filters.date_bill ||
billFilters.counterparty ||
billFilters.project ||
billFilters.frc ||
billFilters.category,
),
);
const activeFilterCount = computed(
() =>
[
filters.text__contains,
filters.number__contains,
filters.status,
filters.status_name__contains,
filters.contract_typ,
filters.date,
filters.date_due,
filters.date_bill,
billFilters.counterparty,
billFilters.project,
billFilters.frc,
billFilters.category,
].filter(Boolean).length,
);
const currentPage = computed({
get() {
return billFilters.page ?? 1;
},
set(page: number) {
billFilters.page = page;
},
});
const selectedCounterpartyId = computed({
get() {
return billFilters.counterparty ?? 0;
},
set(id: number) {
billFilters.counterparty = id || undefined;
},
});
const selectedProjectId = computed({
get() {
return billFilters.project ?? 0;
},
set(id: number) {
billFilters.project = id || undefined;
},
});
const selectedFrcId = computed({
get() {
return billFilters.frc ?? 0;
},
set(id: number) {
billFilters.frc = id || undefined;
},
});
const selectedCategoryId = computed({
get() {
return billFilters.category ?? 0;
},
set(id: number) {
billFilters.category = id || undefined;
},
});
function parseObject(value: unknown): Record<string, unknown> | null {
if (typeof value === "string") {
if (!value.trim()) {
return null;
}
try {
return parseObject(JSON.parse(value) as unknown);
} catch {
return null;
}
}
if (!value || typeof value !== "object") {
return null;
}
return value as Record<string, unknown>;
}
function objectLabel(value: unknown) {
const record = parseObject(value);
if (!record) {
return "";
}
const candidates = ["name", "short_name", "number", "text", "full_name", "get_status_display"];
for (const key of candidates) {
const candidate = record[key];
if (typeof candidate === "string" && candidate.trim()) {
return candidate;
}
}
return "";
}
function resetFilters() {
filters.text__contains = undefined;
filters.number__contains = undefined;
filters.status = undefined;
filters.status_name__contains = undefined;
filters.contract_typ = undefined;
filters.date = undefined;
filters.date_due = undefined;
filters.date_bill = undefined;
billFilters.counterparty = undefined;
billFilters.project = undefined;
billFilters.frc = undefined;
billFilters.category = undefined;
billFilters.page = 1;
}
async function applyFilters() {
billFilters.page = 1;
showFilters.value = false;
await loadBills();
}
async function syncBills() {
syncing.value = true;
try {
await loadBills();
showToast("Данные обновлены с сервера");
} catch (err) {
showToast(errorMessage(err, "Не удалось синхронизировать счета"));
} finally {
syncing.value = false;
}
}
function errorMessage(err: unknown, fallback: string) {
if (typeof err === "object" && err && "detail" in err) {
return String((err as { detail: unknown }).detail);
}
if (err instanceof Error) {
return err.message;
}
return fallback;
}
watch(
() => [
filters.text__contains,
filters.number__contains,
filters.status,
filters.status_name__contains,
filters.contract_typ,
filters.date,
filters.date_due,
filters.date_bill,
billFilters.counterparty,
billFilters.project,
billFilters.frc,
billFilters.category,
],
() => {
billFilters.page = 1;
},
);
</script>
<template>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<section class="bills-page">
<van-search v-model="billTextContains" placeholder="Поиск по описанию" clearable />
<div class="bill-actions">
<van-badge :content="activeFilterCount || undefined">
<van-button size="small" plain type="primary" icon="filter-o" @click="showFilters = true">
Фильтры
</van-button>
</van-badge>
<van-button size="small" plain type="primary" :loading="loading" @click="loadBills()">
Обновить
</van-button>
<van-button size="small" type="primary" :loading="syncing" @click="syncBills">
Обновить с сервера
</van-button>
</div>
<van-popup v-model:show="showFilters" round position="bottom" class="filters-popup">
<div class="filters-sheet">
<div class="filters-header">
<h2>Фильтры</h2>
<van-button size="small" plain type="primary" @click="resetFilters">
Сбросить
</van-button>
</div>
<van-cell-group class="bill-filters">
<van-field
v-model="billNumberContains"
label="Номер"
placeholder="Номер счета"
clearable
/>
<van-field
v-model="billStatus"
label="Статус"
placeholder="Код статуса"
clearable
/>
<van-field
v-model="billStatusNameContains"
label="Статус текстом"
placeholder="Название статуса"
clearable
/>
<van-field
v-model="billContractTyp"
label="Тип"
placeholder="contract_typ"
clearable
/>
<van-field label="Контрагент">
<template #input>
<CounterpartySelect v-model="selectedCounterpartyId" />
</template>
</van-field>
<van-field label="Объект">
<template #input>
<ProjectSelect v-model="selectedProjectId" />
</template>
</van-field>
<van-field label="ЦФО">
<template #input>
<FrcSelect v-model="selectedFrcId" />
</template>
</van-field>
<van-field label="Категория">
<template #input>
<ContractCategorySelect v-model="selectedCategoryId" />
</template>
</van-field>
<van-field v-model="selectedDateBill" label="Дата счета" type="date" clearable />
<van-field v-model="selectedDateDue" label="Срок оплаты" type="date" clearable />
<van-field v-model="billDate" label="Создан" type="date" clearable />
</van-cell-group>
<div class="filters-actions">
<van-button block type="primary" @click="applyFilters">
Применить
</van-button>
</div>
</div>
</van-popup>
<van-loading v-if="loading && bills.length === 0" class="state" type="spinner">
Загрузка...
</van-loading>
<van-empty
v-else-if="bills.length === 0"
:description="hasActiveFilters ? 'Счета не найдены' : 'Счетов пока нет'"
/>
<template v-else>
<van-cell-group>
<van-cell
v-for="bill in bills"
:key="bill.id"
:title="bill.text || bill.number || `Счет #${bill.id}`"
:label="`№ ${bill.number || 'не указан'} · ${objectLabel(bill.counterparty) || 'не указан'}`"
center
is-link
@click="router.push(`/bills/${bill.id}`)"
>
<template #right-icon>
<van-tag :type="bill.status === '5' ? 'success' : bill.status === '3' ? 'warning' : 'primary'">
{{ bill.status_name || bill.status }}
</van-tag>
</template>
</van-cell>
</van-cell-group>
<van-pagination
v-model="currentPage"
class="bill-pagination"
:total-items="count"
:items-per-page="PAGE_SIZE"
prev-text="Назад"
next-text="Вперед"
mode="simple"
/>
</template>
</section>
</template>
<style scoped>
.bills-page {
padding-bottom: 12px;
}
.bill-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 12px 10px;
overflow-x: auto;
}
.filters-popup {
max-height: 85vh;
}
.filters-sheet {
padding: 14px 0 18px;
}
.filters-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px 8px;
}
.filters-header h2 {
margin: 0;
font-size: 18px;
}
.bill-filters {
margin-bottom: 8px;
}
.filters-actions {
padding: 4px 12px 0;
}
.bill-pagination {
margin: 10px 12px 0;
}
</style>
+11 -13
View File
@@ -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,
};
+21 -2
View File
@@ -2,9 +2,24 @@
import { computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { taskApi } from "../../../generated/api";
import type { Employee, Task } from "../../../generated/models";
import RemoteImage from "../../../shared/components/RemoteImage.vue";
import { useModelApi } from "../../../shared/composables/useModelApi";
type TaskMessage = {
id: number;
author: Employee;
recipient: Employee;
text: string;
date: string;
status: string;
task: number;
};
type TaskDetail = Omit<Task, "message_set"> & {
message_set?: TaskMessage[] | null;
};
const route = useRoute();
const router = useRouter();
const taskId = computed(() => Number(route.params.id));
@@ -39,6 +54,10 @@ function formatBoolean(value: boolean) {
return value ? "Да" : "Нет";
}
function messageList(taskItem: Task | null | undefined) {
return (taskItem as TaskDetail | null | undefined)?.message_set ?? [];
}
onMounted(() => {
if (Number.isFinite(taskId.value)) {
loadTask(taskId.value);
@@ -90,10 +109,10 @@ onMounted(() => {
<h2>Диалог</h2>
</div>
<van-empty v-if="!task?.message_set?.length" description="Сообщений пока нет" />
<van-empty v-if="!messageList(task).length" description="Сообщений пока нет" />
<div v-else class="message-list">
<article v-for="message in task.message_set" :key="message.id" class="message-item">
<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>
+44 -6
View File
@@ -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 });
+8 -1
View File
@@ -1,5 +1,9 @@
import { createModelApi } from "./api_client";
import type {
Bill,
BillCreate,
BillUpdate,
BillListParams,
Contract,
ContractCreate,
ContractUpdate,
@@ -37,6 +41,8 @@ import type {
TaskUpdate,
TaskListParams,
TaskTransfer,
TaskTransferCreate,
TaskTransferUpdate,
TaskTransferListParams,
User,
UserCreate,
@@ -44,6 +50,7 @@ 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");
@@ -53,5 +60,5 @@ export const frcApi = createModelApi<Frc, FrcCreate, FrcUpdate, FrcListParams>("
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");
+213 -214
View File
@@ -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;
@@ -296,21 +415,24 @@ export interface FrcListParams extends ListParams {
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 +441,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 +476,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 +523,85 @@ 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;
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 +623,4 @@ export interface UserListParams extends ListParams {
name?: string;
name__contains?: string;
}
@@ -0,0 +1,591 @@
<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;
}
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">
{{ task.text?.trim() || task.result?.trim() || `Задача #${task.id}` }}
</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="task.doer?.avatar_small ?? ''"
/>
<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
class="approval-person-avatar"
round
width="32"
height="32"
: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 ?? ''"
/>
<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>
</van-cell-group>
<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 {
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;
}
.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";
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>