фикс отделов и сотрудников

This commit is contained in:
lizerk 2026-04-15 02:32:01 +03:00
parent 1296ec9021
commit d412d6634b
5 changed files with 224 additions and 26 deletions

View File

@ -1,17 +1,38 @@
"use client";
import { AppShell } from "../../components/AppShell";
import { Modal } from "@/components/ui/Modal";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import type { Department } from "@/types/api";
import { Building2, Search, Users } from "lucide-react";
import { Building2, Plus, Search, Users } from "lucide-react";
const scopeTabs = [
{ value: "all", label: "Все" },
{ value: "mine", label: "Мои" },
] as const;
export default function DepartmentsPage() {
const { user } = useUser();
const [departments, setDepartments] = useState<Department[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [scope, setScope] = useState<"all" | "mine">("all");
const [createOpen, setCreateOpen] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState({ name: "", description: "" });
const canCreateDepartment = Boolean(
user?.auth?.is_staff || user?.auth?.is_superuser,
);
const myDepartmentIds = useMemo(
() => new Set((user?.departments || []).map((department) => department.id)),
[user?.departments],
);
useEffect(() => {
async function loadDepartments() {
@ -33,7 +54,10 @@ export default function DepartmentsPage() {
const filteredDepartments = useMemo(() => {
const q = search.trim().toLowerCase();
const sorted = [...departments].sort((a, b) => a.name.localeCompare(b.name, "ru"));
const visibleDepartments = scope === "mine"
? departments.filter((department) => myDepartmentIds.has(department.id))
: departments;
const sorted = [...visibleDepartments].sort((a, b) => a.name.localeCompare(b.name, "ru"));
if (!q) return sorted;
return sorted.filter((dep) => {
@ -42,7 +66,56 @@ export default function DepartmentsPage() {
const head = dep.head ? `${dep.head.last_name} ${dep.head.first_name}`.toLowerCase() : "";
return name.includes(q) || desc.includes(q) || head.includes(q);
});
}, [departments, search]);
}, [departments, myDepartmentIds, scope, search]);
const scopeCounts = useMemo(
() => ({
all: departments.length,
mine: departments.filter((department) => myDepartmentIds.has(department.id)).length,
}),
[departments, myDepartmentIds],
);
async function handleCreateDepartment() {
const name = draft.name.trim();
const description = draft.description.trim();
if (!name) {
setCreateError("Укажите название отдела");
return;
}
try {
setCreating(true);
setCreateError(null);
const created = await apiClient.createDepartment({
name,
description: description || undefined,
}) as Department;
setDepartments((current) => [...current, created]);
setCreateOpen(false);
setDraft({ name: "", description: "" });
} catch (err) {
console.error("Ошибка создания отдела:", err);
setCreateError("Не удалось создать отдел");
} finally {
setCreating(false);
}
}
function openCreateDepartment() {
setCreateError(null);
setDraft({ name: "", description: "" });
setCreateOpen(true);
}
function closeCreateDepartment() {
if (creating) return;
setCreateOpen(false);
setCreateError(null);
}
return (
<AppShell>
@ -57,6 +130,19 @@ export default function DepartmentsPage() {
</div>
) : (
<section className="app-surface rounded-2xl p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Отделы</p>
{canCreateDepartment ? (
<button
type="button"
onClick={openCreateDepartment}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
>
<Plus size={14} /> Создать отдел
</button>
) : null}
</div>
<div className="relative mb-4">
<Search size={16} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input
@ -67,6 +153,28 @@ export default function DepartmentsPage() {
/>
</div>
<div className="mb-4 flex flex-wrap gap-2">
{scopeTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setScope(tab.value)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
scope === tab.value ? "app-pill-active" : "app-pill"
}`}
>
<span>{tab.label}</span>
<span
className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
scope === tab.value ? "app-pill-count-active" : "app-pill-count"
}`}
>
{scopeCounts[tab.value]}
</span>
</button>
))}
</div>
<div className="space-y-3">
{filteredDepartments.length === 0 ? (
<div className="app-surface-muted rounded-xl p-8 text-center">
@ -106,6 +214,71 @@ export default function DepartmentsPage() {
</div>
</section>
)}
<Modal
isOpen={createOpen}
onClose={closeCreateDepartment}
title="Создать отдел"
size="md"
footer={
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={closeCreateDepartment}
className="app-action-secondary rounded-lg px-4 py-2 text-sm"
>
Отмена
</button>
<button
type="button"
onClick={() => void handleCreateDepartment()}
disabled={creating}
className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
>
{creating ? "Создание..." : "Создать"}
</button>
</div>
}
>
<div className="space-y-4">
{createError ? (
<p className="app-feedback-danger rounded-lg px-3 py-2 text-sm">{createError}</p>
) : null}
<section className="app-surface-muted rounded-xl p-4">
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Название отдела *
</label>
<input
value={draft.name}
onChange={(event) =>
setDraft((current) => ({ ...current, name: event.target.value }))
}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
placeholder="Например, Отдел закупок"
disabled={creating}
/>
</section>
<section className="app-surface-muted rounded-xl p-4">
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Описание
</label>
<textarea
value={draft.description}
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value,
}))
}
className="app-input min-h-28 w-full rounded-lg px-3 py-2 text-sm"
placeholder="Кратко опишите назначение отдела"
disabled={creating}
/>
</section>
</div>
</Modal>
</AppShell>
);
}

