Бета версия (80% функционала)

This commit is contained in:
Konstantin Makaev 2026-02-24 14:13:26 +03:00
parent 3651554d9e
commit b324e61285
9 changed files with 1182 additions and 35 deletions

View File

@ -0,0 +1,93 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { AppShell } from "../../../components/AppShell";
import { apiClient } from "@/lib/api";
import type { Department } from "@/types/api";
import { ArrowLeft, Building2, Users } from "lucide-react";
export default function DepartmentDetailPage() {
const params = useParams<{ id: string }>();
const departmentId = Number(params?.id);
const [department, setDepartment] = useState<Department | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadDepartment() {
if (!departmentId || Number.isNaN(departmentId)) {
setError("Некорректный идентификатор отдела");
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const response = await apiClient.getDepartment(departmentId);
setDepartment(response);
} catch (e) {
console.error("Ошибка загрузки отдела:", e);
setError("Не удалось загрузить отдел");
} finally {
setLoading(false);
}
}
loadDepartment();
}, [departmentId]);
return (
<AppShell>
<div className="space-y-4">
<Link
href="/departments"
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<ArrowLeft size={14} />
К списку отделов
</Link>
{loading ? (
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
<p className="text-sm text-gray-500">Загрузка отдела...</p>
</div>
) : error ? (
<div className="rounded-2xl bg-red-50 p-6 text-center">
<p className="text-sm text-red-800">{error}</p>
</div>
) : department ? (
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="mb-2 inline-flex items-center gap-2 rounded-full bg-sky-50 px-2.5 py-1 text-xs text-sky-700 ring-1 ring-sky-100">
<Building2 size={12} />
Отдел
</div>
<h1 className="text-xl font-semibold text-gray-900">{department.name}</h1>
<p className="mt-2 text-sm text-gray-600">{department.description || "Описание не заполнено"}</p>
<p className="mt-3 text-sm text-gray-700">
Руководитель:{" "}
<span className="font-medium">
{department.head
? `${department.head.last_name} ${department.head.first_name}`.trim()
: "Не назначен"}
</span>
</p>
</div>
<div className="inline-flex shrink-0 items-center gap-1 rounded-full bg-sky-50 px-2.5 py-1 text-xs text-sky-700 ring-1 ring-sky-100">
<Users size={12} />
{department.employees_count ?? 0}
</div>
</div>
</section>
) : null}
</div>
</AppShell>
);
}

View File

@ -3,6 +3,7 @@
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import type { Department } from "@/types/api";
import { Building2, Search, Users } from "lucide-react";
@ -79,7 +80,7 @@ export default function DepartmentsPage() {
: "Не назначен";
return (
<article key={department.id} className="rounded-xl border border-gray-100 bg-white p-4 transition hover:bg-gray-50">
<Link key={department.id} href={`/departments/${department.id}`} className="block rounded-xl border border-gray-100 bg-white p-4 transition hover:bg-gray-50">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900">{department.name}</p>
@ -91,7 +92,7 @@ export default function DepartmentsPage() {
{department.employees_count ?? 0}
</div>
</div>
</article>
</Link>
);
})
)}

View File

@ -0,0 +1,102 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { AppShell } from "../../../components/AppShell";
import { apiClient } from "@/lib/api";
import type { User } from "@/types/api";
const BACKEND_URL = (process.env.NEXT_PUBLIC_BACKEND_URL || "").trim();
function resolveMediaUrl(url?: string | null): string {
const raw = (url || "").trim();
if (!raw) return "";
if (raw.startsWith("data:")) return raw;
if (/^https?:\/\//i.test(raw)) return raw;
if (raw.startsWith("/")) return raw;
if (BACKEND_URL) {
const base = BACKEND_URL.replace(/\/$/, "");
return `${base}/${raw.replace(/^\/+/, "")}`;
}
return `/${raw.replace(/^\/+/, "")}`;
}
export default function EmployeeDetailPage() {
const params = useParams<{ id: string }>();
const employeeId = Number(params?.id);
const [employee, setEmployee] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadEmployee() {
if (!employeeId || Number.isNaN(employeeId)) {
setError("Некорректный идентификатор сотрудника");
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await apiClient.getEmployee(employeeId);
setEmployee(data);
} catch (e: any) {
setError(String(e?.message || "Не удалось загрузить профиль сотрудника"));
} finally {
setLoading(false);
}
}
loadEmployee();
}, [employeeId]);
const fullName = useMemo(() => {
if (!employee) return "";
return `${employee.last_name || ""} ${employee.first_name || ""} ${employee.patronymic || ""}`.trim() || "Сотрудник";
}, [employee]);
return (
<AppShell>
<div className="space-y-4">
<Link href="/employees" className="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
← К списку сотрудников
</Link>
{loading ? (
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
<p className="text-sm text-gray-500">Загрузка профиля...</p>
</div>
) : error ? (
<div className="rounded-2xl bg-red-50 p-6 text-center">
<p className="text-sm text-red-800">{error}</p>
</div>
) : employee ? (
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-lg font-semibold text-white">
{employee.avatar ? (
<Image src={resolveMediaUrl(employee.avatar)} alt={fullName} width={64} height={64} className="h-full w-full object-cover" unoptimized />
) : (
<span>{`${employee.last_name?.[0] || ""}${employee.first_name?.[0] || ""}` || "С"}</span>
)}
</div>
<div className="min-w-0 flex-1">
<h1 className="truncate text-xl font-semibold text-gray-900">{fullName}</h1>
<p className="mt-1 text-sm text-gray-600">Должность: {employee.position?.name || "—"}</p>
<p className="mt-1 text-sm text-gray-600">Email: {employee.email || "—"}</p>
<p className="mt-1 text-sm text-gray-600">Телефон: {employee.phone_number || "—"}</p>
</div>
</div>
</section>
) : null}
</div>
</AppShell>
);
}

