This commit is contained in:
Konstantin Makaev 2026-03-05 16:47:08 +03:00
parent d1891c16c6
commit 95a0ad8293
23 changed files with 3603 additions and 754 deletions

View File

@ -1,110 +0,0 @@
<!-- Use this file to provide workspace-specific custom instructions to Copilot. For more details, visit https://code.visualstudio.com/docs/copilot/copilot-customization#_use-a-githubcopilotinstructionsmd-file -->
- [x] Verify that the copilot-instructions.md file in the .github directory is created.
- [x] Clarify Project Requirements
<!-- Ask for project type, language, and frameworks if not specified. Skip if already provided. -->
- [x] Scaffold the Project
<!--
Ensure that the previous step has been marked as completed.
Call project setup tool with projectType parameter.
Run scaffolding command to create project files and folders.
Use '.' as the working directory.
If no appropriate projectType is available, search documentation using available tools.
Otherwise, create the project structure manually using available file creation tools.
-->
- [ ] Customize the Project
<!--
Verify that all previous steps have been completed successfully and you have marked the step as completed.
Develop a plan to modify codebase according to user requirements.
Apply modifications using appropriate tools and user-provided references.
Skip this step for "Hello World" projects.
-->
- [ ] Install Required Extensions
<!-- ONLY install extensions provided mentioned in the get_project_setup_info. Skip this step otherwise and mark as completed. -->
- [x] Compile the Project
<!--
Verify that all previous steps have been completed.
Install any missing dependencies.
Run diagnostics and resolve any issues.
Check for markdown files in project folder for relevant instructions on how to do this.
-->
- [x] Create and Run Task
<!--
Verify that all previous steps have been completed.
Check https://code.visualstudio.com/docs/debugtest/tasks to determine if the project needs a task. If so, use the create_and_run_task to create and launch a task based on package.json, README.md, and project structure.
Skip this step otherwise.
-->
- [x] Launch the Project
<!--
Verify that all previous steps have been completed.
Prompt user for debug mode, launch only if confirmed.
-->
- [ ] Ensure Documentation is Complete
<!--
Verify that all previous steps have been completed.
Verify that README.md and the copilot-instructions.md file in the .github directory exists and contains current project information.
Clean up the copilot-instructions.md file in the .github directory by removing all HTML comments.
-->
<!--
## Execution Guidelines
PROGRESS TRACKING:
- If any tools are available to manage the above todo list, use it to track progress through this checklist.
- After completing each step, mark it complete and add a summary.
- Read current todo list status before starting each new step.
COMMUNICATION RULES:
- Avoid verbose explanations or printing full command outputs.
- If a step is skipped, state that briefly (e.g. "No extensions needed").
- Do not explain project structure unless asked.
- Keep explanations concise and focused.
DEVELOPMENT RULES:
- Use '.' as the working directory unless user specifies otherwise.
- Avoid adding media or external links unless explicitly requested.
- Use placeholders only with a note that they should be replaced.
- If the user has not specified project details, assume they want a "Hello World" project as a starting point.
- Avoid adding links of any type (URLs, files, folders, etc.) or integrations that are not explicitly required.
- Avoid generating images, videos, or any other media files unless explicitly requested.
- If you need to use any media assets as placeholders, let the user know that these are placeholders and should be replaced with the actual assets later.
- Ensure all generated components serve a clear purpose within the user's requested workflow.
- If a feature is assumed but not confirmed, prompt the user for clarification before including it.
- If you are working on a VS Code extension, use the VS Code API tool with a query to find relevant VS Code API references and samples related to that query.
FOLDER CREATION RULES:
- Always use the current directory as the project root.
- If you are running any terminal commands, use the '.' argument to ensure that the current working directory is used ALWAYS.
- Do not create a new folder unless the user explicitly requests it besides a .vscode folder for a tasks.json file.
- If any of the scaffolding commands mention that the folder name is not correct, let the user know to create a new folder with the correct name and then reopen it again in vscode.
EXTENSION INSTALLATION RULES:
- Only install extension specified by the get_project_setup_info tool. DO NOT INSTALL any other extensions.
PROJECT CONTENT RULES:
- If the user has not specified project details, assume they want a "Hello World" project as a starting point.
- Avoid adding links of any type (URLs, files, folders, etc.) or integrations that are not explicitly required.
- Avoid generating images, videos, or any other media files unless explicitly requested.
- If you need to use any media assets as placeholders, let the user know that these are placeholders and should be replaced with the actual assets later.
- Ensure all generated components serve a clear purpose within the user's requested workflow.
- If a feature is assumed but not confirmed, prompt the user for clarification before including it.
- If you are working on a VS Code extension, use the VS Code API tool with a query to find relevant VS Code API references and samples related to that query.
TASK COMPLETION RULES:
- Your task is complete when:
- Project is successfully scaffolded and compiled without errors
- copilot-instructions.md file in the .github directory exists in the project
- README.md file exists and is up to date
- User is provided with clear instructions to debug/launch the project
Before starting a new task in the above plan, update progress in the plan.
-->
- Work through each checklist item systematically.
- Keep communication concise and focused.
- Follow development best practices.

View File

@ -71,7 +71,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -497,7 +496,6 @@
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz",
"integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
},
@ -1602,7 +1600,6 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -1676,7 +1673,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
@ -2157,7 +2153,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2475,7 +2470,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -3078,7 +3072,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -3256,7 +3249,6 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -5372,7 +5364,6 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -5409,7 +5400,6 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -6090,7 +6080,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=12"
},
@ -6242,7 +6231,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -6527,7 +6515,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -6,25 +6,9 @@ import Link from "next/link";
import Image from "next/image";
import { AppShell } from "../../../components/AppShell";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
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);

View File

