fix
This commit is contained in:
@@ -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 "../../../shared/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,135 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { counterpartyApi } from "../../../generated/api";
|
||||
import type { CounterpartyListParams } from "../../../generated/models";
|
||||
import { useModelApi } from "../../../shared/composables/useModelApi";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const selectedCounterpartyId = defineModel<number>({ required: true });
|
||||
const search = ref("");
|
||||
const showSelector = ref(false);
|
||||
|
||||
const {
|
||||
items: counterparties,
|
||||
filters,
|
||||
loading,
|
||||
} = useModelApi(counterpartyApi, {
|
||||
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as CounterpartyListParams,
|
||||
loadErrorMessage: "Не удалось загрузить контрагентов",
|
||||
cleanListParams: (params) => {
|
||||
if (!params.name__contains?.trim()) {
|
||||
delete params.name__contains;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const selectedCounterparty = computed(() =>
|
||||
counterparties.value.find((counterparty) => counterparty.id === selectedCounterpartyId.value),
|
||||
);
|
||||
|
||||
const selectedCounterpartyName = computed(() => selectedCounterparty.value?.name ?? "все");
|
||||
|
||||
function openSelector() {
|
||||
search.value = "";
|
||||
showSelector.value = true;
|
||||
}
|
||||
|
||||
function selectCounterparty(id: number) {
|
||||
selectedCounterpartyId.value = id;
|
||||
showSelector.value = false;
|
||||
}
|
||||
|
||||
watch(search, (value) => {
|
||||
filters.name__contains = value.trim();
|
||||
filters.offset = 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="counterparty-select" type="button" @click="openSelector">
|
||||
<span>{{ selectedCounterpartyName }}</span>
|
||||
<van-icon name="arrow" />
|
||||
</button>
|
||||
|
||||
<van-popup v-model:show="showSelector" round position="bottom" class="counterparty-popup">
|
||||
<div class="counterparty-popup-header">
|
||||
<h2>Выберите контрагента</h2>
|
||||
<van-button size="small" type="primary" plain @click="selectCounterparty(0)">Все</van-button>
|
||||
</div>
|
||||
|
||||
<van-search v-model="search" placeholder="Поиск по имени" />
|
||||
|
||||
<van-loading v-if="loading" class="counterparty-state" type="spinner">Загрузка...</van-loading>
|
||||
|
||||
<template v-else>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="counterparty in counterparties"
|
||||
:key="counterparty.id"
|
||||
:title="counterparty.name"
|
||||
:label="`ID: ${counterparty.id}`"
|
||||
clickable
|
||||
center
|
||||
@click="selectCounterparty(counterparty.id)"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon
|
||||
v-if="selectedCounterpartyId === counterparty.id"
|
||||
name="success"
|
||||
color="#1989fa"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
|
||||
<van-empty v-if="counterparties.length === 0" description="Контрагенты не найдены" />
|
||||
</template>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.counterparty-popup {
|
||||
min-height: 55vh;
|
||||
padding: 18px 0 28px;
|
||||
}
|
||||
|
||||
.counterparty-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 18px 10px;
|
||||
}
|
||||
|
||||
.counterparty-popup-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.counterparty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
.counterparty-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;
|
||||
}
|
||||
|
||||
.counterparty-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 "../../../shared/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,264 @@
|
||||
<script setup lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
GlobalWorkerOptions,
|
||||
getDocument,
|
||||
type PDFDocumentLoadingTask,
|
||||
type PDFDocumentProxy,
|
||||
type RenderTask,
|
||||
} from "pdfjs-dist";
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
localPath: string;
|
||||
scanUrl: string;
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const show = defineModel<boolean>("show", { required: true });
|
||||
|
||||
GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const pdfDocument = ref<PDFDocumentProxy | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const pageNumber = ref(1);
|
||||
const pageCount = ref(0);
|
||||
const scale = ref(1.1);
|
||||
|
||||
let loadingTask: PDFDocumentLoadingTask | null = null;
|
||||
let renderTask: RenderTask | null = null;
|
||||
const pageInfo = computed(() => `${pageNumber.value} / ${pageCount.value}`);
|
||||
const canGoBack = computed(() => pageNumber.value > 1);
|
||||
const canGoForward = computed(() => pageNumber.value < pageCount.value);
|
||||
|
||||
async function loadDocument() {
|
||||
resetDocument();
|
||||
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
let task: PDFDocumentLoadingTask | null = null;
|
||||
|
||||
try {
|
||||
const bytes = await invoke<number[]>("load_application_file", {
|
||||
localPath: props.localPath,
|
||||
scanUrl: props.scanUrl,
|
||||
});
|
||||
|
||||
if (!bytes.length) {
|
||||
throw new Error("Файл пустой");
|
||||
}
|
||||
|
||||
task = getDocument({ data: new Uint8Array(bytes) });
|
||||
loadingTask = task;
|
||||
|
||||
const document = await task.promise;
|
||||
if (loadingTask !== task) {
|
||||
await document.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
pdfDocument.value = document;
|
||||
pageCount.value = document.numPages;
|
||||
pageNumber.value = 1;
|
||||
scale.value = 1.1;
|
||||
await renderPage();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось открыть PDF";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (task && loadingTask === task) {
|
||||
loadingTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
const document = pdfDocument.value;
|
||||
const canvas = canvasRef.value;
|
||||
|
||||
if (!document || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderTask?.cancel();
|
||||
|
||||
const page = await document.getPage(pageNumber.value);
|
||||
const viewport = page.getViewport({ scale: scale.value });
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
if (!context) {
|
||||
error.value = "Не удалось создать canvas context";
|
||||
return;
|
||||
}
|
||||
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.floor(viewport.width * devicePixelRatio);
|
||||
canvas.height = Math.floor(viewport.height * devicePixelRatio);
|
||||
canvas.style.width = `${viewport.width}px`;
|
||||
canvas.style.height = `${viewport.height}px`;
|
||||
|
||||
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
||||
|
||||
renderTask = page.render({ canvas: canvas, canvasContext: context, viewport });
|
||||
|
||||
try {
|
||||
await renderTask.promise;
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error) || err.name !== "RenderingCancelledException") {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось отрисовать PDF";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetDocument() {
|
||||
renderTask?.cancel();
|
||||
renderTask = null;
|
||||
loadingTask?.destroy().catch(() => undefined);
|
||||
loadingTask = null;
|
||||
pdfDocument.value?.destroy().catch(() => undefined);
|
||||
pdfDocument.value = null;
|
||||
pageCount.value = 0;
|
||||
pageNumber.value = 1;
|
||||
loading.value = false;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
async function goBack() {
|
||||
if (canGoBack.value) {
|
||||
pageNumber.value -= 1;
|
||||
await nextTick();
|
||||
await renderPage();
|
||||
}
|
||||
}
|
||||
|
||||
async function goForward() {
|
||||
if (canGoForward.value) {
|
||||
pageNumber.value += 1;
|
||||
await nextTick();
|
||||
await renderPage();
|
||||
}
|
||||
}
|
||||
|
||||
async function zoomIn() {
|
||||
scale.value = Math.min(scale.value + 0.2, 3);
|
||||
await nextTick();
|
||||
await renderPage();
|
||||
}
|
||||
|
||||
async function zoomOut() {
|
||||
scale.value = Math.max(scale.value - 0.2, 0.5);
|
||||
await nextTick();
|
||||
await renderPage();
|
||||
}
|
||||
|
||||
watch([show, () => props.localPath, () => props.scanUrl], async ([visible]) => {
|
||||
if (visible) {
|
||||
await nextTick();
|
||||
await loadDocument();
|
||||
} else {
|
||||
resetDocument();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetDocument();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<van-popup v-model:show="show" round position="bottom" class="pdf-preview">
|
||||
<div class="pdf-preview__header">
|
||||
<div class="pdf-preview__title">{{ title }}</div>
|
||||
<van-button size="small" plain @click="show = false">Закрыть</van-button>
|
||||
</div>
|
||||
|
||||
<div class="pdf-preview__toolbar">
|
||||
<van-button size="small" plain :disabled="!canGoBack" @click="goBack">
|
||||
Назад
|
||||
</van-button>
|
||||
<div class="pdf-preview__page-info">{{ pageInfo }}</div>
|
||||
<van-button size="small" plain :disabled="!canGoForward" @click="goForward">
|
||||
Вперед
|
||||
</van-button>
|
||||
<van-button size="small" plain @click="zoomOut">-</van-button>
|
||||
<van-button size="small" plain @click="zoomIn">+</van-button>
|
||||
</div>
|
||||
|
||||
<div class="pdf-preview__body">
|
||||
<van-loading v-if="loading">Загрузка PDF...</van-loading>
|
||||
<van-empty v-else-if="error" :description="error" />
|
||||
<div v-else class="pdf-preview__canvas-wrap">
|
||||
<canvas ref="canvasRef" class="pdf-preview__canvas" />
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pdf-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 92vh;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.pdf-preview__header,
|
||||
.pdf-preview__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pdf-preview__header {
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #eef0f4;
|
||||
}
|
||||
|
||||
.pdf-preview__title {
|
||||
min-width: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pdf-preview__toolbar {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid #eef0f4;
|
||||
}
|
||||
|
||||
.pdf-preview__page-info {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
color: #636b74;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pdf-preview__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pdf-preview__canvas-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pdf-preview__canvas {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
</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 "../../../shared/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>
|
||||
@@ -0,0 +1,17 @@
|
||||
import ContractDetailView from "./views/ContractDetailView.vue";
|
||||
import ContractsView from "./views/ContractsView.vue";
|
||||
|
||||
export const contractsRoutes = [
|
||||
{
|
||||
path: "/contracts",
|
||||
name: "contracts",
|
||||
component: ContractsView,
|
||||
meta: { title: "Контракты", requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/contracts/:id",
|
||||
name: "contract-detail",
|
||||
component: ContractDetailView,
|
||||
meta: { title: "Детали контракта", back: true, requiresAuth: true },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,604 @@
|
||||
<script setup lang="ts">
|
||||
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,
|
||||
ContractApplicationFile,
|
||||
ContractApplicationFileListParams,
|
||||
} from "../../../generated/models";
|
||||
import { useModelApi } from "../../../shared/composables/useModelApi";
|
||||
import PdfPreview from "../components/PdfPreview.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const contractId = computed(() => Number(route.params.id));
|
||||
const activeTab = ref("info");
|
||||
|
||||
const {
|
||||
item: contract,
|
||||
loadingItem,
|
||||
error,
|
||||
retrieve: loadContract,
|
||||
} = useModelApi(contractApi, {
|
||||
retrieveErrorMessage: "Не удалось загрузить контракт",
|
||||
autoLoad: false,
|
||||
autoLoadOnFilterChange: false,
|
||||
});
|
||||
const {
|
||||
items: applicationFiles,
|
||||
loading: loadingApplicationFiles,
|
||||
error: applicationFilesError,
|
||||
load: loadApplicationFiles,
|
||||
} = useModelApi(contractApplicationFileApi, {
|
||||
defaultListParams: { ordering: "id" } as ContractApplicationFileListParams,
|
||||
loadErrorMessage: "Не удалось загрузить приложения",
|
||||
autoLoad: false,
|
||||
autoLoadOnFilterChange: false,
|
||||
});
|
||||
|
||||
const viewError = computed(() => error.value || applicationFilesError.value);
|
||||
|
||||
type DetailField = {
|
||||
title: string;
|
||||
key: keyof Contract;
|
||||
};
|
||||
|
||||
type MoneyField = DetailField & {
|
||||
money?: boolean;
|
||||
};
|
||||
|
||||
const mainFields: DetailField[] = [
|
||||
{ title: "Номер", key: "number" },
|
||||
{ title: "Дата", key: "date" },
|
||||
{ title: "Тип", key: "contract_type" },
|
||||
{ title: "Категория", key: "category" },
|
||||
{ title: "Статус", key: "status_name" },
|
||||
{ title: "НДС", key: "nds" },
|
||||
{ title: "Продукт", key: "name_of_product" },
|
||||
{ title: "Комментарий", key: "comment" },
|
||||
];
|
||||
|
||||
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: "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(() => {
|
||||
const item = contract.value;
|
||||
|
||||
if (!item) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
title: "Контрагент",
|
||||
value: item.counterparty?.name ?? item.counterparty_name,
|
||||
},
|
||||
{ title: "Проект", value: item.project?.short_name ?? item.project?.name },
|
||||
{ title: "ФРЦ", value: item.frc?.name },
|
||||
{
|
||||
title: "Сотрудник",
|
||||
value: item.employee?.short_name ?? item.employee?.name,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
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 pdfPreviewVisible = ref(false);
|
||||
const pdfPreviewFile = ref<ContractApplicationFile | null>(null);
|
||||
const pdfPreviewTitle = computed(
|
||||
() => pdfPreviewFile.value?.name || pdfPreviewFile.value?.scan_name || "PDF",
|
||||
);
|
||||
|
||||
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) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "не указано";
|
||||
}
|
||||
|
||||
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("Файл не скачан");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await openPath(localPath);
|
||||
} catch (err) {
|
||||
showToast(errorMessage(err, "Не удалось открыть файл"));
|
||||
}
|
||||
}
|
||||
|
||||
async function openTemplateForm() {
|
||||
if (!templateUrl.value) {
|
||||
showToast("Типовая форма не указана");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await openUrl(templateUrl.value);
|
||||
} catch (err) {
|
||||
showToast(errorMessage(err, "Не удалось открыть типовую форму"));
|
||||
}
|
||||
}
|
||||
|
||||
function isPdfFile(file: ContractApplicationFile) {
|
||||
return `${file.scan_name} ${file.name}`.toLowerCase().includes(".pdf");
|
||||
}
|
||||
|
||||
function openPdfPreview(file: ContractApplicationFile) {
|
||||
pdfPreviewFile.value = file;
|
||||
pdfPreviewVisible.value = true;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown, fallback: string) {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (Number.isFinite(contractId.value)) {
|
||||
loadContract(contractId.value);
|
||||
loadApplicationFiles({
|
||||
ordering: "id",
|
||||
contract_id: contractId.value,
|
||||
} as ContractApplicationFileListParams);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<van-notice-bar
|
||||
v-if="viewError"
|
||||
class="notice"
|
||||
color="#991b1b"
|
||||
background="#fee2e2"
|
||||
left-icon="warning-o"
|
||||
wrapable
|
||||
:scrollable="false"
|
||||
:text="viewError"
|
||||
/>
|
||||
|
||||
<section class="">
|
||||
<van-loading v-if="loadingItem" class="state" type="spinner"
|
||||
>Загрузка...</van-loading
|
||||
>
|
||||
|
||||
<van-empty v-else-if="!contract" description="Контракт не найден" />
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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 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>
|
||||
<div class="file-actions">
|
||||
<van-button
|
||||
v-if="isPdfFile(file)"
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!file.local_path"
|
||||
@click="openPdfPreview(file)"
|
||||
>
|
||||
Просмотр
|
||||
</van-button>
|
||||
<van-button
|
||||
size="small"
|
||||
plain
|
||||
:disabled="!file.local_path"
|
||||
@click="openApplicationFile(file.local_path)"
|
||||
>
|
||||
Открыть
|
||||
</van-button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<PdfPreview
|
||||
v-model:show="pdfPreviewVisible"
|
||||
:local-path="pdfPreviewFile?.local_path ?? ''"
|
||||
:scan-url="pdfPreviewFile?.scan_url ?? ''"
|
||||
:title="pdfPreviewTitle"
|
||||
/>
|
||||
|
||||
<div class="detail-actions">
|
||||
<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;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,392 @@
|
||||
<script setup lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { showToast } from "vant";
|
||||
import { contractApi } from "../../../generated/api";
|
||||
import type { ContractListParams } from "../../../generated/models";
|
||||
import { useModelApi } from "../../../shared/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 {
|
||||
items: contracts,
|
||||
filters,
|
||||
count,
|
||||
loading,
|
||||
error,
|
||||
load: loadContracts,
|
||||
} = useModelApi(contractApi, {
|
||||
defaultListParams: { ordering: "-id", limit: PAGE_SIZE, offset: 0 } as ContractListParams,
|
||||
loadErrorMessage: "Не удалось загрузить контракты",
|
||||
cleanListParams(params) {
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
const selectedCounterpartyId = computed({
|
||||
get() {
|
||||
return filters.counterparty_id ?? 0;
|
||||
},
|
||||
set(id: number) {
|
||||
filters.counterparty_id = id || undefined;
|
||||
},
|
||||
});
|
||||
|
||||
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.category_id ||
|
||||
filters.project_id ||
|
||||
filters.frc_id ||
|
||||
filters.status,
|
||||
),
|
||||
);
|
||||
|
||||
const currentPage = computed({
|
||||
get() {
|
||||
return Math.floor((filters.offset ?? 0) / PAGE_SIZE) + 1;
|
||||
},
|
||||
set(page: number) {
|
||||
filters.offset = (page - 1) * PAGE_SIZE;
|
||||
},
|
||||
});
|
||||
|
||||
interface SyncContractsResult {
|
||||
synced: number;
|
||||
pages: number;
|
||||
applications: number;
|
||||
files_downloaded: number;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
const result = await invoke<SyncContractsResult>("sync_contracts");
|
||||
showToast(
|
||||
`Синхронизировано: ${result.synced}; приложений: ${result.applications}; файлов: ${result.files_downloaded}`,
|
||||
);
|
||||
if (filters.offset) {
|
||||
filters.offset = 0;
|
||||
} else {
|
||||
await loadContracts();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(errorMessage(err, "Не удалось синхронизировать договоры"));
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown, fallback: string) {
|
||||
if (typeof err === "object" && err && "detail" in err) {
|
||||
return String((err as { detail: unknown }).detail);
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
filters.name__contains,
|
||||
filters.number__contains,
|
||||
filters.counterparty_id,
|
||||
filters.category_id,
|
||||
filters.project_id,
|
||||
filters.frc_id,
|
||||
filters.status,
|
||||
],
|
||||
() => {
|
||||
filters.offset = 0;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<van-notice-bar
|
||||
v-if="error"
|
||||
class="notice"
|
||||
color="#991b1b"
|
||||
background="#fee2e2"
|
||||
left-icon="warning-o"
|
||||
wrapable
|
||||
:scrollable="false"
|
||||
:text="error"
|
||||
/>
|
||||
|
||||
<section class="contracts-page">
|
||||
<van-search
|
||||
v-model="filters.name__contains"
|
||||
placeholder="Поиск по названию"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<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
|
||||
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"
|
||||
type="spinner"
|
||||
>
|
||||
Загрузка...
|
||||
</van-loading>
|
||||
|
||||
<van-empty
|
||||
v-else-if="contracts.length === 0"
|
||||
:description="
|
||||
hasActiveFilters ? 'Договоры не найдены' : 'Контрактов пока нет'
|
||||
"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<van-cell-group>
|
||||
<van-cell
|
||||
v-for="contract in contracts"
|
||||
:key="contract.id"
|
||||
:title="contract.name"
|
||||
:label="`№ ${contract.number} · ${contract.counterparty?.name ?? 'не указан'}`"
|
||||
center
|
||||
is-link
|
||||
@click="router.push(`/contracts/${contract.id}`)"
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<van-pagination
|
||||
v-model="currentPage"
|
||||
class="contract-pagination"
|
||||
:total-items="count"
|
||||
:items-per-page="PAGE_SIZE"
|
||||
mode="simple"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contract-filters {
|
||||
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: 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>
|
||||
Reference in New Issue
Block a user