View File

@ -38,6 +38,7 @@ import {
formatProfileDateTime,
formatPhoneForLink,
getEmployeeActionTone,
getProfileDepartmentSummary,
getWorkDuration,
normalizeTelegramLink,
normalizeWhatsAppLink,
@ -101,8 +102,8 @@ export default function UserDetailPage() {
}
}, [currentUser?.id, router, userId]);
const primaryDepartment = useMemo(
() => person?.departments?.[0] || null,
const departmentSummary = useMemo(
() => getProfileDepartmentSummary(person?.departments),
[person?.departments],
);
const [availableSkills, setAvailableSkills] = useState<
@ -449,10 +450,10 @@ export default function UserDetailPage() {
secondaryLine={formatBirthdayWithYear(person.birth_date) || "Не указана"}
roleText={person.position?.name || "Должность не указана"}
departmentBadge={
primaryDepartment ? (
departmentSummary ? (
<ProfileDepartmentBadge
label={primaryDepartment.name}
href={`/departments/${primaryDepartment.id}`}
label={departmentSummary.label}
href={departmentSummary.href}
/>
) : undefined
}

View File

@ -7,12 +7,14 @@ import { apiClient } from "@/lib/api";
import {
formatProfileDate,
formatProfileDateTime,
getProfileDepartmentSummary,
getLatestEmployeeAction,
getUserFullName,
getWorkDuration,
normalizeTelegramLink,
normalizeWhatsAppLink,
sortEmployeeActions,
type ProfileDepartmentSummary,
} from "@/lib/users/userDetailUtils";
import type { DirectoryLoginResult, EmployeeAction } from "@/types/api";
@ -47,11 +49,6 @@ export type ProfileInfoEntry = {
value: string;
};
export type ProfileDepartmentSummary = {
label: string;
href?: string;
};
export type ProfilePageController = {
availableSkills: SkillOption[];
contactEntries: ProfileContactEntry[];
@ -286,18 +283,7 @@ export function useProfilePage(): ProfilePageController {
);
const departmentSummary = useMemo<ProfileDepartmentSummary | null>(() => {
if (!user?.departments?.length) return null;
return {
label:
user.departments.length === 1
? user.departments[0].name
: `${user.departments.length} отделов`,
href:
user.departments.length === 1
? `/departments/${user.departments[0].id}`
: undefined,
};
return getProfileDepartmentSummary(user?.departments);
}, [user?.departments]);
const contactEntries = useMemo<ProfileContactEntry[]>(() => {

View File

@ -55,6 +55,11 @@ export function createEmployeesApi(request: RequestFn) {
return request(`/api/v1/departments/${qs ? '?' + qs : ''}`);
},
getDepartment: (id: number | string) => request(`/api/v1/departments/${id}/`),
createDepartment: (data: { name: string; description?: string }) =>
request('/api/v1/departments/', {
method: 'POST',
body: JSON.stringify(data),
}),
deleteDepartment: (id: number | string) =>
request(`/api/v1/departments/${id}/`, { method: 'DELETE' }),
updateDepartment: (id: number | string, data: { name?: string; description?: string }) =>

View File

@ -1,4 +1,9 @@
import type { EmployeeAction, User } from "@/types/api";
import type { EmployeeAction, EmployeeDepartment, User } from "@/types/api";
export type ProfileDepartmentSummary = {
label: string;
href?: string;
};
export type UserProfileTextField =
| "firstName"
@ -72,6 +77,34 @@ export function getUserInitials(person: User | null): string {
return `${person?.last_name?.[0] || ""}${person?.first_name?.[0] || ""}` || "П";
}
function pluralizeDepartments(count: number): string {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return "отдел";
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return "отдела";
}
return "отделов";
}
export function getProfileDepartmentSummary(
departments?: EmployeeDepartment[] | null,
): ProfileDepartmentSummary | null {
if (!departments?.length) return null;
if (departments.length === 1) {
return {
label: departments[0].name,
href: `/departments/${departments[0].id}`,
};
}
return {
label: `${departments.length} ${pluralizeDepartments(departments.length)}`,
};
}
export function getLatestEmployeeAction(actions?: EmployeeAction[] | null): EmployeeAction | null {
if (!actions || actions.length === 0) return null;