+
This commit is contained in:
parent
d1891c16c6
commit
95a0ad8293
110
frontend/.github/copilot-instructions.md
vendored
110
frontend/.github/copilot-instructions.md
vendored
@ -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.
|
||||
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@ -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"
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
825
frontend/src/app/equipment/page.tsx
Normal file
825
frontend/src/app/equipment/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 */
|
||||
}
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
945
frontend/src/app/procurement/page.tsx
Normal file
945
frontend/src/app/procurement/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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://')) {
|
||||
|
||||
@ -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
53
frontend/src/lib/url.ts
Normal 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}`;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user