This commit is contained in:
che
2026-07-23 12:06:08 +05:00
parent 4cfc5d6ae6
commit dddf6516d3
47 changed files with 2253 additions and 94 deletions
+23 -26
View File
@@ -1,12 +1,18 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuth } from "./composables/useAuth";
const route = useRoute();
const router = useRouter();
const { logout } = useAuth();
const activeTab = computed({
get() {
if (route.path.startsWith("/contracts")) {
return "/contracts";
}
return route.path.startsWith("/users") ? "/users" : "/tasks";
},
set(path: string) {
@@ -18,17 +24,32 @@ const activeTab = computed({
const title = computed(() => String(route.meta.title ?? "EWA Mobile"));
const showBack = computed(() => Boolean(route.meta.back));
const showShellNavigation = computed(() => route.path !== "/login");
async function logoutAndRedirect() {
await logout();
router.replace("/login");
}
</script>
<template>
<van-nav-bar :title="title" :left-arrow="showBack" fixed placeholder @click-left="router.back()" />
<van-nav-bar
:title="title"
:left-arrow="showBack"
:right-text="showShellNavigation ? 'Выйти' : ''"
fixed
placeholder
@click-left="router.back()"
@click-right="logoutAndRedirect"
/>
<main class="page">
<router-view />
</main>
<van-tabbar v-model="activeTab" route placeholder safe-area-inset-bottom>
<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>
@@ -65,30 +86,6 @@ body {
padding: 24px 14px 40px;
}
.hero {
margin: 12px 2px 18px;
}
.eyebrow {
margin: 0 0 6px;
color: #1989fa;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2rem, 9vw, 4rem);
line-height: 1;
}
.subtitle {
margin: 12px 0 0;
color: #646566;
}
.notice {
margin-bottom: 12px;
border-radius: 14px;
+135
View File
@@ -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 "../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>
+18 -16
View File
@@ -1,28 +1,27 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, ref, watch } from "vue";
import { employeeApi } from "../generated/api";
import type { EmployeeListParams } from "../generated/models";
import { useModelApi } from "../composables/useModelApi";
const PAGE_SIZE = 20;
const selectedEmployeeId = defineModel<number>({ required: true });
const search = ref("");
const showSelector = ref(false);
const {
items: employees,
filters,
loading,
load: loadEmployees,
} = useModelApi(employeeApi, {
defaultListParams: { ordering: "id" },
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as EmployeeListParams,
loadErrorMessage: "Не удалось загрузить сотрудников",
});
const filteredEmployees = computed(() => {
const value = search.value.trim().toLowerCase();
if (!value) {
return employees.value;
}
return employees.value.filter((employee) => employee.name.toLowerCase().includes(value));
cleanListParams: (params) => {
if (!params.name__contains?.trim()) {
delete params.name__contains;
}
},
});
const selectedEmployee = computed(() =>
@@ -41,7 +40,10 @@ function selectEmployee(id: number) {
showSelector.value = false;
}
onMounted(loadEmployees);
watch(search, (value) => {
filters.name__contains = value.trim();
filters.offset = 0;
});
</script>
<template>
@@ -63,7 +65,7 @@ onMounted(loadEmployees);
<template v-else>
<van-cell-group inset>
<van-cell
v-for="employee in filteredEmployees"
v-for="employee in employees"
:key="employee.id"
:title="employee.name"
:label="`ID: ${employee.id}`"
@@ -72,7 +74,7 @@ onMounted(loadEmployees);
@click="selectEmployee(employee.id)"
>
<template #icon>
<van-image class="employee-avatar" round width="36" height="36" :src="employee.avatar" />
<van-image class="employee-avatar" round width="36" height="36" :src="employee.avatar_small ?? ''" />
</template>
<template #right-icon>
<van-icon v-if="selectedEmployeeId === employee.id" name="success" color="#1989fa" />
@@ -80,7 +82,7 @@ onMounted(loadEmployees);
</van-cell>
</van-cell-group>
<van-empty v-if="filteredEmployees.length === 0" description="Сотрудники не найдены" />
<van-empty v-if="employees.length === 0" description="Сотрудники не найдены" />
</template>
</van-popup>
</template>
+47
View File
@@ -0,0 +1,47 @@
import { invoke } from "@tauri-apps/api/core";
import { ref } from "vue";
const TOKEN_STORAGE_KEY = "ewa-mobile.authToken";
const authToken = ref(localStorage.getItem(TOKEN_STORAGE_KEY) ?? "");
let restored = false;
interface AuthTokenResponse {
token: string;
}
export function useAuth() {
async function login(username: string, password: string) {
const response = await invoke<AuthTokenResponse>("auth_login", { username, password });
authToken.value = response.token;
localStorage.setItem(TOKEN_STORAGE_KEY, response.token);
restored = true;
}
async function logout() {
authToken.value = "";
localStorage.removeItem(TOKEN_STORAGE_KEY);
restored = false;
await invoke("auth_logout");
}
async function restoreToken() {
if (restored || !authToken.value) {
return;
}
await invoke("auth_set_token", { token: authToken.value });
restored = true;
}
return {
authToken,
isAuthenticated,
login,
logout,
restoreToken,
};
}
export function isAuthenticated() {
return Boolean(authToken.value);
}
+30 -2
View File
@@ -1,4 +1,4 @@
import { ref, shallowRef } from "vue";
import { onMounted, reactive, ref, shallowRef, watch } from "vue";
import { showToast } from "vant";
import type { BaseEntity, ListParams, ModelApi } from "../generated/api_client";
@@ -8,6 +8,10 @@ interface UseModelApiOptions<Params> {
retrieveErrorMessage?: string;
createErrorMessage?: string;
showErrorToast?: boolean;
autoLoad?: boolean;
autoLoadOnFilterChange?: boolean;
debounceMs?: number;
cleanListParams?: (params: Params) => void;
}
export function useModelApi<
@@ -22,14 +26,23 @@ export function useModelApi<
const loadingItem = ref(false);
const creating = ref(false);
const error = ref("");
const count = ref(0);
const filters = reactive({ ...(options.defaultListParams ?? {}) } as Params) as Params;
let loadTimer: ReturnType<typeof setTimeout> | undefined;
const autoLoad = options.autoLoad ?? true;
const autoLoadOnFilterChange = options.autoLoadOnFilterChange ?? true;
const debounceMs = options.debounceMs ?? 250;
async function load(params?: Params) {
loading.value = true;
error.value = "";
const listParams = params ?? filters;
options.cleanListParams?.(listParams);
try {
const response = await api.list(params ?? options.defaultListParams);
const response = await api.list(listParams);
items.value = response.results;
count.value = response.count;
} catch (err) {
handleError(err, options.loadErrorMessage ?? "Не удалось загрузить данные");
} finally {
@@ -77,9 +90,24 @@ export function useModelApi<
}
}
if (autoLoadOnFilterChange) {
watch(filters, () => {
clearTimeout(loadTimer);
loadTimer = setTimeout(load, debounceMs);
});
}
if (autoLoad) {
onMounted(() => {
load();
});
}
return {
items,
item,
filters,
count,
loading,
loadingItem,
creating,
+20
View File
@@ -1,13 +1,29 @@
import { createModelApi } from "./api_client";
import type {
Contract,
ContractCreate,
ContractUpdate,
ContractListParams,
Counterparty,
CounterpartyCreate,
CounterpartyUpdate,
CounterpartyListParams,
Employee,
EmployeeCreate,
EmployeeUpdate,
EmployeeListParams,
Frc,
FrcCreate,
FrcUpdate,
FrcListParams,
Message,
MessageCreate,
MessageUpdate,
MessageListParams,
Project,
ProjectCreate,
ProjectUpdate,
ProjectListParams,
Task,
TaskCreate,
TaskUpdate,
@@ -18,7 +34,11 @@ import type {
UserListParams,
} from "./models";
export const contractApi = createModelApi<Contract, ContractCreate, ContractUpdate, ContractListParams>("contract");
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");
export const messageApi = createModelApi<Message, MessageCreate, MessageUpdate, MessageListParams>("message");
export const projectApi = createModelApi<Project, ProjectCreate, ProjectUpdate, ProjectListParams>("project");
export const taskApi = createModelApi<Task, TaskCreate, TaskUpdate, TaskListParams>("task");
export const userApi = createModelApi<User, UserCreate, UserUpdate, UserListParams>("users");
+205 -3
View File
@@ -1,25 +1,194 @@
import type { ListParams } from "./api_client";
export interface Contract {
id: number;
name: string;
number: string;
absolute_url: string;
contract_type: string;
category: string;
amount_total_display: string;
amount_by_ds: string;
comment: string;
date: string;
status_name: string;
status: string;
nds: string;
name_of_product: string;
counterparty_name: string;
amount: number;
month_pay: number | null;
avans_pay: string;
amount_total: number;
estimate_nds_cost: number;
estimate_nds_cert: number;
bill_cost_sum: number | null;
bill_paid_sum: number | null;
income_total: number;
arrears: number;
counterparty_id: number | null;
project_id: number | null;
frc_id: number | null;
employee_id: number | null;
counterparty: Counterparty | null;
project: Project | null;
frc: Frc | null;
employee: Employee | null;
}
export interface ContractCreate {
name: string;
number: string;
absolute_url: string;
contract_type: string;
category: string;
amount_total_display: string;
amount_by_ds: string;
comment: string;
date: string;
status_name: string;
status: string;
nds: string;
name_of_product: string;
counterparty_name: string;
amount: number;
month_pay?: number | null;
avans_pay: string;
amount_total: number;
estimate_nds_cost: number;
estimate_nds_cert: number;
bill_cost_sum?: number | null;
bill_paid_sum?: number | null;
income_total: number;
arrears: number;
counterparty_id?: number | null;
project_id?: number | null;
frc_id?: number | null;
employee_id?: number | null;
}
export interface ContractUpdate {
name?: string;
number?: string;
absolute_url?: string;
contract_type?: string;
category?: string;
amount_total_display?: string;
amount_by_ds?: string;
comment?: string;
date?: string;
status_name?: string;
status?: string;
nds?: string;
name_of_product?: string;
counterparty_name?: string;
amount?: number;
month_pay?: number | null;
avans_pay?: string;
amount_total?: number;
estimate_nds_cost?: number;
estimate_nds_cert?: number;
bill_cost_sum?: number | null;
bill_paid_sum?: number | null;
income_total?: number;
arrears?: number;
counterparty_id?: number | null;
project_id?: number | null;
frc_id?: number | null;
employee_id?: number | null;
}
export interface ContractListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
number?: string;
number__contains?: string;
date?: string;
status?: string;
status_name?: string;
status_name__contains?: string;
contract_type?: string;
contract_type__contains?: string;
category?: string;
category__contains?: string;
counterparty_name__contains?: string;
name_of_product__contains?: string;
counterparty_id?: number | null;
project_id?: number | null;
frc_id?: number | null;
employee_id?: number | null;
}
export interface Counterparty {
id: number;
name: string;
}
export interface CounterpartyCreate {
name: string;
}
export interface CounterpartyUpdate {
name?: string;
}
export interface CounterpartyListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
}
export interface Employee {
id: number;
name: string;
avatar: string;
short_name: string;
avatar_small: string | null;
}
export interface EmployeeCreate {
name: string;
avatar: string;
short_name: string;
avatar_small?: string | null;
}
export interface EmployeeUpdate {
name?: string;
avatar?: string;
short_name?: string;
avatar_small?: string | null;
}
export interface EmployeeListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
short_name?: string;
short_name__contains?: string;
}
export interface Frc {
id: number;
name: string;
icon: string;
balance: number;
}
export interface FrcCreate {
name: string;
icon: string;
balance: number;
}
export interface FrcUpdate {
name?: string;
icon?: string;
balance?: number;
}
export interface FrcListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
}
export interface Message {
@@ -47,6 +216,39 @@ export interface MessageListParams extends ListParams {
employee_id?: number;
}
export interface Project {
id: number;
name: string;
full_name: string;
short_name: string;
locality_id: number | null;
locality_name: string | null;
}
export interface ProjectCreate {
name: string;
full_name: string;
short_name: string;
locality_id?: number | null;
locality_name?: string | null;
}
export interface ProjectUpdate {
name?: string;
full_name?: string;
short_name?: string;
locality_id?: number | null;
locality_name?: string | null;
}
export interface ProjectListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
short_name?: string;
short_name__contains?: string;
}
export interface Task {
id: number;
name: string;
+39 -4
View File
@@ -1,4 +1,8 @@
import { createRouter, createWebHashHistory } from "vue-router";
import { isAuthenticated, useAuth } from "../composables/useAuth";
import ContractDetailView from "../views/ContractDetailView.vue";
import ContractsView from "../views/ContractsView.vue";
import LoginView from "../views/LoginView.vue";
import TaskCreateView from "../views/TaskCreateView.vue";
import TaskDetailView from "../views/TaskDetailView.vue";
import TasksView from "../views/TasksView.vue";
@@ -15,25 +19,56 @@ export const router = createRouter({
path: "/tasks",
name: "tasks",
component: TasksView,
meta: { title: "Задачи" },
meta: { title: "Задачи", requiresAuth: true },
},
{
path: "/tasks/new",
name: "task-create",
component: TaskCreateView,
meta: { title: "Новая задача", back: true },
meta: { title: "Новая задача", back: true, requiresAuth: true },
},
{
path: "/tasks/:id",
name: "task-detail",
component: TaskDetailView,
meta: { title: "Детали задачи", back: true },
meta: { title: "Детали задачи", back: true, requiresAuth: true },
},
{
path: "/users",
name: "users",
component: UsersView,
meta: { title: "Пользователи" },
meta: { title: "Пользователи", requiresAuth: true },
},
{
path: "/contracts",
name: "contracts",
component: ContractsView,
meta: { title: "Контракты", requiresAuth: true },
},
{
path: "/contracts/:id",
name: "contract-detail",
component: ContractDetailView,
meta: { title: "Детали контракта", back: true, requiresAuth: true },
},
{
path: "/login",
name: "login",
component: LoginView,
meta: { title: "Вход" },
},
],
});
router.beforeEach(async (to) => {
const { restoreToken } = useAuth();
await restoreToken();
if (to.meta.requiresAuth && !isAuthenticated()) {
return { path: "/login", query: { redirect: to.fullPath } };
}
if (to.path === "/login" && isAuthenticated()) {
return "/tasks";
}
});
+147
View File
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { contractApi } from "../generated/api";
import type { Contract } from "../generated/models";
import { useModelApi } from "../composables/useModelApi";
const route = useRoute();
const router = useRouter();
const contractId = computed(() => Number(route.params.id));
const {
item: contract,
loadingItem,
error,
retrieve: loadContract,
} = useModelApi(contractApi, {
retrieveErrorMessage: "Не удалось загрузить контракт",
autoLoad: false,
autoLoadOnFilterChange: false,
});
type DetailField = {
title: string;
key: keyof Contract;
};
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" },
{ 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" },
];
const relationFields = computed(() => {
const item = contract.value;
if (!item) {
return [];
}
return [
{ title: "Контрагент", value: item.counterparty?.name ?? item.counterparty_name },
{ title: "Контрагент ID", value: item.counterparty_id },
{ 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 },
];
});
function fieldValue(field: DetailField) {
return formatValue(contract.value?.[field.key]);
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === "") {
return "не указано";
}
return String(value);
}
onMounted(() => {
if (Number.isFinite(contractId.value)) {
loadContract(contractId.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="!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>
<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-group inset title="Связи">
<van-cell
v-for="field in relationFields"
:key="field.title"
:title="field.title"
:value="formatValue(field.value)"
/>
</van-cell-group>
<div class="detail-actions">
<van-button block round type="primary" plain @click="router.back()">Назад</van-button>
</div>
</template>
</section>
</template>
+200
View File
@@ -0,0 +1,200 @@
<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 "../composables/useModelApi";
import CounterpartySelect from "../components/CounterpartySelect.vue";
const PAGE_SIZE = 10;
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.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 hasActiveFilters = computed(() =>
Boolean(
filters.name__contains ||
filters.number__contains ||
filters.counterparty_id,
),
);
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;
}
const syncing = ref(false);
async function syncContracts() {
syncing.value = true;
try {
const result = await invoke<SyncContractsResult>("sync_contracts");
showToast(`Синхронизировано: ${result.synced}`);
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.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="card list-card">
<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()">
Обновить
</van-button>
<van-button size="small" type="primary" :loading="syncing" @click="syncContracts">
Синхронизация
</van-button>
</div>
</div>
<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 inset>
<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}`)"
>
<template #right-icon>
<van-tag plain type="primary">Contract</van-tag>
</template>
</van-cell>
</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: 12px;
}
.contract-pagination {
margin: 14px 16px 0;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast } from "vant";
import { useAuth } from "../composables/useAuth";
const route = useRoute();
const router = useRouter();
const { login } = useAuth();
const username = ref("");
const password = ref("");
const loading = ref(false);
const error = ref("");
async function submitLogin() {
const cleanUsername = username.value.trim();
if (!cleanUsername || !password.value) {
showToast("Введите логин и пароль");
return;
}
loading.value = true;
error.value = "";
try {
await login(cleanUsername, password.value);
await router.replace(String(route.query.redirect ?? "/tasks"));
} catch (err) {
error.value = errorMessage(err);
} finally {
loading.value = false;
}
}
function errorMessage(err: unknown) {
if (typeof err === "object" && err && "detail" in err) {
return String((err as { detail: unknown }).detail);
}
if (err instanceof Error) {
return err.message;
}
return "Не удалось войти";
}
</script>
<template>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<van-form class="card" @submit="submitLogin">
<van-field
v-model="username"
name="username"
label="Логин"
placeholder="Введите логин"
autocomplete="username"
clearable
:disabled="loading"
/>
<van-field
v-model="password"
name="password"
label="Пароль"
type="password"
placeholder="Введите пароль"
autocomplete="current-password"
clearable
:disabled="loading"
/>
<div class="form-actions">
<van-button block round type="primary" native-type="submit" :loading="loading">
Войти
</van-button>
</div>
</van-form>
</template>
+2 -6
View File
@@ -17,6 +17,8 @@ const {
create: createTaskApi,
} = useModelApi(taskApi, {
createErrorMessage: "Не удалось создать задачу",
autoLoad: false,
autoLoadOnFilterChange: false,
});
async function createTask() {
@@ -41,12 +43,6 @@ async function createTask() {
</script>
<template>
<section class="hero">
<p class="eyebrow">New task</p>
<h1>Создать задачу</h1>
<p class="subtitle">Заполните название и при необходимости выберите автора и ответственного.</p>
</section>
<van-notice-bar
v-if="error"
class="notice"
+6 -9
View File
@@ -19,6 +19,8 @@ const {
retrieve: loadTask,
} = useModelApi(taskApi, {
retrieveErrorMessage: "Не удалось загрузить задачу",
autoLoad: false,
autoLoadOnFilterChange: false,
});
const {
items: messages,
@@ -30,8 +32,10 @@ const {
} = useModelApi(messageApi, {
loadErrorMessage: "Не удалось загрузить сообщения",
createErrorMessage: "Не удалось отправить сообщение",
autoLoad: false,
autoLoadOnFilterChange: false,
});
const { items: employees, load: loadEmployees } = useModelApi(employeeApi, {
const { items: employees } = useModelApi(employeeApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить сотрудников",
});
@@ -71,7 +75,7 @@ function employeeName(id: number) {
}
function employeeAvatar(id: number) {
return employees.value.find((employee) => employee.id === id)?.avatar ?? "";
return employees.value.find((employee) => employee.id === id)?.avatar_small ?? "";
}
onMounted(() => {
@@ -79,17 +83,10 @@ onMounted(() => {
loadTask(taskId.value);
loadMessages({ task_id: taskId.value });
}
loadEmployees();
});
</script>
<template>
<section class="hero detail-hero">
<p class="eyebrow">Task details</p>
<h1>Детали</h1>
<p class="subtitle">Просмотр задачи и диалог сотрудников.</p>
</section>
<van-notice-bar
v-if="error || messageError"
class="notice"
+1 -13
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import { employeeApi, taskApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
@@ -14,7 +13,7 @@ const {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить задачи",
});
const { items: employees, load: loadEmployees } = useModelApi(employeeApi, {
const { items: employees } = useModelApi(employeeApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить сотрудников",
});
@@ -26,20 +25,9 @@ function employeeName(id: number | null) {
return employees.value.find((employee) => employee.id === id)?.name ?? `#${id}`;
}
onMounted(() => {
loadTasks();
loadEmployees();
});
</script>
<template>
<section class="hero">
<p class="eyebrow">Task manager</p>
<h1>Мои задачи</h1>
<p class="subtitle">Данные загружаются через `che_api` и отображаются компонентами Vant.</p>
</section>
<van-notice-bar
v-if="error"
class="notice"
-9
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { userApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
@@ -12,17 +11,9 @@ const {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить пользователей",
});
onMounted(loadUsers);
</script>
<template>
<section class="hero">
<p class="eyebrow">Directory</p>
<h1>Пользователи</h1>
<p class="subtitle">Вторая страница для проверки маршрутизации и панели навигации.</p>
</section>
<van-notice-bar
v-if="error"
class="notice"