first
This commit is contained in:
+167
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const activeTab = computed({
|
||||
get() {
|
||||
return route.path.startsWith("/users") ? "/users" : "/tasks";
|
||||
},
|
||||
set(path: string) {
|
||||
if (path !== route.path) {
|
||||
router.push(path);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const title = computed(() => String(route.meta.title ?? "EWA Mobile"));
|
||||
const showBack = computed(() => Boolean(route.meta.back));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<van-nav-bar :title="title" :left-arrow="showBack" fixed placeholder @click-left="router.back()" />
|
||||
|
||||
<main class="page">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<van-tabbar 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="/users" name="/users" icon="friends-o">Пользователи</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
color: #172033;
|
||||
background: #f7f8fa;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
width: min(760px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 24px 14px 40px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.card {
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 40px rgba(36, 42, 56, 0.08);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
padding: 14px 0 16px;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stacked-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.state {
|
||||
justify-content: center;
|
||||
padding: 34px 0;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 14px 0 16px;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
|
||||
.employee-popup {
|
||||
box-sizing: border-box;
|
||||
max-height: 78vh;
|
||||
padding: 16px 0 22px;
|
||||
}
|
||||
|
||||
.employee-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.employee-avatar {
|
||||
margin-right: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { employeeApi } from "../generated/api";
|
||||
import { useModelApi } from "../composables/useModelApi";
|
||||
|
||||
const selectedEmployeeId = defineModel<number>({ required: true });
|
||||
const search = ref("");
|
||||
const showSelector = ref(false);
|
||||
|
||||
const {
|
||||
items: employees,
|
||||
loading,
|
||||
load: loadEmployees,
|
||||
} = useModelApi(employeeApi, {
|
||||
defaultListParams: { ordering: "id" },
|
||||
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));
|
||||
});
|
||||
|
||||
const selectedEmployee = computed(() =>
|
||||
employees.value.find((employee) => employee.id === selectedEmployeeId.value),
|
||||
);
|
||||
|
||||
const selectedEmployeeName = computed(() => selectedEmployee.value?.name ?? "не указан");
|
||||
|
||||
function openSelector() {
|
||||
search.value = "";
|
||||
showSelector.value = true;
|
||||
}
|
||||
|
||||
function selectEmployee(id: number) {
|
||||
selectedEmployeeId.value = id;
|
||||
showSelector.value = false;
|
||||
}
|
||||
|
||||
onMounted(loadEmployees);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="employee-select" type="button" @click="openSelector">
|
||||
<span>{{ selectedEmployeeName }}</span>
|
||||
<van-icon name="arrow" />
|
||||
</button>
|
||||
|
||||
<van-popup v-model:show="showSelector" round position="bottom" class="employee-popup">
|
||||
<div class="employee-popup-header">
|
||||
<h2>Выберите сотрудника</h2>
|
||||
<van-button size="small" type="primary" plain @click="selectEmployee(0)">Не указан</van-button>
|
||||
</div>
|
||||
|
||||
<van-search v-model="search" placeholder="Поиск по имени" />
|
||||
|
||||
<van-loading v-if="loading" class="employee-state" type="spinner">Загрузка...</van-loading>
|
||||
|
||||
<template v-else>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="employee in filteredEmployees"
|
||||
:key="employee.id"
|
||||
:title="employee.name"
|
||||
:label="`ID: ${employee.id}`"
|
||||
clickable
|
||||
center
|
||||
@click="selectEmployee(employee.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-image class="employee-avatar" round width="36" height="36" :src="employee.avatar" />
|
||||
</template>
|
||||
<template #right-icon>
|
||||
<van-icon v-if="selectedEmployeeId === employee.id" name="success" color="#1989fa" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
|
||||
<van-empty v-if="filteredEmployees.length === 0" description="Сотрудники не найдены" />
|
||||
</template>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.employee-popup {
|
||||
min-height: 55vh;
|
||||
padding: 18px 0 28px;
|
||||
}
|
||||
|
||||
.employee-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 18px 10px;
|
||||
}
|
||||
|
||||
.employee-popup-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.employee-avatar {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.employee-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
.employee-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;
|
||||
}
|
||||
|
||||
.employee-select .van-icon {
|
||||
color: #969799;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ref, shallowRef } from "vue";
|
||||
import { showToast } from "vant";
|
||||
import type { BaseEntity, ListParams, ModelApi } from "../generated/api_client";
|
||||
|
||||
interface UseModelApiOptions<Params> {
|
||||
defaultListParams?: Params;
|
||||
loadErrorMessage?: string;
|
||||
retrieveErrorMessage?: string;
|
||||
createErrorMessage?: string;
|
||||
showErrorToast?: boolean;
|
||||
}
|
||||
|
||||
export function useModelApi<
|
||||
T extends BaseEntity,
|
||||
CreateDTO = Partial<T>,
|
||||
UpdateDTO = Partial<T>,
|
||||
Params extends ListParams = ListParams,
|
||||
>(api: ModelApi<T, CreateDTO, UpdateDTO, Params>, options: UseModelApiOptions<Params> = {}) {
|
||||
const items = shallowRef<T[]>([]);
|
||||
const item = shallowRef<T | null>(null);
|
||||
const loading = ref(false);
|
||||
const loadingItem = ref(false);
|
||||
const creating = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load(params?: Params) {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const response = await api.list(params ?? options.defaultListParams);
|
||||
items.value = response.results;
|
||||
} catch (err) {
|
||||
handleError(err, options.loadErrorMessage ?? "Не удалось загрузить данные");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload: CreateDTO) {
|
||||
creating.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const item = await api.create(payload);
|
||||
items.value = [...items.value, item];
|
||||
return item;
|
||||
} catch (err) {
|
||||
handleError(err, options.createErrorMessage ?? "Не удалось создать объект");
|
||||
return null;
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retrieve(id: number) {
|
||||
loadingItem.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
item.value = await api.retrieve(id);
|
||||
return item.value;
|
||||
} catch (err) {
|
||||
handleError(err, options.retrieveErrorMessage ?? "Не удалось загрузить объект");
|
||||
return null;
|
||||
} finally {
|
||||
loadingItem.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(err: unknown, fallback: string) {
|
||||
const message = errorMessage(err, fallback);
|
||||
error.value = message;
|
||||
|
||||
if (options.showErrorToast !== false) {
|
||||
showToast(message);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
item,
|
||||
loading,
|
||||
loadingItem,
|
||||
creating,
|
||||
error,
|
||||
load,
|
||||
retrieve,
|
||||
create,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createModelApi } from "./api_client";
|
||||
import type {
|
||||
Employee,
|
||||
EmployeeCreate,
|
||||
EmployeeUpdate,
|
||||
EmployeeListParams,
|
||||
Message,
|
||||
MessageCreate,
|
||||
MessageUpdate,
|
||||
MessageListParams,
|
||||
Task,
|
||||
TaskCreate,
|
||||
TaskUpdate,
|
||||
TaskListParams,
|
||||
User,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
UserListParams,
|
||||
} from "./models";
|
||||
|
||||
export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee");
|
||||
export const messageApi = createModelApi<Message, MessageCreate, MessageUpdate, MessageListParams>("message");
|
||||
export const taskApi = createModelApi<Task, TaskCreate, TaskUpdate, TaskListParams>("task");
|
||||
export const userApi = createModelApi<User, UserCreate, UserUpdate, UserListParams>("users");
|
||||
@@ -0,0 +1,86 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface BaseEntity {
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export interface ListParams {
|
||||
ordering?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
count: number;
|
||||
results: T[];
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ModelApi<
|
||||
T extends BaseEntity,
|
||||
CreateDTO = Partial<T>,
|
||||
UpdateDTO = Partial<T>,
|
||||
Params extends ListParams = ListParams,
|
||||
> {
|
||||
list: (params?: Params) => Promise<PaginatedResponse<T>>;
|
||||
retrieve: (id: number) => Promise<T>;
|
||||
create: (payload: CreateDTO) => Promise<T>;
|
||||
update: (id: number, payload: UpdateDTO) => Promise<T>;
|
||||
remove: (id: number) => Promise<{ deleted: true }>;
|
||||
}
|
||||
|
||||
export function createModelApi<
|
||||
T extends BaseEntity,
|
||||
CreateDTO = Partial<T>,
|
||||
UpdateDTO = Partial<T>,
|
||||
Params extends ListParams = ListParams,
|
||||
>(resource: string): ModelApi<T, CreateDTO, UpdateDTO, Params> {
|
||||
return {
|
||||
list(params) {
|
||||
return invoke<PaginatedResponse<T>>("che_api", {
|
||||
request: { resource, action: "list", params: serializeListParams(params) },
|
||||
});
|
||||
},
|
||||
|
||||
retrieve(id) {
|
||||
return invoke<T>("che_api", {
|
||||
request: { resource, action: "retrieve", id },
|
||||
});
|
||||
},
|
||||
|
||||
create(payload) {
|
||||
return invoke<T>("che_api", {
|
||||
request: { resource, action: "create", payload },
|
||||
});
|
||||
},
|
||||
|
||||
update(id, payload) {
|
||||
return invoke<T>("che_api", {
|
||||
request: { resource, action: "update", id, payload },
|
||||
});
|
||||
},
|
||||
|
||||
remove(id) {
|
||||
return invoke<{ deleted: true }>("che_api", {
|
||||
request: { resource, action: "delete", id },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function serializeListParams(params?: ListParams) {
|
||||
const serialized: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(params ?? {})) {
|
||||
if (value !== null && value !== undefined) {
|
||||
serialized[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ListParams } from "./api_client";
|
||||
|
||||
export interface Employee {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface EmployeeCreate {
|
||||
name: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface EmployeeUpdate {
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface EmployeeListParams extends ListParams {
|
||||
id?: number;
|
||||
name?: string;
|
||||
name__contains?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
task_id: number;
|
||||
employee_id: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MessageCreate {
|
||||
task_id: number;
|
||||
employee_id: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MessageUpdate {
|
||||
task_id?: number;
|
||||
employee_id?: number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface MessageListParams extends ListParams {
|
||||
id?: number;
|
||||
task_id?: number;
|
||||
employee_id?: number;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
name: string;
|
||||
author_id: number | null;
|
||||
responsible_id: number | null;
|
||||
}
|
||||
|
||||
export interface TaskCreate {
|
||||
name: string;
|
||||
author_id?: number | null;
|
||||
responsible_id?: number | null;
|
||||
}
|
||||
|
||||
export interface TaskUpdate {
|
||||
name?: string;
|
||||
author_id?: number | null;
|
||||
responsible_id?: number | null;
|
||||
}
|
||||
|
||||
export interface TaskListParams extends ListParams {
|
||||
id?: number;
|
||||
name?: string;
|
||||
name__contains?: string;
|
||||
author_id?: number | null;
|
||||
responsible_id?: number | null;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserCreate {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface UserListParams extends ListParams {
|
||||
id?: number;
|
||||
name?: string;
|
||||
name__contains?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import Vant from "vant";
|
||||
import "vant/lib/index.css";
|
||||
import App from "./App.vue";
|
||||
import { router } from "./router";
|
||||
|
||||
createApp(App).use(router).use(Vant).mount("#app");
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import TaskCreateView from "../views/TaskCreateView.vue";
|
||||
import TaskDetailView from "../views/TaskDetailView.vue";
|
||||
import TasksView from "../views/TasksView.vue";
|
||||
import UsersView from "../views/UsersView.vue";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
redirect: "/tasks",
|
||||
},
|
||||
{
|
||||
path: "/tasks",
|
||||
name: "tasks",
|
||||
component: TasksView,
|
||||
meta: { title: "Задачи" },
|
||||
},
|
||||
{
|
||||
path: "/tasks/new",
|
||||
name: "task-create",
|
||||
component: TaskCreateView,
|
||||
meta: { title: "Новая задача", back: true },
|
||||
},
|
||||
{
|
||||
path: "/tasks/:id",
|
||||
name: "task-detail",
|
||||
component: TaskDetailView,
|
||||
meta: { title: "Детали задачи", back: true },
|
||||
},
|
||||
{
|
||||
path: "/users",
|
||||
name: "users",
|
||||
component: UsersView,
|
||||
meta: { title: "Пользователи" },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { showToast } from "vant";
|
||||
import { taskApi } from "../generated/api";
|
||||
import { useModelApi } from "../composables/useModelApi";
|
||||
import EmployeeSelect from "../components/EmployeeSelect.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const newTaskName = ref("");
|
||||
const selectedAuthorId = ref(0);
|
||||
const selectedResponsibleId = ref(0);
|
||||
|
||||
const {
|
||||
creating,
|
||||
error,
|
||||
create: createTaskApi,
|
||||
} = useModelApi(taskApi, {
|
||||
createErrorMessage: "Не удалось создать задачу",
|
||||
});
|
||||
|
||||
async function createTask() {
|
||||
const name = newTaskName.value.trim();
|
||||
if (!name) {
|
||||
showToast("Введите название задачи");
|
||||
return;
|
||||
}
|
||||
|
||||
const task = await createTaskApi({
|
||||
name,
|
||||
author_id: selectedAuthorId.value || null,
|
||||
responsible_id: selectedResponsibleId.value || null,
|
||||
});
|
||||
|
||||
if (task) {
|
||||
showToast("Задача создана");
|
||||
router.push("/tasks");
|
||||
}
|
||||
}
|
||||
|
||||
</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"
|
||||
color="#991b1b"
|
||||
background="#fee2e2"
|
||||
left-icon="warning-o"
|
||||
wrapable
|
||||
:scrollable="false"
|
||||
:text="error"
|
||||
/>
|
||||
|
||||
<van-form class="card" @submit="createTask">
|
||||
<van-field
|
||||
v-model="newTaskName"
|
||||
name="name"
|
||||
label="Название"
|
||||
placeholder="Например: Купить молоко"
|
||||
clearable
|
||||
:disabled="creating"
|
||||
/>
|
||||
<van-field label="Автор">
|
||||
<template #input>
|
||||
<EmployeeSelect v-model="selectedAuthorId" />
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field label="Ответственный">
|
||||
<template #input>
|
||||
<EmployeeSelect v-model="selectedResponsibleId" />
|
||||
</template>
|
||||
</van-field>
|
||||
<div class="form-actions stacked-actions">
|
||||
<van-button
|
||||
block
|
||||
round
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="creating"
|
||||
:disabled="!newTaskName.trim()"
|
||||
>
|
||||
Создать задачу
|
||||
</van-button>
|
||||
<van-button block round plain type="default" @click="router.back()">Отмена</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
|
||||
</template>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { showToast } from "vant";
|
||||
import { employeeApi, messageApi, taskApi } from "../generated/api";
|
||||
import { useModelApi } from "../composables/useModelApi";
|
||||
import EmployeeSelect from "../components/EmployeeSelect.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const taskId = computed(() => Number(route.params.id));
|
||||
const messageText = ref("");
|
||||
const selectedEmployeeId = ref(0);
|
||||
|
||||
const {
|
||||
item: task,
|
||||
loadingItem,
|
||||
error,
|
||||
retrieve: loadTask,
|
||||
} = useModelApi(taskApi, {
|
||||
retrieveErrorMessage: "Не удалось загрузить задачу",
|
||||
});
|
||||
const {
|
||||
items: messages,
|
||||
loading: loadingMessages,
|
||||
creating: sendingMessage,
|
||||
error: messageError,
|
||||
load: loadMessages,
|
||||
create: createMessageApi,
|
||||
} = useModelApi(messageApi, {
|
||||
loadErrorMessage: "Не удалось загрузить сообщения",
|
||||
createErrorMessage: "Не удалось отправить сообщение",
|
||||
});
|
||||
const { items: employees, load: loadEmployees } = useModelApi(employeeApi, {
|
||||
defaultListParams: { ordering: "id" },
|
||||
loadErrorMessage: "Не удалось загрузить сотрудников",
|
||||
});
|
||||
|
||||
async function sendMessage() {
|
||||
if (!Number.isFinite(taskId.value)) {
|
||||
showToast("Некорректный ID задачи");
|
||||
return;
|
||||
}
|
||||
|
||||
const text = messageText.value.trim();
|
||||
if (!text) {
|
||||
showToast("Введите сообщение");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedEmployeeId.value) {
|
||||
showToast("Выберите сотрудника");
|
||||
return;
|
||||
}
|
||||
|
||||
const message = await createMessageApi({
|
||||
task_id: taskId.value,
|
||||
employee_id: selectedEmployeeId.value,
|
||||
text,
|
||||
});
|
||||
|
||||
if (message) {
|
||||
messageText.value = "";
|
||||
await loadMessages({ task_id: taskId.value });
|
||||
showToast("Сообщение отправлено");
|
||||
}
|
||||
}
|
||||
|
||||
function employeeName(id: number) {
|
||||
return employees.value.find((employee) => employee.id === id)?.name ?? `#${id}`;
|
||||
}
|
||||
|
||||
function employeeAvatar(id: number) {
|
||||
return employees.value.find((employee) => employee.id === id)?.avatar ?? "";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (Number.isFinite(taskId.value)) {
|
||||
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"
|
||||
color="#991b1b"
|
||||
background="#fee2e2"
|
||||
left-icon="warning-o"
|
||||
wrapable
|
||||
:scrollable="false"
|
||||
:text="error || messageError"
|
||||
/>
|
||||
|
||||
<section class="card detail-card">
|
||||
<van-loading v-if="loadingItem" class="state" type="spinner">Загрузка...</van-loading>
|
||||
|
||||
<van-empty v-else-if="!task" description="Задача не найдена" />
|
||||
|
||||
<template v-else>
|
||||
<van-cell-group inset>
|
||||
<van-cell title="ID" :value="task.id" />
|
||||
<van-cell title="Название" :value="task.name" />
|
||||
<van-cell title="Автор" :value="task.author_id ?? 'не указан'" />
|
||||
<van-cell title="Ответственный" :value="task.responsible_id ?? 'не указан'" />
|
||||
</van-cell-group>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="card messages-card">
|
||||
<div class="list-title">
|
||||
<h2>Диалог</h2>
|
||||
<van-button
|
||||
size="small"
|
||||
plain
|
||||
type="primary"
|
||||
:loading="loadingMessages"
|
||||
@click="loadMessages({ task_id: taskId })"
|
||||
>
|
||||
Обновить
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<van-loading v-if="loadingMessages && messages.length === 0" class="state" type="spinner">
|
||||
Загрузка сообщений...
|
||||
</van-loading>
|
||||
|
||||
<van-empty v-else-if="messages.length === 0" description="Сообщений пока нет" />
|
||||
|
||||
<div v-else class="message-list">
|
||||
<article v-for="message in messages" :key="message.id" class="message-item">
|
||||
<van-image
|
||||
class="message-avatar"
|
||||
round
|
||||
width="40"
|
||||
height="40"
|
||||
:src="employeeAvatar(message.employee_id)"
|
||||
/>
|
||||
<div class="message-bubble">
|
||||
<div class="message-author">{{ employeeName(message.employee_id) }}</div>
|
||||
<div class="message-text">{{ message.text }}</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<van-form class="card" @submit="sendMessage">
|
||||
<van-field
|
||||
name="employee_id"
|
||||
label="Отправитель"
|
||||
placeholder="Выберите сотрудника"
|
||||
>
|
||||
<template #input>
|
||||
<EmployeeSelect v-model="selectedEmployeeId" />
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model="messageText"
|
||||
name="text"
|
||||
label="Сообщение"
|
||||
type="textarea"
|
||||
rows="2"
|
||||
autosize
|
||||
clearable
|
||||
placeholder="Напишите сообщение"
|
||||
:disabled="sendingMessage"
|
||||
/>
|
||||
<div class="form-actions stacked-actions">
|
||||
<van-button
|
||||
block
|
||||
round
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="sendingMessage"
|
||||
:disabled="!Number.isFinite(taskId) || !messageText.trim() || !selectedEmployeeId"
|
||||
>
|
||||
Отправить сообщение
|
||||
</van-button>
|
||||
<van-button block round type="primary" plain @click="router.back()">Назад</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { employeeApi, taskApi } from "../generated/api";
|
||||
import { useModelApi } from "../composables/useModelApi";
|
||||
|
||||
const router = useRouter();
|
||||
const {
|
||||
items: tasks,
|
||||
loading,
|
||||
error,
|
||||
load: loadTasks,
|
||||
} = useModelApi(taskApi, {
|
||||
defaultListParams: { ordering: "id" },
|
||||
loadErrorMessage: "Не удалось загрузить задачи",
|
||||
});
|
||||
const { items: employees, load: loadEmployees } = useModelApi(employeeApi, {
|
||||
defaultListParams: { ordering: "id" },
|
||||
loadErrorMessage: "Не удалось загрузить сотрудников",
|
||||
});
|
||||
|
||||
function employeeName(id: number | null) {
|
||||
if (id === null || id === 0) {
|
||||
return "не указан";
|
||||
}
|
||||
|
||||
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"
|
||||
color="#991b1b"
|
||||
background="#fee2e2"
|
||||
left-icon="warning-o"
|
||||
wrapable
|
||||
:scrollable="false"
|
||||
:text="error"
|
||||
/>
|
||||
|
||||
<section class="card list-card">
|
||||
<div class="list-title">
|
||||
<h2>Список</h2>
|
||||
<div class="list-actions">
|
||||
<van-button size="small" plain type="primary" :loading="loading" @click="loadTasks()">
|
||||
Обновить
|
||||
</van-button>
|
||||
<van-button size="small" round type="primary" icon="plus" @click="router.push('/tasks/new')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-loading v-if="loading && tasks.length === 0" class="state" type="spinner">
|
||||
Загрузка...
|
||||
</van-loading>
|
||||
|
||||
<van-empty v-else-if="tasks.length === 0" description="Задач пока нет" />
|
||||
|
||||
<van-cell-group v-else inset>
|
||||
<van-cell
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:title="task.name"
|
||||
center
|
||||
is-link
|
||||
@click="router.push(`/tasks/${task.id}`)"
|
||||
>
|
||||
<template #label>
|
||||
ID: {{ task.id }} · Автор: {{ employeeName(task.author_id) }} · Ответственный:
|
||||
{{ employeeName(task.responsible_id) }}
|
||||
</template>
|
||||
<template #right-icon>
|
||||
<van-tag plain type="primary">Task</van-tag>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { userApi } from "../generated/api";
|
||||
import { useModelApi } from "../composables/useModelApi";
|
||||
|
||||
const {
|
||||
items: users,
|
||||
loading,
|
||||
error,
|
||||
load: loadUsers,
|
||||
} = useModelApi(userApi, {
|
||||
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"
|
||||
color="#991b1b"
|
||||
background="#fee2e2"
|
||||
left-icon="warning-o"
|
||||
wrapable
|
||||
:scrollable="false"
|
||||
:text="error"
|
||||
/>
|
||||
|
||||
<section class="card list-card">
|
||||
<div class="list-title">
|
||||
<h2>Список</h2>
|
||||
<van-button size="small" plain type="primary" :loading="loading" @click="loadUsers()">
|
||||
Обновить
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<van-loading v-if="loading && users.length === 0" class="state" type="spinner">
|
||||
Загрузка...
|
||||
</van-loading>
|
||||
|
||||
<van-empty v-else-if="users.length === 0" description="Пользователей пока нет" />
|
||||
|
||||
<van-cell-group v-else inset>
|
||||
<van-cell v-for="user in users" :key="user.id" :title="user.name" center>
|
||||
<template #label> ID: {{ user.id }} </template>
|
||||
<template #right-icon>
|
||||
<van-tag plain type="success">User</van-tag>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</section>
|
||||
</template>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
Reference in New Issue
Block a user