фикс отделов и сотрудников
This commit is contained in:
parent
1296ec9021
commit
d412d6634b
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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[]>(() => {
|
||||
|
||||
@ -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 }) =>
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user