feat(regulations): improve department creation and counters

This commit is contained in:
lizerk 2026-07-22 22:50:39 +03:00
parent b9829a613e
commit 69ec1905e1
4 changed files with 144 additions and 62 deletions

View File

@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import {
@ -51,6 +52,13 @@ import { displayUserName, formatDate, loadAllPages, userProfileLink } from "@/li
import type { DepartmentMemberLink, DepartmentPermissionChoice, DepartmentRole, Document, User } from "@/types/api";
import { toast } from "sonner";
const RegulationCreateForm = dynamic(
() => import("@/components/documents/DocumentUploadForm").then((mod) => ({
default: mod.RegulationCreateForm,
})),
{ ssr: false },
);
type DepartmentMemberModalMode = "add" | "assignRole";
type DepartmentRoleDraft = {
id: number | null;
@ -995,6 +1003,9 @@ export default function DepartmentDetailPage() {
const [membersExpanded, setMembersExpanded] = useState(false);
const [regulationsExpanded, setRegulationsExpanded] = useState(false);
const [departmentRegulations, setDepartmentRegulations] = useState<Document[]>([]);
const [departmentRegulationsLoading, setDepartmentRegulationsLoading] = useState(true);
const [regulationsRefreshKey, setRegulationsRefreshKey] = useState(0);
const [createRegulationOpen, setCreateRegulationOpen] = useState(false);
const [selectedRegulation, setSelectedRegulation] = useState<Document | null>(null);
const [roleMenuOpenForId, setRoleMenuOpenForId] = useState<number | null>(null);
const roleMenuRef = useRef<HTMLDivElement | null>(null);
@ -1023,9 +1034,9 @@ export default function DepartmentDetailPage() {
if (!Number.isFinite(departmentId)) return;
let cancelled = false;
setRegulationsExpanded(false);
const loadDepartmentRegulations = async () => {
setDepartmentRegulationsLoading(true);
try {
const regulations = await loadAllPages<Document>((pageParams) => (
apiClient.getDocuments({ ...pageParams, is_regulation: true })
@ -1046,6 +1057,8 @@ export default function DepartmentDetailPage() {
} catch (error) {
console.error("Не удалось загрузить регламенты отдела:", error);
if (!cancelled) setDepartmentRegulations([]);
} finally {
if (!cancelled) setDepartmentRegulationsLoading(false);
}
};
@ -1053,6 +1066,10 @@ export default function DepartmentDetailPage() {
return () => {
cancelled = true;
};
}, [departmentId, regulationsRefreshKey]);
useEffect(() => {
setRegulationsExpanded(false);
}, [departmentId]);
const refreshSelectedRegulation = async () => {
@ -1425,62 +1442,93 @@ export default function DepartmentDetailPage() {
</section>
</section>
{departmentRegulations.length > 0 ? (
<section className="app-surface rounded-2xl p-4 sm:p-5">
<div className={regulationsExpanded ? "mb-4 flex items-center justify-between gap-3" : "flex items-center justify-between gap-3"}>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
Регламенты отдела
</p>
<MetaChip tone="accent">{departmentRegulations.length}</MetaChip>
</div>
<p className="app-text-muted mt-1 text-sm">
Документы, требующие ознакомления сотрудников отдела.
<section className="app-surface rounded-2xl p-4 sm:p-5">
<div className={regulationsExpanded || !departmentRegulations.length ? "mb-4 flex items-center justify-between gap-3" : "flex items-center justify-between gap-3"}>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
Регламенты отдела
</p>
<MetaChip tone="accent">
{departmentRegulationsLoading ? "…" : departmentRegulations.length}
</MetaChip>
</div>
<p className="app-text-muted mt-1 text-sm">
Документы, требующие ознакомления сотрудников отдела.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => setRegulationsExpanded((current) => !current)}
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
aria-label={regulationsExpanded ? "Свернуть список регламентов" : "Развернуть список регламентов"}
aria-expanded={regulationsExpanded}
onClick={() => setCreateRegulationOpen(true)}
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium"
>
<ChevronDown
size={15}
className={`transition-transform duration-200 ${regulationsExpanded ? "rotate-180" : ""}`}
/>
<Plus size={14} />
<span className="hidden sm:inline">Создать регламент</span>
<span className="sm:hidden">Создать</span>
</button>
{departmentRegulations.length > 0 ? (
<button
type="button"
onClick={() => setRegulationsExpanded((current) => !current)}
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
aria-label={regulationsExpanded ? "Свернуть список регламентов" : "Развернуть список регламентов"}
aria-expanded={regulationsExpanded}
>
<ChevronDown
size={15}
className={`transition-transform duration-200 ${regulationsExpanded ? "rotate-180" : ""}`}
/>
</button>
) : null}
</div>
</div>
{regulationsExpanded ? (
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)]">
{departmentRegulations.map((document) => (
<button
key={document.id}
type="button"
onClick={() => setSelectedRegulation(document)}
className="flex w-full min-w-0 items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-3 text-left transition last:border-b-0 hover:bg-[var(--surface-secondary)]"
{departmentRegulationsLoading ? (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
<div className="mx-auto h-6 w-6 animate-spin rounded-full border-2 border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
<p className="app-text-muted mt-3 text-sm">Загружаем регламенты...</p>
</div>
) : departmentRegulations.length === 0 ? (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
<ScrollText size={24} className="app-text-muted mx-auto mb-3" />
<p className="text-sm font-medium text-[var(--foreground)]">
Регламентов пока нет
</p>
<p className="app-text-muted mt-2 text-sm">
Создайте первый регламент для сотрудников этого отдела.
</p>
</div>
) : regulationsExpanded ? (
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)]">
{departmentRegulations.map((document, index) => (
<button
key={document.id}
type="button"
onClick={() => setSelectedRegulation(document)}
className="flex w-full min-w-0 items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-3 text-left transition last:border-b-0 hover:bg-[var(--surface-secondary)]"
>
<span
className="app-selected app-accent-text flex h-9 min-w-9 shrink-0 items-center justify-center rounded-lg px-1 text-sm font-semibold tabular-nums"
aria-label={`Регламент ${index + 1} из ${departmentRegulations.length}`}
>
<span className="app-selected app-accent-text flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<ScrollText size={17} />
{index + 1}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
{document.title}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
{document.title}
</span>
<span className="app-text-muted mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<RegulationAcknowledgementStatus document={document} />
<span>{formatDate(document.uploaded_at || document.created_at)}</span>
</span>
<span className="app-text-muted mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<RegulationAcknowledgementStatus document={document} />
<span>{formatDate(document.uploaded_at || document.created_at)}</span>
</span>
<ChevronRight size={16} className="app-text-muted shrink-0" />
</button>
))}
</div>
) : null}
</section>
) : null}
</span>
<ChevronRight size={16} className="app-text-muted shrink-0" />
</button>
))}
</div>
) : null}
</section>
<DepartmentFeedSection
canCreatePosts={
@ -1574,6 +1622,23 @@ export default function DepartmentDetailPage() {
});
}}
/>
<Modal
isOpen={createRegulationOpen}
onClose={() => setCreateRegulationOpen(false)}
title="Создать регламент отдела"
size="xl"
>
<RegulationCreateForm
initialDepartmentId={h.department?.id ?? departmentId}
onCancel={() => setCreateRegulationOpen(false)}
onSuccess={() => {
setCreateRegulationOpen(false);
setRegulationsExpanded(true);
setRegulationsRefreshKey((current) => current + 1);
}}
/>
</Modal>
</AppShell>
);
}

