This commit is contained in:
che
2026-07-27 15:24:23 +05:00
parent 13362528cd
commit 2dd7d33c8b
29 changed files with 2796 additions and 353 deletions
+17
View File
@@ -0,0 +1,17 @@
import BillsView from "./views/BillsView.vue";
import BillDetailView from "./views/BillDetailView.vue";
export const supplyRoutes = [
{
path: "/bills",
name: "bills",
component: BillsView,
meta: { title: "Счета", requiresAuth: true },
},
{
path: "/bills/:id",
name: "bill-detail",
component: BillDetailView,
meta: { title: "Детали счета", back: true, requiresAuth: true },
},
];
+283
View File
@@ -0,0 +1,283 @@
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { showToast } from "vant";
import { billApi } from "../../../generated/api";
import { useModelApi } from "../../../shared/composables/useModelApi";
import DocumentApprovalTasks from "../../../shared/components/DocumentApprovalTasks.vue";
const route = useRoute();
const billId = computed(() => Number(route.params.id));
const activeTab = ref("info");
const {
item: bill,
loadingItem,
error,
retrieve: loadBill,
} = useModelApi(billApi, {
retrieveErrorMessage: "Не удалось загрузить счет",
autoLoad: false,
autoLoadOnFilterChange: false,
});
type BillRelated = Record<string, unknown>;
function parseJson(value: unknown): unknown {
if (typeof value === "string") {
if (!value.trim()) {
return null;
}
try {
return JSON.parse(value) as unknown;
} catch {
return null;
}
}
return value;
}
function parseObject(value: unknown): BillRelated | null {
const parsed = parseJson(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
return parsed as BillRelated;
}
function objectLabel(value: unknown) {
const record = parseObject(value);
if (!record) {
return "не указано";
}
const candidates = ["name", "short_name", "number", "text", "full_name", "get_status_display"];
for (const key of candidates) {
const candidate = record[key];
if (typeof candidate === "string" && candidate.trim()) {
return candidate;
}
}
return "не указано";
}
function money(value: number | null | undefined) {
if (typeof value !== "number") {
return "не указано";
}
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 2,
}).format(value);
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === "") {
return "не указано";
}
return String(value);
}
async function openScan() {
if (!bill.value?.scan) {
showToast("У счета нет файла");
return;
}
await invoke("open_remote_file", { url: bill.value.scan });
}
function relatedDocuments() {
const raw = parseJson(bill.value?.transferdocument_set);
if (!Array.isArray(raw)) {
return [];
}
return raw;
}
onMounted(() => {
if (Number.isFinite(billId.value)) {
loadBill(billId.value);
}
});
</script>
<template>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<section class="card detail-card">
<van-loading v-if="loadingItem" class="state" type="spinner">Загрузка...</van-loading>
<van-empty v-else-if="!bill" description="Счет не найден" />
<template v-else>
<div class="bill-summary">
<div class="bill-summary__title">{{ bill.text || bill.number || `Счет #${bill.id}` }}</div>
<div class="bill-summary__meta">
{{ bill.number || 'не указан' }} · {{ bill.date_bill || bill.date }}
</div>
<div class="bill-summary__badges">
<van-tag type="primary">{{ bill.status_name || bill.status }}</van-tag>
<van-tag plain>{{ bill.contract_typ || 'не указан' }}</van-tag>
</div>
<van-cell-group inset>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Контрагент</div>
<div class="detail-value">{{ objectLabel(bill.counterparty) }}</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Проект</div>
<div class="detail-value">{{ objectLabel(bill.project) }}</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Договор</div>
<div class="detail-value">{{ objectLabel(bill.contract) }}</div>
</div>
</template>
</van-cell>
<van-cell>
<template #title>
<div class="detail-cell">
<div class="detail-label">Сумма</div>
<div class="detail-value detail-value--money">{{ money(bill.cost) }}</div>
</div>
</template>
</van-cell>
</van-cell-group>
</div>
<van-tabs v-model:active="activeTab" shrink sticky class="bill-tabs">
<van-tab name="info" title="Информация">
<div class="tab-panel">
<van-cell-group>
<van-cell title="Номер" :value="formatValue(bill.number)" />
<van-cell title="Описание" :value="formatValue(bill.text)" />
<van-cell title="Дата счета" :value="formatValue(bill.date_bill)" />
<van-cell title="Дата" :value="formatValue(bill.date)" />
<van-cell title="Срок оплаты" :value="formatValue(bill.date_due)" />
<van-cell title="Статус" :value="bill.status_name || bill.status" />
<van-cell title="Тип" :value="formatValue(bill.contract_typ)" />
<van-cell title="Комментарий" :value="formatValue(bill.comment)" />
</van-cell-group>
</div>
</van-tab>
<van-tab name="finance" title="Финансы">
<div class="tab-panel">
<van-cell-group>
<van-cell title="Сумма" :value="money(bill.cost)" />
<van-cell title="Оплачено" :value="money(bill.paid)" />
<van-cell title="К оплате" :value="money(bill.to_payd)" />
<van-cell title="НДС" :value="money(bill.nds_cost)" />
<van-cell title="Реестр" :value="bill.pp_maked ? 'Да' : 'Нет'" />
<van-cell title="Архив" :value="bill.archive_s ? 'Да' : 'Нет'" />
</van-cell-group>
</div>
</van-tab>
<van-tab name="relations" title="Связи">
<div class="tab-panel">
<van-cell-group>
<van-cell title="Контрагент" :value="objectLabel(bill.counterparty)" />
<van-cell title="Проект" :value="objectLabel(bill.project)" />
<van-cell title="Договор" :value="objectLabel(bill.contract)" />
<van-cell title="ФРЦ" :value="objectLabel(bill.frc)" />
<van-cell title="Ответственный" :value="objectLabel(bill.responsible)" />
<van-cell title="Автор" :value="objectLabel(bill.author)" />
<van-cell title="Категория" :value="objectLabel(bill.category)" />
</van-cell-group>
<van-cell-group v-if="relatedDocuments().length" title="Документы">
<van-cell v-for="(doc, index) in relatedDocuments()" :key="String((doc as Record<string, unknown>).id ?? index)">
<template #title>
<div class="detail-cell">
<div class="detail-label">{{ String((doc as Record<string, unknown>).number ?? 'Документ') }}</div>
<div class="detail-value">
{{ String((doc as Record<string, unknown>).date ?? '') }}
{{ (doc as Record<string, unknown>).cost ? `· ${formatValue((doc as Record<string, unknown>).cost)}` : '' }}
</div>
</div>
</template>
</van-cell>
</van-cell-group>
</div>
</van-tab>
<van-tab name="approval" title="Согласование">
<div class="tab-panel">
<DocumentApprovalTasks
v-if="bill"
:document-id="bill.id"
filter-name="bill"
title="Согласование счета"
/>
</div>
</van-tab>
<van-tab name="file" title="Файл">
<div class="tab-panel">
<van-empty v-if="!bill.scan" description="Файл не прикреплен" />
<div v-else class="form-actions stacked-actions">
<van-button block round type="primary" plain @click="openScan">
Открыть файл
</van-button>
</div>
</div>
</van-tab>
</van-tabs>
</template>
</section>
</template>
<style scoped>
.bill-summary {
padding: 16px 16px 10px;
}
.bill-summary__title {
font-size: 22px;
font-weight: 700;
line-height: 1.25;
}
.bill-summary__meta {
margin-top: 4px;
color: var(--van-text-color-2);
font-size: 13px;
}
.bill-summary__badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
</style>
+492
View File
@@ -0,0 +1,492 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { showToast } from "vant";
import { billApi } from "../../../generated/api";
import type { Bill, BillCreate, BillListParams, BillUpdate } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
import CounterpartySelect from "../../contracts/components/CounterpartySelect.vue";
import ContractCategorySelect from "../../contracts/components/ContractCategorySelect.vue";
import FrcSelect from "../../contracts/components/FrcSelect.vue";
import ProjectSelect from "../../contracts/components/ProjectSelect.vue";
type BillFilterParams = Omit<BillListParams, "counterparty" | "frc" | "project" | "category"> & {
page?: number;
counterparty?: number;
frc?: number;
project?: number;
category?: number;
};
const PAGE_SIZE = 10;
const router = useRouter();
const {
items: bills,
filters,
count,
loading,
error,
load: loadBills,
} = useModelApi<Bill, BillCreate, BillUpdate, BillListParams>(billApi, {
defaultListParams: { ordering: "-id", page: 1 } as BillListParams,
loadErrorMessage: "Не удалось загрузить счета",
cleanListParams(params) {
params.page = params.page || 1;
params.text__contains = typeof params.text__contains === "string" ? params.text__contains.trim() || undefined : undefined;
params.number__contains = typeof params.number__contains === "string" ? params.number__contains.trim() || undefined : undefined;
params.status = params.status || undefined;
params.status_name__contains = typeof params.status_name__contains === "string" ? params.status_name__contains.trim() || undefined : undefined;
params.contract_typ = params.contract_typ || undefined;
params.date = params.date || undefined;
params.date_due = params.date_due || undefined;
params.date_bill = params.date_bill || undefined;
},
});
const billFilters = filters as unknown as BillFilterParams;
const billTextContains = computed<string>({
get() {
return typeof filters.text__contains === "string" ? filters.text__contains : "";
},
set(value) {
filters.text__contains = value || undefined;
},
});
const billNumberContains = computed<string>({
get() {
return typeof filters.number__contains === "string" ? filters.number__contains : "";
},
set(value) {
filters.number__contains = value || undefined;
},
});
const billStatus = computed<string>({
get() {
return typeof filters.status === "string" ? filters.status : "";
},
set(value) {
filters.status = value || undefined;
},
});
const billStatusNameContains = computed<string>({
get() {
return typeof filters.status_name__contains === "string" ? filters.status_name__contains : "";
},
set(value) {
filters.status_name__contains = value || undefined;
},
});
const billContractTyp = computed<string>({
get() {
return typeof filters.contract_typ === "string" ? filters.contract_typ : "";
},
set(value) {
filters.contract_typ = value || undefined;
},
});
const billDate = computed<string>({
get() {
return typeof filters.date === "string" ? filters.date : "";
},
set(value) {
filters.date = value || undefined;
},
});
const selectedDateBill = computed<string>({
get() {
return typeof filters.date_bill === "string" ? filters.date_bill : "";
},
set(value) {
filters.date_bill = value || undefined;
},
});
const selectedDateDue = computed<string>({
get() {
return typeof filters.date_due === "string" ? filters.date_due : "";
},
set(value) {
filters.date_due = value || undefined;
},
});
const showFilters = ref(false);
const syncing = ref(false);
const hasActiveFilters = computed(() =>
Boolean(
filters.text__contains ||
filters.number__contains ||
filters.status ||
filters.status_name__contains ||
filters.contract_typ ||
filters.date ||
filters.date_due ||
filters.date_bill ||
billFilters.counterparty ||
billFilters.project ||
billFilters.frc ||
billFilters.category,
),
);
const activeFilterCount = computed(
() =>
[
filters.text__contains,
filters.number__contains,
filters.status,
filters.status_name__contains,
filters.contract_typ,
filters.date,
filters.date_due,
filters.date_bill,
billFilters.counterparty,
billFilters.project,
billFilters.frc,
billFilters.category,
].filter(Boolean).length,
);
const currentPage = computed({
get() {
return billFilters.page ?? 1;
},
set(page: number) {
billFilters.page = page;
},
});
const selectedCounterpartyId = computed({
get() {
return billFilters.counterparty ?? 0;
},
set(id: number) {
billFilters.counterparty = id || undefined;
},
});
const selectedProjectId = computed({
get() {
return billFilters.project ?? 0;
},
set(id: number) {
billFilters.project = id || undefined;
},
});
const selectedFrcId = computed({
get() {
return billFilters.frc ?? 0;
},
set(id: number) {
billFilters.frc = id || undefined;
},
});
const selectedCategoryId = computed({
get() {
return billFilters.category ?? 0;
},
set(id: number) {
billFilters.category = id || undefined;
},
});
function parseObject(value: unknown): Record<string, unknown> | null {
if (typeof value === "string") {
if (!value.trim()) {
return null;
}
try {
return parseObject(JSON.parse(value) as unknown);
} catch {
return null;
}
}
if (!value || typeof value !== "object") {
return null;
}
return value as Record<string, unknown>;
}
function objectLabel(value: unknown) {
const record = parseObject(value);
if (!record) {
return "";
}
const candidates = ["name", "short_name", "number", "text", "full_name", "get_status_display"];
for (const key of candidates) {
const candidate = record[key];
if (typeof candidate === "string" && candidate.trim()) {
return candidate;
}
}
return "";
}
function resetFilters() {
filters.text__contains = undefined;
filters.number__contains = undefined;
filters.status = undefined;
filters.status_name__contains = undefined;
filters.contract_typ = undefined;
filters.date = undefined;
filters.date_due = undefined;
filters.date_bill = undefined;
billFilters.counterparty = undefined;
billFilters.project = undefined;
billFilters.frc = undefined;
billFilters.category = undefined;
billFilters.page = 1;
}
async function applyFilters() {
billFilters.page = 1;
showFilters.value = false;
await loadBills();
}
async function syncBills() {
syncing.value = true;
try {
await loadBills();
showToast("Данные обновлены с сервера");
} catch (err) {
showToast(errorMessage(err, "Не удалось синхронизировать счета"));
} finally {
syncing.value = false;
}
}
function errorMessage(err: unknown, fallback: string) {
if (typeof err === "object" && err && "detail" in err) {
return String((err as { detail: unknown }).detail);
}
if (err instanceof Error) {
return err.message;
}
return fallback;
}
watch(
() => [
filters.text__contains,
filters.number__contains,
filters.status,
filters.status_name__contains,
filters.contract_typ,
filters.date,
filters.date_due,
filters.date_bill,
billFilters.counterparty,
billFilters.project,
billFilters.frc,
billFilters.category,
],
() => {
billFilters.page = 1;
},
);
</script>
<template>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<section class="bills-page">
<van-search v-model="billTextContains" placeholder="Поиск по описанию" clearable />
<div class="bill-actions">
<van-badge :content="activeFilterCount || undefined">
<van-button size="small" plain type="primary" icon="filter-o" @click="showFilters = true">
Фильтры
</van-button>
</van-badge>
<van-button size="small" plain type="primary" :loading="loading" @click="loadBills()">
Обновить
</van-button>
<van-button size="small" type="primary" :loading="syncing" @click="syncBills">
Обновить с сервера
</van-button>
</div>
<van-popup v-model:show="showFilters" round position="bottom" class="filters-popup">
<div class="filters-sheet">
<div class="filters-header">
<h2>Фильтры</h2>
<van-button size="small" plain type="primary" @click="resetFilters">
Сбросить
</van-button>
</div>
<van-cell-group class="bill-filters">
<van-field
v-model="billNumberContains"
label="Номер"
placeholder="Номер счета"
clearable
/>
<van-field
v-model="billStatus"
label="Статус"
placeholder="Код статуса"
clearable
/>
<van-field
v-model="billStatusNameContains"
label="Статус текстом"
placeholder="Название статуса"
clearable
/>
<van-field
v-model="billContractTyp"
label="Тип"
placeholder="contract_typ"
clearable
/>
<van-field label="Контрагент">
<template #input>
<CounterpartySelect v-model="selectedCounterpartyId" />
</template>
</van-field>
<van-field label="Объект">
<template #input>
<ProjectSelect v-model="selectedProjectId" />
</template>
</van-field>
<van-field label="ЦФО">
<template #input>
<FrcSelect v-model="selectedFrcId" />
</template>
</van-field>
<van-field label="Категория">
<template #input>
<ContractCategorySelect v-model="selectedCategoryId" />
</template>
</van-field>
<van-field v-model="selectedDateBill" label="Дата счета" type="date" clearable />
<van-field v-model="selectedDateDue" label="Срок оплаты" type="date" clearable />
<van-field v-model="billDate" label="Создан" type="date" clearable />
</van-cell-group>
<div class="filters-actions">
<van-button block type="primary" @click="applyFilters">
Применить
</van-button>
</div>
</div>
</van-popup>
<van-loading v-if="loading && bills.length === 0" class="state" type="spinner">
Загрузка...
</van-loading>
<van-empty
v-else-if="bills.length === 0"
:description="hasActiveFilters ? 'Счета не найдены' : 'Счетов пока нет'"
/>
<template v-else>
<van-cell-group>
<van-cell
v-for="bill in bills"
:key="bill.id"
:title="bill.text || bill.number || `Счет #${bill.id}`"
:label="`№ ${bill.number || 'не указан'} · ${objectLabel(bill.counterparty) || 'не указан'}`"
center
is-link
@click="router.push(`/bills/${bill.id}`)"
>
<template #right-icon>
<van-tag :type="bill.status === '5' ? 'success' : bill.status === '3' ? 'warning' : 'primary'">
{{ bill.status_name || bill.status }}
</van-tag>
</template>
</van-cell>
</van-cell-group>
<van-pagination
v-model="currentPage"
class="bill-pagination"
:total-items="count"
:items-per-page="PAGE_SIZE"
prev-text="Назад"
next-text="Вперед"
mode="simple"
/>
</template>
</section>
</template>
<style scoped>
.bills-page {
padding-bottom: 12px;
}
.bill-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 12px 10px;
overflow-x: auto;
}
.filters-popup {
max-height: 85vh;
}
.filters-sheet {
padding: 14px 0 18px;
}
.filters-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px 8px;
}
.filters-header h2 {
margin: 0;
font-size: 18px;
}
.bill-filters {
margin-bottom: 8px;
}
.filters-actions {
padding: 4px 12px 0;
}
.bill-pagination {
margin: 10px 12px 0;
}
</style>