View File

@ -3,6 +3,7 @@
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import type { User } from "@/types/api";
export default function EmployeesPage() {
@ -116,7 +117,7 @@ export default function EmployeesPage() {
const statusColor = isOnline ? 'text-green-600' : 'text-gray-500';
return (
<article key={employee.id} className="flex items-center gap-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<Link key={employee.id} href={`/users/${employee.id}`} className="flex items-center gap-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100 transition hover:bg-gray-50">
<div className="relative h-12 w-12">
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{employee.avatar ? (
@ -143,7 +144,7 @@ export default function EmployeesPage() {
)}
</div>
</div>
</article>
</Link>
);
})}

View File

@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "../../components/AppShell";
import { useUser } from "@/contexts/UserContext";
@ -275,15 +276,28 @@ export default function ProfilePage() {
{departments.length === 0 ? (
<p className="text-sm text-gray-500">Отделы не указаны</p>
) : (
departments.map((d) => (
<div key={d.id} className="flex items-center justify-between gap-3 rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-800">
<div className="min-w-0">
<p className="truncate font-medium">{d.name}</p>
{d.role_name ? <p className="truncate text-xs text-gray-500">{d.role_name}</p> : null}
departments.map((d, index) => {
const deptId = Number((d as any).id ?? (d as any).department_id);
const isValidDeptId = Number.isFinite(deptId) && deptId > 0;
return isValidDeptId ? (
<Link key={deptId} href={`/departments/${deptId}`} className="flex items-center justify-between gap-3 rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-800 transition hover:bg-gray-100">
<div className="min-w-0">
<p className="truncate font-medium">{d.name}</p>
{d.role_name ? <p className="truncate text-xs text-gray-500">{d.role_name}</p> : null}
</div>
{d.is_head ? <span className="shrink-0 rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">Руководитель</span> : null}
</Link>
) : (
<div key={`${d.name || "dept"}-${index}`} className="flex items-center justify-between gap-3 rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-800">
<div className="min-w-0">
<p className="truncate font-medium">{d.name}</p>
{d.role_name ? <p className="truncate text-xs text-gray-500">{d.role_name}</p> : null}
</div>
{d.is_head ? <span className="shrink-0 rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">Руководитель</span> : null}
</div>
{d.is_head ? <span className="shrink-0 rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">Руководитель</span> : null}
</div>
))
);
})
)}
</div>
</div>

View File

@ -2,11 +2,44 @@
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { useUser } from "@/contexts/UserContext";
import { canManageRequests, canProcessRequests } from "@/lib/permissions";
import { useEffect, useMemo, useState } from "react";
import type { Request } from "@/types/api";
import { Search, FileSignature } from "lucide-react";
import Link from "next/link";
import type { Request, RequestComment, User, Department } from "@/types/api";
import { Check, FileSignature, MessageSquare, Plus, Search, X } from "lucide-react";
const statusMeta: Record<Request["status"], { label: string; className: string }> = {
type RequestFormState = {
type: string;
title: string;
date_from: string;
date_to: string;
comment: string;
department_ids: number[];
recipient_ids: number[];
cc_user_ids: number[];
sent_to_all_department: boolean;
attachment: File | null;
};
const emptyForm: RequestFormState = {
type: "",
title: "",
date_from: "",
date_to: "",
comment: "",
department_ids: [],
recipient_ids: [],
cc_user_ids: [],
sent_to_all_department: false,
attachment: null,
};
const statusMeta: Record<string, { label: string; className: string }> = {
draft: {
label: "Черновик",
className: "bg-slate-100 text-slate-700 ring-slate-200",
},
pending: {
label: "На рассмотрении",
className: "bg-amber-50 text-amber-700 ring-amber-100",
@ -19,6 +52,10 @@ const statusMeta: Record<Request["status"], { label: string; className: string }
label: "Отклонено",
className: "bg-rose-50 text-rose-700 ring-rose-100",
},
cancelled: {
label: "Отменено",
className: "bg-gray-100 text-gray-700 ring-gray-200",
},
in_progress: {
label: "В работе",
className: "bg-sky-50 text-sky-700 ring-sky-100",
@ -29,6 +66,15 @@ const statusMeta: Record<Request["status"], { label: string; className: string }
},
};
const requestTypeLabels: Record<string, string> = {
vacation: "Отпуск",
sick_leave: "Больничный",
day_off: "Отгул",
transfer: "Перевод",
dismissal: "Увольнение",
other: "Другое",
};
const defaultStatusMeta = {
label: "Неизвестный статус",
className: "bg-gray-50 text-gray-700 ring-gray-200",
@ -46,18 +92,87 @@ function formatDate(value?: string): string {
}
export default function RequestsPage() {
const { user } = useUser();
const [requests, setRequests] = useState<Request[]>([]);
const [employees, setEmployees] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [commentsMap, setCommentsMap] = useState<Record<number, RequestComment[]>>({});
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
const [createOpen, setCreateOpen] = useState(false);
const [editingRequestId, setEditingRequestId] = useState<number | null>(null);
const [form, setForm] = useState<RequestFormState>(emptyForm);
const [search, setSearch] = useState("");
const [view, setView] = useState<"" | "mine" | "addressed">("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [employeeFilter, setEmployeeFilter] = useState("");
const [dateFromFilter, setDateFromFilter] = useState("");
const [dateToFilter, setDateToFilter] = useState("");
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [nextPage, setNextPage] = useState<number | null>(null);
const auth = user?.auth;
const canProcess = canProcessRequests(user);
const canManage = canManageRequests(user);
const departmentNameMap = useMemo(
() => new Map((departments || []).map((d) => [d.id, d.name])),
[departments]
);
const displayUserName = (person?: User | null) => {
if (!person) return "—";
const full = `${person.last_name || ""} ${person.first_name || ""}`.trim();
return full || (person as any)?.full_name || (person as any)?.display_name || person.email || "Пользователь";
};
const userProfileLink = (person?: User | null) => {
if (!person?.id) return "";
if (user?.id && person.id === user.id) return "/profile";
return `/users/${person.id}`;
};
const extractNextPage = (nextUrl?: string | null): number | null => {
if (!nextUrl) return null;
try {
const parsed = new URL(nextUrl, window.location.origin);
const rawPage = parsed.searchParams.get("page");
const num = Number(rawPage);
return Number.isFinite(num) && num > 0 ? num : null;
} catch {
return null;
}
};
const buildRequestParams = (page: number): Record<string, string | number> => {
const params: Record<string, string | number> = { page, limit: 20 };
if (view === "mine") params.view = "mine";
if (view === "addressed") params.addressed_to_me = "true";
if (typeFilter) params.type = typeFilter;
if (statusFilter) params.status = statusFilter;
if (employeeFilter) params.employee_id = employeeFilter;
if (dateFromFilter) params.date_from = dateFromFilter;
if (dateToFilter) params.date_to = dateToFilter;
return params;
};
useEffect(() => {
async function loadRequests() {
try {
setLoading(true);
setError(null);
const response = await apiClient.getRequests();
const response = await apiClient.getRequests(buildRequestParams(1));
setRequests(response.results || []);
setNextPage(extractNextPage(response.next));
} catch (err) {
console.error("Ошибка загрузки заявлений:", err);
setError("Не удалось загрузить заявления");
@ -67,6 +182,23 @@ export default function RequestsPage() {
}
loadRequests();
}, [view, typeFilter, statusFilter, employeeFilter, dateFromFilter, dateToFilter]);
useEffect(() => {
async function loadLookups() {
try {
const [employeesResponse, departmentsResponse] = await Promise.all([
apiClient.getEmployees({ limit: 200 }),
apiClient.getDepartments({ limit: 200 }),
]);
setEmployees(employeesResponse.results || []);
setDepartments(departmentsResponse.results || []);
} catch {
setEmployees([]);
setDepartments([]);
}
}
loadLookups();
}, []);
const filteredRequests = useMemo(() => {
@ -80,14 +212,350 @@ export default function RequestsPage() {
if (!q) return sorted;
return sorted.filter((item) => {
const title = item.title.toLowerCase();
const description = item.description.toLowerCase();
const type = item.request_type.toLowerCase();
const author = `${item.created_by?.last_name || ""} ${item.created_by?.first_name || ""}`.toLowerCase();
const title = (item.display_title || item.title || "").toLowerCase();
const description = (item.comment || item.description || "").toLowerCase();
const type = String(item.type || item.request_type || "").toLowerCase();
const author = displayUserName(item.employee || item.created_by).toLowerCase();
return title.includes(q) || description.includes(q) || type.includes(q) || author.includes(q);
});
}, [requests, search]);
const resetForm = () => {
setForm(emptyForm);
};
const openEdit = (req: Request) => {
setEditingRequestId(req.id);
setCreateOpen(false);
setActionError(null);
setActionSuccess(null);
setForm({
type: req.type || req.request_type || "",
title: req.title || "",
date_from: req.date_from || "",
date_to: req.date_to || "",
comment: req.comment || req.description || "",
department_ids: (req.departments || []).map(Number).filter((n) => Number.isFinite(n)),
recipient_ids: (req.recipients || []).map((u) => u.id).filter(Boolean),
cc_user_ids: (req.cc_users || []).map((u) => u.id).filter(Boolean),
sent_to_all_department: Boolean(req.sent_to_all_department),
attachment: null,
});
};
const handleCreateOrUpdate = async (mode: "create" | "edit", saveAs: "draft" | "submit") => {
try {
setBusyKey(`${mode}-${saveAs}`);
setActionError(null);
setActionSuccess(null);
if (saveAs === "submit" && !form.sent_to_all_department && form.department_ids.length === 0 && form.recipient_ids.length === 0) {
setActionError("Укажите получателей или отделы.");
return;
}
const payload = {
type: form.type,
title: form.title,
date_from: form.date_from || null,
date_to: form.date_to || null,
comment: form.comment,
department_ids: form.department_ids,
recipient_ids: form.recipient_ids,
cc_user_ids: form.cc_user_ids,
sent_to_all_department: form.sent_to_all_department,
attachment: form.attachment,
};
if (mode === "create") {
await apiClient.createRequest(payload, saveAs);
setActionSuccess(saveAs === "draft" ? "Черновик сохранён." : "Заявление создано.");
setCreateOpen(false);
} else if (editingRequestId) {
await apiClient.updateRequest(editingRequestId, payload, saveAs);
setActionSuccess("Заявление обновлено.");
setEditingRequestId(null);
}
resetForm();
const response = await apiClient.getRequests(buildRequestParams(1));
setRequests(response.results || []);
setNextPage(extractNextPage(response.next));
} catch (e: any) {
setActionError(String(e?.message || "Не удалось сохранить заявление"));
} finally {
setBusyKey(null);
}
};
const handleLoadMore = async () => {
if (!nextPage || loadingMore) return;
try {
setLoadingMore(true);
setError(null);
const response = await apiClient.getRequests(buildRequestParams(nextPage));
const chunk = response.results || [];
setRequests((prev) => {
const known = new Set(prev.map((r) => r.id));
const uniqueChunk = chunk.filter((r) => !known.has(r.id));
return [...prev, ...uniqueChunk];
});
setNextPage(extractNextPage(response.next));
} catch (e: any) {
setError(String(e?.message || "Не удалось загрузить ещё заявления"));
} finally {
setLoadingMore(false);
}
};
const handleApprove = async (id: number) => {
try {
setBusyKey(`approve-${id}`);
setActionError(null);
await apiClient.approveRequest(id);
setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, status: "approved" } : r)));
} catch (e: any) {
setActionError(String(e?.message || "Не удалось одобрить заявление"));
} finally {
setBusyKey(null);
}
};
const handleReject = async (id: number) => {
try {
setBusyKey(`reject-${id}`);
setActionError(null);
await apiClient.rejectRequest(id);
setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, status: "rejected" } : r)));
} catch (e: any) {
setActionError(String(e?.message || "Не удалось отклонить заявление"));
} finally {
setBusyKey(null);
}
};
const handleCancel = async (id: number) => {
try {
setBusyKey(`cancel-${id}`);
setActionError(null);
await apiClient.cancelRequest(id);
setRequests((prev) => prev.map((r) => (r.id === id ? { ...r, status: "cancelled" } : r)));
} catch (e: any) {
setActionError(String(e?.message || "Не удалось отменить заявление"));
} finally {
setBusyKey(null);
}
};
const handleDelete = async (id: number) => {
try {
setBusyKey(`delete-${id}`);
setActionError(null);
await apiClient.deleteRequest(id);
setRequests((prev) => prev.filter((r) => r.id !== id));
} catch (e: any) {
setActionError(String(e?.message || "Не удалось удалить заявление"));
} finally {
setBusyKey(null);
}
};
const toggleComments = async (requestId: number) => {
const isOpen = Boolean(expandedComments[requestId]);
setExpandedComments((prev) => ({ ...prev, [requestId]: !isOpen }));
if (!isOpen && !commentsMap[requestId]) {
try {
const comments = await apiClient.getRequestComments(requestId);
setCommentsMap((prev) => ({ ...prev, [requestId]: comments }));
} catch {
setCommentsMap((prev) => ({ ...prev, [requestId]: [] }));
}
}
};
const handleAddComment = async (requestId: number) => {
const text = (commentDrafts[requestId] || "").trim();
if (!text) return;
try {
setBusyKey(`comment-${requestId}`);
const saved = await apiClient.addRequestComment(requestId, text);
setCommentsMap((prev) => ({ ...prev, [requestId]: [...(prev[requestId] || []), saved] }));
setCommentDrafts((prev) => ({ ...prev, [requestId]: "" }));
} catch (e: any) {
setActionError(String(e?.message || "Не удалось добавить комментарий"));
} finally {
setBusyKey(null);
}
};
const handleDeleteComment = async (requestId: number, commentId: number) => {
try {
setBusyKey(`comment-delete-${commentId}`);
await apiClient.deleteRequestComment(requestId, commentId);
setCommentsMap((prev) => ({
...prev,
[requestId]: (prev[requestId] || []).filter((c) => c.id !== commentId),
}));
} catch (e: any) {
setActionError(String(e?.message || "Не удалось удалить комментарий"));
} finally {
setBusyKey(null);
}
};
const isFinal = (status?: string) => ["approved", "rejected", "cancelled"].includes(String(status || "").toLowerCase());
const RequestEditor = ({ mode }: { mode: "create" | "edit" }) => (
<div className="mt-3 rounded-xl border border-gray-200 bg-gray-50 p-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<select
value={form.type}
onChange={(e) => setForm((p) => ({ ...p, type: e.target.value }))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="">Выберите тип</option>
{Object.entries(requestTypeLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<input
value={form.title}
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
placeholder="Тема"
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<input
type="date"
value={form.date_from}
onChange={(e) => setForm((p) => ({ ...p, date_from: e.target.value }))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<input
type="date"
value={form.date_to}
onChange={(e) => setForm((p) => ({ ...p, date_to: e.target.value }))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<select
multiple
value={form.department_ids.map(String)}
onChange={(e) => {
const values = Array.from(e.target.selectedOptions).map((o) => Number(o.value));
setForm((p) => ({ ...p, department_ids: values }));
}}
className="h-28 rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
{departments.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<select
multiple
value={form.recipient_ids.map(String)}
onChange={(e) => {
const values = Array.from(e.target.selectedOptions).map((o) => Number(o.value));
setForm((p) => ({ ...p, recipient_ids: values }));
}}
className="h-28 rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
{employees
.filter((emp) => !user?.id || emp.id !== user.id)
.map((emp) => (
<option key={emp.id} value={emp.id}>
{displayUserName(emp)}
</option>
))}
</select>
<select
multiple
value={form.cc_user_ids.map(String)}
onChange={(e) => {
const values = Array.from(e.target.selectedOptions).map((o) => Number(o.value));
setForm((p) => ({ ...p, cc_user_ids: values }));
}}
className="h-28 rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
{employees
.filter((emp) => !user?.id || emp.id !== user.id)
.map((emp) => (
<option key={emp.id} value={emp.id}>
{displayUserName(emp)}
</option>
))}
</select>
<textarea
value={form.comment}
onChange={(e) => setForm((p) => ({ ...p, comment: e.target.value }))}
placeholder="Комментарий"
className="rounded-lg border border-gray-300 px-3 py-2 text-sm sm:col-span-2"
rows={3}
/>
<input
type="file"
onChange={(e) => setForm((p) => ({ ...p, attachment: e.target.files?.[0] || null }))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm sm:col-span-2"
/>
</div>
<label className="mt-2 flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={form.sent_to_all_department}
onChange={(e) => setForm((p) => ({ ...p, sent_to_all_department: e.target.checked }))}
/>
Отправить всем сотрудникам выбранных отделов
</label>
<div className="mt-3 flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={() => {
if (mode === "create") {
setCreateOpen(false);
} else {
setEditingRequestId(null);
}
resetForm();
}}
className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
>
Отмена
</button>
<button
type="button"
onClick={() => handleCreateOrUpdate(mode, "draft")}
disabled={busyKey !== null}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
Сохранить как черновик
</button>
<button
type="button"
onClick={() => handleCreateOrUpdate(mode, "submit")}
disabled={busyKey !== null}
className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60"
>
{mode === "create" ? "Создать" : "Сохранить"}
</button>
</div>
</div>
);
return (
<AppShell>
{loading ? (
@ -101,6 +569,66 @@ export default function RequestsPage() {
</div>
) : (
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500">Заявления</p>
<button
type="button"
onClick={() => {
setCreateOpen((v) => !v);
setEditingRequestId(null);
setActionError(null);
setActionSuccess(null);
resetForm();
}}
className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600"
>
<Plus size={14} /> Создать заявление
</button>
</div>
{actionError ? <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p> : null}
{actionSuccess ? <p className="mb-3 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{actionSuccess}</p> : null}
{createOpen ? <RequestEditor mode="create" /> : null}
<div className="mb-3 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4">
<select value={view} onChange={(e) => setView(e.target.value as "" | "mine" | "addressed")} className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800">
<option value="">Все доступные</option>
<option value="mine">Мои заявления</option>
<option value="addressed">Адресованные мне</option>
</select>
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800">
<option value="">Любой тип</option>
{Object.entries(requestTypeLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800">
<option value="">Любой статус</option>
{Object.entries(statusMeta).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</select>
<select value={employeeFilter} onChange={(e) => setEmployeeFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800">
<option value="">Любой сотрудник</option>
{employees.map((emp) => (
<option key={emp.id} value={emp.id}>
{displayUserName(emp)}
</option>
))}
</select>
<input type="date" value={dateFromFilter} onChange={(e) => setDateFromFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800" />
<input type="date" value={dateToFilter} onChange={(e) => setDateToFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800" />
</div>
<div className="relative mb-4">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
@ -119,19 +647,38 @@ export default function RequestsPage() {
</div>
) : (
filteredRequests.map((item) => {
const authorName = `${item.created_by?.last_name || ""} ${item.created_by?.first_name || ""}`.trim() || "Неизвестно";
const assigneeName = item.assigned_to
? `${item.assigned_to.last_name} ${item.assigned_to.first_name}`.trim()
: "Не назначен";
const statusKey = String(item.status || "").toLowerCase() as Request["status"];
const requestAuthor = item.employee || item.created_by;
const authorName = displayUserName(requestAuthor);
const approverName = displayUserName(item.approver || item.assigned_to);
const statusKey = String(item.status || "").toLowerCase();
const status = statusMeta[statusKey] ?? defaultStatusMeta;
const authorLink = userProfileLink(requestAuthor);
const approverLink = userProfileLink(item.approver || item.assigned_to);
const requestTypeLabel = requestTypeLabels[String(item.type || item.request_type || "")] || String(item.type || item.request_type || "Другое");
const canProcessThis = Boolean(
canProcess &&
statusKey === "pending" &&
item.is_recipient &&
requestAuthor?.id &&
user?.id &&
requestAuthor.id !== user.id
);
const isAuthor = Boolean(requestAuthor?.id && user?.id && requestAuthor.id === user.id);
const canEditThis = isAuthor && !isFinal(statusKey);
const canCancelThis = isAuthor && !isFinal(statusKey);
const canDeleteThis = (isAuthor && !isFinal(statusKey)) || canManage;
const comments = commentsMap[item.id] || [];
const commentsOpen = Boolean(expandedComments[item.id]);
const departmentLabels = (item.departments || [])
.map((id) => departmentNameMap.get(Number(id)) || `Отдел #${id}`)
.join(", ");
return (
<article key={item.id} className="rounded-xl border border-gray-100 bg-white p-4 transition hover:bg-gray-50">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900">{item.title}</p>
<p className="mt-1 text-xs text-gray-500">Тип: {item.request_type}</p>
<p className="truncate text-sm font-semibold text-gray-900">{item.display_title || item.title || "Без заголовка"}</p>
<p className="mt-1 text-xs text-gray-500">Тип: {requestTypeLabel}</p>
</div>
<span className={`inline-flex shrink-0 rounded-full px-2.5 py-1 text-xs ring-1 ${status.className}`}>
@ -139,19 +686,142 @@ export default function RequestsPage() {
</span>
</div>
<p className="mt-3 text-sm text-gray-700">{item.description}</p>
<p className="mt-3 text-sm text-gray-700">{item.comment || item.description || "—"}</p>
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-500 sm:grid-cols-2">
<p>Автор: {authorName}</p>
<p>Исполнитель: {assigneeName}</p>
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-500 sm:grid-cols-2 lg:grid-cols-3">
<p>
Автор:{" "}
{authorLink ? (
<Link href={authorLink} className="text-sky-700 underline decoration-sky-300 underline-offset-2 hover:text-sky-800">
{authorName}
</Link>
) : (
<span>{authorName}</span>
)}
</p>
<p>
Решение:{" "}
{approverLink && item.approver ? (
<Link href={approverLink} className="text-sky-700 underline decoration-sky-300 underline-offset-2 hover:text-sky-800">
{approverName}
</Link>
) : (
<span>{item.approver ? approverName : "—"}</span>
)}
</p>
<p>Получатели: {item.recipient_count ?? item.recipients?.length ?? 0}</p>
<p>В копии: {item.cc_count ?? item.cc_users?.length ?? 0}</p>
<p>Период: {item.date_from ? formatDate(item.date_from) : "—"}{item.date_to ? ` — ${formatDate(item.date_to)}` : ""}</p>
<p>Создано: {formatDate(item.created_at)}</p>
<p>Обновлено: {formatDate(item.updated_at)}</p>
{departmentLabels ? <p className="sm:col-span-2 lg:col-span-3">Отделы: {departmentLabels}</p> : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{canProcessThis ? (
<>
<button type="button" onClick={() => handleApprove(item.id)} disabled={busyKey === `approve-${item.id}`} className="inline-flex items-center gap-1 rounded-lg bg-emerald-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600 disabled:opacity-60">
<Check size={14} /> Одобрить
</button>
<button type="button" onClick={() => handleReject(item.id)} disabled={busyKey === `reject-${item.id}`} className="inline-flex items-center gap-1 rounded-lg bg-rose-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-rose-600 disabled:opacity-60">
<X size={14} /> Отклонить
</button>
</>
) : null}
{canEditThis ? (
<button type="button" onClick={() => openEdit(item)} className="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50">
Редактировать
</button>
) : null}
{canCancelThis ? (
<button type="button" onClick={() => handleCancel(item.id)} disabled={busyKey === `cancel-${item.id}`} className="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60">
Отменить
</button>
) : null}
{canDeleteThis ? (
<button type="button" onClick={() => handleDelete(item.id)} disabled={busyKey === `delete-${item.id}`} className="rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs font-medium text-rose-700 hover:bg-rose-100 disabled:opacity-60">
Удалить
</button>
) : null}
<button
type="button"
onClick={() => toggleComments(item.id)}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
<MessageSquare size={14} /> Комментарии ({item.comments_count ?? comments.length})
</button>
</div>
{editingRequestId === item.id ? <RequestEditor mode="edit" /> : null}
{commentsOpen ? (
<div className="mt-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="space-y-2">
{(commentsMap[item.id] || []).length === 0 ? (
<p className="text-xs text-gray-500">Комментариев пока нет</p>
) : (
(commentsMap[item.id] || []).map((c) => {
const canDeleteComment = Boolean(c.author?.id && (user?.id === c.author.id || auth?.is_staff || auth?.is_superuser));
return (
<div key={c.id} className="rounded-lg bg-white px-3 py-2 text-xs text-gray-700 ring-1 ring-gray-100">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium">{displayUserName(c.author)}</span>
<div className="flex items-center gap-2">
<span className="text-gray-500">{formatDate(c.created_at)}</span>
{canDeleteComment ? (
<button type="button" onClick={() => handleDeleteComment(item.id, c.id)} className="text-rose-600 hover:text-rose-700">
удалить
</button>
) : null}
</div>
</div>
<p>{c.text}</p>
</div>
);
})
)}
</div>
<div className="mt-2 flex items-center gap-2">
<input
value={commentDrafts[item.id] || ""}
onChange={(e) => setCommentDrafts((prev) => ({ ...prev, [item.id]: e.target.value }))}
placeholder="Добавить комментарий"
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-xs"
/>
<button
type="button"
onClick={() => handleAddComment(item.id)}
disabled={busyKey === `comment-${item.id}`}
className="rounded-lg bg-sky-500 px-3 py-2 text-xs font-medium text-white hover:bg-sky-600 disabled:opacity-60"
>
Отправить
</button>
</div>
</div>
) : null}
</article>
);
})
)}
</div>
{nextPage ? (
<div className="mt-4 flex justify-center">
<button
type="button"
onClick={handleLoadMore}
disabled={loadingMore}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
{loadingMore ? "Загружаем..." : "Загрузить ещё"}
</button>
</div>
) : null}
</section>
)}
</AppShell>

View File

@ -0,0 +1,139 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { AppShell } from "../../../components/AppShell";
import { apiClient } from "@/lib/api";
import type { User } from "@/types/api";
const BACKEND_URL = (process.env.NEXT_PUBLIC_BACKEND_URL || "").trim();
function resolveMediaUrl(url?: string | null): string {
const raw = (url || "").trim();
if (!raw) return "";
if (raw.startsWith("data:")) return raw;
if (/^https?:\/\//i.test(raw)) return raw;
// Приоритет same-origin: для /media/... в dev/proxy это самый надёжный путь.
if (raw.startsWith("/")) return raw;
if (BACKEND_URL) {
const base = BACKEND_URL.replace(/\/$/, "");
return `${base}/${raw.replace(/^\/+/, "")}`;
}
return `/${raw.replace(/^\/+/, "")}`;
}
export default function UserDetailPage() {
const params = useParams<{ id: string }>();
const userId = Number(params?.id);
const [person, setPerson] = useState<User | null>(null);
const [avatarFailed, setAvatarFailed] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadUser() {
if (!userId || Number.isNaN(userId)) {
setError("Некорректный идентификатор пользователя");
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await apiClient.getEmployee(userId);
setPerson(data);
} catch (e: any) {
setError(String(e?.message || "Не удалось загрузить пользователя"));
} finally {
setLoading(false);
}
}
loadUser();
}, [userId]);
useEffect(() => {
setAvatarFailed(false);
}, [person?.avatar, person?.id]);
const fullName = useMemo(() => {
if (!person) return "";
return `${person.last_name || ""} ${person.first_name || ""} ${person.patronymic || ""}`.trim() || "Пользователь";
}, [person]);
const avatarUrl = resolveMediaUrl(person?.avatar);
const initials = `${person?.last_name?.[0] || ""}${person?.first_name?.[0] || ""}` || "П";
return (
<AppShell>
<div className="space-y-4">
<Link href="/users" className="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
← К списку пользователей
</Link>
{loading ? (
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
<p className="text-sm text-gray-500">Загрузка пользователя...</p>
</div>
) : error ? (
<div className="rounded-2xl bg-red-50 p-6 text-center">
<p className="text-sm text-red-800">{error}</p>
</div>
) : person ? (
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-lg font-semibold text-white">
{avatarUrl && !avatarFailed ? (
<Image
src={avatarUrl}
alt={fullName}
width={64}
height={64}
className="h-full w-full object-cover"
unoptimized
onError={() => setAvatarFailed(true)}
/>
) : (
<span>{initials}</span>
)}
</div>
<div className="min-w-0 flex-1">
<h1 className="truncate text-xl font-semibold text-gray-900">{fullName}</h1>
<p className="mt-1 text-sm text-gray-600">Должность: {person.position?.name || "—"}</p>
<p className="mt-1 text-sm text-gray-600">Email: {person.email || "—"}</p>
<p className="mt-1 text-sm text-gray-600">Телефон: {person.phone_number || "—"}</p>
<p className="mt-1 text-sm text-gray-600">Telegram: {person.telegram || "—"}</p>
<p className="mt-1 text-sm text-gray-600">WhatsApp: {person.whatsapp || "—"}</p>
{person.wechat ? <p className="mt-1 text-sm text-gray-600">WeChat: {person.wechat}</p> : null}
</div>
</div>
<div className="mt-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Отделы</p>
{(person.departments || []).length === 0 ? (
<p className="text-sm text-gray-500">Не указаны</p>
) : (
<div className="space-y-2">
{(person.departments || []).map((d) => (
<Link key={d.id} href={`/departments/${d.id}`} className="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-800 hover:bg-gray-100">
<span>{d.name}</span>
{d.is_head ? <span className="rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">Руководитель</span> : null}
</Link>
))}
</div>
)}
</div>
</section>
) : null}
</div>
</AppShell>
);
}

View File

@ -0,0 +1,99 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import type { User } from "@/types/api";
import { Search, Users } from "lucide-react";
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadUsers() {
try {
setLoading(true);
setError(null);
const response = await apiClient.getEmployees({ limit: 200 });
setUsers(response.results || []);
} catch (e: any) {
setError(String(e?.message || "Не удалось загрузить пользователей"));
} finally {
setLoading(false);
}
}
loadUsers();
}, []);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
const sorted = [...users].sort((a, b) => {
const aName = `${a.last_name || ""} ${a.first_name || ""}`.trim().toLowerCase();
const bName = `${b.last_name || ""} ${b.first_name || ""}`.trim().toLowerCase();
return aName.localeCompare(bName, "ru");
});
if (!q) return sorted;
return sorted.filter((u) => {
const fullName = `${u.last_name || ""} ${u.first_name || ""} ${u.patronymic || ""}`.toLowerCase();
const email = (u.email || "").toLowerCase();
const phone = (u.phone_number || "").toLowerCase();
const position = (u.position?.name || "").toLowerCase();
return fullName.includes(q) || email.includes(q) || phone.includes(q) || position.includes(q);
});
}, [users, search]);
return (
<AppShell>
{loading ? (
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
<p className="text-sm text-gray-500">Загрузка пользователей...</p>
</div>
) : error ? (
<div className="rounded-2xl bg-red-50 p-6 text-center">
<p className="text-sm text-red-800">{error}</p>
</div>
) : (
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<div className="relative mb-4">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск пользователя"
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<div className="space-y-2">
{filteredUsers.length === 0 ? (
<div className="rounded-xl bg-gray-50 p-8 text-center">
<Users size={22} className="mx-auto mb-2 text-gray-400" />
<p className="text-sm text-gray-500">Пользователи не найдены</p>
</div>
) : (
filteredUsers.map((u) => {
const fullName = `${u.last_name || ""} ${u.first_name || ""} ${u.patronymic || ""}`.trim() || "Пользователь";
const departmentsText = (u.departments || []).map((d) => d.name).filter(Boolean).join(", ");
return (
<Link key={u.id} href={`/users/${u.id}`} className="block rounded-xl border border-gray-100 bg-white p-3 transition hover:bg-gray-50">
<p className="text-sm font-semibold text-gray-900">{fullName}</p>
<p className="mt-1 text-xs text-gray-500">{u.position?.name || "Сотрудник"}</p>
{departmentsText ? <p className="mt-1 text-xs text-gray-500">{departmentsText}</p> : null}
</Link>
);
})
)}
</div>
</section>
)}
</AppShell>
);
}

View File

@ -119,15 +119,43 @@ export interface Document {
export interface Request {
id: number;
title: string;
description: string;
request_type: string;
status: 'pending' | 'approved' | 'rejected' | 'in_progress' | 'completed';
created_by: User;
type?: 'vacation' | 'sick_leave' | 'day_off' | 'transfer' | 'dismissal' | 'other';
request_type?: string;
display_title?: string;
description?: string;
comment?: string;
status: 'draft' | 'pending' | 'approved' | 'rejected' | 'cancelled' | 'in_progress' | 'completed';
employee?: User;
created_by?: User;
approver?: User;
assigned_to?: User;
department?: number | null;
departments?: number[];
recipients?: User[];
cc_users?: User[];
sent_to_all_department?: boolean;
recipient_count?: number;
cc_count?: number;
is_recipient?: boolean;
comments_count?: number;
is_final?: boolean;
attachment?: string | null;
attachment_url?: string | null;
date_from?: string | null;
date_to?: string | null;
decided_at?: string | null;
created_at: string;
updated_at: string;
}
export interface RequestComment {
id: number;
request: number;
author: User;
text: string;
created_at: string;
}
// Communications types
export interface Chat {
id: number;