fix
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
use che_tauri::{Filter, FilterSet};
|
use che_tauri::{Filter, FilterSet};
|
||||||
|
|
||||||
use super::models::{Contract, ContractApplicationFile, Counterparty};
|
use super::models::{Contract, ContractApplicationFile, ContractCategory, Counterparty};
|
||||||
|
|
||||||
static CONTRACTAPP_FILTERS: &[Filter] = &[
|
static CONTRACTAPP_FILTERS: &[Filter] = &[
|
||||||
Filter::exact("id"),
|
Filter::exact("id"),
|
||||||
@@ -16,6 +16,7 @@ static CONTRACTAPP_FILTERS: &[Filter] = &[
|
|||||||
Filter::contains("contract_type"),
|
Filter::contains("contract_type"),
|
||||||
Filter::exact("category"),
|
Filter::exact("category"),
|
||||||
Filter::contains("category"),
|
Filter::contains("category"),
|
||||||
|
Filter::exact("category_id"),
|
||||||
Filter::contains("counterparty_name"),
|
Filter::contains("counterparty_name"),
|
||||||
Filter::contains("name_of_product"),
|
Filter::contains("name_of_product"),
|
||||||
Filter::exact("counterparty_id"),
|
Filter::exact("counterparty_id"),
|
||||||
@@ -24,6 +25,14 @@ static CONTRACTAPP_FILTERS: &[Filter] = &[
|
|||||||
Filter::exact("employee_id"),
|
Filter::exact("employee_id"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
static CONTRACT_CATEGORY_FILTERS: &[Filter] = &[
|
||||||
|
Filter::exact("id"),
|
||||||
|
Filter::exact("name"),
|
||||||
|
Filter::contains("name"),
|
||||||
|
Filter::exact("name_group"),
|
||||||
|
Filter::contains("name_group"),
|
||||||
|
];
|
||||||
|
|
||||||
static COUNTERPARTY_FILTERS: &[Filter] = &[
|
static COUNTERPARTY_FILTERS: &[Filter] = &[
|
||||||
Filter::exact("id"),
|
Filter::exact("id"),
|
||||||
Filter::exact("name"),
|
Filter::exact("name"),
|
||||||
@@ -43,6 +52,10 @@ pub fn contractapp_filterset() -> FilterSet<Contract> {
|
|||||||
FilterSet::new(CONTRACTAPP_FILTERS)
|
FilterSet::new(CONTRACTAPP_FILTERS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn contract_category_filterset() -> FilterSet<ContractCategory> {
|
||||||
|
FilterSet::new(CONTRACT_CATEGORY_FILTERS)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn counterparty_filterset() -> FilterSet<Counterparty> {
|
pub fn counterparty_filterset() -> FilterSet<Counterparty> {
|
||||||
FilterSet::new(COUNTERPARTY_FILTERS)
|
FilterSet::new(COUNTERPARTY_FILTERS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS contract_category (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
name_group TEXT NOT NULL,
|
||||||
|
template TEXT,
|
||||||
|
is_questionnair BOOLEAN NOT NULL,
|
||||||
|
parent INTEGER,
|
||||||
|
secure_group INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE contractapp ADD COLUMN category_id INTEGER REFERENCES contract_category(id);
|
||||||
@@ -173,6 +173,88 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"table": "contract_category",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ty": "integer",
|
||||||
|
"primary_key": true,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": true,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name_group",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "template",
|
||||||
|
"ty": "text",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": true,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "is_questionnair",
|
||||||
|
"ty": "boolean",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": false,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parent",
|
||||||
|
"ty": "integer",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": true,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "secure_group",
|
||||||
|
"ty": "integer",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": true,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"table": "contractapp",
|
"table": "contractapp",
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -242,6 +324,20 @@
|
|||||||
"default": null,
|
"default": null,
|
||||||
"foreign_key": null
|
"foreign_key": null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "category_id",
|
||||||
|
"ty": "integer",
|
||||||
|
"primary_key": false,
|
||||||
|
"nullable": true,
|
||||||
|
"auto": false,
|
||||||
|
"unique": false,
|
||||||
|
"max_length": null,
|
||||||
|
"default": null,
|
||||||
|
"foreign_key": {
|
||||||
|
"table": "contract_category",
|
||||||
|
"column": "id"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "amount_total_display",
|
"name": "amount_total_display",
|
||||||
"ty": "text",
|
"ty": "text",
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ impl AppModule for ContractappModule {
|
|||||||
serializers::contractapp_serializer(),
|
serializers::contractapp_serializer(),
|
||||||
filters::contractapp_filterset(),
|
filters::contractapp_filterset(),
|
||||||
);
|
);
|
||||||
|
ctx.resource::<models::ContractCategory>(
|
||||||
|
"contract_category",
|
||||||
|
serializers::contract_category_serializer(),
|
||||||
|
filters::contract_category_filterset(),
|
||||||
|
);
|
||||||
ctx.resource::<models::Counterparty>(
|
ctx.resource::<models::Counterparty>(
|
||||||
"counterparty",
|
"counterparty",
|
||||||
serializers::counterparty_serializer(),
|
serializers::counterparty_serializer(),
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ pub struct Contract {
|
|||||||
pub absolute_url: String,
|
pub absolute_url: String,
|
||||||
pub contract_type: String,
|
pub contract_type: String,
|
||||||
pub category: String,
|
pub category: String,
|
||||||
|
|
||||||
|
#[field(foreign_key = ContractCategory)]
|
||||||
|
pub category_id: Option<i64>,
|
||||||
|
|
||||||
pub amount_total_display: String,
|
pub amount_total_display: String,
|
||||||
pub amount_by_ds: String,
|
pub amount_by_ds: String,
|
||||||
pub comment: String,
|
pub comment: String,
|
||||||
@@ -48,6 +52,20 @@ pub struct Contract {
|
|||||||
pub employee_id: Option<i64>,
|
pub employee_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Model)]
|
||||||
|
#[model(table = "contract_category")]
|
||||||
|
pub struct ContractCategory {
|
||||||
|
#[field(primary_key)]
|
||||||
|
pub id: i64,
|
||||||
|
|
||||||
|
pub name: String,
|
||||||
|
pub name_group: String,
|
||||||
|
pub template: Option<String>,
|
||||||
|
pub is_questionnair: bool,
|
||||||
|
pub parent: Option<i64>,
|
||||||
|
pub secure_group: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Model)]
|
#[derive(Debug, Clone, Model)]
|
||||||
#[model(table = "counterparty")]
|
#[model(table = "counterparty")]
|
||||||
pub struct Counterparty {
|
pub struct Counterparty {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::apps::{
|
|||||||
projectapp::{models::Project, serializers::project_serializer},
|
projectapp::{models::Project, serializers::project_serializer},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::models::{Contract, ContractApplicationFile, Counterparty};
|
use super::models::{Contract, ContractApplicationFile, ContractCategory, Counterparty};
|
||||||
|
|
||||||
static CONTRACT_FIELDS: &[Field] = &[
|
static CONTRACT_FIELDS: &[Field] = &[
|
||||||
Field::new("id").read_only(),
|
Field::new("id").read_only(),
|
||||||
@@ -15,6 +15,7 @@ static CONTRACT_FIELDS: &[Field] = &[
|
|||||||
Field::new("absolute_url"),
|
Field::new("absolute_url"),
|
||||||
Field::new("contract_type"),
|
Field::new("contract_type"),
|
||||||
Field::new("category"),
|
Field::new("category"),
|
||||||
|
Field::new("category_id").required(false).nullable(),
|
||||||
Field::new("amount_total_display"),
|
Field::new("amount_total_display"),
|
||||||
Field::new("amount_by_ds"),
|
Field::new("amount_by_ds"),
|
||||||
Field::new("comment"),
|
Field::new("comment"),
|
||||||
@@ -38,12 +39,22 @@ static CONTRACT_FIELDS: &[Field] = &[
|
|||||||
Field::new("project_id").required(false).nullable(),
|
Field::new("project_id").required(false).nullable(),
|
||||||
Field::new("frc_id").required(false).nullable(),
|
Field::new("frc_id").required(false).nullable(),
|
||||||
Field::new("employee_id").required(false).nullable(),
|
Field::new("employee_id").required(false).nullable(),
|
||||||
|
Field::related("category_ref", "category_id", &CATEGORY_RELATION),
|
||||||
Field::related("counterparty", "counterparty_id", &COUNTERPARTY_RELATION),
|
Field::related("counterparty", "counterparty_id", &COUNTERPARTY_RELATION),
|
||||||
Field::related("project", "project_id", &PROJECT_RELATION),
|
Field::related("project", "project_id", &PROJECT_RELATION),
|
||||||
Field::related("frc", "frc_id", &FRC_RELATION),
|
Field::related("frc", "frc_id", &FRC_RELATION),
|
||||||
Field::related("employee", "employee_id", &EMPLOYEE_RELATION),
|
Field::related("employee", "employee_id", &EMPLOYEE_RELATION),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
static CONTRACT_CATEGORY_FIELDS: &[Field] = &[
|
||||||
|
Field::new("id").read_only(),
|
||||||
|
Field::new("name"),
|
||||||
|
Field::new("name_group"),
|
||||||
|
Field::new("template").required(false).nullable(),
|
||||||
|
Field::new("is_questionnair"),
|
||||||
|
Field::new("parent").required(false).nullable(),
|
||||||
|
Field::new("secure_group").required(false).nullable(),
|
||||||
|
];
|
||||||
static COUNTERPARTY_FIELDS: &[Field] = &[Field::new("id").read_only(), Field::new("name")];
|
static COUNTERPARTY_FIELDS: &[Field] = &[Field::new("id").read_only(), Field::new("name")];
|
||||||
static CONTRACT_APPLICATION_FILE_FIELDS: &[Field] = &[
|
static CONTRACT_APPLICATION_FILE_FIELDS: &[Field] = &[
|
||||||
Field::new("id").read_only(),
|
Field::new("id").read_only(),
|
||||||
@@ -65,6 +76,8 @@ static CONTRACT_APPLICATION_FILE_FIELDS: &[Field] = &[
|
|||||||
];
|
];
|
||||||
static COUNTERPARTY_RELATION: RelatedModel<Counterparty> =
|
static COUNTERPARTY_RELATION: RelatedModel<Counterparty> =
|
||||||
RelatedModel::new(counterparty_serializer);
|
RelatedModel::new(counterparty_serializer);
|
||||||
|
static CATEGORY_RELATION: RelatedModel<ContractCategory> =
|
||||||
|
RelatedModel::new(contract_category_serializer);
|
||||||
static CONTRACT_RELATION: RelatedModel<Contract> = RelatedModel::new(contractapp_serializer);
|
static CONTRACT_RELATION: RelatedModel<Contract> = RelatedModel::new(contractapp_serializer);
|
||||||
static PROJECT_RELATION: RelatedModel<Project> = RelatedModel::new(project_serializer);
|
static PROJECT_RELATION: RelatedModel<Project> = RelatedModel::new(project_serializer);
|
||||||
static FRC_RELATION: RelatedModel<Frc> = RelatedModel::new(frc_serializer);
|
static FRC_RELATION: RelatedModel<Frc> = RelatedModel::new(frc_serializer);
|
||||||
@@ -74,6 +87,10 @@ pub fn contractapp_serializer() -> ModelSerializer<Contract> {
|
|||||||
ModelSerializer::new(CONTRACT_FIELDS)
|
ModelSerializer::new(CONTRACT_FIELDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn contract_category_serializer() -> ModelSerializer<ContractCategory> {
|
||||||
|
ModelSerializer::new(CONTRACT_CATEGORY_FIELDS)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn counterparty_serializer() -> ModelSerializer<Counterparty> {
|
pub fn counterparty_serializer() -> ModelSerializer<Counterparty> {
|
||||||
ModelSerializer::new(COUNTERPARTY_FIELDS)
|
ModelSerializer::new(COUNTERPARTY_FIELDS)
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-5
@@ -18,6 +18,12 @@ struct ContractPage {
|
|||||||
results: Vec<RemoteContract>,
|
results: Vec<RemoteContract>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ContractCategoryPage {
|
||||||
|
links: PageLinks,
|
||||||
|
results: Vec<RemoteContractCategory>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct PageLinks {
|
struct PageLinks {
|
||||||
next: Option<String>,
|
next: Option<String>,
|
||||||
@@ -34,6 +40,8 @@ struct RemoteContract {
|
|||||||
contract_type: String,
|
contract_type: String,
|
||||||
#[serde(rename = "get_category")]
|
#[serde(rename = "get_category")]
|
||||||
category: String,
|
category: String,
|
||||||
|
#[serde(default)]
|
||||||
|
category_id: Option<i64>,
|
||||||
#[serde(rename = "get_amount_total")]
|
#[serde(rename = "get_amount_total")]
|
||||||
amount_total_display: String,
|
amount_total_display: String,
|
||||||
#[serde(rename = "get_amount_by_ds")]
|
#[serde(rename = "get_amount_by_ds")]
|
||||||
@@ -62,6 +70,17 @@ struct RemoteContract {
|
|||||||
get_employee: Option<RemoteEmployee>,
|
get_employee: Option<RemoteEmployee>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RemoteContractCategory {
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
name_group: String,
|
||||||
|
template: Option<String>,
|
||||||
|
is_questionnair: bool,
|
||||||
|
parent: Option<i64>,
|
||||||
|
secure_group: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct RemoteBillCost {
|
struct RemoteBillCost {
|
||||||
#[serde(rename = "cost__sum")]
|
#[serde(rename = "cost__sum")]
|
||||||
@@ -151,6 +170,8 @@ pub async fn sync_contracts(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ApiError::bad_request("sync requires [remote].base_url config"))?;
|
.ok_or_else(|| ApiError::bad_request("sync requires [remote].base_url config"))?;
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
sync_contract_categories(api, &client, &token, &remote.base_url).await?;
|
||||||
|
|
||||||
let mut next_url = Some(format!(
|
let mut next_url = Some(format!(
|
||||||
"{}/api/contract/?page_size=100",
|
"{}/api/contract/?page_size=100",
|
||||||
remote.base_url.trim_end_matches('/')
|
remote.base_url.trim_end_matches('/')
|
||||||
@@ -182,12 +203,14 @@ pub async fn sync_contracts(
|
|||||||
for contract in &page.results {
|
for contract in &page.results {
|
||||||
upsert_contract_dependencies(api, contract).await?;
|
upsert_contract_dependencies(api, contract).await?;
|
||||||
upsert_contract(api, contract).await?;
|
upsert_contract(api, contract).await?;
|
||||||
let detail = fetch_contract_detail(&client, &token, &remote.base_url, contract.id).await?;
|
let detail =
|
||||||
|
fetch_contract_detail(&client, &token, &remote.base_url, contract.id).await?;
|
||||||
|
|
||||||
for application in &detail.contractapplicationfile_set {
|
for application in &detail.contractapplicationfile_set {
|
||||||
let local_path =
|
let local_path =
|
||||||
download_application_file(&client, &token, &app_data_dir, application).await?;
|
download_application_file(&client, &token, &app_data_dir, application).await?;
|
||||||
upsert_contract_application_file(api, contract.id, application, &local_path).await?;
|
upsert_contract_application_file(api, contract.id, application, &local_path)
|
||||||
|
.await?;
|
||||||
applications += 1;
|
applications += 1;
|
||||||
if !local_path.is_empty() {
|
if !local_path.is_empty() {
|
||||||
files_downloaded += 1;
|
files_downloaded += 1;
|
||||||
@@ -208,6 +231,74 @@ pub async fn sync_contracts(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn sync_contract_categories(
|
||||||
|
api: &TauriApi,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
base_url: &str,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let mut next_url = Some(format!(
|
||||||
|
"{}/api/cont/category/?page_size=100",
|
||||||
|
base_url.trim_end_matches('/')
|
||||||
|
));
|
||||||
|
|
||||||
|
while let Some(url) = next_url {
|
||||||
|
let response = client
|
||||||
|
.get(url)
|
||||||
|
.header(reqwest::header::AUTHORIZATION, format!("Token {token}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let detail = response.text().await.unwrap_or_default();
|
||||||
|
return Err(ApiError::new(
|
||||||
|
"remote_error",
|
||||||
|
format!("remote category request failed with {status}: {detail}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = response.json::<ContractCategoryPage>().await?;
|
||||||
|
for category in &page.results {
|
||||||
|
upsert_contract_category(api, category).await?;
|
||||||
|
}
|
||||||
|
next_url = page.links.next;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_contract_category(
|
||||||
|
api: &TauriApi,
|
||||||
|
category: &RemoteContractCategory,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO contract_category (
|
||||||
|
id, name, name_group, template, is_questionnair, parent, secure_group
|
||||||
|
)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
name_group = excluded.name_group,
|
||||||
|
template = excluded.template,
|
||||||
|
is_questionnair = excluded.is_questionnair,
|
||||||
|
parent = excluded.parent,
|
||||||
|
secure_group = excluded.secure_group",
|
||||||
|
)
|
||||||
|
.bind(category.id)
|
||||||
|
.bind(&category.name)
|
||||||
|
.bind(&category.name_group)
|
||||||
|
.bind(category.template.as_deref())
|
||||||
|
.bind(category.is_questionnair)
|
||||||
|
.bind(category.parent)
|
||||||
|
.bind(category.secure_group)
|
||||||
|
.execute(api.state().db().pool())
|
||||||
|
.await
|
||||||
|
.map_err(database_error)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn fetch_contract_detail(
|
async fn fetch_contract_detail(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
token: &str,
|
token: &str,
|
||||||
@@ -250,7 +341,11 @@ async fn download_application_file(
|
|||||||
.await
|
.await
|
||||||
.map_err(file_error)?;
|
.map_err(file_error)?;
|
||||||
|
|
||||||
let file_name = format!("{}_{}", application.id, sanitize_file_name(&application.scan_name));
|
let file_name = format!(
|
||||||
|
"{}_{}",
|
||||||
|
application.id,
|
||||||
|
sanitize_file_name(&application.scan_name)
|
||||||
|
);
|
||||||
let path = directory.join(file_name);
|
let path = directory.join(file_name);
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
@@ -379,7 +474,7 @@ async fn upsert_contract_dependencies(
|
|||||||
async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<(), ApiError> {
|
async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<(), ApiError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO contractapp (
|
"INSERT INTO contractapp (
|
||||||
id, name, number, absolute_url, contract_type, category, amount_total_display,
|
id, name, number, absolute_url, contract_type, category, category_id, amount_total_display,
|
||||||
amount_by_ds, comment, date, status_name, status, nds, name_of_product,
|
amount_by_ds, comment, date, status_name, status, nds, name_of_product,
|
||||||
counterparty_name, amount, month_pay, avans_pay, amount_total, estimate_nds_cost,
|
counterparty_name, amount, month_pay, avans_pay, amount_total, estimate_nds_cost,
|
||||||
estimate_nds_cert, bill_cost_sum, bill_paid_sum, income_total, arrears,
|
estimate_nds_cert, bill_cost_sum, bill_paid_sum, income_total, arrears,
|
||||||
@@ -387,7 +482,7 @@ async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<()
|
|||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
||||||
?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29
|
?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30
|
||||||
)
|
)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
@@ -395,6 +490,7 @@ async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<()
|
|||||||
absolute_url = excluded.absolute_url,
|
absolute_url = excluded.absolute_url,
|
||||||
contract_type = excluded.contract_type,
|
contract_type = excluded.contract_type,
|
||||||
category = excluded.category,
|
category = excluded.category,
|
||||||
|
category_id = excluded.category_id,
|
||||||
amount_total_display = excluded.amount_total_display,
|
amount_total_display = excluded.amount_total_display,
|
||||||
amount_by_ds = excluded.amount_by_ds,
|
amount_by_ds = excluded.amount_by_ds,
|
||||||
comment = excluded.comment,
|
comment = excluded.comment,
|
||||||
@@ -425,6 +521,7 @@ async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<()
|
|||||||
.bind(&contract.absolute_url)
|
.bind(&contract.absolute_url)
|
||||||
.bind(&contract.contract_type)
|
.bind(&contract.contract_type)
|
||||||
.bind(&contract.category)
|
.bind(&contract.category)
|
||||||
|
.bind(contract.category_id)
|
||||||
.bind(&contract.amount_total_display)
|
.bind(&contract.amount_total_display)
|
||||||
.bind(&contract.amount_by_ds)
|
.bind(&contract.amount_by_ds)
|
||||||
.bind(&contract.comment)
|
.bind(&contract.comment)
|
||||||
|
|||||||
+17
-112
@@ -47,118 +47,23 @@ async function logoutAndRedirect() {
|
|||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<van-tabbar v-if="showShellNavigation" v-model="activeTab" route placeholder safe-area-inset-bottom>
|
<van-tabbar
|
||||||
<van-tabbar-item to="/tasks" name="/tasks" icon="todo-list-o">Задачи</van-tabbar-item>
|
v-if="showShellNavigation"
|
||||||
<van-tabbar-item to="/contracts" name="/contracts" icon="orders-o">Контракты</van-tabbar-item>
|
v-model="activeTab"
|
||||||
<van-tabbar-item to="/users" name="/users" icon="friends-o">Пользователи</van-tabbar-item>
|
route
|
||||||
|
placeholder
|
||||||
|
safe-area-inset-bottom
|
||||||
|
>
|
||||||
|
<van-tabbar-item to="/tasks" name="/tasks" icon="todo-list-o"
|
||||||
|
>Задачи</van-tabbar-item
|
||||||
|
>
|
||||||
|
<van-tabbar-item to="/contracts" name="/contracts" icon="orders-o"
|
||||||
|
>Контракты</van-tabbar-item
|
||||||
|
>
|
||||||
|
<van-tabbar-item to="/users" name="/users" icon="friends-o"
|
||||||
|
>Пользователи</van-tabbar-item
|
||||||
|
>
|
||||||
</van-tabbar>
|
</van-tabbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style></style>
|
||||||
:root {
|
|
||||||
font-family:
|
|
||||||
Inter,
|
|
||||||
ui-sans-serif,
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
"Segoe UI",
|
|
||||||
sans-serif;
|
|
||||||
color: #172033;
|
|
||||||
background: #f7f8fa;
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f7f8fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: min(760px, 100%);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px 14px 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notice {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 12px 40px rgba(36, 42, 56, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
padding: 14px 16px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-card {
|
|
||||||
padding: 14px 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 0 16px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stacked-actions {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state {
|
|
||||||
justify-content: center;
|
|
||||||
padding: 34px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-card {
|
|
||||||
padding: 14px 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-actions {
|
|
||||||
padding: 16px 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employee-popup {
|
|
||||||
box-sizing: border-box;
|
|
||||||
max-height: 78vh;
|
|
||||||
padding: 16px 0 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employee-popup-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 0 16px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employee-avatar {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { contractCategoryApi } from "../generated/api";
|
||||||
|
import type { ContractCategoryListParams } from "../generated/models";
|
||||||
|
import { useModelApi } from "../composables/useModelApi";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
const selectedCategoryId = defineModel<number>({ required: true });
|
||||||
|
const search = ref("");
|
||||||
|
const showSelector = ref(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: categories,
|
||||||
|
filters,
|
||||||
|
loading,
|
||||||
|
} = useModelApi(contractCategoryApi, {
|
||||||
|
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as ContractCategoryListParams,
|
||||||
|
loadErrorMessage: "Не удалось загрузить категории",
|
||||||
|
cleanListParams(params) {
|
||||||
|
params.name__contains = params.name__contains?.trim() || undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCategory = computed(() =>
|
||||||
|
categories.value.find((category) => category.id === selectedCategoryId.value),
|
||||||
|
);
|
||||||
|
const selectedCategoryName = computed(() => selectedCategory.value?.name ?? "все");
|
||||||
|
|
||||||
|
function openSelector() {
|
||||||
|
search.value = "";
|
||||||
|
showSelector.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCategory(id: number) {
|
||||||
|
selectedCategoryId.value = id;
|
||||||
|
showSelector.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(search, (value) => {
|
||||||
|
filters.name__contains = value.trim();
|
||||||
|
filters.offset = 0;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button class="entity-select" type="button" @click="openSelector">
|
||||||
|
<span>{{ selectedCategoryName }}</span>
|
||||||
|
<van-icon name="arrow" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<van-popup v-model:show="showSelector" round position="bottom" class="entity-popup">
|
||||||
|
<div class="entity-popup-header">
|
||||||
|
<h2>Выберите категорию</h2>
|
||||||
|
<van-button size="small" type="primary" plain @click="selectCategory(0)">Все</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-search v-model="search" placeholder="Поиск по категории" />
|
||||||
|
|
||||||
|
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell
|
||||||
|
v-for="category in categories"
|
||||||
|
:key="category.id"
|
||||||
|
:title="category.name"
|
||||||
|
:label="category.name_group || `ID: ${category.id}`"
|
||||||
|
clickable
|
||||||
|
center
|
||||||
|
@click="selectCategory(category.id)"
|
||||||
|
>
|
||||||
|
<template #right-icon>
|
||||||
|
<van-icon v-if="selectedCategoryId === category.id" name="success" color="#1989fa" />
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<van-empty v-if="categories.length === 0" description="Категории не найдены" />
|
||||||
|
</template>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.entity-popup {
|
||||||
|
min-height: 55vh;
|
||||||
|
padding: 18px 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 18px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-popup-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 36px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #323233;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-select .van-icon {
|
||||||
|
color: #969799;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { frcApi } from "../generated/api";
|
||||||
|
import type { FrcListParams } from "../generated/models";
|
||||||
|
import { useModelApi } from "../composables/useModelApi";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const selectedFrcId = defineModel<number>({ required: true });
|
||||||
|
const search = ref("");
|
||||||
|
const showSelector = ref(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: frcs,
|
||||||
|
filters,
|
||||||
|
loading,
|
||||||
|
} = useModelApi(frcApi, {
|
||||||
|
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as FrcListParams,
|
||||||
|
loadErrorMessage: "Не удалось загрузить ФРЦ",
|
||||||
|
cleanListParams(params) {
|
||||||
|
params.name__contains = params.name__contains?.trim() || undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedFrc = computed(() => frcs.value.find((frc) => frc.id === selectedFrcId.value));
|
||||||
|
const selectedFrcName = computed(() => selectedFrc.value?.name ?? "все");
|
||||||
|
|
||||||
|
function openSelector() {
|
||||||
|
search.value = "";
|
||||||
|
showSelector.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFrc(id: number) {
|
||||||
|
selectedFrcId.value = id;
|
||||||
|
showSelector.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(search, (value) => {
|
||||||
|
filters.name__contains = value.trim();
|
||||||
|
filters.offset = 0;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button class="entity-select" type="button" @click="openSelector">
|
||||||
|
<span>{{ selectedFrcName }}</span>
|
||||||
|
<van-icon name="arrow" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<van-popup v-model:show="showSelector" round position="bottom" class="entity-popup">
|
||||||
|
<div class="entity-popup-header">
|
||||||
|
<h2>Выберите ФРЦ</h2>
|
||||||
|
<van-button size="small" type="primary" plain @click="selectFrc(0)">Все</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-search v-model="search" placeholder="Поиск по названию" />
|
||||||
|
|
||||||
|
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell
|
||||||
|
v-for="frc in frcs"
|
||||||
|
:key="frc.id"
|
||||||
|
:title="frc.name"
|
||||||
|
:label="`Баланс: ${frc.balance}`"
|
||||||
|
clickable
|
||||||
|
center
|
||||||
|
@click="selectFrc(frc.id)"
|
||||||
|
>
|
||||||
|
<template #right-icon>
|
||||||
|
<van-icon v-if="selectedFrcId === frc.id" name="success" color="#1989fa" />
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<van-empty v-if="frcs.length === 0" description="ФРЦ не найдены" />
|
||||||
|
</template>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.entity-popup {
|
||||||
|
min-height: 55vh;
|
||||||
|
padding: 18px 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 18px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-popup-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 36px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #323233;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-select .van-icon {
|
||||||
|
color: #969799;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { projectApi } from "../generated/api";
|
||||||
|
import type { ProjectListParams } from "../generated/models";
|
||||||
|
import { useModelApi } from "../composables/useModelApi";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const selectedProjectId = defineModel<number>({ required: true });
|
||||||
|
const search = ref("");
|
||||||
|
const showSelector = ref(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: projects,
|
||||||
|
filters,
|
||||||
|
loading,
|
||||||
|
} = useModelApi(projectApi, {
|
||||||
|
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as ProjectListParams,
|
||||||
|
loadErrorMessage: "Не удалось загрузить проекты",
|
||||||
|
cleanListParams(params) {
|
||||||
|
params.name__contains = params.name__contains?.trim() || undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedProject = computed(() =>
|
||||||
|
projects.value.find((project) => project.id === selectedProjectId.value),
|
||||||
|
);
|
||||||
|
const selectedProjectName = computed(
|
||||||
|
() => selectedProject.value?.short_name || selectedProject.value?.name || "все",
|
||||||
|
);
|
||||||
|
|
||||||
|
function openSelector() {
|
||||||
|
search.value = "";
|
||||||
|
showSelector.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectProject(id: number) {
|
||||||
|
selectedProjectId.value = id;
|
||||||
|
showSelector.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(search, (value) => {
|
||||||
|
filters.name__contains = value.trim();
|
||||||
|
filters.offset = 0;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button class="entity-select" type="button" @click="openSelector">
|
||||||
|
<span>{{ selectedProjectName }}</span>
|
||||||
|
<van-icon name="arrow" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<van-popup v-model:show="showSelector" round position="bottom" class="entity-popup">
|
||||||
|
<div class="entity-popup-header">
|
||||||
|
<h2>Выберите проект</h2>
|
||||||
|
<van-button size="small" type="primary" plain @click="selectProject(0)">Все</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-search v-model="search" placeholder="Поиск по проекту" />
|
||||||
|
|
||||||
|
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell
|
||||||
|
v-for="project in projects"
|
||||||
|
:key="project.id"
|
||||||
|
:title="project.short_name || project.name"
|
||||||
|
:label="project.full_name || `ID: ${project.id}`"
|
||||||
|
clickable
|
||||||
|
center
|
||||||
|
@click="selectProject(project.id)"
|
||||||
|
>
|
||||||
|
<template #right-icon>
|
||||||
|
<van-icon v-if="selectedProjectId === project.id" name="success" color="#1989fa" />
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<van-empty v-if="projects.length === 0" description="Проекты не найдены" />
|
||||||
|
</template>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.entity-popup {
|
||||||
|
min-height: 55vh;
|
||||||
|
padding: 18px 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 18px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-popup-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 36px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #323233;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-select .van-icon {
|
||||||
|
color: #969799;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,10 @@ import type {
|
|||||||
ContractApplicationFileCreate,
|
ContractApplicationFileCreate,
|
||||||
ContractApplicationFileUpdate,
|
ContractApplicationFileUpdate,
|
||||||
ContractApplicationFileListParams,
|
ContractApplicationFileListParams,
|
||||||
|
ContractCategory,
|
||||||
|
ContractCategoryCreate,
|
||||||
|
ContractCategoryUpdate,
|
||||||
|
ContractCategoryListParams,
|
||||||
Counterparty,
|
Counterparty,
|
||||||
CounterpartyCreate,
|
CounterpartyCreate,
|
||||||
CounterpartyUpdate,
|
CounterpartyUpdate,
|
||||||
@@ -40,6 +44,7 @@ import type {
|
|||||||
|
|
||||||
export const contractApi = createModelApi<Contract, ContractCreate, ContractUpdate, ContractListParams>("contract");
|
export const contractApi = createModelApi<Contract, ContractCreate, ContractUpdate, ContractListParams>("contract");
|
||||||
export const contractApplicationFileApi = createModelApi<ContractApplicationFile, ContractApplicationFileCreate, ContractApplicationFileUpdate, ContractApplicationFileListParams>("contract_application_file");
|
export const contractApplicationFileApi = createModelApi<ContractApplicationFile, ContractApplicationFileCreate, ContractApplicationFileUpdate, ContractApplicationFileListParams>("contract_application_file");
|
||||||
|
export const contractCategoryApi = createModelApi<ContractCategory, ContractCategoryCreate, ContractCategoryUpdate, ContractCategoryListParams>("contract_category");
|
||||||
export const counterpartyApi = createModelApi<Counterparty, CounterpartyCreate, CounterpartyUpdate, CounterpartyListParams>("counterparty");
|
export const counterpartyApi = createModelApi<Counterparty, CounterpartyCreate, CounterpartyUpdate, CounterpartyListParams>("counterparty");
|
||||||
export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee");
|
export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee");
|
||||||
export const frcApi = createModelApi<Frc, FrcCreate, FrcUpdate, FrcListParams>("frc");
|
export const frcApi = createModelApi<Frc, FrcCreate, FrcUpdate, FrcListParams>("frc");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface Contract {
|
|||||||
absolute_url: string;
|
absolute_url: string;
|
||||||
contract_type: string;
|
contract_type: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
category_id: number | null;
|
||||||
amount_total_display: string;
|
amount_total_display: string;
|
||||||
amount_by_ds: string;
|
amount_by_ds: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
@@ -30,6 +31,7 @@ export interface Contract {
|
|||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
frc_id: number | null;
|
frc_id: number | null;
|
||||||
employee_id: number | null;
|
employee_id: number | null;
|
||||||
|
category_ref: ContractCategory | null;
|
||||||
counterparty: Counterparty | null;
|
counterparty: Counterparty | null;
|
||||||
project: Project | null;
|
project: Project | null;
|
||||||
frc: Frc | null;
|
frc: Frc | null;
|
||||||
@@ -42,6 +44,7 @@ export interface ContractCreate {
|
|||||||
absolute_url: string;
|
absolute_url: string;
|
||||||
contract_type: string;
|
contract_type: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
category_id?: number | null;
|
||||||
amount_total_display: string;
|
amount_total_display: string;
|
||||||
amount_by_ds: string;
|
amount_by_ds: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
@@ -73,6 +76,7 @@ export interface ContractUpdate {
|
|||||||
absolute_url?: string;
|
absolute_url?: string;
|
||||||
contract_type?: string;
|
contract_type?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
|
category_id?: number | null;
|
||||||
amount_total_display?: string;
|
amount_total_display?: string;
|
||||||
amount_by_ds?: string;
|
amount_by_ds?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
@@ -112,6 +116,7 @@ export interface ContractListParams extends ListParams {
|
|||||||
contract_type__contains?: string;
|
contract_type__contains?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
category__contains?: string;
|
category__contains?: string;
|
||||||
|
category_id?: number | null;
|
||||||
counterparty_name__contains?: string;
|
counterparty_name__contains?: string;
|
||||||
name_of_product__contains?: string;
|
name_of_product__contains?: string;
|
||||||
counterparty_id?: number | null;
|
counterparty_id?: number | null;
|
||||||
@@ -182,6 +187,42 @@ export interface ContractApplicationFileListParams extends ListParams {
|
|||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContractCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
name_group: string;
|
||||||
|
template: string | null;
|
||||||
|
is_questionnair: boolean;
|
||||||
|
parent: number | null;
|
||||||
|
secure_group: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractCategoryCreate {
|
||||||
|
name: string;
|
||||||
|
name_group: string;
|
||||||
|
template?: string | null;
|
||||||
|
is_questionnair: boolean;
|
||||||
|
parent?: number | null;
|
||||||
|
secure_group?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractCategoryUpdate {
|
||||||
|
name?: string;
|
||||||
|
name_group?: string;
|
||||||
|
template?: string | null;
|
||||||
|
is_questionnair?: boolean;
|
||||||
|
parent?: number | null;
|
||||||
|
secure_group?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractCategoryListParams extends ListParams {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
name__contains?: string;
|
||||||
|
name_group?: string;
|
||||||
|
name_group__contains?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Counterparty {
|
export interface Counterparty {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { openPath } from "@tauri-apps/plugin-opener";
|
import { openPath, openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { computed, onMounted } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { showToast } from "vant";
|
import { showToast } from "vant";
|
||||||
import { contractApi, contractApplicationFileApi } from "../generated/api";
|
import { contractApi, contractApplicationFileApi } from "../generated/api";
|
||||||
import type { Contract, ContractApplicationFileListParams } from "../generated/models";
|
import type {
|
||||||
|
Contract,
|
||||||
|
ContractApplicationFileListParams,
|
||||||
|
} from "../generated/models";
|
||||||
import { useModelApi } from "../composables/useModelApi";
|
import { useModelApi } from "../composables/useModelApi";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const contractId = computed(() => Number(route.params.id));
|
const contractId = computed(() => Number(route.params.id));
|
||||||
|
const activeTab = ref("info");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
item: contract,
|
item: contract,
|
||||||
@@ -40,34 +44,34 @@ type DetailField = {
|
|||||||
key: keyof Contract;
|
key: keyof Contract;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MoneyField = DetailField & {
|
||||||
|
money?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const mainFields: DetailField[] = [
|
const mainFields: DetailField[] = [
|
||||||
{ title: "ID", key: "id" },
|
|
||||||
{ title: "Название", key: "name" },
|
|
||||||
{ title: "Номер", key: "number" },
|
{ title: "Номер", key: "number" },
|
||||||
{ title: "Дата", key: "date" },
|
{ title: "Дата", key: "date" },
|
||||||
{ title: "Тип", key: "contract_type" },
|
{ title: "Тип", key: "contract_type" },
|
||||||
{ title: "Категория", key: "category" },
|
{ title: "Категория", key: "category" },
|
||||||
{ title: "Статус", key: "status_name" },
|
{ title: "Статус", key: "status_name" },
|
||||||
{ title: "Код статуса", key: "status" },
|
|
||||||
{ title: "НДС", key: "nds" },
|
{ title: "НДС", key: "nds" },
|
||||||
{ title: "Продукт", key: "name_of_product" },
|
{ title: "Продукт", key: "name_of_product" },
|
||||||
{ title: "Комментарий", key: "comment" },
|
{ title: "Комментарий", key: "comment" },
|
||||||
{ title: "URL", key: "absolute_url" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const moneyFields: DetailField[] = [
|
const moneyFields: MoneyField[] = [
|
||||||
{ title: "Сумма", key: "amount" },
|
{ title: "Платеж в месяц", key: "month_pay", money: true },
|
||||||
{ title: "Сумма итого", key: "amount_total" },
|
{ title: "Аванс", key: "avans_pay" },
|
||||||
|
{ title: "Сумма", key: "amount", money: true },
|
||||||
|
{ title: "Сумма итого", key: "amount_total", money: true },
|
||||||
{ title: "Сумма итого строкой", key: "amount_total_display" },
|
{ title: "Сумма итого строкой", key: "amount_total_display" },
|
||||||
{ title: "Сумма по ДС", key: "amount_by_ds" },
|
{ title: "Сумма по ДС", key: "amount_by_ds" },
|
||||||
{ title: "Платеж в месяц", key: "month_pay" },
|
{ title: "Смета НДС стоимость", key: "estimate_nds_cost", money: true },
|
||||||
{ title: "Аванс", key: "avans_pay" },
|
{ title: "Смета НДС сертификат", key: "estimate_nds_cert", money: true },
|
||||||
{ title: "Смета НДС стоимость", key: "estimate_nds_cost" },
|
{ title: "Счета сумма", key: "bill_cost_sum", money: true },
|
||||||
{ title: "Смета НДС сертификат", key: "estimate_nds_cert" },
|
{ title: "Счета оплачено", key: "bill_paid_sum", money: true },
|
||||||
{ title: "Счета сумма", key: "bill_cost_sum" },
|
{ title: "Доход всего", key: "income_total", money: true },
|
||||||
{ title: "Счета оплачено", key: "bill_paid_sum" },
|
{ title: "Задолженность", key: "arrears", money: true },
|
||||||
{ title: "Доход всего", key: "income_total" },
|
|
||||||
{ title: "Задолженность", key: "arrears" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const relationFields = computed(() => {
|
const relationFields = computed(() => {
|
||||||
@@ -78,20 +82,65 @@ const relationFields = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ title: "Контрагент", value: item.counterparty?.name ?? item.counterparty_name },
|
{
|
||||||
{ title: "Контрагент ID", value: item.counterparty_id },
|
title: "Контрагент",
|
||||||
|
value: item.counterparty?.name ?? item.counterparty_name,
|
||||||
|
},
|
||||||
{ title: "Проект", value: item.project?.short_name ?? item.project?.name },
|
{ title: "Проект", value: item.project?.short_name ?? item.project?.name },
|
||||||
{ title: "Проект ID", value: item.project_id },
|
|
||||||
{ title: "ФРЦ", value: item.frc?.name },
|
{ title: "ФРЦ", value: item.frc?.name },
|
||||||
{ title: "ФРЦ ID", value: item.frc_id },
|
{
|
||||||
{ title: "Баланс ФРЦ", value: item.frc?.balance },
|
title: "Сотрудник",
|
||||||
{ title: "Сотрудник", value: item.employee?.short_name ?? item.employee?.name },
|
value: item.employee?.short_name ?? item.employee?.name,
|
||||||
{ title: "Сотрудник ID", value: item.employee_id },
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
function fieldValue(field: DetailField) {
|
const approvalFields = computed(() => {
|
||||||
return formatValue(contract.value?.[field.key]);
|
const item = contract.value;
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ title: "Статус", value: item.status_name },
|
||||||
|
{ title: "Код статуса", value: item.status },
|
||||||
|
{
|
||||||
|
title: "Сотрудник",
|
||||||
|
value: item.employee?.short_name ?? item.employee?.name,
|
||||||
|
},
|
||||||
|
{ title: "Сотрудник ID", value: item.employee_id },
|
||||||
|
{ title: "Комментарий", value: item.comment },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateUrl = computed(
|
||||||
|
() => contract.value?.category_ref?.template ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryStatus = computed(
|
||||||
|
() => contract.value?.status_name ?? "не указано",
|
||||||
|
);
|
||||||
|
const summaryCategory = computed(
|
||||||
|
() =>
|
||||||
|
contract.value?.category_ref?.name ??
|
||||||
|
contract.value?.category ??
|
||||||
|
"не указано",
|
||||||
|
);
|
||||||
|
const summaryCounterparty = computed(
|
||||||
|
() =>
|
||||||
|
contract.value?.counterparty?.name ??
|
||||||
|
contract.value?.counterparty_name ??
|
||||||
|
"не указано",
|
||||||
|
);
|
||||||
|
const summaryAmount = computed(() => formatMoney(contract.value?.amount_total));
|
||||||
|
|
||||||
|
function displayValue(value: unknown) {
|
||||||
|
return formatValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayMoneyValue(value: unknown) {
|
||||||
|
return formatMoney(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(value: unknown) {
|
function formatValue(value: unknown) {
|
||||||
@@ -102,6 +151,21 @@ function formatValue(value: unknown) {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMoney(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return "не указано";
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number(value);
|
||||||
|
if (!Number.isFinite(amount)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat("ru-RU", {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
async function openApplicationFile(localPath: string) {
|
async function openApplicationFile(localPath: string) {
|
||||||
if (!localPath) {
|
if (!localPath) {
|
||||||
showToast("Файл не скачан");
|
showToast("Файл не скачан");
|
||||||
@@ -115,6 +179,19 @@ async function openApplicationFile(localPath: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openTemplateForm() {
|
||||||
|
if (!templateUrl.value) {
|
||||||
|
showToast("Типовая форма не указана");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openUrl(templateUrl.value);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(errorMessage(err, "Не удалось открыть типовую форму"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function errorMessage(err: unknown, fallback: string) {
|
function errorMessage(err: unknown, fallback: string) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
return err.message;
|
return err.message;
|
||||||
@@ -146,49 +223,195 @@ onMounted(() => {
|
|||||||
:text="viewError"
|
:text="viewError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="card detail-card">
|
<section class="">
|
||||||
<van-loading v-if="loadingItem" class="state" type="spinner">Загрузка...</van-loading>
|
<van-loading v-if="loadingItem" class="state" type="spinner"
|
||||||
|
>Загрузка...</van-loading
|
||||||
|
>
|
||||||
|
|
||||||
<van-empty v-else-if="!contract" description="Контракт не найден" />
|
<van-empty v-else-if="!contract" description="Контракт не найден" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<div class="contract-summary">
|
||||||
|
<div class="contract-summary__title">{{ contract.name }}</div>
|
||||||
|
<div class="contract-summary__meta">
|
||||||
|
№ {{ contract.number }} · {{ contract.date }}
|
||||||
|
</div>
|
||||||
|
<div class="contract-summary__badges">
|
||||||
|
<van-tag type="primary">{{ summaryStatus }}</van-tag>
|
||||||
|
<van-tag plain>{{ summaryCategory }}</van-tag>
|
||||||
|
</div>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell
|
<van-cell>
|
||||||
v-for="field in mainFields"
|
<template #title>
|
||||||
:key="field.key"
|
<div class="detail-cell">
|
||||||
:title="field.title"
|
<div class="detail-label">ЦФО</div>
|
||||||
:value="fieldValue(field)"
|
<div class="detail-value">
|
||||||
/>
|
{{ contract.frc?.name ?? "не указано" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell>
|
||||||
|
<template #title>
|
||||||
|
<div class="detail-cell">
|
||||||
|
<div class="detail-label">Объект</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
{{
|
||||||
|
contract.project?.short_name ??
|
||||||
|
contract.project?.name ??
|
||||||
|
"не указано"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
|
||||||
|
<van-cell>
|
||||||
|
<template #title>
|
||||||
|
<div class="detail-cell">
|
||||||
|
<div class="detail-label">Предмет договора</div>
|
||||||
|
<div class="detail-value detail-value--money">
|
||||||
|
{{ contract.name_of_product }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell>
|
||||||
|
<template #title>
|
||||||
|
<div class="detail-cell">
|
||||||
|
<div class="detail-label">Контрагент</div>
|
||||||
|
<div class="detail-value">{{ summaryCounterparty }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell>
|
||||||
|
<template #title>
|
||||||
|
<div class="detail-cell">
|
||||||
|
<div class="detail-label">Сумма итого</div>
|
||||||
|
<div class="detail-value detail-value--money">
|
||||||
|
{{ summaryAmount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-tabs v-model:active="activeTab" shrink sticky class="contract-tabs">
|
||||||
|
<van-tab name="info" title="Информация">
|
||||||
|
<div class="tab-panel">
|
||||||
|
<van-cell-group>
|
||||||
|
<van-cell v-for="field in mainFields" :key="field.key">
|
||||||
|
<template #title>
|
||||||
|
<div class="detail-cell">
|
||||||
|
<div class="detail-label">{{ field.title }}</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
{{ displayValue(contract?.[field.key]) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<van-cell-group inset title="Суммы">
|
<van-cell-group title="Финансы">
|
||||||
<van-cell
|
<van-cell v-for="field in moneyFields" :key="field.key">
|
||||||
v-for="field in moneyFields"
|
<template #title>
|
||||||
:key="field.key"
|
<div class="detail-cell">
|
||||||
:title="field.title"
|
<div class="detail-label">{{ field.title }}</div>
|
||||||
:value="fieldValue(field)"
|
<div class="detail-value detail-value--money">
|
||||||
/>
|
{{ displayMoneyValue(contract?.[field.key]) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<van-cell-group inset title="Связи">
|
<van-cell-group title="Связи">
|
||||||
<van-cell
|
<van-cell v-for="field in relationFields" :key="field.title">
|
||||||
v-for="field in relationFields"
|
<template #title>
|
||||||
:key="field.title"
|
<div class="detail-cell">
|
||||||
:title="field.title"
|
<div class="detail-label">{{ field.title }}</div>
|
||||||
:value="formatValue(field.value)"
|
<div class="detail-value">
|
||||||
/>
|
{{ formatValue(field.value) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<van-cell-group inset title="Приложения">
|
<van-cell-group title="Техническое">
|
||||||
<van-cell v-if="loadingApplicationFiles" title="Загрузка приложений..." />
|
<van-cell>
|
||||||
<van-cell v-else-if="applicationFiles.length === 0" title="Приложений нет" />
|
<template #title>
|
||||||
|
<div class="detail-cell">
|
||||||
|
<div class="detail-label">ID</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
{{ formatValue(contract.id) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell>
|
||||||
|
<template #title>
|
||||||
|
<div class="detail-cell">
|
||||||
|
<div class="detail-label">Код статуса</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
{{ formatValue(contract.status) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell>
|
||||||
|
<template #title>
|
||||||
|
<div class="detail-cell">
|
||||||
|
<div class="detail-label">URL</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
{{ formatValue(contract.absolute_url) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
</div>
|
||||||
|
</van-tab>
|
||||||
|
|
||||||
|
<van-tab name="approval" title="Согласование">
|
||||||
|
<div class="tab-panel">
|
||||||
|
<van-cell-group>
|
||||||
|
<van-cell v-for="field in approvalFields" :key="field.title">
|
||||||
|
<template #title>
|
||||||
|
<div class="detail-cell">
|
||||||
|
<div class="detail-label">{{ field.title }}</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
{{ formatValue(field.value) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
</div>
|
||||||
|
</van-tab>
|
||||||
|
|
||||||
|
<van-tab name="applications" title="Приложения">
|
||||||
|
<div class="tab-panel">
|
||||||
|
<van-cell-group>
|
||||||
<van-cell
|
<van-cell
|
||||||
v-for="file in applicationFiles"
|
v-if="loadingApplicationFiles"
|
||||||
v-else
|
title="Загрузка приложений..."
|
||||||
:key="file.id"
|
/>
|
||||||
:title="file.name"
|
<van-cell
|
||||||
:label="`${file.file_type_display} · ${file.status_display} · ${file.scan_name}`"
|
v-else-if="applicationFiles.length === 0"
|
||||||
>
|
title="Приложений нет"
|
||||||
|
/>
|
||||||
|
<van-cell v-for="file in applicationFiles" v-else :key="file.id">
|
||||||
|
<template #title>
|
||||||
|
<div class="detail-cell">
|
||||||
|
<div class="detail-label">{{ file.name }}</div>
|
||||||
|
<div class="detail-value detail-value--muted">
|
||||||
|
{{ file.file_type_display }} · {{ file.status_display }} ·
|
||||||
|
{{ file.scan_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #value>
|
<template #value>
|
||||||
<van-button
|
<van-button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -202,10 +425,139 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
</div>
|
||||||
|
</van-tab>
|
||||||
|
|
||||||
|
<van-tab name="works" title="Работы">
|
||||||
|
<div class="tab-panel">
|
||||||
|
<van-empty description="Работы по договору пока не загружены" />
|
||||||
|
</div>
|
||||||
|
</van-tab>
|
||||||
|
|
||||||
|
<van-tab name="archive" title="Архив">
|
||||||
|
<div class="tab-panel">
|
||||||
|
<van-empty description="Архивные материалы пока не загружены" />
|
||||||
|
</div>
|
||||||
|
</van-tab>
|
||||||
|
|
||||||
|
<van-tab name="templates" title="Типовые формы">
|
||||||
|
<div class="tab-panel">
|
||||||
|
<van-cell-group>
|
||||||
|
<van-cell
|
||||||
|
:title="contract.category_ref?.name ?? contract.category"
|
||||||
|
:label="contract.category_ref?.name_group"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="detail-cell">
|
||||||
|
<div class="detail-label">
|
||||||
|
{{ contract.category_ref?.name ?? contract.category }}
|
||||||
|
</div>
|
||||||
|
<div class="detail-value detail-value--muted">
|
||||||
|
{{ contract.category_ref?.name_group ?? "Типовая форма" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #value>
|
||||||
|
<van-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:disabled="!templateUrl"
|
||||||
|
@click="openTemplateForm"
|
||||||
|
>
|
||||||
|
Открыть
|
||||||
|
</van-button>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
<van-empty
|
||||||
|
v-if="!templateUrl"
|
||||||
|
description="Типовая форма не указана"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</van-tab>
|
||||||
|
</van-tabs>
|
||||||
|
|
||||||
<div class="detail-actions">
|
<div class="detail-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>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.contract-summary {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 12px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(36, 42, 56, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-summary__title {
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-summary__meta {
|
||||||
|
padding: 4px 16px 0;
|
||||||
|
color: #969799;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-summary__badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: #636b74;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value--muted {
|
||||||
|
color: #374151;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value--money {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-tabs {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-tabs :deep(.van-tabs__wrap) {
|
||||||
|
box-shadow: 0 1px 0 #ebedf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+208
-20
@@ -6,9 +6,22 @@ import { showToast } from "vant";
|
|||||||
import { contractApi } from "../generated/api";
|
import { contractApi } from "../generated/api";
|
||||||
import type { ContractListParams } from "../generated/models";
|
import type { ContractListParams } from "../generated/models";
|
||||||
import { useModelApi } from "../composables/useModelApi";
|
import { useModelApi } from "../composables/useModelApi";
|
||||||
|
import ContractCategorySelect from "../components/ContractCategorySelect.vue";
|
||||||
import CounterpartySelect from "../components/CounterpartySelect.vue";
|
import CounterpartySelect from "../components/CounterpartySelect.vue";
|
||||||
|
import FrcSelect from "../components/FrcSelect.vue";
|
||||||
|
import ProjectSelect from "../components/ProjectSelect.vue";
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
const CONTRACT_STATUS_OPTIONS = [
|
||||||
|
{ value: "AN", text: "Аннулирован" },
|
||||||
|
{ value: "IP", text: "В работе" },
|
||||||
|
{ value: "OW", text: "На доработке" },
|
||||||
|
{ value: "OS", text: "На подписи" },
|
||||||
|
{ value: "OK", text: "Окончен" },
|
||||||
|
{ value: "WA", text: "Проект" },
|
||||||
|
{ value: "TE", text: "Расторгнут" },
|
||||||
|
{ value: "AU", text: "Согласован" },
|
||||||
|
];
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
@@ -25,6 +38,10 @@ const {
|
|||||||
params.name__contains = params.name__contains?.trim() || undefined;
|
params.name__contains = params.name__contains?.trim() || undefined;
|
||||||
params.number__contains = params.number__contains?.trim() || undefined;
|
params.number__contains = params.number__contains?.trim() || undefined;
|
||||||
params.counterparty_id = params.counterparty_id || undefined;
|
params.counterparty_id = params.counterparty_id || undefined;
|
||||||
|
params.category_id = params.category_id || undefined;
|
||||||
|
params.project_id = params.project_id || undefined;
|
||||||
|
params.frc_id = params.frc_id || undefined;
|
||||||
|
params.status = params.status || undefined;
|
||||||
params.limit = PAGE_SIZE;
|
params.limit = PAGE_SIZE;
|
||||||
params.offset = params.offset ?? 0;
|
params.offset = params.offset ?? 0;
|
||||||
},
|
},
|
||||||
@@ -39,11 +56,51 @@ const selectedCounterpartyId = computed({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedCategoryId = computed({
|
||||||
|
get() {
|
||||||
|
return filters.category_id ?? 0;
|
||||||
|
},
|
||||||
|
set(id: number) {
|
||||||
|
filters.category_id = id || undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedProjectId = computed({
|
||||||
|
get() {
|
||||||
|
return filters.project_id ?? 0;
|
||||||
|
},
|
||||||
|
set(id: number) {
|
||||||
|
filters.project_id = id || undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedFrcId = computed({
|
||||||
|
get() {
|
||||||
|
return filters.frc_id ?? 0;
|
||||||
|
},
|
||||||
|
set(id: number) {
|
||||||
|
filters.frc_id = id || undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedStatus = computed({
|
||||||
|
get() {
|
||||||
|
return filters.status ?? "";
|
||||||
|
},
|
||||||
|
set(status: string) {
|
||||||
|
filters.status = status || undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const hasActiveFilters = computed(() =>
|
const hasActiveFilters = computed(() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
filters.name__contains ||
|
filters.name__contains ||
|
||||||
filters.number__contains ||
|
filters.number__contains ||
|
||||||
filters.counterparty_id,
|
filters.counterparty_id ||
|
||||||
|
filters.category_id ||
|
||||||
|
filters.project_id ||
|
||||||
|
filters.frc_id ||
|
||||||
|
filters.status,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -64,6 +121,35 @@ interface SyncContractsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const syncing = ref(false);
|
const syncing = ref(false);
|
||||||
|
const showFilters = ref(false);
|
||||||
|
|
||||||
|
const activeFilterCount = computed(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
filters.number__contains,
|
||||||
|
filters.counterparty_id,
|
||||||
|
filters.category_id,
|
||||||
|
filters.project_id,
|
||||||
|
filters.frc_id,
|
||||||
|
filters.status,
|
||||||
|
].filter(Boolean).length,
|
||||||
|
);
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filters.number__contains = undefined;
|
||||||
|
filters.counterparty_id = undefined;
|
||||||
|
filters.category_id = undefined;
|
||||||
|
filters.project_id = undefined;
|
||||||
|
filters.frc_id = undefined;
|
||||||
|
filters.status = undefined;
|
||||||
|
filters.offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyFilters() {
|
||||||
|
filters.offset = 0;
|
||||||
|
showFilters.value = false;
|
||||||
|
await loadContracts();
|
||||||
|
}
|
||||||
|
|
||||||
async function syncContracts() {
|
async function syncContracts() {
|
||||||
syncing.value = true;
|
syncing.value = true;
|
||||||
@@ -98,7 +184,15 @@ function errorMessage(err: unknown, fallback: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [filters.name__contains, filters.number__contains, filters.counterparty_id],
|
() => [
|
||||||
|
filters.name__contains,
|
||||||
|
filters.number__contains,
|
||||||
|
filters.counterparty_id,
|
||||||
|
filters.category_id,
|
||||||
|
filters.project_id,
|
||||||
|
filters.frc_id,
|
||||||
|
filters.status,
|
||||||
|
],
|
||||||
() => {
|
() => {
|
||||||
filters.offset = 0;
|
filters.offset = 0;
|
||||||
},
|
},
|
||||||
@@ -117,14 +211,37 @@ watch(
|
|||||||
:text="error"
|
:text="error"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="card list-card">
|
<section class="contracts-page">
|
||||||
<van-search
|
<van-search
|
||||||
v-model="filters.name__contains"
|
v-model="filters.name__contains"
|
||||||
placeholder="Поиск по названию"
|
placeholder="Поиск по названию"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<van-cell-group inset class="contract-filters">
|
<div class="contract-actions">
|
||||||
|
<van-badge :content="activeFilterCount || undefined">
|
||||||
|
<van-button size="small" plain type="primary" icon="filter-o" @click="showFilters = true">
|
||||||
|
Фильтры
|
||||||
|
</van-button>
|
||||||
|
</van-badge>
|
||||||
|
<van-button size="small" plain type="primary" :loading="loading" @click="loadContracts()">
|
||||||
|
Обновить
|
||||||
|
</van-button>
|
||||||
|
<van-button size="small" type="primary" :loading="syncing" @click="syncContracts">
|
||||||
|
Синхронизация
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-popup v-model:show="showFilters" round position="bottom" class="filters-popup">
|
||||||
|
<div class="filters-sheet">
|
||||||
|
<div class="filters-header">
|
||||||
|
<h2>Фильтры</h2>
|
||||||
|
<van-button size="small" plain type="primary" @click="resetFilters">
|
||||||
|
Сбросить
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-cell-group class="contract-filters">
|
||||||
<van-field
|
<van-field
|
||||||
v-model="filters.number__contains"
|
v-model="filters.number__contains"
|
||||||
label="Номер"
|
label="Номер"
|
||||||
@@ -136,19 +253,44 @@ watch(
|
|||||||
<CounterpartySelect v-model="selectedCounterpartyId" />
|
<CounterpartySelect v-model="selectedCounterpartyId" />
|
||||||
</template>
|
</template>
|
||||||
</van-field>
|
</van-field>
|
||||||
|
<van-field label="Категория">
|
||||||
|
<template #input>
|
||||||
|
<ContractCategorySelect v-model="selectedCategoryId" />
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
<van-field label="Проект">
|
||||||
|
<template #input>
|
||||||
|
<ProjectSelect v-model="selectedProjectId" />
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
<van-field label="ФРЦ">
|
||||||
|
<template #input>
|
||||||
|
<FrcSelect v-model="selectedFrcId" />
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
<van-field label="Статус">
|
||||||
|
<template #input>
|
||||||
|
<select v-model="selectedStatus" class="native-select">
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option
|
||||||
|
v-for="status in CONTRACT_STATUS_OPTIONS"
|
||||||
|
:key="status.value"
|
||||||
|
:value="status.value"
|
||||||
|
>
|
||||||
|
{{ status.text }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<div class="list-title">
|
<div class="filters-actions">
|
||||||
<h2>Список</h2>
|
<van-button block type="primary" @click="applyFilters">
|
||||||
<div class="list-actions">
|
Применить
|
||||||
<van-button size="small" plain type="primary" :loading="loading" @click="loadContracts()">
|
|
||||||
Обновить
|
|
||||||
</van-button>
|
|
||||||
<van-button size="small" type="primary" :loading="syncing" @click="syncContracts">
|
|
||||||
Синхронизация
|
|
||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
<van-loading
|
<van-loading
|
||||||
v-if="loading && contracts.length === 0"
|
v-if="loading && contracts.length === 0"
|
||||||
@@ -166,7 +308,7 @@ watch(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<van-cell-group inset>
|
<van-cell-group>
|
||||||
<van-cell
|
<van-cell
|
||||||
v-for="contract in contracts"
|
v-for="contract in contracts"
|
||||||
:key="contract.id"
|
:key="contract.id"
|
||||||
@@ -175,11 +317,7 @@ watch(
|
|||||||
center
|
center
|
||||||
is-link
|
is-link
|
||||||
@click="router.push(`/contracts/${contract.id}`)"
|
@click="router.push(`/contracts/${contract.id}`)"
|
||||||
>
|
/>
|
||||||
<template #right-icon>
|
|
||||||
<van-tag plain type="primary">Contract</van-tag>
|
|
||||||
</template>
|
|
||||||
</van-cell>
|
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<van-pagination
|
<van-pagination
|
||||||
@@ -195,10 +333,60 @@ watch(
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.contract-filters {
|
.contract-filters {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contracts-page {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-popup {
|
||||||
|
max-height: 85vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-sheet {
|
||||||
|
padding: 14px 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-actions {
|
||||||
|
padding: 4px 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contract-pagination {
|
.contract-pagination {
|
||||||
margin: 14px 16px 0;
|
margin: 10px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-select {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #323233;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 24px;
|
||||||
|
text-align: right;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user