This commit is contained in:
che
2026-07-25 10:31:32 +05:00
parent 42029533fd
commit 860ba21dd0
15 changed files with 1362 additions and 230 deletions
+17 -112
View File
@@ -47,118 +47,23 @@ async function logoutAndRedirect() {
<router-view />
</main>
<van-tabbar v-if="showShellNavigation" v-model="activeTab" 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
v-if="showShellNavigation"
v-model="activeTab"
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>
</template>
<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>
<style></style>
+128
View File
@@ -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>
+126
View File
@@ -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>
+130
View File
@@ -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>
+5
View File
@@ -8,6 +8,10 @@ import type {
ContractApplicationFileCreate,
ContractApplicationFileUpdate,
ContractApplicationFileListParams,
ContractCategory,
ContractCategoryCreate,
ContractCategoryUpdate,
ContractCategoryListParams,
Counterparty,
CounterpartyCreate,
CounterpartyUpdate,
@@ -40,6 +44,7 @@ import type {
export const contractApi = createModelApi<Contract, ContractCreate, ContractUpdate, ContractListParams>("contract");
export const contractApplicationFileApi = createModelApi<ContractApplicationFile, ContractApplicationFileCreate, ContractApplicationFileUpdate, ContractApplicationFileListParams>("contract_application_file");
export const contractCategoryApi = createModelApi<ContractCategory, ContractCategoryCreate, ContractCategoryUpdate, ContractCategoryListParams>("contract_category");
export const counterpartyApi = createModelApi<Counterparty, CounterpartyCreate, CounterpartyUpdate, CounterpartyListParams>("counterparty");
export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee");
export const frcApi = createModelApi<Frc, FrcCreate, FrcUpdate, FrcListParams>("frc");
+41
View File
@@ -7,6 +7,7 @@ export interface Contract {
absolute_url: string;
contract_type: string;
category: string;
category_id: number | null;
amount_total_display: string;
amount_by_ds: string;
comment: string;
@@ -30,6 +31,7 @@ export interface Contract {
project_id: number | null;
frc_id: number | null;
employee_id: number | null;
category_ref: ContractCategory | null;
counterparty: Counterparty | null;
project: Project | null;
frc: Frc | null;
@@ -42,6 +44,7 @@ export interface ContractCreate {
absolute_url: string;
contract_type: string;
category: string;
category_id?: number | null;
amount_total_display: string;
amount_by_ds: string;
comment: string;
@@ -73,6 +76,7 @@ export interface ContractUpdate {
absolute_url?: string;
contract_type?: string;
category?: string;
category_id?: number | null;
amount_total_display?: string;
amount_by_ds?: string;
comment?: string;
@@ -112,6 +116,7 @@ export interface ContractListParams extends ListParams {
contract_type__contains?: string;
category?: string;
category__contains?: string;
category_id?: number | null;
counterparty_name__contains?: string;
name_of_product__contains?: string;
counterparty_id?: number | null;
@@ -182,6 +187,42 @@ export interface ContractApplicationFileListParams extends ListParams {
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 {
id: number;
name: string;
+429 -77
View File
@@ -1,15 +1,19 @@
<script setup lang="ts">
import { openPath } from "@tauri-apps/plugin-opener";
import { computed, onMounted } from "vue";
import { openPath, openUrl } from "@tauri-apps/plugin-opener";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast } from "vant";
import { contractApi, contractApplicationFileApi } from "../generated/api";
import type { Contract, ContractApplicationFileListParams } from "../generated/models";
import type {
Contract,
ContractApplicationFileListParams,
} from "../generated/models";
import { useModelApi } from "../composables/useModelApi";
const route = useRoute();
const router = useRouter();
const contractId = computed(() => Number(route.params.id));
const activeTab = ref("info");
const {
item: contract,
@@ -40,34 +44,34 @@ type DetailField = {
key: keyof Contract;
};
type MoneyField = DetailField & {
money?: boolean;
};
const mainFields: DetailField[] = [
{ title: "ID", key: "id" },
{ title: "Название", key: "name" },
{ title: "Номер", key: "number" },
{ title: "Дата", key: "date" },
{ title: "Тип", key: "contract_type" },
{ title: "Категория", key: "category" },
{ title: "Статус", key: "status_name" },
{ title: "Код статуса", key: "status" },
{ title: "НДС", key: "nds" },
{ title: "Продукт", key: "name_of_product" },
{ title: "Комментарий", key: "comment" },
{ title: "URL", key: "absolute_url" },
];
const moneyFields: DetailField[] = [
{ title: "Сумма", key: "amount" },
{ title: "Сумма итого", key: "amount_total" },
const moneyFields: MoneyField[] = [
{ title: "Платеж в месяц", key: "month_pay", money: true },
{ title: "Аванс", key: "avans_pay" },
{ title: "Сумма", key: "amount", money: true },
{ title: "Сумма итого", key: "amount_total", money: true },
{ title: "Сумма итого строкой", key: "amount_total_display" },
{ title: "Сумма по ДС", key: "amount_by_ds" },
{ title: "Платеж в месяц", key: "month_pay" },
{ title: "Аванс", key: "avans_pay" },
{ title: "Смета НДС стоимость", key: "estimate_nds_cost" },
{ title: "Смета НДС сертификат", key: "estimate_nds_cert" },
{ title: "Счета сумма", key: "bill_cost_sum" },
{ title: "Счета оплачено", key: "bill_paid_sum" },
{ title: "Доход всего", key: "income_total" },
{ title: "Задолженность", key: "arrears" },
{ title: "Смета НДС стоимость", key: "estimate_nds_cost", money: true },
{ title: "Смета НДС сертификат", key: "estimate_nds_cert", money: true },
{ title: "Счета сумма", key: "bill_cost_sum", money: true },
{ title: "Счета оплачено", key: "bill_paid_sum", money: true },
{ title: "Доход всего", key: "income_total", money: true },
{ title: "Задолженность", key: "arrears", money: true },
];
const relationFields = computed(() => {
@@ -78,20 +82,65 @@ const relationFields = computed(() => {
}
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: "Проект ID", value: item.project_id },
{ title: "ФРЦ", value: item.frc?.name },
{ title: "ФРЦ ID", value: item.frc_id },
{ title: "Баланс ФРЦ", value: item.frc?.balance },
{ title: "Сотрудник", value: item.employee?.short_name ?? item.employee?.name },
{ title: "Сотрудник ID", value: item.employee_id },
{
title: "Сотрудник",
value: item.employee?.short_name ?? item.employee?.name,
},
];
});
function fieldValue(field: DetailField) {
return formatValue(contract.value?.[field.key]);
const approvalFields = computed(() => {
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) {
@@ -102,6 +151,21 @@ function formatValue(value: unknown) {
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) {
if (!localPath) {
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) {
if (err instanceof Error) {
return err.message;
@@ -146,66 +223,341 @@ onMounted(() => {
:text="viewError"
/>
<section class="card detail-card">
<van-loading v-if="loadingItem" class="state" type="spinner">Загрузка...</van-loading>
<section class="">
<van-loading v-if="loadingItem" class="state" type="spinner"
>Загрузка...</van-loading
>
<van-empty v-else-if="!contract" description="Контракт не найден" />
<template v-else>
<van-cell-group inset>
<van-cell
v-for="field in mainFields"
:key="field.key"
:title="field.title"
:value="fieldValue(field)"
/>
</van-cell-group>
<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>
<template #title>
<div class="detail-cell">
<div class="detail-label">ЦФО</div>
<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-group inset title="Суммы">
<van-cell
v-for="field in moneyFields"
:key="field.key"
:title="field.title"
:value="fieldValue(field)"
/>
</van-cell-group>
<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-cell-group inset title="Связи">
<van-cell
v-for="field in relationFields"
:key="field.title"
:title="field.title"
:value="formatValue(field.value)"
/>
</van-cell-group>
<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 inset title="Приложения">
<van-cell v-if="loadingApplicationFiles" title="Загрузка приложений..." />
<van-cell v-else-if="applicationFiles.length === 0" title="Приложений нет" />
<van-cell
v-for="file in applicationFiles"
v-else
:key="file.id"
:title="file.name"
:label="`${file.file_type_display} · ${file.status_display} · ${file.scan_name}`"
>
<template #value>
<van-button
size="small"
type="primary"
plain
:disabled="!file.local_path"
@click="openApplicationFile(file.local_path)"
>
Открыть
</van-button>
</template>
</van-cell>
</van-cell-group>
<van-cell-group title="Финансы">
<van-cell v-for="field in moneyFields" :key="field.key">
<template #title>
<div class="detail-cell">
<div class="detail-label">{{ field.title }}</div>
<div class="detail-value detail-value--money">
{{ displayMoneyValue(contract?.[field.key]) }}
</div>
</div>
</template>
</van-cell>
</van-cell-group>
<van-cell-group title="Связи">
<van-cell v-for="field in relationFields" :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>
<van-cell-group title="Техническое">
<van-cell>
<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
v-if="loadingApplicationFiles"
title="Загрузка приложений..."
/>
<van-cell
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>
<van-button
size="small"
type="primary"
plain
:disabled="!file.local_path"
@click="openApplicationFile(file.local_path)"
>
Открыть
</van-button>
</template>
</van-cell>
</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">
<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>
</template>
</section>
</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>
+222 -34
View File
@@ -6,9 +6,22 @@ import { showToast } from "vant";
import { contractApi } from "../generated/api";
import type { ContractListParams } from "../generated/models";
import { useModelApi } from "../composables/useModelApi";
import ContractCategorySelect from "../components/ContractCategorySelect.vue";
import CounterpartySelect from "../components/CounterpartySelect.vue";
import FrcSelect from "../components/FrcSelect.vue";
import ProjectSelect from "../components/ProjectSelect.vue";
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 {
@@ -25,6 +38,10 @@ const {
params.name__contains = params.name__contains?.trim() || undefined;
params.number__contains = params.number__contains?.trim() || 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.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(() =>
Boolean(
filters.name__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 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() {
syncing.value = true;
@@ -98,7 +184,15 @@ function errorMessage(err: unknown, fallback: string) {
}
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;
},
@@ -117,39 +211,87 @@ watch(
:text="error"
/>
<section class="card list-card">
<section class="contracts-page">
<van-search
v-model="filters.name__contains"
placeholder="Поиск по названию"
clearable
/>
<van-cell-group inset class="contract-filters">
<van-field
v-model="filters.number__contains"
label="Номер"
placeholder="Номер договора"
clearable
/>
<van-field label="Контрагент">
<template #input>
<CounterpartySelect v-model="selectedCounterpartyId" />
</template>
</van-field>
</van-cell-group>
<div class="list-title">
<h2>Список</h2>
<div class="list-actions">
<van-button size="small" plain type="primary" :loading="loading" @click="loadContracts()">
Обновить
<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-button size="small" type="primary" :loading="syncing" @click="syncContracts">
Синхронизация
</van-button>
</div>
</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
v-model="filters.number__contains"
label="Номер"
placeholder="Номер договора"
clearable
/>
<van-field label="Контрагент">
<template #input>
<CounterpartySelect v-model="selectedCounterpartyId" />
</template>
</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>
<div class="filters-actions">
<van-button block type="primary" @click="applyFilters">
Применить
</van-button>
</div>
</div>
</van-popup>
<van-loading
v-if="loading && contracts.length === 0"
class="state"
@@ -166,7 +308,7 @@ watch(
/>
<template v-else>
<van-cell-group inset>
<van-cell-group>
<van-cell
v-for="contract in contracts"
:key="contract.id"
@@ -175,11 +317,7 @@ watch(
center
is-link
@click="router.push(`/contracts/${contract.id}`)"
>
<template #right-icon>
<van-tag plain type="primary">Contract</van-tag>
</template>
</van-cell>
/>
</van-cell-group>
<van-pagination
@@ -195,10 +333,60 @@ watch(
<style scoped>
.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 {
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>