@ -0,0 +1,825 @@
"use client";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { useUser } from "@/contexts/UserContext";
import { canManageRequests } from "@/lib/permissions";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import type { Equipment, EquipmentCategory, EquipmentComment, User, Department } from "@/types/api";
import { ChevronDown, Filter, MessageSquare, Monitor, Paperclip, Pencil, Plus, Search, Trash2, X, FileSignature } from "lucide-react";
/* ──── form state ──── */
type EquipmentFormState = {
name: string;
description: string;
category: number | null;
department: number | null;
assigned_to: number | null;
serial_number: string;
purchase_date: string;
purchase_cost: string;
location: string;
attachment: File | null;
};
const emptyForm: EquipmentFormState = {
name: "",
description: "",
category: null,
department: null,
assigned_to: null,
serial_number: "",
purchase_date: "",
purchase_cost: "",
location: "",
attachment: null,
};
/* ──── status badges ──── */
const statusMeta: Record<string, { label: string; className: string }> = {
available: { label: "Доступно", className: "bg-emerald-50 text-emerald-700 ring-emerald-100" },
in_use: { label: "В использовании", className: "bg-sky-50 text-sky-700 ring-sky-100" },
maintenance: { label: "На обслуживании", className: "bg-amber-50 text-amber-700 ring-amber-100" },
retired: { label: "Списано", className: "bg-gray-100 text-gray-700 ring-gray-200" },
broken: { label: "Сломано", className: "bg-rose-50 text-rose-700 ring-rose-100" },
};
const defaultStatusMeta = { label: "—", className: "bg-gray-50 text-gray-700 ring-gray-200" };
function formatDate(value?: string | null): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" });
}
/* ──── main page ──── */
export default function EquipmentPage() {
const { user } = useUser();
const [items, setItems] = useState<Equipment[]>([]);
const [employees, setEmployees] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [categories, setCategories] = useState<EquipmentCategory[]>([]);
const [commentsMap, setCommentsMap] = useState<Record<number, EquipmentComment[]>>({});
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<EquipmentFormState>(emptyForm);
const [searchQuery, setSearch] = useState("");
const [employeeFilter, setEmployeeFilter] = useState("");
const [dateFromFilter, setDateFromFilter] = useState("");
const [dateToFilter, setDateToFilter] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const [attachmentPreview, setAttachmentPreview] = useState<{ url: string; name: string } | null>(null);
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 canManage = canManageRequests(user);
/* ──── helpers ──── */
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 num = Number(parsed.searchParams.get("page"));
return Number.isFinite(num) && num > 0 ? num : null;
} catch {
return null;
}
};
const buildParams = (page: number): Record<string, string | number> => {
const p: Record<string, string | number> = { page };
if (employeeFilter) p.assigned_to = employeeFilter;
if (dateFromFilter) p.purchase_date_after = dateFromFilter;
if (dateToFilter) p.purchase_date_before = dateToFilter;
return p;
};
const getCategoryName = (eq: Equipment): string => {
if (typeof eq.category === "object" && eq.category?.name) return eq.category.name;
if (eq.category_name) return eq.category_name;
const cat = categories.find((c) => c.id === Number(eq.category));
return cat?.name || "—";
};
const getDepartmentName = (eq: Equipment): string => {
if (eq.department_name) return eq.department_name;
const dep = departments.find((d) => d.id === Number(eq.department));
return dep?.name || "—";
};
const getAssignedUser = (eq: Equipment): User | null => {
if (eq.assigned_to_details) return eq.assigned_to_details;
if (typeof eq.assigned_to === "object" && eq.assigned_to) return eq.assigned_to as User;
if (typeof eq.assigned_to === "number") {
return employees.find((e) => e.id === eq.assigned_to) || null;
}
return null;
};
/* ──── load data ──── */
useEffect(() => {
(async () => {
try {
setLoading(true);
setError(null);
const res = await apiClient.getEquipment(buildParams(1));
const results = Array.isArray(res) ? res : (res.results || []);
setItems(results);
setNextPage(extractNextPage(res.next));
} catch (e: any) {
console.error("Ошибка загрузки оборудования:", e);
setError("Не удалось загрузить оборудование");
} finally {
setLoading(false);
}
})();
}, [employeeFilter, dateFromFilter, dateToFilter]);
useEffect(() => {
async function loadAllPages<T extends { id: number }>(fetcher: (params: any) => Promise<any>): Promise<T[]> {
const all: T[] = [];
let page = 1;
while (true) {
const res = await fetcher({ page, limit: 200 });
const results = Array.isArray(res) ? res : (res.results || []);
all.push(...results);
if (Array.isArray(res) || !res.next) break;
page++;
}
return all;
}
(async () => {
try {
const [allEmployees, allDepartments, allCategories] = await Promise.all([
loadAllPages<User>((p) => apiClient.getEmployees(p)),
loadAllPages<Department>((p) => apiClient.getDepartments(p)),
loadAllPages<EquipmentCategory>((p) => apiClient.getEquipmentCategories(p)),
]);
setEmployees(allEmployees);
setDepartments(allDepartments);
setCategories(allCategories);
} catch (e) {
console.error("Ошибка загрузки справочников:", e);
}
})();
}, []);
/* ──── filtered ──── */
const filteredItems = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
const sorted = [...items].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
if (!q) return sorted;
return sorted.filter((item) => {
const name = (item.name || "").toLowerCase();
const desc = (item.description || "").toLowerCase();
const sn = (item.serial_number || "").toLowerCase();
const assigned = displayUserName(getAssignedUser(item)).toLowerCase();
const cat = getCategoryName(item).toLowerCase();
return name.includes(q) || desc.includes(q) || sn.includes(q) || assigned.includes(q) || cat.includes(q);
});
}, [items, searchQuery, employees, categories, departments]);
/* ──── form ──── */
const resetForm = () => setForm(emptyForm);
const openEdit = (eq: Equipment) => {
setEditingId(eq.id);
setCreateOpen(false);
setActionError(null);
setActionSuccess(null);
setForm({
name: eq.name || "",
description: eq.description || "",
category: typeof eq.category === "object" ? eq.category?.id ?? null : eq.category ?? null,
department: eq.department ?? null,
assigned_to: typeof eq.assigned_to === "object" ? (eq.assigned_to as User)?.id ?? null : (eq.assigned_to as number) ?? null,
serial_number: eq.serial_number || "",
purchase_date: eq.purchase_date || "",
purchase_cost: String(eq.purchase_cost || ""),
location: eq.location || "",
attachment: null,
});
};
const handleSave = async (mode: "create" | "edit") => {
try {
setBusyKey(`${mode}-save`);
setActionError(null);
setActionSuccess(null);
if (!form.name.trim()) { setActionError("Укажите название оборудования."); return; }
if (!form.category) { setActionError("Выберите категорию."); return; }
if (!form.department) { setActionError("Выберите отдел."); return; }
if (!form.purchase_date) { setActionError("Укажите дату покупки."); return; }
if (!form.purchase_cost) { setActionError("Укажите стоимость."); return; }
const payload: Record<string, any> = {
name: form.name,
description: form.description,
category: form.category,
department: form.department,
purchase_date: form.purchase_date,
purchase_cost: form.purchase_cost,
};
if (form.assigned_to) payload.assigned_to = form.assigned_to;
if (form.serial_number) payload.serial_number = form.serial_number;
if (form.location) payload.location = form.location;
if (form.attachment) payload.attachment = form.attachment;
if (mode === "create") {
await apiClient.createEquipment(payload);
setActionSuccess("Оборудование добавлено.");
setCreateOpen(false);
} else if (editingId) {
await apiClient.updateEquipment(editingId, payload);
setActionSuccess("Оборудование обновлено.");
setEditingId(null);
}
resetForm();
const res = await apiClient.getEquipment(buildParams(1));
const results = Array.isArray(res) ? res : (res.results || []);
setItems(results);
setNextPage(extractNextPage(res.next));
} catch (e: any) {
const raw = String(e?.message || "Не удалось сохранить");
let readable = raw;
try {
const parsed = JSON.parse(raw);
readable = Object.entries(parsed).map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(", ") : val}`).join(". ");
} catch {}
setActionError(readable);
} finally {
setBusyKey(null);
}
};
const handleLoadMore = async () => {
if (!nextPage || loadingMore) return;
try {
setLoadingMore(true);
const res = await apiClient.getEquipment(buildParams(nextPage));
const chunk = Array.isArray(res) ? res : (res.results || []);
setItems((prev) => {
const known = new Set(prev.map((r) => r.id));
return [...prev, ...chunk.filter((r: Equipment) => !known.has(r.id))];
});
setNextPage(extractNextPage(res.next));
} catch {
setError("Не удалось загрузить ещё");
} finally {
setLoadingMore(false);
}
};
/* ──── actions ──── */
const handleDelete = async (id: number) => {
if (!confirm("Удалить это оборудование?")) return;
try { setBusyKey(`delete-${id}`); await apiClient.deleteEquipment(id); setItems((p) => p.filter((r) => r.id !== id)); } catch { setActionError("Не удалось удалить"); } finally { setBusyKey(null); } };
const toggleComments = async (eqId: number) => {
const isOpen = Boolean(expandedComments[eqId]);
setExpandedComments((p) => ({ ...p, [eqId]: !isOpen }));
if (!isOpen && !commentsMap[eqId]) {
try {
const c = await apiClient.getEquipmentComments(eqId);
setCommentsMap((p) => ({ ...p, [eqId]: Array.isArray(c) ? c : c.results || [] }));
} catch {
setCommentsMap((p) => ({ ...p, [eqId]: [] }));
}
}
};
const handleAddComment = async (eqId: number) => {
const text = (commentDrafts[eqId] || "").trim();
if (!text) return;
try {
setBusyKey(`comment-${eqId}`);
const saved = await apiClient.addEquipmentComment(eqId, text);
setCommentsMap((p) => ({ ...p, [eqId]: [...(p[eqId] || []), saved] }));
setCommentDrafts((p) => ({ ...p, [eqId]: "" }));
} catch {
setActionError("Не удалось добавить комментарий");
} finally {
setBusyKey(null);
}
};
const handleDeleteComment = async (eqId: number, commentId: number) => {
try {
setBusyKey(`comment-delete-${commentId}`);
await apiClient.deleteEquipmentComment(eqId, commentId);
setCommentsMap((p) => ({ ...p, [eqId]: (p[eqId] || []).filter((c) => c.id !== commentId) }));
} catch {
setActionError("Не удалось удалить комментарий");
} finally {
setBusyKey(null);
}
};
const fileInputRef = useRef<HTMLInputElement>(null);
/* ──── SearchableSelect (single) ──── */
const SearchableSelectSingle = ({ label, items: selectItems, selectedId, onSelect, placeholder }: {
label: string;
items: { id: number; name: string }[];
selectedId: number | null;
onSelect: (id: number | null) => void;
placeholder?: string;
}) => {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const filtered = selectItems.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
const selectedName = selectItems.find((i) => i.id === selectedId)?.name;
return (
<div ref={ref} className="relative">
<label className="mb-1 block text-xs font-medium text-gray-500">{label}</label>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-sm text-gray-800"
>
<span className="truncate">
{selectedName ? selectedName : <span className="text-gray-400">{placeholder || "Выбрать..."}</span>}
</span>
<ChevronDown size={14} className={`ml-2 shrink-0 text-gray-400 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="border-b border-gray-100 p-2">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Поиск..." className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none" autoFocus />
</div>
<div className="max-h-40 overflow-y-auto p-1">
{selectedId && (
<button type="button" onClick={() => { onSelect(null); setOpen(false); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-400 hover:bg-gray-50">
Сбросить
</button>
)}
{filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-gray-400">Ничего не найдено</p>
) : (
filtered.map((item) => (
<button
key={item.id}
type="button"
onClick={() => { onSelect(item.id); setOpen(false); }}
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 ${selectedId === item.id ? "bg-sky-50 text-sky-700 font-medium" : ""}`}
>
{item.name}
</button>
))
)}
</div>
</div>
)}
</div>
);
};
const modalMode: "create" | "edit" = editingId ? "edit" : "create";
const isModalOpen = createOpen || editingId !== null;
const closeModal = () => {
setCreateOpen(false);
setEditingId(null);
resetForm();
setActionError(null);
};
const activeFilterCount = [employeeFilter, dateFromFilter, dateToFilter].filter(Boolean).length;
/* ──── render ──── */
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">
{/* Header */}
<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={() => { setEditingId(null); resetForm(); setActionError(null); setActionSuccess(null); setCreateOpen(true); }}
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>}
{actionSuccess && <p className="mb-3 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{actionSuccess}</p>}
{/* Search + filter toggle */}
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={searchQuery}
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>
<button
type="button"
title="Фильтры"
onClick={() => setFiltersOpen((v) => !v)}
className={`relative inline-flex items-center justify-center rounded-lg border p-2.5 transition ${filtersOpen ? "border-sky-400 bg-sky-50 text-sky-600" : "border-gray-200 bg-gray-50 text-gray-500 hover:bg-gray-100"}`}
>
<Filter size={16} />
{activeFilterCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">
{activeFilterCount}
</span>
)}
</button>
</div>
{/* Filters panel */}
{filtersOpen && (
<div className="mb-3 flex flex-col gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3">
<select value={employeeFilter} onChange={(e) => setEmployeeFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white 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>
<div className="flex items-center gap-2">
<input type="date" value={dateFromFilter} onChange={(e) => setDateFromFilter(e.target.value)} className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" placeholder="С" />
<span className="text-xs text-gray-400">—</span>
<input type="date" value={dateToFilter} onChange={(e) => setDateToFilter(e.target.value)} className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" placeholder="По" />
</div>
{activeFilterCount > 0 && (
<button type="button" onClick={() => { setEmployeeFilter(""); setDateFromFilter(""); setDateToFilter(""); }} className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 transition">
Очистить фильтры
</button>
)}
</div>
)}
{/* Items list */}
<div className="space-y-3">
{filteredItems.length === 0 ? (
<div className="rounded-xl bg-gray-50 p-8 text-center">
<Monitor size={22} className="mx-auto mb-2 text-gray-400" />
<p className="text-sm text-gray-500">Записи об оборудовании не найдены</p>
</div>
) : (
filteredItems.map((item) => {
const assignedUser = getAssignedUser(item);
const assignedName = displayUserName(assignedUser);
const assignedLink = userProfileLink(assignedUser);
const statusKey = String(item.status || "").toLowerCase();
const status = statusMeta[statusKey] ?? defaultStatusMeta;
const isAuthor = Boolean(item.created_by?.id && user?.id && item.created_by.id === user.id);
const canDeleteThis = isAuthor || canManage;
const canEditThis = isAuthor || canManage;
const comments = commentsMap[item.id] || [];
const commentsOpen = Boolean(expandedComments[item.id]);
return (
<article key={item.id} className="rounded-xl border border-gray-100 bg-white p-4 transition hover:bg-gray-50">
{/* Assigned user + purchase date */}
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
{assignedUser ? (
assignedLink ? (
<Link href={assignedLink} className="flex items-center gap-2 group">
{assignedUser.avatar ? (
<img src={assignedUser.avatar} alt={assignedName} className="h-8 w-8 rounded-full object-cover ring-1 ring-gray-200" />
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-xs font-semibold text-sky-700 ring-1 ring-sky-200">
{(assignedUser.first_name?.[0] || assignedUser.last_name?.[0] || "?").toUpperCase()}
</span>
)}
<span className="text-sm font-medium text-gray-800 group-hover:text-sky-700">{assignedName}</span>
</Link>
) : (
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-xs font-semibold text-sky-700 ring-1 ring-sky-200">
{(assignedUser.first_name?.[0] || assignedUser.last_name?.[0] || "?").toUpperCase()}
</span>
<span className="text-sm font-medium text-gray-800">{assignedName}</span>
</div>
)
) : (
<span className="text-sm text-gray-400">Не назначено</span>
)}
</div>
<span className="text-xs text-gray-500">{formatDate(item.purchase_date)}</span>
</div>
{/* Name + status */}
<div className="flex items-start justify-between gap-3">
<p className="min-w-0 truncate text-sm font-semibold text-gray-900">{item.name || "Без названия"}</p>
{statusKey && <span className={`inline-flex shrink-0 rounded-full px-2.5 py-1 text-xs ring-1 ${status.className}`}>{status.label}</span>}
</div>
{/* Description */}
{item.description && (
<p className="mt-2 text-sm text-gray-700">{item.description}</p>
)}
{/* Meta */}
<div className="mt-3 flex flex-col gap-1.5 text-xs text-gray-500">
<p>Категория: {getCategoryName(item)}</p>
<p>Отдел: {getDepartmentName(item)}</p>
{item.serial_number && <p>Серийный номер: {item.serial_number}</p>}
{item.location && <p>Расположение: {item.location}</p>}
<p>Стоимость: {item.purchase_cost ? `${Number(item.purchase_cost).toLocaleString("ru-RU")} ₽` : "—"}</p>
<p>Добавлено: {formatDate(item.created_at)}</p>
{(item.image || item.attachment || item.attachment_url) && (
<div className="flex items-center gap-1.5 min-w-0">
<button type="button" onClick={() => {
const url = item.image || item.attachment_url || item.attachment || "";
const name = decodeURIComponent(url.split("/").pop() || "Вложение");
setAttachmentPreview({ url, name });
}} className="inline-flex items-center gap-1.5 min-w-0 max-w-full text-sky-700 hover:text-sky-800">
<Paperclip size={13} className="shrink-0" />
<span className="truncate font-medium underline decoration-sky-300 underline-offset-2">
{decodeURIComponent((item.image || item.attachment_url || item.attachment || "").split("/").pop() || "Вложение")}
</span>
</button>
</div>
)}
</div>
{/* Action buttons */}
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<button type="button" title={`Комментарии (${(item as any).comments_count ?? comments.length})`} onClick={() => toggleComments(item.id)} className="relative inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white p-1.5 text-gray-600 hover:bg-gray-50">
<MessageSquare size={15} />
{((item as any).comments_count ?? comments.length) > 0 && (
<span className="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">{(item as any).comments_count ?? comments.length}</span>
)}
</button>
{canEditThis && (
<button type="button" title="Редактировать" onClick={() => openEdit(item)} className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white p-1.5 text-gray-600 hover:bg-gray-50"><Pencil size={15} /></button>
)}
{canDeleteThis && (
<button type="button" title="Удалить" onClick={() => handleDelete(item.id)} disabled={busyKey === `delete-${item.id}`} className="inline-flex items-center justify-center rounded-lg border border-rose-200 bg-rose-50 p-1.5 text-rose-600 hover:bg-rose-100 disabled:opacity-60"><Trash2 size={15} /></button>
)}
</div>
{/* Comments */}
{commentsOpen && (
<div className="mt-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="space-y-2">
{comments.length === 0 ? (
<p className="text-xs text-gray-500">Комментариев пока нет</p>
) : (
comments.map((c) => {
const canDel = 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>
{canDel && <button type="button" onClick={() => handleDeleteComment(item.id, c.id)} className="text-rose-600 hover:text-rose-700">удалить</button>}
</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((p) => ({ ...p, [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>
)}
</article>
);
})
)}
</div>
{/* Load more */}
{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>
)}
</section>
)}
{/* ===== Modal create/edit ===== */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}>
<div className="relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl bg-white p-5 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-900">{modalMode === "create" ? "Добавить оборудование" : "Редактировать оборудование"}</h2>
<button type="button" onClick={closeModal} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button>
</div>
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
<div className="flex flex-col gap-3">
{/* Название */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Название оборудования *</label>
<input
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
placeholder="Ноутбук Lenovo ThinkPad X1..."
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
{/* Категория */}
<SearchableSelectSingle
label="Категория *"
placeholder="Выберите категорию..."
items={categories.map((c) => ({ id: c.id, name: c.name }))}
selectedId={form.category}
onSelect={(id) => setForm((p) => ({ ...p, category: id }))}
/>
{/* Отдел */}
<SearchableSelectSingle
label="Отдел *"
placeholder="Выберите отдел..."
items={departments.map((d) => ({ id: d.id, name: d.name }))}
selectedId={form.department}
onSelect={(id) => setForm((p) => ({ ...p, department: id }))}
/>
{/* Кому выдано */}
<SearchableSelectSingle
label="Кому выдано"
placeholder="Выберите сотрудника..."
items={employees.map((emp) => ({ id: emp.id, name: displayUserName(emp) }))}
selectedId={form.assigned_to}
onSelect={(id) => setForm((p) => ({ ...p, assigned_to: id }))}
/>
{/* Дата покупки + стоимость */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Дата покупки *</label>
<input
type="date"
value={form.purchase_date}
onChange={(e) => setForm((p) => ({ ...p, purchase_date: e.target.value }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Стоимость (₽) *</label>
<input
type="number"
step="0.01"
value={form.purchase_cost}
onChange={(e) => setForm((p) => ({ ...p, purchase_cost: e.target.value }))}
placeholder="0.00"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
</div>
{/* Серийный номер */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Серийный номер</label>
<input
value={form.serial_number}
onChange={(e) => setForm((p) => ({ ...p, serial_number: e.target.value }))}
placeholder="SN-XXXXX"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
{/* Расположение */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Расположение</label>
<input
value={form.location}
onChange={(e) => setForm((p) => ({ ...p, location: e.target.value }))}
placeholder="Офис 305, стол 2..."
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
{/* Описание */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Описание</label>
<textarea
value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
placeholder="Описание оборудования..."
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
{/* Фото */}
<div>
<input ref={fileInputRef} type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={(e) => {
const file = e.target.files?.[0] || null;
if (file) {
const ext = file.name.split(".").pop()?.toLowerCase() || "";
if (!["pdf", "jpg", "jpeg", "png"].includes(ext)) { setActionError(`Файл «${file.name}» не поддерживается. Разрешены: PDF, JPG, PNG.`); e.target.value = ""; return; }
}
setForm((p) => ({ ...p, attachment: file }));
}} />
<button type="button" onClick={() => fileInputRef.current?.click()} className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">
<Paperclip size={14} />
{form.attachment ? form.attachment.name : "Прикрепить фото"}
</button>
</div>
</div>
{/* Buttons */}
<div className="mt-5 flex flex-wrap items-center justify-end gap-2 border-t border-gray-100 pt-4">
<button type="button" onClick={closeModal} 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={() => handleSave(modalMode)} 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">
{modalMode === "create" ? "Добавить" : "Сохранить"}
</button>
</div>
</div>
</div>
)}
{/* ===== Attachment preview ===== */}
{attachmentPreview && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4" onClick={(e) => { if (e.target === e.currentTarget) setAttachmentPreview(null); }}>
<div className="relative flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<p className="truncate text-sm font-medium text-gray-800">{attachmentPreview.name}</p>
<div className="flex items-center gap-2">
<a href={attachmentPreview.url} download className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50">Скачать</a>
<button type="button" onClick={() => setAttachmentPreview(null)} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
{(() => {
const url = attachmentPreview.url;
const ext = url.split(".").pop()?.toLowerCase() || "";
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"];
const fallback = (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<FileSignature size={40} className="text-gray-300" />
<p className="text-sm text-gray-500">{attachmentPreview.name}</p>
<a href={url} download className="rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-600">Скачать файл</a>
</div>
);
if (imageExts.includes(ext)) return <img src={url} alt={attachmentPreview.name} className="mx-auto max-h-[70vh] rounded-lg object-contain" />;
return fallback;
})()}
</div>
</div>
</div>
)}
</AppShell>
);
}

View File

@ -19,8 +19,23 @@
}
}
html, body {
overflow-x: hidden;
overscroll-behavior-x: none;
-webkit-overflow-scrolling: touch;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Скрытые полосы прокрутки везде */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
*::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
}

View File

@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/Providers";
@ -18,6 +18,13 @@ export const metadata: Metadata = {
description: "Корпоративная система управления EUSRR",
};
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
interactiveWidget: 'resizes-content',
};
export default function RootLayout({
children,
}: Readonly<{

View File

@ -20,25 +20,10 @@ import '@chatscope/chat-ui-kit-styles/dist/default/styles.min.css';
import { AppShell } from '@/components/AppShell';
import { apiClient } from '@/lib/api';
import { useUser } from '@/contexts/UserContext';
import { resolveMediaUrl, getWebSocketUrl } from '@/lib/url';
import NewChatModal from '@/components/NewChatModal';
import type { Chat as ChatType, Message as MessageType } from '@/types/api';
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "https://corp.robotail.pro";
function resolveAvatarUrl(url?: string | null): string {
if (!url) return "";
if (url.startsWith("data:")) return url;
if (/^https?:\/\//i.test(url)) return url;
if (url.startsWith("//")) return `https:${url}`;
if (url.startsWith("/") && BACKEND_URL) {
return `${BACKEND_URL.replace(/\/$/, "")}${url}`;
}
if (BACKEND_URL) {
return `${BACKEND_URL.replace(/\/$/, "")}/${url.replace(/^\/+/, "")}`;
}
return url;
}
function getChatTitle(chat: ChatType, currentUserId?: number): string {
const chatKind = chat.chat_type || chat.type;
const rawName = (chat.name || "").trim();
@ -70,18 +55,18 @@ function getChatTitle(chat: ChatType, currentUserId?: number): string {
function getChatAvatar(chat: ChatType, currentUserId?: number): string {
const chatKind = chat.chat_type || chat.type;
if (chatKind === "direct" || chatKind === "private") {
if (chat.interlocutor?.avatar) return resolveAvatarUrl(chat.interlocutor.avatar);
if (chat.interlocutor?.avatar) return resolveMediaUrl(chat.interlocutor.avatar);
const detailsOther = (chat.participant_details || []).find((p) => p.id !== currentUserId);
if (detailsOther?.avatar) return resolveAvatarUrl(detailsOther.avatar);
if (detailsOther?.avatar) return resolveMediaUrl(detailsOther.avatar);
const participants = (chat.participants || []).filter(
(p): p is Exclude<typeof p, number> => typeof p === "object" && p !== null
);
const other = participants.find((p) => p.id !== currentUserId);
if (other?.avatar) return resolveAvatarUrl(other.avatar);
if (other?.avatar) return resolveMediaUrl(other.avatar);
}
return resolveAvatarUrl(chat.avatar);
return resolveMediaUrl(chat.avatar);
}
export default function ChatscapeMessenger() {
@ -162,7 +147,7 @@ export default function ChatscapeMessenger() {
useEffect(() => {
if (!selectedChatId) return;
const ws = new WebSocket('ws://localhost:9000/ws/');
const ws = new WebSocket(getWebSocketUrl());
wsRef.current = ws;
ws.onopen = () => {
@ -400,7 +385,7 @@ export default function ChatscapeMessenger() {
>
{!isOwn && msg.author && (
<Avatar
src={resolveAvatarUrl(msg.author.avatar)}
src={resolveMediaUrl(msg.author.avatar)}
name={msg.author_name || ''}
size="sm"
/>
@ -416,13 +401,13 @@ export default function ChatscapeMessenger() {
<div key={att.id}>
{isImage ? (
<img
src={resolveAvatarUrl(att.file_url)}
src={resolveMediaUrl(att.file_url)}
alt={att.file_name}
className="max-w-xs rounded shadow"
/>
) : (
<a
href={resolveAvatarUrl(att.file_url)}
href={resolveMediaUrl(att.file_url)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sky-600 hover:underline text-sm"

View File

@ -1,17 +1,16 @@
"use client";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { MessageCircle, Send, ArrowLeft, Paperclip, X, FileText, ChevronRight, Reply, Pencil, Trash2, Smile } from "lucide-react";
import { MessageCircle, Send, ArrowLeft, Paperclip, X, FileText, ChevronRight, ChevronDown, Reply, Pencil, Trash2, Smile } from "lucide-react";
import { AppShell } from "../../../components/AppShell";
import { apiClient } from "@/lib/api";
import type { Chat, Message, MessageAttachment } from "@/types/api";
import { useUser } from "@/contexts/UserContext";
import { useWebSocket } from "@/hooks/useWebSocket";
import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "https://corp.robotail.pro";
import { resolveMediaUrl } from "@/lib/url";
function getUserFullName(lastName?: string, firstName?: string): string {
return `${lastName || ""} ${firstName || ""}`.trim();
@ -188,34 +187,6 @@ function normalizePossiblyEncodedUrl(value: string): string {
}
}
function resolveAttachmentUrl(url?: string | null): string {
if (!url) return "";
if (url.startsWith("data:")) return url;
if (/^https?:\/\//i.test(url)) return normalizePossiblyEncodedUrl(url);
if (url.startsWith("//")) return normalizePossiblyEncodedUrl(`https:${url}`);
if (url.startsWith("/") && BACKEND_URL) {
return normalizePossiblyEncodedUrl(`${BACKEND_URL.replace(/\/$/, "")}${url}`);
}
if (BACKEND_URL) {
return normalizePossiblyEncodedUrl(`${BACKEND_URL.replace(/\/$/, "")}/${url.replace(/^\/+/, "")}`);
}
return normalizePossiblyEncodedUrl(url);
}
function resolveAvatarUrl(url?: string | null): string {
if (!url) return "";
if (url.startsWith("data:")) return url;
if (/^https?:\/\//i.test(url)) return normalizePossiblyEncodedUrl(url);
if (url.startsWith("//")) return normalizePossiblyEncodedUrl(`https:${url}`);
if (url.startsWith("/") && BACKEND_URL) {
return normalizePossiblyEncodedUrl(`${BACKEND_URL.replace(/\/$/, "")}${url}`);
}
if (BACKEND_URL) {
return normalizePossiblyEncodedUrl(`${BACKEND_URL.replace(/\/$/, "")}/${url.replace(/^\/+/, "")}`);
}
return normalizePossiblyEncodedUrl(url);
}
function uniqueMessagesById(items: Message[]): Message[] {
const map = new Map<number, Message>();
items.forEach((msg) => {
@ -293,6 +264,10 @@ export default function MessageDialogPage() {
const [brokenMedia, setBrokenMedia] = useState<Record<number, boolean>>({});
const [useOriginalImage, setUseOriginalImage] = useState<Record<number, boolean>>({});
const [mediaPreview, setMediaPreview] = useState<{ type: "image" | "video"; src: string; name: string } | null>(null);
const [floatingDate, setFloatingDate] = useState<string | null>(null);
const [showFloatingDate, setShowFloatingDate] = useState(false);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const floatingDateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const messageInputRef = useRef<HTMLTextAreaElement | null>(null);
const messagesViewportRef = useRef<ScrollableMessageListInner | null>(null);
@ -466,13 +441,13 @@ export default function MessageDialogPage() {
}
else if (data.type === 'reaction_added') {
// Реакция добавлена
if (data.reactions_summary) {
if (data.reactions_summary && data.message_id) {
updateMessageReactionsSummary(data.message_id, data.reactions_summary);
}
}
else if (data.type === 'reaction_removed') {
// Реакция удалена
if (data.reactions_summary) {
if (data.reactions_summary && data.message_id) {
updateMessageReactionsSummary(data.message_id, data.reactions_summary);
}
}
@ -497,6 +472,9 @@ export default function MessageDialogPage() {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
if (floatingDateTimeoutRef.current) {
clearTimeout(floatingDateTimeoutRef.current);
}
};
}, []);
@ -697,7 +675,11 @@ export default function MessageDialogPage() {
if (initialAnchorId) {
const el = viewport.querySelector(`[data-message-id="${initialAnchorId}"]`) as HTMLElement | null;
if (el) {
el.scrollIntoView({ block: "center" });
// Ручной scrollTop вместо scrollIntoView (iOS Safari не поддерживает scrollIntoView в overflow контейнерах)
const containerRect = viewport.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const offset = elRect.top - containerRect.top + viewport.scrollTop - viewport.clientHeight / 2 + elRect.height / 2;
viewport.scrollTop = Math.max(0, offset);
initialScrolledRef.current = true;
anchorScrollAttemptsRef.current = 0;
setInitialAnchorId(null);
@ -710,7 +692,10 @@ export default function MessageDialogPage() {
const children = viewport.querySelectorAll("[data-message-id]");
const byIndex = children.item(initialAnchorIndex) as HTMLElement | null;
if (byIndex) {
byIndex.scrollIntoView({ block: "center" });
const containerRect = viewport.getBoundingClientRect();
const elRect = byIndex.getBoundingClientRect();
const offset = elRect.top - containerRect.top + viewport.scrollTop - viewport.clientHeight / 2 + elRect.height / 2;
viewport.scrollTop = Math.max(0, offset);
initialScrolledRef.current = true;
anchorScrollAttemptsRef.current = 0;
setInitialAnchorId(null);
@ -719,15 +704,15 @@ export default function MessageDialogPage() {
}
}
// Даем DOM время на отрисовку (максимум 3 попытки вместо 8)
if (anchorScrollAttemptsRef.current < 3) {
// Даем DOM время на отрисовку (максимум 5 попыток, iOS рендерит медленнее)
if (anchorScrollAttemptsRef.current < 5) {
anchorScrollAttemptsRef.current += 1;
setTimeout(() => setAnchorRetryTick((v) => v + 1), 50);
setTimeout(() => setAnchorRetryTick((v) => v + 1), 100);
return;
}
// Якорь не найден после 3 попыток - скроллим вниз
console.warn('Anchor not found after 3 attempts, scrolling to bottom');
// Якорь не найден после 5 попыток - скроллим вниз
console.warn('Anchor not found after 5 attempts, scrolling to bottom');
setInitialAnchorId(null);
setInitialAnchorIndex(null);
}
@ -834,6 +819,40 @@ export default function MessageDialogPage() {
if (!loadingNewer && !messagesLoading && hasMoreNewer && distanceFromBottom <= 120) {
loadNewerMessages();
}
// Обновление плавающей даты
const messageEls = viewport.querySelectorAll<HTMLElement>('[data-message-date]');
let topDate: string | null = null;
const viewportTop = viewport.getBoundingClientRect().top;
for (let i = 0; i < messageEls.length; i++) {
const el = messageEls[i];
const rect = el.getBoundingClientRect();
if (rect.bottom > viewportTop) {
const isoDate = el.getAttribute('data-message-date');
if (isoDate) {
const d = new Date(isoDate);
if (!Number.isNaN(d.getTime())) {
topDate = formatDayDivider(d);
}
}
break;
}
}
if (topDate) {
setFloatingDate(topDate);
setShowFloatingDate(true);
if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current);
floatingDateTimeoutRef.current = setTimeout(() => {
setShowFloatingDate(false);
}, 1500);
}
// Показ кнопки scroll-to-bottom когда не внизу
const isAtBottom = isNearBottom();
setShowScrollToBottom(!isAtBottom);
};
viewport.addEventListener("scroll", onScroll, { passive: true });
@ -1016,6 +1035,27 @@ export default function MessageDialogPage() {
return () => window.removeEventListener("keydown", onKey);
}, []);
// При открытии мобильной клавиатуры прокручиваем чат вниз
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
let prevHeight = vv.height;
const onResize = () => {
const newHeight = vv.height;
if (newHeight < prevHeight) {
requestAnimationFrame(() => {
messagesViewportRef.current?.scrollToBottom('auto');
});
}
prevHeight = newHeight;
};
vv.addEventListener('resize', onResize);
return () => vv.removeEventListener('resize', onResize);
}, []);
useEffect(() => {
const onGlobalWheel = (e: WheelEvent) => {
if (e.defaultPrevented || mediaPreview) return;
@ -1074,14 +1114,14 @@ export default function MessageDialogPage() {
</div>
)}
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<section className="flex h-full min-h-0 flex-col overflow-hidden lg:bg-white lg:rounded-2xl lg:p-5 lg:shadow-sm lg:ring-1 lg:ring-gray-100">
{chat ? (
<>
<header className="mb-4 flex shrink-0 items-center justify-between gap-3 border-b border-gray-100 pb-4">
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-gray-100 pb-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{getChatAvatar(chat, user?.id) ? (
<img src={resolveAvatarUrl(getChatAvatar(chat, user?.id))} alt={getChatTitle(chat, user?.id)} className="h-full w-full object-cover" />
<img src={resolveMediaUrl(getChatAvatar(chat, user?.id))} alt={getChatTitle(chat, user?.id)} className="h-full w-full object-cover" />
) : (
getChatInitials(chat, user?.id)
)}
@ -1108,13 +1148,24 @@ export default function MessageDialogPage() {
</Link>
</header>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Плавающая дата */}
{floatingDate && (
<div
className={`pointer-events-none absolute left-0 right-0 top-2 z-20 text-center transition-opacity duration-300 ${showFloatingDate ? 'opacity-100' : 'opacity-0'}`}
>
<span className="inline-block rounded-full bg-white/95 px-3 py-1 text-xs text-gray-500 shadow-sm ring-1 ring-gray-200 backdrop-blur">
{floatingDate}
</span>
</div>
)}
<ScrollableMessageList
ref={messagesViewportRef}
autoScrollToBottom={true}
autoScrollToBottomOnMount={true}
scrollBehavior="smooth"
className="min-h-0 flex-1 rounded-xl bg-gray-50 p-4"
className="min-h-0 flex-1 bg-gray-50 p-3"
>
{messagesLoading ? (
<p className="text-center text-sm text-gray-500">Загрузка сообщений...</p>
@ -1141,22 +1192,15 @@ export default function MessageDialogPage() {
const hasActions = canReply || canManage;
return (
<div key={message.id} data-message-id={message.id} className="mb-3 overflow-x-hidden last:mb-0">
{showDayDivider && currentDate ? (
<div className="sticky top-2 z-10 mb-4 text-center">
<span className="inline-block rounded-full bg-white/95 px-3 py-1 text-xs text-gray-500 shadow-sm ring-1 ring-gray-200 backdrop-blur">
{formatDayDivider(currentDate)}
</span>
</div>
) : null}
<React.Fragment key={message.id}>
<div data-message-id={message.id} data-message-date={currentDate?.toISOString() || ''} className="mb-3 last:mb-0">
<div className={`flex ${isMine ? "justify-end" : "justify-start"}`}>
{!isMine ? (
<div className="relative mr-2 mt-1 h-8 w-8 shrink-0">
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-[10px] font-semibold text-white">
{message.avatar || message.author?.avatar ? (
<img
src={resolveAvatarUrl(message.avatar || message.author?.avatar || "")}
src={resolveMediaUrl(message.avatar || message.author?.avatar || "")}
alt={message.author_name || "Автор"}
className="h-full w-full object-cover"
/>
@ -1242,7 +1286,7 @@ export default function MessageDialogPage() {
{isImageAttachment(att) ? (
brokenMedia[att.id] ? (
<a
href={resolveAttachmentUrl(att.file_url)}
href={resolveMediaUrl(att.file_url)}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"
@ -1253,14 +1297,14 @@ export default function MessageDialogPage() {
) : (
<button
type="button"
onClick={() => setMediaPreview({ type: "image", src: resolveAttachmentUrl(att.file_url), name: att.file_name })}
onClick={() => setMediaPreview({ type: "image", src: resolveMediaUrl(att.file_url), name: att.file_name })}
className="block w-full overflow-hidden rounded-lg"
>
{(() => {
const hasThumbnail = Boolean(att.thumbnail);
const src = useOriginalImage[att.id]
? resolveAttachmentUrl(att.file_url)
: resolveAttachmentUrl(att.thumbnail || att.file_url);
? resolveMediaUrl(att.file_url)
: resolveMediaUrl(att.thumbnail || att.file_url);
return (
<img
@ -1289,7 +1333,7 @@ export default function MessageDialogPage() {
) : isVideoAttachment(att) ? (
brokenMedia[att.id] ? (
<a
href={resolveAttachmentUrl(att.file_url)}
href={resolveMediaUrl(att.file_url)}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"
@ -1302,14 +1346,14 @@ export default function MessageDialogPage() {
) : (
<button
type="button"
onClick={() => setMediaPreview({ type: "video", src: resolveAttachmentUrl(att.file_url), name: att.file_name })}
onClick={() => setMediaPreview({ type: "video", src: resolveMediaUrl(att.file_url), name: att.file_name })}
className="block w-full overflow-hidden rounded-lg"
>
<video
preload="metadata"
playsInline
muted
src={resolveAttachmentUrl(att.file_url)}
src={resolveMediaUrl(att.file_url)}
width={att.width || undefined}
height={att.height || undefined}
className="max-h-64 w-full rounded-lg bg-black"
@ -1325,7 +1369,7 @@ export default function MessageDialogPage() {
) : isAudioAttachment(att) ? (
brokenMedia[att.id] ? (
<a
href={resolveAttachmentUrl(att.file_url)}
href={resolveMediaUrl(att.file_url)}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"
@ -1345,12 +1389,12 @@ export default function MessageDialogPage() {
messagesViewportRef.current?.updateScrollPosition();
}}
>
<source src={resolveAttachmentUrl(att.file_url)} type={att.mime_type || "audio/mpeg"} />
<source src={resolveMediaUrl(att.file_url)} type={att.mime_type || "audio/mpeg"} />
</audio>
)
) : (
<a
href={resolveAttachmentUrl(att.file_url)}
href={resolveMediaUrl(att.file_url)}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 text-sm text-sky-700 hover:bg-white"
@ -1397,13 +1441,34 @@ export default function MessageDialogPage() {
</div>
</div>
</div>
</React.Fragment>
);
})}
</div>
)}
</ScrollableMessageList>
<div className="mt-3 shrink-0 border-t border-gray-100 bg-white pt-3">
{/* Кнопка прокрутки вниз */}
{showScrollToBottom && (
<div className="pointer-events-auto absolute bottom-25 right-3 z-20 transition-opacity duration-300">
<button
type="button"
onClick={() => {
const container = messagesViewportRef.current?.containerRef?.current;
if (container) {
container.scrollTop = container.scrollHeight;
}
}}
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-sky-500 text-white shadow-lg transition hover:bg-sky-600 active:scale-95"
title="Вернуться к новым сообщениям"
aria-label="Вернуться к новым сообщениям"
>
<ChevronDown size={18} />
</button>
</div>
)}
<div className="shrink-0 border-t border-gray-100 bg-white pt-3">
{/* Индикатор "печатает..." */}
{isTyping && (
<div className="mb-2 flex items-center gap-2 text-xs text-gray-500 italic">
@ -1471,7 +1536,7 @@ export default function MessageDialogPage() {
</div>
) : null}
<div className="flex items-center gap-2">
<div className="flex items-start gap-2">
<input
ref={fileInputRef}
type="file"
@ -1483,10 +1548,10 @@ export default function MessageDialogPage() {
type="button"
onClick={handlePickFiles}
disabled={Boolean(editingMessageId)}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 leading-none transition hover:bg-gray-50 hover:text-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 leading-none transition hover:bg-gray-50 hover:text-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
title={editingMessageId ? "При редактировании вложения недоступны" : "Добавить файлы"}
>
<Paperclip size={16} />
<Paperclip size={15} />
</button>
<div className="relative w-full" data-composer-emoji="true">
<button
@ -1497,7 +1562,7 @@ export default function MessageDialogPage() {
setActionsMenuAnchor(null);
setReactionPickerForMessageId(null);
}}
className="absolute right-2 top-1 z-10 inline-flex h-6 w-6 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-sky-600"
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex h-6 w-6 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-sky-600"
title="Смайлы"
>
<Smile size={14} />
@ -1535,19 +1600,19 @@ export default function MessageDialogPage() {
handleSend();
}
}}
rows={2}
rows={1}
placeholder={editingMessageId ? "Редактируйте сообщение..." : "Введите сообщение..."}
className="w-full resize-none rounded-xl border border-gray-200 bg-white px-3 py-2 pr-10 text-sm text-gray-900 outline-none ring-0 transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
className="w-full resize-none rounded-lg border border-gray-200 bg-white h-9 px-3 py-2 pr-10 text-sm text-gray-900 outline-none ring-0 transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<button
type="button"
onClick={handleSend}
disabled={sending || (editingMessageId ? !messageText.trim() : (!messageText.trim() && attachedFiles.length === 0))}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-sky-500 text-white leading-none transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500 text-white leading-none transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
title={editingMessageId ? "Сохранить" : "Отправить"}
>
<Send size={16} />
<Send size={15} />
</button>
</div>
</div>

View File

@ -3,14 +3,13 @@
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
import type { Chat } from "@/types/api";
import { Search, MessageCircle } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useUser } from "@/contexts/UserContext";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "https://corp.robotail.pro";
function getUserFullName(lastName?: string, firstName?: string): string {
return `${lastName || ""} ${firstName || ""}`.trim();
}
@ -111,20 +110,6 @@ function getChatInitials(chat: Chat, currentUserId?: number, currentUser?: { fir
.join("") || "Ч";
}
function resolveAvatarUrl(url?: string | null): string {
if (!url) return "";
if (url.startsWith("data:")) return url;
if (/^https?:\/\//i.test(url)) return encodeURI(url);
if (url.startsWith("//")) return encodeURI(`https:${url}`);
if (url.startsWith("/") && BACKEND_URL) {
return encodeURI(`${BACKEND_URL.replace(/\/$/, "")}${url}`);
}
if (BACKEND_URL) {
return encodeURI(`${BACKEND_URL.replace(/\/$/, "")}/${url.replace(/^\/+/, "")}`);
}
return encodeURI(url);
}
function formatTime(date?: string): string {
if (!date) return "";
const d = new Date(date);
@ -195,7 +180,7 @@ export default function MessagesPage() {
// FRONTEND-ONLY: добираем детали чатов, чтобы получить имя/аватар собеседника
// для private/direct в случае, когда list не отдает avatar/participant_details.
const needDetails = items.filter((chat) => {
const needDetails = items.filter((chat: Chat) => {
const kind = chat.chat_type || chat.type;
if (kind !== "private" && kind !== "direct") return false;
@ -211,7 +196,7 @@ export default function MessagesPage() {
}
const details = await Promise.all(
needDetails.map(async (chat) => {
needDetails.map(async (chat: Chat) => {
try {
return await apiClient.getChat(chat.id);
} catch {
@ -225,7 +210,7 @@ export default function MessagesPage() {
if (d) detailsMap.set(d.id, d);
});
const merged = items.map((chat) => {
const merged = items.map((chat: Chat) => {
const detail = detailsMap.get(chat.id);
if (!detail) return chat;
return {
@ -306,7 +291,7 @@ export default function MessagesPage() {
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-xs font-semibold text-white">
{chatAvatar ? (
<Image
src={resolveAvatarUrl(chatAvatar)}
src={resolveMediaUrl(chatAvatar)}
alt={chatTitle}
width={40}
height={40}

View File

@ -1,8 +1,9 @@
"use client";
import { Heart, MessageSquare, Pencil, Plus, Send, Trash2, X } from "lucide-react";
import { Heart, ImageIcon, MessageSquare, Paperclip, Pencil, Plus, Send, Trash2, X } from "lucide-react";
import { AppShell } from "../components/AppShell";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
import { useEffect, useRef, useState } from "react";
import type { Comment, Post } from "@/types/api";
import { useUser } from "@/contexts/UserContext";
@ -31,6 +32,10 @@ export default function Home() {
const [commentSending, setCommentSending] = useState(false);
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [editingCommentText, setEditingCommentText] = useState("");
const [commentImage, setCommentImage] = useState<File | null>(null);
const [commentAttachment, setCommentAttachment] = useState<File | null>(null);
const commentImageRef = useRef<HTMLInputElement | null>(null);
const commentAttachmentRef = useRef<HTMLInputElement | null>(null);
const [commentActionId, setCommentActionId] = useState<number | null>(null);
const commentsBottomRef = useRef<HTMLDivElement | null>(null);
const [createPostOpen, setCreatePostOpen] = useState(false);
@ -59,20 +64,7 @@ export default function Home() {
return `${(lastName || "").trim().charAt(0)}${(firstName || "").trim().charAt(0)}` || "П";
};
const resolveMediaUrl = (url?: string | null) => {
const raw = (url || "").trim();
if (!raw) return "";
if (/^https?:\/\//i.test(raw)) {
return raw;
}
const backendBase = (process.env.NEXT_PUBLIC_BACKEND_URL || "https://corp.robotail.pro").replace(/\/$/, "");
if (raw.startsWith("/")) {
return `${backendBase}${raw}`;
}
return `${backendBase}/${raw}`;
};
const withImageCacheBuster = (url: string, postId: number) => {
if (!url) return "";
@ -306,13 +298,20 @@ export default function Home() {
const handleCreateComment = async () => {
if (!activePost) return;
const text = newComment.trim();
if (!text) return;
if (!text && !commentImage && !commentAttachment) return;
setCommentSending(true);
try {
const created = await apiClient.createComment(activePost.id, text);
const created = await apiClient.createComment(
activePost.id,
text || " ",
commentImage || undefined,
commentAttachment || undefined
);
setComments((prev) => [...prev, created]);
setNewComment("");
setCommentImage(null);
setCommentAttachment(null);
setPosts((prev) =>
prev.map((p) =>
p.id === activePost.id
@ -438,8 +437,8 @@ export default function Home() {
title,
body,
department: createType === "department" ? Number(createDepartmentId) : undefined,
image: createImage,
attachment: createAttachment,
image: createImage || undefined,
attachment: createAttachment || undefined,
});
if (createImage) {
@ -454,8 +453,8 @@ export default function Home() {
title,
body,
department: createType === "department" ? Number(createDepartmentId) : undefined,
image: createImage,
attachment: createAttachment,
image: createImage || undefined,
attachment: createAttachment || undefined,
});
}
@ -540,7 +539,7 @@ export default function Home() {
? post.author.is_active
: Boolean(post.author?.id && user?.id && post.author.id === user.id && user.is_active);
const postText = (post.content || post.body || "").trim();
const postText = (post.body || post.content || "").trim();
// Форматируем дату
const postDate = new Date(post.created_at);
@ -602,14 +601,29 @@ export default function Home() {
</div>
) : null}
</header>
{post.title ? (
<h3 className="mb-1 text-base font-semibold text-gray-900">{post.title}</h3>
) : null}
{postText ? (
<p className="text-sm leading-6 text-gray-800">{postText}</p>
<p className="text-sm leading-6 text-gray-800 whitespace-pre-line">{postText}</p>
) : null}
{post.image && (
<div className="mt-3 overflow-hidden rounded-lg">
<img src={withImageCacheBuster(resolveMediaUrl(post.image), post.id)} alt="" className="w-full" />
</div>
)}
{(post.attachment || post.attachment_url) && (
<div className="mt-3">
<a
href={resolveMediaUrl(post.attachment || post.attachment_url)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-2 text-sm text-sky-600 ring-1 ring-gray-200 hover:bg-gray-100"
>
📎 Вложение
</a>
</div>
)}
<div className="mt-4 flex items-center gap-4 text-sm text-gray-600">
<div
className="relative"
@ -685,7 +699,7 @@ export default function Home() {
className="absolute inset-0 bg-black/50"
/>
<div className="absolute inset-x-3 top-4 bottom-4 mx-auto flex max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 sm:inset-x-6">
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl max-h-[80vh] mx-auto px-3 flex flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 sm:px-6">
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<p className="text-sm font-semibold text-gray-900">
{editingPostId ? "Редактировать публикацию" : "Создать публикацию"}
@ -906,7 +920,26 @@ export default function Home() {
</div>
</div>
) : (
<p className="mt-1 text-sm leading-6 text-gray-800">{commentText}</p>
<>
{commentText && <p className="mt-1 text-sm leading-6 text-gray-800">{commentText}</p>}
{comment.image && (
<div className="mt-2 overflow-hidden rounded-lg">
<img src={resolveMediaUrl(comment.image)} alt="" className="max-h-60 rounded-lg" />
</div>
)}
{comment.attachment && (
<div className="mt-2">
<a
href={resolveMediaUrl(comment.attachment)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md bg-white px-2.5 py-1.5 text-xs text-sky-600 ring-1 ring-gray-200 hover:bg-gray-50"
>
📎 Вложение
</a>
</div>
)}
</>
)}
</div>
</div>
@ -919,7 +952,43 @@ export default function Home() {
</div>
<div className="border-t border-gray-100 px-4 py-3">
{(commentImage || commentAttachment) && (
<div className="mb-2 flex flex-wrap gap-2">
{commentImage && (
<div className="flex items-center gap-1.5 rounded-md bg-sky-50 px-2 py-1 text-xs text-sky-700 ring-1 ring-sky-200">
<ImageIcon size={12} />
<span className="max-w-[120px] truncate">{commentImage.name}</span>
<button type="button" onClick={() => setCommentImage(null)} className="ml-0.5 text-sky-400 hover:text-sky-700"><X size={12} /></button>
</div>
)}
{commentAttachment && (
<div className="flex items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-xs text-gray-700 ring-1 ring-gray-200">
<Paperclip size={12} />
<span className="max-w-[120px] truncate">{commentAttachment.name}</span>
<button type="button" onClick={() => setCommentAttachment(null)} className="ml-0.5 text-gray-400 hover:text-gray-700"><X size={12} /></button>
</div>
)}
</div>
)}
<div className="flex items-center gap-2">
<input ref={commentImageRef} type="file" accept="image/*" className="hidden" onChange={(e) => { setCommentImage(e.target.files?.[0] || null); e.target.value = ""; }} />
<input ref={commentAttachmentRef} type="file" className="hidden" onChange={(e) => { setCommentAttachment(e.target.files?.[0] || null); e.target.value = ""; }} />
<button
type="button"
onClick={() => commentImageRef.current?.click()}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title="Прикрепить изображение"
>
<ImageIcon size={18} />
</button>
<button
type="button"
onClick={() => commentAttachmentRef.current?.click()}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title="Прикрепить файл"
>
<Paperclip size={18} />
</button>
<input
type="text"
value={newComment}
@ -935,7 +1004,7 @@ export default function Home() {
/>
<button
type="button"
disabled={commentSending || !newComment.trim()}
disabled={commentSending || (!newComment.trim() && !commentImage && !commentAttachment)}
onClick={handleCreateComment}
className="flex h-10 w-10 items-center justify-center rounded-lg bg-sky-500 text-white disabled:cursor-not-allowed disabled:opacity-50"
>

View File

@ -0,0 +1,945 @@
"use client";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { useUser } from "@/contexts/UserContext";
import { canManageRequests } from "@/lib/permissions";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import Link from "next/link";
import type {
ProcurementRequest,
ProcurementItem,
User,
Department,
UrgencyLevel,
ProcurementStatus,
} from "@/types/api";
import {
AlertTriangle,
Check,
ChevronDown,
ChevronUp,
CircleDot,
ClipboardCheck,
Filter,
Loader2,
Package,
Pencil,
Play,
Plus,
Search,
Send,
ShoppingCart,
ThumbsDown,
ThumbsUp,
Trash2,
X,
XCircle,
} from "lucide-react";
/* ══════════════════════════════════════════════════════
Constants & helpers
══════════════════════════════════════════════════════ */
const statusMeta: Record<string, { label: string; cls: string }> = {
draft: { label: "Черновик", cls: "bg-slate-100 text-slate-700 ring-slate-200" },
pending: { label: "На согласовании", cls: "bg-amber-50 text-amber-700 ring-amber-100" },
approved: { label: "Одобрено", cls: "bg-emerald-50 text-emerald-700 ring-emerald-100" },
in_progress: { label: "В работе", cls: "bg-sky-50 text-sky-700 ring-sky-100" },
completed: { label: "Завершено", cls: "bg-teal-50 text-teal-700 ring-teal-100" },
rejected: { label: "Отклонено", cls: "bg-rose-50 text-rose-700 ring-rose-100" },
cancelled: { label: "Отменено", cls: "bg-gray-100 text-gray-600 ring-gray-200" },
};
const defaultStatusMeta = { label: "—", cls: "bg-gray-50 text-gray-700 ring-gray-200" };
const urgencyMeta: Record<string, { label: string; cls: string }> = {
low: { label: "Низкая", cls: "text-gray-500" },
medium: { label: "Средняя", cls: "text-sky-600" },
high: { label: "Высокая", cls: "text-amber-600" },
critical: { label: "Критическая", cls: "text-rose-600" },
};
type ScopeTab = "all" | "mine" | "pending_approvals" | "available";
const scopeTabs: { value: ScopeTab; label: string }[] = [
{ value: "all", label: "Все" },
{ value: "mine", label: "Мои" },
{ value: "pending_approvals", label: "На согласование" },
{ value: "available", label: "Доступные" },
];
function fmt(d?: string | null) {
if (!d) return "";
const dt = new Date(d);
return Number.isNaN(dt.getTime()) ? "" : dt.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" });
}
function money(v?: string | number | null) {
if (v === null || v === undefined || v === "") return "—";
return Number(v).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " ₽";
}
/* ══════════════════════════════════════════════════════
Item row (позиция заявки) — for inline editing
══════════════════════════════════════════════════════ */
type ItemDraft = {
name: string;
description: string;
quantity: string;
unit: string;
estimated_unit_price: string;
supplier_info: string;
};
const emptyItem: ItemDraft = { name: "", description: "", quantity: "1", unit: "шт", estimated_unit_price: "", supplier_info: "" };
/* ══════════════════════════════════════════════════════
Form state
══════════════════════════════════════════════════════ */
type FormState = {
title: string;
description: string;
department: number | null;
urgency: UrgencyLevel;
items: ItemDraft[];
};
const emptyForm: FormState = {
title: "",
description: "",
department: null,
urgency: "medium",
items: [{ ...emptyItem }],
};
/* ══════════════════════════════════════════════════════
Main page component
══════════════════════════════════════════════════════ */
export default function ProcurementPage() {
const { user } = useUser();
const canManage = canManageRequests(user);
/* ── data ── */
const [requests, setRequests] = useState<ProcurementRequest[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
/* ── UI flags ── */
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);
/* ── scope / filters ── */
const [scope, setScope] = useState<ScopeTab>("all");
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [urgencyFilter, setUrgencyFilter] = useState("");
const [departmentFilter, setDepartmentFilter] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
/* ── form modal ── */
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
/* ── expanded details ── */
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [detailsCache, setDetailsCache] = useState<Record<number, ProcurementRequest>>({});
/* ══════════ helpers ══════════ */
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.email || "Пользователь";
};
const userLink = (person?: User | null) => {
if (!person?.id) return "";
return user?.id && person.id === user.id ? "/profile" : `/users/${person.id}`;
};
const extractNextPage = (nextUrl?: string | null): number | null => {
if (!nextUrl) return null;
try {
const u = new URL(nextUrl, window.location.origin);
const n = Number(u.searchParams.get("page"));
return Number.isFinite(n) && n > 0 ? n : null;
} catch { return null; }
};
const getDeptName = (req: ProcurementRequest) => {
if (req.department_name) return req.department_name;
if (req.department_details?.name) return req.department_details.name;
const d = departments.find((dep) => dep.id === Number(req.department));
return d?.name || "—";
};
/* ══════════ data loading ══════════ */
const buildParams = useCallback((page: number): Record<string, string | number> => {
const p: Record<string, string | number> = { page };
if (scope === "mine") p.scope = "mine";
else if (scope === "available") p.scope = "available";
if (statusFilter) p.status = statusFilter;
if (urgencyFilter) p.urgency = urgencyFilter;
if (departmentFilter) p.department = departmentFilter;
if (searchQuery.trim()) p.search = searchQuery.trim();
return p;
}, [scope, statusFilter, urgencyFilter, departmentFilter, searchQuery]);
const loadPage1 = useCallback(async () => {
try {
setLoading(true);
setError(null);
let res: any;
if (scope === "pending_approvals") {
res = await apiClient.getPendingApprovals(buildParams(1));
} else {
res = await apiClient.getProcurementRequests(buildParams(1));
}
const results = Array.isArray(res) ? res : (res.results || []);
setRequests(results);
setNextPage(extractNextPage(res.next));
} catch (e: any) {
console.error("Load procurement error:", e);
setError("Не удалось загрузить заявки на закупку");
} finally {
setLoading(false);
}
}, [buildParams, scope]);
useEffect(() => { loadPage1(); }, [loadPage1]);
// Load departments once
useEffect(() => {
(async () => {
try {
const all: Department[] = [];
let pg = 1;
while (true) {
const res = await apiClient.getDepartments({ page: pg, limit: 200 });
const chunk = Array.isArray(res) ? res : (res.results || []);
all.push(...chunk);
if (Array.isArray(res) || !res.next) break;
pg++;
}
setDepartments(all);
} catch { /* silent */ }
})();
}, []);
/* ══════════ load more ══════════ */
const handleLoadMore = async () => {
if (!nextPage || loadingMore) return;
try {
setLoadingMore(true);
let res: any;
if (scope === "pending_approvals") {
res = await apiClient.getPendingApprovals(buildParams(nextPage));
} else {
res = await apiClient.getProcurementRequests(buildParams(nextPage));
}
const chunk = Array.isArray(res) ? res : (res.results || []);
setRequests((prev) => {
const known = new Set(prev.map((r) => r.id));
return [...prev, ...chunk.filter((r: ProcurementRequest) => !known.has(r.id))];
});
setNextPage(extractNextPage(res.next));
} catch {
setError("Не удалось загрузить ещё");
} finally {
setLoadingMore(false);
}
};
/* ══════════ expand detail ══════════ */
const toggleExpand = async (id: number) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) { next.delete(id); } else { next.add(id); }
return next;
});
if (!detailsCache[id]) {
try {
const detail = await apiClient.getProcurementRequest(id);
setDetailsCache((p) => ({ ...p, [id]: detail }));
} catch { /* silent */ }
}
};
/* ══════════ client-side filter ══════════ */
const filteredRequests = useMemo(() => {
// already filtered server-side, but search may be local too
return [...requests].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
}, [requests]);
/* ══════════ form ══════════ */
const resetForm = () => setForm({ ...emptyForm, items: [{ ...emptyItem }] });
const openCreate = () => {
setEditingId(null);
resetForm();
setActionError(null);
setActionSuccess(null);
setCreateOpen(true);
};
const openEdit = (req: ProcurementRequest) => {
setCreateOpen(false);
setEditingId(req.id);
setActionError(null);
setActionSuccess(null);
const detail = detailsCache[req.id] || req;
setForm({
title: detail.title || "",
description: detail.description || "",
department: detail.department ?? null,
urgency: detail.urgency || "medium",
items: (detail.items && detail.items.length > 0)
? detail.items.map((it) => ({
name: it.name || "",
description: it.description || "",
quantity: String(it.quantity || "1"),
unit: it.unit || "шт",
estimated_unit_price: String(it.estimated_unit_price || ""),
supplier_info: it.supplier_info || "",
}))
: [{ ...emptyItem }],
});
};
const closeModal = () => {
setCreateOpen(false);
setEditingId(null);
resetForm();
setActionError(null);
};
const modalMode: "create" | "edit" = editingId ? "edit" : "create";
const isModalOpen = createOpen || editingId !== null;
/* ── save ── */
const handleSave = async () => {
try {
setBusyKey("save");
setActionError(null);
if (!form.title.trim()) { setActionError("Укажите название заявки."); return; }
if (!form.description.trim()) { setActionError("Укажите описание и обоснование."); return; }
if (!form.department) { setActionError("Выберите отдел."); return; }
// Filter out empty item rows
const validItems = form.items.filter((it) => it.name.trim());
if (validItems.length === 0) { setActionError("Добавьте хотя бы одну позицию."); return; }
for (const it of validItems) {
if (!it.quantity || Number(it.quantity) <= 0) { setActionError(`Позиция «${it.name}»: укажите количество.`); return; }
if (!it.estimated_unit_price || Number(it.estimated_unit_price) <= 0) { setActionError(`Позиция «${it.name}»: укажите цену за единицу.`); return; }
}
const payload: any = {
title: form.title,
description: form.description,
department: form.department,
urgency: form.urgency,
items: validItems.map((it) => ({
name: it.name,
description: it.description || undefined,
quantity: it.quantity,
unit: it.unit || "шт",
estimated_unit_price: it.estimated_unit_price,
supplier_info: it.supplier_info || undefined,
})),
};
if (modalMode === "create") {
await apiClient.createProcurementRequest(payload);
setActionSuccess("Заявка создана (черновик).");
setCreateOpen(false);
} else if (editingId) {
await apiClient.updateProcurementRequest(editingId, {
title: payload.title,
description: payload.description,
urgency: payload.urgency,
});
setActionSuccess("Заявка обновлена.");
setEditingId(null);
}
resetForm();
await loadPage1();
} catch (e: any) {
const raw = String(e?.message || "Ошибка сохранения");
let readable = raw;
try {
const parsed = JSON.parse(raw);
readable = Object.entries(parsed).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`).join(". ");
} catch { /* keep raw */ }
setActionError(readable);
} finally {
setBusyKey(null);
}
};
/* ══════════ workflow actions ══════════ */
const refreshOne = async (id: number) => {
try {
const updated = await apiClient.getProcurementRequest(id);
setRequests((prev) => prev.map((r) => (r.id === id ? updated : r)));
setDetailsCache((p) => ({ ...p, [id]: updated }));
} catch { /* reload all */ await loadPage1(); }
};
const doAction = async (key: string, fn: () => Promise<any>, id: number, successMsg: string) => {
try {
setBusyKey(key);
setActionError(null);
await fn();
setActionSuccess(successMsg);
await refreshOne(id);
} catch (e: any) {
const raw = String(e?.message || "Ошибка");
let readable = raw;
try { const p = JSON.parse(raw); readable = typeof p === "object" ? Object.entries(p).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`).join(". ") : raw; } catch {}
setActionError(readable);
} finally {
setBusyKey(null);
}
};
const handleSubmit = (id: number) => doAction(`submit-${id}`, () => apiClient.submitProcurementRequest(id), id, "Заявка отправлена на согласование.");
const handleApprove = (id: number) => doAction(`approve-${id}`, () => apiClient.approveProcurementRequest(id), id, "Заявка одобрена.");
const handleReject = (id: number) => doAction(`reject-${id}`, () => apiClient.rejectProcurementRequest(id), id, "Заявка отклонена.");
const handleStart = (id: number) => doAction(`start-${id}`, () => apiClient.startWorkProcurementRequest(id), id, "Вы взяли заявку в работу.");
const handleComplete= (id: number) => doAction(`complete-${id}`,() => apiClient.completeProcurementRequest(id), id, "Заявка завершена.");
const handleCancel = (id: number) => doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id), id, "Заявка отменена.");
const handleDelete = async (id: number) => {
if (!confirm("Удалить эту заявку? Доступно только для черновиков.")) return;
try {
setBusyKey(`delete-${id}`);
await apiClient.deleteProcurementRequest(id);
setRequests((p) => p.filter((r) => r.id !== id));
} catch (e: any) {
setActionError(String(e?.message || "Не удалось удалить"));
} finally {
setBusyKey(null);
}
};
/* ══════════ form helpers ══════════ */
const addItemRow = () => setForm((f) => ({ ...f, items: [...f.items, { ...emptyItem }] }));
const removeItemRow = (idx: number) => setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
const updateItemRow = (idx: number, patch: Partial<ItemDraft>) =>
setForm((f) => ({ ...f, items: f.items.map((it, i) => (i === idx ? { ...it, ...patch } : it)) }));
/* ══════════ SearchableSelect ══════════ */
const SearchableSelectSingle = ({ label, items: selectItems, selectedId, onSelect, placeholder }: {
label: string;
items: { id: number; name: string }[];
selectedId: number | null;
onSelect: (id: number | null) => void;
placeholder?: string;
}) => {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const filtered = selectItems.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
const selectedName = selectItems.find((i) => i.id === selectedId)?.name;
return (
<div ref={ref} className="relative">
<label className="mb-1 block text-xs font-medium text-gray-500">{label}</label>
<button type="button" onClick={() => setOpen((v) => !v)} className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-sm text-gray-800">
<span className="truncate">{selectedName || <span className="text-gray-400">{placeholder || "Выбрать..."}</span>}</span>
<ChevronDown size={14} className={`ml-2 shrink-0 text-gray-400 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="border-b border-gray-100 p-2">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Поиск..." className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none" autoFocus />
</div>
<div className="max-h-40 overflow-y-auto p-1">
{selectedId && (
<button type="button" onClick={() => { onSelect(null); setOpen(false); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-400 hover:bg-gray-50">Сбросить</button>
)}
{filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-gray-400">Ничего не найдено</p>
) : filtered.map((item) => (
<button key={item.id} type="button" onClick={() => { onSelect(item.id); setOpen(false); }} className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 ${selectedId === item.id ? "bg-sky-50 text-sky-700 font-medium" : ""}`}>
{item.name}
</button>
))}
</div>
</div>
)}
</div>
);
};
const activeFilterCount = [statusFilter, urgencyFilter, departmentFilter].filter(Boolean).length;
const isFinal = (s?: string) => ["completed", "rejected", "cancelled"].includes(String(s || "").toLowerCase());
/* ══════════════════════════════════════════════════════
RENDER
══════════════════════════════════════════════════════ */
return (
<AppShell>
{loading ? (
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
<Loader2 size={28} className="mx-auto mb-3 animate-spin text-sky-500" />
<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">
{/* ── header ── */}
<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={openCreate} 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>
{/* ── alerts ── */}
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
{actionSuccess && <p className="mb-3 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{actionSuccess}</p>}
{/* ── scope tabs ── */}
<div className="mb-4 flex gap-1 rounded-lg bg-gray-100 p-1">
{scopeTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setScope(tab.value)}
className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition ${scope === tab.value ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}
>
{tab.label}
</button>
))}
</div>
{/* ── search + filter ── */}
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") loadPage1(); }}
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>
<button
type="button"
title="Фильтры"
onClick={() => setFiltersOpen((v) => !v)}
className={`relative inline-flex items-center justify-center rounded-lg border p-2.5 transition ${filtersOpen ? "border-sky-400 bg-sky-50 text-sky-600" : "border-gray-200 bg-gray-50 text-gray-500 hover:bg-gray-100"}`}
>
<Filter size={16} />
{activeFilterCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">{activeFilterCount}</span>
)}
</button>
</div>
{/* ── filters panel ── */}
{filtersOpen && (
<div className="mb-3 flex flex-col gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3">
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800">
<option value="">Все статусы</option>
{Object.entries(statusMeta).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select value={urgencyFilter} onChange={(e) => setUrgencyFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800">
<option value="">Все уровни срочности</option>
{Object.entries(urgencyMeta).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select value={departmentFilter} onChange={(e) => setDepartmentFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800">
<option value="">Все отделы</option>
{departments.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
{activeFilterCount > 0 && (
<button type="button" onClick={() => { setStatusFilter(""); setUrgencyFilter(""); setDepartmentFilter(""); }} className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 transition">
Очистить фильтры
</button>
)}
</div>
)}
{/* ══════════ Request cards ══════════ */}
<div className="space-y-3">
{filteredRequests.length === 0 ? (
<div className="rounded-xl bg-gray-50 p-8 text-center">
<ShoppingCart size={22} className="mx-auto mb-2 text-gray-400" />
<p className="text-sm text-gray-500">Заявок на закупку не найдено</p>
</div>
) : filteredRequests.map((req) => {
const st = String(req.status || "").toLowerCase();
const sMeta = statusMeta[st] ?? defaultStatusMeta;
const urg = urgencyMeta[req.urgency] ?? urgencyMeta.medium;
const isAuthor = Boolean(req.requestor?.id && user?.id && req.requestor.id === user.id);
const isExecutor = Boolean(req.executor?.id && user?.id && req.executor.id === user.id);
const isDraft = st === "draft";
const isPending = st === "pending";
const isApproved = st === "approved";
const isInProgress = st === "in_progress";
const expanded = expandedIds.has(req.id);
const detail = detailsCache[req.id];
return (
<article key={req.id} className="rounded-xl border border-gray-100 bg-white transition hover:shadow-sm">
{/* ── card head ── */}
<button type="button" onClick={() => toggleExpand(req.id)} className="flex w-full items-start gap-3 p-4 text-left">
<div className="flex-1 min-w-0">
{/* Title + status */}
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate text-sm font-semibold text-gray-900">{req.title || "Без названия"}</p>
<span className={`inline-flex shrink-0 items-center rounded-full px-2.5 py-1 text-xs ring-1 ${sMeta.cls}`}>{sMeta.label}</span>
</div>
{/* Meta line */}
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
<span>{getDeptName(req)}</span>
<span className="text-gray-300">·</span>
<span className={urg.cls}>{urg.label} срочность</span>
<span className="text-gray-300">·</span>
<span>{fmt(req.created_at)}</span>
{req.total_estimated_cost && (
<>
<span className="text-gray-300">·</span>
<span className="font-medium text-gray-700">{money(req.total_estimated_cost)}</span>
</>
)}
</div>
{/* Requestor */}
<div className="mt-2 flex items-center gap-1.5 text-xs text-gray-500">
<span>Заявитель:</span>
{(() => {
const link = userLink(req.requestor);
const name = displayUserName(req.requestor);
return link
? <Link href={link} onClick={(e) => e.stopPropagation()} className="font-medium text-sky-700 hover:underline">{name}</Link>
: <span className="font-medium text-gray-700">{name}</span>;
})()}
{req.executor && (
<>
<span className="ml-2">Исполнитель:</span>
{(() => {
const link = userLink(req.executor);
const name = displayUserName(req.executor);
return link
? <Link href={link} onClick={(e) => e.stopPropagation()} className="font-medium text-sky-700 hover:underline">{name}</Link>
: <span className="font-medium text-gray-700">{name}</span>;
})()}
</>
)}
</div>
</div>
<ChevronDown size={16} className={`mt-1 shrink-0 text-gray-400 transition ${expanded ? "rotate-180" : ""}`} />
</button>
{/* ── expanded detail ── */}
{expanded && (
<div className="border-t border-gray-100 px-4 pb-4 pt-3">
{/* Description */}
<p className="mb-3 text-sm text-gray-700 whitespace-pre-line">{(detail || req).description || "—"}</p>
{/* Items table */}
{detail?.items && detail.items.length > 0 && (
<div className="mb-3">
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">Позиции</p>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full text-xs">
<thead className="bg-gray-50 text-gray-500">
<tr>
<th className="px-3 py-2 text-left font-medium">Название</th>
<th className="px-3 py-2 text-right font-medium">Кол-во</th>
<th className="px-3 py-2 text-left font-medium">Ед.</th>
<th className="px-3 py-2 text-right font-medium">Цена/ед.</th>
<th className="px-3 py-2 text-right font-medium">Итого</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{detail.items.map((it, idx) => (
<tr key={idx} className="text-gray-700">
<td className="px-3 py-2">
<p className="font-medium">{it.name}</p>
{it.description && <p className="text-gray-500 mt-0.5">{it.description}</p>}
{it.supplier_info && <p className="text-gray-400 mt-0.5">Поставщик: {it.supplier_info}</p>}
</td>
<td className="px-3 py-2 text-right">{it.quantity}</td>
<td className="px-3 py-2">{it.unit}</td>
<td className="px-3 py-2 text-right">{money(it.estimated_unit_price)}</td>
<td className="px-3 py-2 text-right font-medium">{money(it.total_price)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Approvals */}
{detail?.approvals && detail.approvals.length > 0 && (
<div className="mb-3">
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">Согласования</p>
<div className="flex flex-col gap-1.5">
{detail.approvals.map((a) => {
const aSt = String(a.status).toLowerCase();
const icon = aSt === "approved" ? <Check size={13} className="text-emerald-500" /> : aSt === "rejected" ? <X size={13} className="text-rose-500" /> : <CircleDot size={13} className="text-amber-500" />;
return (
<div key={a.id} className="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-2 text-xs">
{icon}
<span className="font-medium text-gray-700">{displayUserName(a.approver)}</span>
<span className="text-gray-400">({a.role === "department_head" ? "Руководитель" : a.role === "finance_manager" ? "Финансист" : "Директор"})</span>
{a.comment && <span className="ml-auto italic text-gray-500">«{a.comment}»</span>}
</div>
);
})}
</div>
</div>
)}
{/* Workflow action buttons */}
<div className="flex flex-wrap items-center gap-1.5">
{/* Draft → Submit */}
{isDraft && isAuthor && (
<button type="button" onClick={() => handleSubmit(req.id)} disabled={busyKey === `submit-${req.id}`}
className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-600 disabled:opacity-60">
<Send size={13} /> На согласование
</button>
)}
{/* Draft → Edit */}
{isDraft && isAuthor && (
<button type="button" onClick={() => openEdit(req)} 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">
<Pencil size={13} /> Редактировать
</button>
)}
{/* Draft → Delete */}
{isDraft && (isAuthor || canManage) && (
<button type="button" onClick={() => handleDelete(req.id)} disabled={busyKey === `delete-${req.id}`}
className="inline-flex items-center gap-1 rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs font-medium text-rose-600 hover:bg-rose-100 disabled:opacity-60">
<Trash2 size={13} /> Удалить
</button>
)}
{/* Pending → Approve / Reject (for approvers; simplified) */}
{isPending && !isAuthor && (
<>
<button type="button" onClick={() => handleApprove(req.id)} disabled={busyKey === `approve-${req.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">
<ThumbsUp size={13} /> Одобрить
</button>
<button type="button" onClick={() => handleReject(req.id)} disabled={busyKey === `reject-${req.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">
<ThumbsDown size={13} /> Отклонить
</button>
</>
)}
{/* Approved → Start work */}
{isApproved && !req.executor && (
<button type="button" onClick={() => handleStart(req.id)} disabled={busyKey === `start-${req.id}`}
className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-600 disabled:opacity-60">
<Play size={13} /> Взять в работу
</button>
)}
{/* In progress → Complete */}
{isInProgress && isExecutor && (
<button type="button" onClick={() => handleComplete(req.id)} disabled={busyKey === `complete-${req.id}`}
className="inline-flex items-center gap-1 rounded-lg bg-teal-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-teal-600 disabled:opacity-60">
<ClipboardCheck size={13} /> Завершить
</button>
)}
{/* Cancel (author, not final) */}
{isAuthor && !isFinal(st) && st !== "draft" && (
<button type="button" onClick={() => handleCancel(req.id)} disabled={busyKey === `cancel-${req.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-600 hover:bg-gray-50 disabled:opacity-60">
<XCircle size={13} /> Отменить
</button>
)}
</div>
</div>
)}
</article>
);
})}
</div>
{/* ── load more ── */}
{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>
)}
</section>
)}
{/* ══════════ Create / Edit modal ══════════ */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}>
<div className="relative max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl bg-white p-5 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-900">{modalMode === "create" ? "Новая заявка на закупку" : "Редактировать заявку"}</h2>
<button type="button" onClick={closeModal} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button>
</div>
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
<div className="flex flex-col gap-3">
{/* Название */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Название заявки *</label>
<input
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
placeholder="Закупка офисной техники..."
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
{/* Описание */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Описание и обоснование *</label>
<textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Обоснуйте необходимость закупки..."
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
{/* Отдел + Срочность */}
<div className="grid grid-cols-2 gap-3">
<SearchableSelectSingle
label="Отдел *"
placeholder="Выберите отдел..."
items={departments.map((d) => ({ id: d.id, name: d.name }))}
selectedId={form.department}
onSelect={(id) => setForm((f) => ({ ...f, department: id }))}
/>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Срочность</label>
<select
value={form.urgency}
onChange={(e) => setForm((f) => ({ ...f, urgency: e.target.value as UrgencyLevel }))}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
>
<option value="low">Низкая</option>
<option value="medium">Средняя</option>
<option value="high">Высокая</option>
<option value="critical">Критическая</option>
</select>
</div>
</div>
{/* ── Items ── */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-xs font-medium text-gray-500">Позиции *</label>
<button type="button" onClick={addItemRow} className="inline-flex items-center gap-1 text-xs font-medium text-sky-600 hover:text-sky-700">
<Plus size={13} /> Добавить
</button>
</div>
<div className="space-y-3">
{form.items.map((it, idx) => (
<div key={idx} className="relative rounded-xl border border-gray-200 bg-gray-50 p-3">
{form.items.length > 1 && (
<button type="button" onClick={() => removeItemRow(idx)} className="absolute right-2 top-2 rounded p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-600">
<X size={14} />
</button>
)}
<div className="grid gap-2">
<input
value={it.name}
onChange={(e) => updateItemRow(idx, { name: e.target.value })}
placeholder="Название позиции *"
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none"
/>
<input
value={it.description}
onChange={(e) => updateItemRow(idx, { description: e.target.value })}
placeholder="Описание (необязательно)"
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none"
/>
<div className="grid grid-cols-4 gap-2">
<input
type="number"
value={it.quantity}
onChange={(e) => updateItemRow(idx, { quantity: e.target.value })}
placeholder="Кол-во"
min={1}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none"
/>
<input
value={it.unit}
onChange={(e) => updateItemRow(idx, { unit: e.target.value })}
placeholder="Ед."
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none"
/>
<input
type="number"
step="0.01"
value={it.estimated_unit_price}
onChange={(e) => updateItemRow(idx, { estimated_unit_price: e.target.value })}
placeholder="Цена/ед."
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none"
/>
<div className="flex items-center text-xs text-gray-500">
{it.quantity && it.estimated_unit_price ? money(Number(it.quantity) * Number(it.estimated_unit_price)) : "—"}
</div>
</div>
<input
value={it.supplier_info}
onChange={(e) => updateItemRow(idx, { supplier_info: e.target.value })}
placeholder="Информация о поставщике (необязательно)"
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none"
/>
</div>
</div>
))}
</div>
</div>
</div>
{/* ── modal buttons ── */}
<div className="mt-5 flex flex-wrap items-center justify-end gap-2 border-t border-gray-100 pt-4">
<button type="button" onClick={closeModal} 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={handleSave} disabled={busyKey === "save"} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">
{modalMode === "create" ? "Создать черновик" : "Сохранить"}
</button>
</div>
</div>
</div>
)}
</AppShell>
);
}

View File

@ -6,21 +6,9 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "../../components/AppShell";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
import { Camera, Pencil, Save, X } from "lucide-react";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "https://corp.robotail.pro";
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 `https:${raw}`;
const base = BACKEND_URL.replace(/\/$/, "");
if (raw.startsWith("/")) return `${base}${raw}`;
return `${base}/${raw.replace(/^\/+/, "")}`;
}
function formatDate(value?: string): string {
if (!value) return "";
const d = new Date(value);

File diff suppressed because it is too large Load Diff

View File

@ -3,30 +3,11 @@
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 { resolveMediaUrl } from "@/lib/url";
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);
@ -73,7 +54,7 @@ export default function UserDetailPage() {
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 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>
@ -91,13 +72,10 @@ export default function UserDetailPage() {
<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
<img
src={avatarUrl}
alt={fullName}
width={64}
height={64}
className="h-full w-full object-cover"
unoptimized
onError={() => setAvatarFailed(true)}
/>
) : (
@ -110,8 +88,8 @@ export default function UserDetailPage() {
<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.telegram ? <p className="mt-1 text-sm text-gray-600">Telegram: {person.telegram}</p> : null}
{person.whatsapp ? <p className="mt-1 text-sm text-gray-600">WhatsApp: {person.whatsapp}</p> : null}
{person.wechat ? <p className="mt-1 text-sm text-gray-600">WeChat: {person.wechat}</p> : null}
</div>
</div>

View File

@ -1,14 +1,14 @@
"use client";
import { Bell, Building2, CalendarDays, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Search, Users, Wallet, X, Sparkles } from "lucide-react";
import { Bell, Building2, CalendarDays, ChevronDown, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users, Wallet, X, Sparkles } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, useEffect, useState, useCallback } from "react";
import { ReactNode, useEffect, useRef, useState, useCallback } from "react";
import { apiClient } from "@/lib/api";
import { useUser } from "@/contexts/UserContext";
import { CalendarModal } from "@/components/CalendarModal";
import CalendarParticipantsModal from "@/components/CalendarParticipantsModal";
import { NotificationCenter } from "@/components/NotificationCenter";
import { NotificationCenter, NotificationPanel } from "@/components/NotificationCenter";
import { EventModal } from "@/components/EventModal";
import { ViewDayEventsModal } from "@/components/ViewDayEventsModal";
import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal";
@ -39,11 +39,11 @@ type LeftNavContentProps = {
const navItems = [
{ href: "/", label: "Лента", icon: HomeIcon },
{ href: "/messages", label: "Сообщения", icon: MessageSquare },
{ href: "/messages-chatscope", label: "Мессенджер (NEW)", icon: Sparkles },
{ href: "/calendar", label: "Календарь", icon: CalendarDays },
{ href: "/employees", label: "Сотрудники", icon: Users },
{ href: "/departments", label: "Отделы", icon: Building2 },
{ href: "/requests", label: "Заявления", icon: FileSignature },
{ href: "/equipment", label: "Оборудование", icon: Monitor },
{ href: "/procurement", label: "Закупки", icon: ShoppingCart },
{ href: "/documents", label: "Документы", icon: FileText },
{ href: "/finances", label: "Финансы", icon: Wallet },
];
@ -53,6 +53,9 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
const { user, logout } = useUser();
const [searchQuery, setSearchQuery] = useState("");
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
// Закрытие меню при клике вне
useEffect(() => {
@ -87,20 +90,42 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
return (
<header className="sticky top-0 z-[40] border-b border-slate-100 bg-white/90 backdrop-blur">
<div className="mx-auto max-w-6xl px-4 sm:px-8">
<div className="flex h-14 items-center justify-between gap-3">
<div className="flex h-8 lg:h-14 items-center justify-between gap-3">
<button
type="button"
onClick={onOpenLeftNav}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100 lg:hidden"
aria-label="Открыть левое меню"
>
<Menu size={20} className="text-gray-700" />
<Menu size={20} className="text-gray-700"/>
</button>
<button
type="button"
onClick={() => {
setIsSearchOpen((v) => {
if (!v) setTimeout(() => searchInputRef.current?.focus(), 50);
return !v;
});
setIsNotificationsOpen(false);
}}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100 lg:hidden"
aria-label="Поиск"
>
<Search size={20} className="text-gray-700" />
</button>
<Link href="/" className="flex items-center justify-center">
<img src="/logo.png" alt="Логотип" className="h-10 w-auto lg:h-11" />
<img src="/logo.png" alt="Логотип" className="mt-3 lg:mt-0 h-10 w-auto lg:h-11 bg-white lg:bg-transparent rounded pb-0.5 lg:pb-0" />
</Link>
<div className="lg:hidden">
<NotificationCenter variant="mobile" isOpen={isNotificationsOpen} onToggle={() => {
setIsNotificationsOpen((v) => !v);
setIsSearchOpen(false);
}} />
</div>
<button
type="button"
onClick={onOpenCalendar}
@ -168,64 +193,46 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
</div>
</div>
<div className="pb-3 lg:hidden">
<div className="flex items-center gap-2">
<NotificationCenter variant="mobile" />
<div className="relative flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitSearch();
}
}}
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-10 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="Поиск"
/>
</div>
<div className="relative h-10 w-10" id="user-menu-root-mobile">
<div
className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white hover:bg-sky-500 cursor-pointer"
title={userName}
onClick={() => setUserMenuOpen((v) => !v)}
>
{user?.avatar ? (
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
)}
</div>
{user?.is_active ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
{/* Меню пользователя */}
{userMenuOpen && (
<div className="absolute right-0 top-12 z-[60] w-48 rounded-xl bg-white py-2 shadow-lg ring-1 ring-slate-100 animate-fade-in">
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-sky-50"
onClick={() => { setUserMenuOpen(false); router.push('/profile'); }}
>Мой профиль</button>
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-sky-50"
onClick={() => { setUserMenuOpen(false); router.push('/settings'); }}
>Настройки</button>
<div className="my-1 border-t border-slate-100" />
<button
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
onClick={() => { setUserMenuOpen(false); logout(); }}
>Выйти</button>
</div>
)}
</div>
{/* Мобильная строка поиска */}
<div
className={`overflow-hidden transition-all duration-300 lg:hidden ${
isSearchOpen ? "max-h-20 pb-3" : "max-h-0"
}`}
>
<div className="relative">
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitSearch();
setIsSearchOpen(false);
}
if (e.key === "Escape") {
setIsSearchOpen(false);
}
}}
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-11 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="Поиск по сайту..."
/>
</div>
</div>
{/* Мобильная панель уведомлений */}
<div
className={`overflow-hidden transition-all duration-300 lg:hidden ${
isNotificationsOpen ? "max-h-[70vh] pb-3" : "max-h-0"
}`}
>
{isNotificationsOpen && (
<NotificationPanel onClose={() => setIsNotificationsOpen(false)} />
)}
</div>
</div>
</header>
);
@ -282,13 +289,22 @@ export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }:
}
export function AppShell({ children }: AppShellProps) {
const { user, loading } = useUser();
const { user, loading, logout } = useUser();
const router = useRouter();
const pathname = usePathname();
const isMessageDialogPage = pathname.startsWith('/messages/') && pathname !== '/messages';
// Вычисляем данные пользователя один раз на уровне AppShell
const userInitials = user
? `${user.last_name?.[0] || ''}${user.first_name?.[0] || ''}`
: 'Г';
const userName = user
? `${user.last_name} ${user.first_name}`.trim()
: 'Гость';
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false);
const [isProfileExpanded, setIsProfileExpanded] = useState(false);
// Состояния модалов календаря
const [showCalendarModal, setShowCalendarModal] = useState(false);
@ -472,7 +488,7 @@ export function AppShell({ children }: AppShellProps) {
return (
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'} bg-gradient-to-b from-sky-50 via-white to-white text-gray-900 flex flex-col`}>
<Header onOpenLeftNav={() => setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
<div className="mx-auto flex w-full flex-1 min-h-0 max-w-6xl gap-6 px-4 py-6 sm:px-8 lg:py-8">
<div className="mx-auto flex w-full flex-1 min-h-0 max-w-6xl gap-6 px-4 py-4 sm:px-8 lg:py-8">
<LeftNav />
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${isMessageDialogPage ? 'overflow-visible' : ''}`}>{children}</main>
<CalendarSidebar
@ -508,6 +524,49 @@ export function AppShell({ children }: AppShellProps) {
<X size={20} className="text-gray-700" />
</button>
</div>
{/* Профиль пользователя */}
<div className="mb-6 rounded-xl bg-gradient-to-br from-sky-50 to-sky-100 ring-1 ring-sky-100 overflow-hidden">
<button
onClick={() => setIsProfileExpanded(!isProfileExpanded)}
className="w-full flex items-center gap-3 p-4 hover:bg-sky-200/50 transition-colors"
>
<div className="h-12 w-12 overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white flex items-center justify-center flex-shrink-0">
{user?.avatar ? (
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
)}
</div>
<div className="min-w-0 flex-1 text-left">
<p className="text-sm font-semibold text-gray-900">{userName}</p>
{user?.is_active && (
<p className="text-xs text-sky-600">Онлайн</p>
)}
</div>
<ChevronDown
size={20}
className={`text-gray-700 transition-transform flex-shrink-0 ${isProfileExpanded ? "rotate-180" : ""}`}
/>
</button>
{isProfileExpanded && (
<div className="border-t border-sky-200 p-2 space-y-2">
<button
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-white text-gray-700"
onClick={() => { setIsMobileLeftNavOpen(false); router.push('/profile'); }}
>Мой профиль</button>
<button
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-white text-gray-700"
onClick={() => { setIsMobileLeftNavOpen(false); router.push('/settings'); }}
>Настройки</button>
<button
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-red-50 text-red-600"
onClick={() => { setIsMobileLeftNavOpen(false); logout(); }}
>Выйти</button>
</div>
)}
</div>
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
</div>
</div>

View File

@ -9,31 +9,50 @@ import { ru } from 'date-fns/locale/ru';
interface NotificationCenterProps {
variant?: 'default' | 'mobile';
isOpen?: boolean;
onToggle?: () => void;
}
export function NotificationCenter({ variant = 'default' }: NotificationCenterProps) {
const [isOpen, setIsOpen] = useState(false);
export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen, onToggle }: NotificationCenterProps) {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const isControlled = externalIsOpen !== undefined;
const isOpen = isControlled ? externalIsOpen : internalIsOpen;
const [showSettings, setShowSettings] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { notifications: notificationsData, unreadCount, markAsRead, markAllAsRead, loading } = useNotifications();
const { isSupported, isSubscribed, subscribe, unsubscribe, permission, isLoading: pushLoading } = useWebPush();
// Обеспечиваем что notifications всегда массив
const notifications = Array.isArray(notificationsData) ? notificationsData : [];
const toggleOpen = () => {
if (onToggle) {
onToggle();
} else {
setInternalIsOpen((v) => !v);
}
};
const close = () => {
if (onToggle && isOpen) {
onToggle();
} else {
setInternalIsOpen(false);
}
setShowSettings(false);
};
// Закрытие при клике вне компонента
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setShowSettings(false);
close();
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
}, [isOpen]);
const handleNotificationClick = async (notification: any) => {
if (!notification.is_read) {
@ -58,13 +77,10 @@ export function NotificationCenter({ variant = 'default' }: NotificationCenterPr
<div className="relative" ref={dropdownRef}>
{/* Кнопка уведомлений */}
<button
onClick={() => {
setIsOpen(!isOpen);
setShowSettings(false);
}}
onClick={toggleOpen}
className={
variant === 'mobile'
? "relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-gray-50 hover:bg-slate-100"
? "relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full hover:bg-slate-100"
: "relative flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
}
aria-label="Уведомления"
@ -77,8 +93,8 @@ export function NotificationCenter({ variant = 'default' }: NotificationCenterPr
)}
</button>
{/* Dropdown */}
{isOpen && (
{/* Dropdown — только для desktop */}
{isOpen && variant === 'default' && (
<div className="absolute right-0 top-12 z-[60] w-80 sm:w-96 bg-white rounded-xl shadow-lg ring-1 ring-slate-100 max-h-[600px] flex flex-col animate-fade-in">
{/* Заголовок */}
<div className="flex items-center justify-between p-4 border-b border-slate-100">
@ -100,7 +116,7 @@ export function NotificationCenter({ variant = 'default' }: NotificationCenterPr
<Settings className="w-4 h-4 text-gray-500" />
</button>
<button
onClick={() => setIsOpen(false)}
onClick={() => close()}
className="p-1 hover:bg-slate-100 rounded"
aria-label="Закрыть"
>
@ -204,3 +220,117 @@ export function NotificationCenter({ variant = 'default' }: NotificationCenterPr
</div>
);
}
/** Встраиваемая панель уведомлений (для выдвижных блоков) */
export function NotificationPanel({ onClose }: { onClose?: () => void }) {
const { notifications: notificationsData, unreadCount, markAsRead, markAllAsRead, loading } = useNotifications();
const { isSupported, isSubscribed, subscribe, unsubscribe, permission, isLoading: pushLoading } = useWebPush();
const [showSettings, setShowSettings] = useState(false);
const notifications = Array.isArray(notificationsData) ? notificationsData : [];
const handleNotificationClick = async (notification: any) => {
if (!notification.is_read) {
await markAsRead(notification.id);
}
if (notification.action_url) {
window.location.href = notification.action_url;
}
};
const handleTogglePush = async () => {
if (isSubscribed) {
await unsubscribe();
} else {
await subscribe();
}
};
return (
<div className="flex flex-col max-h-[60vh] bg-white rounded-xl shadow-sm ring-1 ring-gray-100 overflow-hidden">
<div className="flex items-center justify-between p-3 border-b border-slate-100">
<h3 className="font-semibold text-sm">Уведомления</h3>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button onClick={markAllAsRead} className="text-xs text-sky-600 hover:text-sky-700 font-medium">
Прочитать все
</button>
)}
<button onClick={() => setShowSettings(!showSettings)} className="p-1 hover:bg-slate-100 rounded" aria-label="Настройки">
<Settings className="w-4 h-4 text-gray-500" />
</button>
{onClose && (
<button onClick={onClose} className="p-1 hover:bg-slate-100 rounded" aria-label="Закрыть">
<X className="w-4 h-4 text-gray-500" />
</button>
)}
</div>
</div>
{showSettings && (
<div className="p-3 bg-slate-50 border-b border-slate-100">
<h4 className="font-medium mb-2 text-xs">Push уведомления</h4>
{!isSupported ? (
<p className="text-xs text-gray-500">Не поддерживаются браузером</p>
) : permission === 'denied' ? (
<p className="text-xs text-red-500">Запрещены. Измените настройки браузера.</p>
) : (
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600">{isSubscribed ? 'Включены' : 'Отключены'}</span>
<button
onClick={handleTogglePush}
disabled={pushLoading}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
isSubscribed ? 'bg-red-100 text-red-700 hover:bg-red-200' : 'bg-sky-600 text-white hover:bg-sky-700'
} disabled:opacity-50`}
>
{pushLoading ? '...' : isSubscribed ? 'Отключить' : 'Включить'}
</button>
</div>
)}
</div>
)}
<div className="overflow-y-auto flex-1">
{loading ? (
<div className="p-6 text-center text-gray-500 text-sm">Загрузка...</div>
) : notifications.length === 0 ? (
<div className="p-6 text-center text-gray-500">
<Bell className="w-10 h-10 mx-auto mb-2 opacity-20 text-gray-400" />
<p className="text-sm">Нет уведомлений</p>
</div>
) : (
<ul>
{notifications.slice(0, 20).map((notification) => (
<li
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`p-3 border-b border-slate-100 hover:bg-slate-100 cursor-pointer transition-colors ${
!notification.is_read ? 'bg-sky-50/50' : ''
}`}
>
<div className="flex items-start gap-2">
{!notification.is_read && (
<div className="w-1.5 h-1.5 bg-sky-600 rounded-full mt-1.5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm mb-0.5 truncate text-gray-800">{notification.title}</h4>
<p className="text-xs text-gray-600 line-clamp-2 mb-1">{notification.short_message || notification.message}</p>
<p className="text-[10px] text-gray-400">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true, locale: ru })}
</p>
</div>
</div>
</li>
))}
</ul>
)}
</div>
{notifications.length > 0 && (
<div className="p-2 border-t border-slate-100 bg-slate-50 text-center">
<a href="/notifications" className="text-xs text-sky-600 hover:text-sky-700 font-medium">Показать все</a>
</div>
)}
</div>
);
}