View File

@ -1549,7 +1549,7 @@ function DocumentsPageContent() {
})}
{/* Document Cards */}
{filteredDocuments.map((doc) => {
{filteredDocuments.map((doc, documentIndex) => {
const authorName = doc.created_by
? `${doc.created_by.last_name || ''} ${doc.created_by.first_name || ''}`.trim()
: null;
@ -1602,16 +1602,25 @@ function DocumentsPageContent() {
<div className="relative flex min-w-0 flex-col">
<div className="contents">
<div className="order-1 flex min-w-0 flex-wrap items-center gap-1.5 pr-14">
<span
className={`${doc.is_regulation ? "app-selected app-accent-text" : "app-badge"} inline-flex max-w-full items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium`}
>
{doc.is_regulation ? (
<ScrollText size={12} className="shrink-0" />
) : (
<FileText size={12} className="shrink-0" />
)}
{doc.is_regulation ? "Регламент" : "Документ"}
</span>
{activeSection === "regulations" ? (
<span
className="app-selected app-accent-text inline-flex h-7 min-w-7 items-center justify-center rounded-full px-2 text-xs font-semibold tabular-nums"
aria-label={`Регламент ${documentIndex + 1} из ${filteredDocuments.length}`}
>
{documentIndex + 1}
</span>
) : (
<span
className={`${doc.is_regulation ? "app-selected app-accent-text" : "app-badge"} inline-flex max-w-full items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium`}
>
{doc.is_regulation ? (
<ScrollText size={12} className="shrink-0" />
) : (
<FileText size={12} className="shrink-0" />
)}
{doc.is_regulation ? "Регламент" : "Документ"}
</span>
)}
{doc.tags?.slice(0, 3).map((tag) => (
<span
key={tag.id}

View File

@ -43,6 +43,7 @@ interface DocumentUploadFormProps {
onCancel?: () => void;
onFolderCreated?: () => void;
currentFolderId?: number | null;
initialDepartmentId?: number | null;
mode?: "document" | "regulation";
}
@ -122,9 +123,11 @@ export function DocumentUploadForm({
onCancel,
onFolderCreated,
currentFolderId,
initialDepartmentId,
mode = "document",
}: DocumentUploadFormProps) {
const isDedicatedRegulation = mode === "regulation";
const initialDepartmentIds = initialDepartmentId ? [initialDepartmentId] : [];
const [isRegulation, setIsRegulation] = useState(isDedicatedRegulation);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
@ -137,10 +140,14 @@ export function DocumentUploadForm({
// New fields for extended functionality
const [sentToAll, setSentToAll] = useState(true);
const [acknowledgementMode, setAcknowledgementMode] = useState<DocumentAudienceMode>("all");
const [acknowledgementMode, setAcknowledgementMode] = useState<DocumentAudienceMode>(
initialDepartmentIds.length > 0 ? "restricted" : "all",
);
const [selectedDepartments, setSelectedDepartments] = useState<number[]>([]);
const [selectedRecipients, setSelectedRecipients] = useState<number[]>([]);
const [acknowledgementDepartments, setAcknowledgementDepartments] = useState<number[]>([]);
const [acknowledgementDepartments, setAcknowledgementDepartments] = useState<number[]>(
initialDepartmentIds,
);
const [acknowledgementRecipients, setAcknowledgementRecipients] = useState<number[]>([]);
// Metadata fields
@ -527,10 +534,10 @@ export function DocumentUploadForm({
setFolderCreateError(null);
setSentToAll(true);
setIsRegulation(isDedicatedRegulation);
setAcknowledgementMode("all");
setAcknowledgementMode(initialDepartmentIds.length > 0 ? "restricted" : "all");
setSelectedDepartments([]);
setSelectedRecipients([]);
setAcknowledgementDepartments([]);
setAcknowledgementDepartments(initialDepartmentIds);
setAcknowledgementRecipients([]);
setSelectedTags([]);

View File

@ -320,11 +320,12 @@ export function FeedRegulationCard({
type="button"
onClick={() => onAcknowledge(document)}
disabled={isAcknowledging}
className="app-action-ghost inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-60"
className="app-action-ghost inline-flex h-9 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-60"
title="Ознакомился(лась)"
aria-label="Ознакомился(лась)"
>
{isAcknowledging ? <Loader2 size={16} className="animate-spin" /> : <CheckCircle2 size={16} />}
<span>{isAcknowledging ? "Подтверждение..." : "Ознакомился(лась)"}</span>
</button>
) : null}
{document.file_url ? (