View File

@ -303,30 +303,12 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
*/
scrollToEnd = (scrollBehavior: ScrollBehavior = this.props.scrollBehavior || 'smooth') => {
const container = this.containerRef.current;
const scrollPoint = this.scrollPointRef.current;
if (!container || !scrollPoint) return;
if (!container) return;
// Используем getBoundingClientRect для точного позиционирования
const parentRect = container.getBoundingClientRect();
const childRect = scrollPoint.getBoundingClientRect();
// Вычисляем offset относительно родителя
const scrollOffset = childRect.top + container.scrollTop - parentRect.top;
if (container.scrollBy) {
container.scrollBy({
top: scrollOffset,
behavior: scrollBehavior,
});
} else {
// Fallback для старых браузеров
container.scrollTop = scrollOffset;
}
// Принудительно ставим scrollTop на максимум — самый надёжный способ
container.scrollTop = container.scrollHeight;
this.lastClientHeight = container.clientHeight;
// Блокируем автоматический скролл Chrome на мобильных
this.noScroll = true;
};

View File

@ -45,6 +45,7 @@ export const CalendarCard = memo(function CalendarCard({
const [showCalendarMenu, setShowCalendarMenu] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [eventsViewMode, setEventsViewMode] = useState<"week" | "month">("week");
const fileInputRef = useRef<HTMLInputElement>(null);
// Загружаем события через хук
@ -218,6 +219,25 @@ export const CalendarCard = memo(function CalendarCard({
.slice(0, 12);
}, [events]);
// События в выбранном месяце (привязано к monthDate мини-календаря)
const monthEvents = useMemo(() => {
const monthStart = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
monthStart.setHours(0, 0, 0, 0);
const monthEnd = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 0);
monthEnd.setHours(23, 59, 59, 999);
return [...events]
.filter((ev) => ev.start)
.filter((ev) => {
const start = new Date(ev.start || "");
if (Number.isNaN(start.getTime())) return false;
return start >= monthStart && start <= monthEnd;
})
.sort((a, b) => new Date(a.start || "").getTime() - new Date(b.start || "").getTime())
.slice(0, 30);
}, [events, monthDate]);
return (
<>
{/* Селектор календаря */}
@ -411,55 +431,81 @@ export const CalendarCard = memo(function CalendarCard({
</div>
</div>
{/* События на этой неделе */}
{/* События — неделя / месяц */}
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">На этой неделе</p>
{loading ? (
<p className="text-xs text-gray-500">Загрузка...</p>
) : error ? (
<p className="text-xs text-red-600">{error}</p>
) : weekEvents.length === 0 ? (
<p className="text-xs text-gray-500">Событий нет</p>
) : (
<div className="space-y-2">
{weekEvents.map((event) => {
const start = event.start ? new Date(event.start) : null;
const dateLabel =
start && !Number.isNaN(start.getTime())
? start.toLocaleString("ru-RU", {
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "Без даты";
<div className="mb-3 flex items-center gap-1 rounded-lg bg-gray-100 p-0.5">
<button
type="button"
onClick={() => setEventsViewMode("week")}
className={`flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition ${
eventsViewMode === "week"
? "bg-white text-gray-800 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
Неделя
</button>
<button
type="button"
onClick={() => setEventsViewMode("month")}
className={`flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition ${
eventsViewMode === "month"
? "bg-white text-gray-800 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
Месяц
</button>
</div>
return (
<button
key={`${event.id}-${event.start || ""}`}
type="button"
onClick={() => handleEventClick(event)}
className="w-full rounded-lg bg-gray-50 px-2.5 py-2 text-left transition hover:bg-gray-100"
>
<div className="flex items-center gap-1">
<div
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: event.color_event || event.color || "#3498db" }}
/>
<p className="truncate text-xs font-medium text-gray-800">{event.title}</p>
{(event.is_recurring || event.rule) && (
<span className="text-[10px] text-sky-600 flex-shrink-0" title="Повторяющееся событие">
⟲
</span>
)}
</div>
<p className="mt-0.5 text-[11px] text-gray-500">{dateLabel}</p>
</button>
);
})}
</div>
)}
{(() => {
const displayEvents = eventsViewMode === "week" ? weekEvents : monthEvents;
if (loading) return <p className="text-xs text-gray-500">Загрузка...</p>;
if (error) return <p className="text-xs text-red-600">{error}</p>;
if (displayEvents.length === 0) return <p className="text-xs text-gray-500">Событий нет</p>;
return (
<div className="space-y-2">
{displayEvents.map((event) => {
const start = event.start ? new Date(event.start) : null;
const dateLabel =
start && !Number.isNaN(start.getTime())
? start.toLocaleString("ru-RU", {
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "Без даты";
return (
<button
key={`${eventsViewMode}-${event.id}-${event.start || ""}`}
type="button"
onClick={() => handleEventClick(event)}
className="w-full rounded-lg bg-gray-50 px-2.5 py-2 text-left transition hover:bg-gray-100"
>
<div className="flex items-center gap-1">
<div
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: event.color_event || event.color || "#3498db" }}
/>
<p className="truncate text-xs font-medium text-gray-800">{event.title}</p>
{(event.is_recurring || event.rule) && (
<span className="text-[10px] text-sky-600 flex-shrink-0" title="Повторяющееся событие">
⟲
</span>
)}
</div>
<p className="mt-0.5 text-[11px] text-gray-500">{dateLabel}</p>
</button>
);
})}
</div>
);
})()}
</div>
</>
);

View File

@ -2,6 +2,7 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef } from "react";
import { apiClient } from "@/lib/api";
import { useUser } from "@/contexts/UserContext";
type Calendar = {
id: number;
@ -22,6 +23,7 @@ type CalendarContextType = {
const CalendarContext = createContext<CalendarContextType | undefined>(undefined);
export function CalendarProvider({ children }: { children: ReactNode }) {
const { user } = useUser();
const [calendars, setCalendars] = useState<Calendar[]>([]);
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
@ -51,8 +53,13 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
}, []);
useEffect(() => {
loadCalendars();
}, []);
if (user) {
loadCalendars();
} else {
setCalendars([]);
initialLoadDone.current = false;
}
}, [user, loadCalendars]);
return (
<CalendarContext.Provider

View File

@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { getWebSocketUrl } from '@/lib/url';
interface WebSocketMessage {
type: string;
@ -51,8 +52,8 @@ export function useWebSocket({
isConnectingRef.current = true;
try {
// Простую логику построения URL
let wsUrl = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:9000/ws/';
// Используем функцию для построения WebSocket URL
let wsUrl = getWebSocketUrl();
// // Если нет протокола - добавляем
// if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {

View File

@ -310,14 +310,19 @@ class ApiClient {
async createRequest(data: any, saveAs?: 'draft' | 'submitted'): Promise<any> {
const formData = new FormData();
// Добавляем все поля из data
Object.keys(data).forEach((key) => {
if (key === 'attachments' && Array.isArray(data[key])) {
data[key].forEach((file: File) => {
formData.append('attachments', file);
});
} else if (key === 'attachment' && data[key] instanceof File) {
formData.append('attachment', data[key]);
} else if (Array.isArray(data[key])) {
data[key].forEach((val: any) => {
formData.append(key, String(val));
});
} else if (data[key] !== null && data[key] !== undefined) {
formData.append(key, data[key]);
formData.append(key, String(data[key]));
}
});
@ -338,7 +343,12 @@ class ApiClient {
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
let detail = `${response.status} ${response.statusText}`;
try {
const body = await response.json();
detail = JSON.stringify(body);
} catch {}
throw new Error(detail);
}
return response.json();
@ -352,8 +362,14 @@ class ApiClient {
data[key].forEach((file: File) => {
formData.append('attachments', file);
});
} else if (key === 'attachment' && data[key] instanceof File) {
formData.append('attachment', data[key]);
} else if (Array.isArray(data[key])) {
data[key].forEach((val: any) => {
formData.append(key, String(val));
});
} else if (data[key] !== null && data[key] !== undefined) {
formData.append(key, data[key]);
formData.append(key, String(data[key]));
}
});
@ -374,7 +390,12 @@ class ApiClient {
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
let detail = `${response.status} ${response.statusText}`;
try {
const body = await response.json();
detail = JSON.stringify(body);
} catch {}
throw new Error(detail);
}
return response.json();
@ -780,9 +801,30 @@ class ApiClient {
}
// Посты - CRUD операции
async createPost(data: { content: string; attachments?: File[] }): Promise<any> {
async createPost(data: {
type?: 'company' | 'department' | 'employee';
title?: string;
body?: string;
content?: string;
department?: number;
image?: File;
attachment?: File;
attachments?: File[];
}): Promise<any> {
const formData = new FormData();
formData.append('content', data.content);
// API требует title и body как отдельные поля
if (data.title) formData.append('title', data.title);
if (data.body) formData.append('body', data.body);
if (data.content) formData.append('content', data.content);
// Опциональные поля
if (data.type) formData.append('type', data.type);
if (data.department) formData.append('department', String(data.department));
if (data.image) formData.append('image', data.image);
if (data.attachment) formData.append('attachment', data.attachment);
// Вложения (attachments)
if (data.attachments) {
data.attachments.forEach((file) => {
formData.append('attachments', file);
@ -802,15 +844,38 @@ class ApiClient {
});
if (!response.ok) {
const errorText = await response.text();
console.error('API Error Response:', errorText);
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async updatePost(postId: number, data: { content: string; attachments?: File[] }): Promise<any> {
async updatePost(postId: number, data: {
type?: 'company' | 'department' | 'employee';
title?: string;
body?: string;
content?: string;
department?: number;
image?: File;
attachment?: File;
attachments?: File[];
}): Promise<any> {
const formData = new FormData();
formData.append('content', data.content);
// API требует title и body как отдельные поля
if (data.title) formData.append('title', data.title);
if (data.body) formData.append('body', data.body);
if (data.content) formData.append('content', data.content);
// Опциональные поля
if (data.type) formData.append('type', data.type);
if (data.department !== undefined) formData.append('department', String(data.department));
if (data.image) formData.append('image', data.image);
if (data.attachment) formData.append('attachment', data.attachment);
// Вложения (attachments)
if (data.attachments) {
data.attachments.forEach((file) => {
formData.append('attachments', file);
@ -830,6 +895,8 @@ class ApiClient {
});
if (!response.ok) {
const errorText = await response.text();
console.error('API Error Response:', errorText);
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
@ -869,7 +936,35 @@ class ApiClient {
return this.request(url);
}
async createComment(postId: number, text: string): Promise<any> {
async createComment(postId: number, text: string, image?: File, attachment?: File): Promise<any> {
if (image || attachment) {
const formData = new FormData();
formData.append('post', String(postId));
formData.append('text', text);
if (image) formData.append('image', image);
if (attachment) formData.append('attachment', attachment);
const token = this.getToken();
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch('/api/v1/comments/', {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
console.error('API Error Response:', errorText);
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
return this.request('/api/v1/comments/', {
method: 'POST',
body: JSON.stringify({ post: postId, text }),
@ -1053,6 +1148,299 @@ class ApiClient {
method: 'POST',
});
}
// ==================== Оборудование ====================
async getEquipmentCategories(params?: Record<string, string | number>): Promise<any> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, String(value));
}
});
}
const url = `/api/v1/procurement/equipment-categories/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async getEquipment(params?: Record<string, string | number>): Promise<any> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, String(value));
}
});
}
const url = `/api/v1/procurement/equipment/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async createEquipment(data: any): Promise<any> {
const formData = new FormData();
Object.keys(data).forEach((key) => {
if (key === 'attachment' && data[key] instanceof File) {
formData.append('attachment', data[key]);
} else if (Array.isArray(data[key])) {
data[key].forEach((val: any) => {
formData.append(key, String(val));
});
} else if (data[key] !== null && data[key] !== undefined) {
formData.append(key, String(data[key]));
}
});
const token = this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch('/api/v1/procurement/equipment/', {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
const body = await response.json();
detail = JSON.stringify(body);
} catch {}
throw new Error(detail);
}
return response.json();
}
async updateEquipment(equipmentId: number, data: any): Promise<any> {
const formData = new FormData();
Object.keys(data).forEach((key) => {
if (key === 'attachment' && data[key] instanceof File) {
formData.append('attachment', data[key]);
} else if (Array.isArray(data[key])) {
data[key].forEach((val: any) => {
formData.append(key, String(val));
});
} else if (data[key] !== null && data[key] !== undefined) {
formData.append(key, String(data[key]));
}
});
const token = this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`/api/v1/procurement/equipment/${equipmentId}/`, {
method: 'PATCH',
headers,
body: formData,
});
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
const body = await response.json();
detail = JSON.stringify(body);
} catch {}
throw new Error(detail);
}
return response.json();
}
async deleteEquipment(equipmentId: number): Promise<void> {
await this.request(`/api/v1/procurement/equipment/${equipmentId}/`, {
method: 'DELETE',
});
}
async approveEquipment(equipmentId: number): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/approve/`, {
method: 'POST',
});
}
async rejectEquipment(equipmentId: number): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/reject/`, {
method: 'POST',
});
}
async cancelEquipment(equipmentId: number): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/cancel/`, {
method: 'POST',
});
}
async getEquipmentComments(equipmentId: number): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/comments/`);
}
async addEquipmentComment(equipmentId: number, text: string): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/comments/`, {
method: 'POST',
body: JSON.stringify({ text }),
});
}
async deleteEquipmentComment(equipmentId: number, commentId: number): Promise<void> {
await this.request(`/api/v1/procurement/equipment/${equipmentId}/comments/${commentId}/`, {
method: 'DELETE',
});
}
// ==================== Закупки (Procurement Requests) ====================
async getProcurementRequests(params?: Record<string, string | number>): Promise<any> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, String(value));
}
});
}
const url = `/api/v1/procurement/requests/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async getProcurementRequest(id: number): Promise<any> {
return this.request(`/api/v1/procurement/requests/${id}/`);
}
async createProcurementRequest(data: any): Promise<any> {
return this.request('/api/v1/procurement/requests/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
async updateProcurementRequest(id: number, data: any): Promise<any> {
return this.request(`/api/v1/procurement/requests/${id}/`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
async deleteProcurementRequest(id: number): Promise<void> {
await this.request(`/api/v1/procurement/requests/${id}/`, {
method: 'DELETE',
});
}
async submitProcurementRequest(id: number): Promise<any> {
return this.request(`/api/v1/procurement/requests/${id}/submit/`, {
method: 'POST',
});
}
async approveProcurementRequest(id: number, comment?: string): Promise<any> {
return this.request(`/api/v1/procurement/requests/${id}/approve/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment: comment || '' }),
});
}
async rejectProcurementRequest(id: number, comment?: string): Promise<any> {
return this.request(`/api/v1/procurement/requests/${id}/reject/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment: comment || '' }),
});
}
async startWorkProcurementRequest(id: number): Promise<any> {
return this.request(`/api/v1/procurement/requests/${id}/start_work/`, {
method: 'POST',
});
}
async completeProcurementRequest(id: number): Promise<any> {
return this.request(`/api/v1/procurement/requests/${id}/complete/`, {
method: 'POST',
});
}
async cancelProcurementRequest(id: number, reason?: string): Promise<any> {
return this.request(`/api/v1/procurement/requests/${id}/cancel/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason || '' }),
});
}
async getMyProcurementRequests(params?: Record<string, string | number>): Promise<any> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, String(value));
}
});
}
const url = `/api/v1/procurement/requests/my_requests/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async getPendingApprovals(params?: Record<string, string | number>): Promise<any> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, String(value));
}
});
}
const url = `/api/v1/procurement/requests/pending_approvals/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
// ==================== Позиции закупок (Procurement Items) ====================
async getProcurementItems(params?: Record<string, string | number>): Promise<any> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, String(value));
}
});
}
const url = `/api/v1/procurement/items/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async createProcurementItem(data: any): Promise<any> {
return this.request('/api/v1/procurement/items/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
async updateProcurementItem(id: number, data: any): Promise<any> {
return this.request(`/api/v1/procurement/items/${id}/`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
async deleteProcurementItem(id: number): Promise<void> {
await this.request(`/api/v1/procurement/items/${id}/`, {
method: 'DELETE',
});
}
}
export const apiClient = new ApiClient();

53
frontend/src/lib/url.ts Normal file
View File

@ -0,0 +1,53 @@
/**
* Утилиты для построения URL с учетом переменных окружения
*/
export function getBackendUrl(): string {
if (typeof window === 'undefined') {
// На сервере используем публичный адрес
return process.env.NEXT_PUBLIC_BACKEND_URL || 'https://corp.robotail.pro';
}
// На клиенте используем публичный URL
return process.env.NEXT_PUBLIC_BACKEND_URL || 'https://corp.robotail.pro';
}
export function getWebSocketUrl(): string {
const backendUrl = getBackendUrl();
// Преобразуем http(s) в ws(s)
if (backendUrl.startsWith('https://')) {
return backendUrl.replace(/^https:\/\//, 'wss://') + '/ws/';
}
if (backendUrl.startsWith('http://')) {
return backendUrl.replace(/^http:\/\//, 'ws://') + '/ws/';
}
// На случай если URL без протокола
return `${backendUrl.startsWith('localhost') ? 'ws://' : 'wss://'}${backendUrl}/ws/`;
}
export function resolveMediaUrl(url?: string | null): string {
const raw = (url || '').trim();
if (!raw) return '';
// Если data-URI или полный URL, возвращаем как есть
if (/^data:/i.test(raw) || /^https?:\/\/|^wss?:\/\//i.test(raw)) {
return raw;
}
const backendBase = getBackendUrl().replace(/\/$/, '');
// Если путь начинается с /, добавляем к base URL
if (raw.startsWith('/')) {
return `${backendBase}${raw}`;
}
// Иначе добавляем слеш
return `${backendBase}/${raw}`;
}
export function buildApiUrl(endpoint: string): string {
const base = getBackendUrl().replace(/\/$/, '');
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `${base}${path}`;
}

View File

@ -84,6 +84,8 @@ export interface Post {
body?: string;
title?: string;
image?: string;
attachment?: string | null;
attachment_url?: string | null;
tags?: string[];
created_at: string;
updated_at: string;
@ -98,6 +100,8 @@ export interface Comment {
author: User;
content?: string;
text?: string;
image?: string | null;
attachment?: string | null;
created_at: string;
updated_at: string;
}
@ -156,6 +160,99 @@ export interface RequestComment {
created_at: string;
}
// Equipment types
export interface EquipmentCategory {
id: number;
name: string;
description?: string;
}
export interface Equipment {
id: number;
name: string;
description?: string;
category: number | EquipmentCategory;
category_name?: string;
department: number;
department_name?: string;
serial_number?: string;
inventory_number?: string;
status?: string;
condition?: string;
purchase_date: string;
purchase_cost: string | number;
assigned_to?: User | number | null;
assigned_to_details?: User;
location?: string;
notes?: string;
image?: string | null;
attachment?: string | null;
attachment_url?: string | null;
created_by?: User;
created_at: string;
updated_at: string;
}
export interface EquipmentComment {
id: number;
equipment: number;
author: User;
text: string;
created_at: string;
}
// Procurement Request types
export type ProcurementStatus = 'draft' | 'pending' | 'approved' | 'in_progress' | 'completed' | 'rejected' | 'cancelled';
export type UrgencyLevel = 'low' | 'medium' | 'high' | 'critical';
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
export type ApprovalRole = 'department_head' | 'finance_manager' | 'director';
export interface ProcurementItem {
id: number;
request: number;
name: string;
description?: string;
quantity: string | number;
unit: string;
estimated_unit_price: string | number;
total_price?: string | number;
supplier_info?: string;
equipment?: number | null;
}
export interface ProcurementApproval {
id: number;
request: number;
approver: User;
role: ApprovalRole;
status: ApprovalStatus;
comment?: string;
decided_at?: string | null;
created_at: string;
}
export interface ProcurementRequest {
id: number;
title: string;
description: string;
department: number;
department_name?: string;
department_details?: Department;
requestor: User;
executor?: User | null;
status: ProcurementStatus;
urgency: UrgencyLevel;
items?: ProcurementItem[];
approvals?: ProcurementApproval[];
required_approvals?: ApprovalRole[];
actual_cost?: string | number | null;
is_editable?: boolean;
budget_available?: boolean;
total_estimated_cost?: string | number;
created_at: string;
updated_at: string;
}
// Communications types
export interface Chat {
id: number;