refactor(ui): polish department feed and mobile nav placement

This commit is contained in:
lizerk 2026-04-14 19:11:17 +03:00
parent beb4b8b86b
commit 70d2c55348
27 changed files with 690 additions and 94 deletions

View File

@ -40,6 +40,7 @@ from rest_framework.response import Response
from ...permissions import (
AdminOrActionOrModelPerms,
AdminOrDeptAllowed,
can_publish_department_posts,
has_dept_perm,
)
from ..serializers import (
@ -500,8 +501,8 @@ class DepartmentViewSet(viewsets.ModelViewSet):
"can_assign_roles": has_dept_perm(
request.user, dept.id, DeptPerm.ASSIGN_ROLE
),
"can_publish_posts": has_dept_perm(
request.user, dept.id, DeptPerm.CREATE_POST
"can_publish_posts": can_publish_department_posts(
request.user, dept.id
),
"can_manage_feed": has_dept_perm(
request.user, dept.id, DeptPerm.MANAGE_FEED

View File

@ -43,6 +43,11 @@ class PostLikerSerializer(AuthorMiniSerializer):
class PostListSerializer(serializers.ModelSerializer):
author_id = serializers.IntegerField(read_only=True)
author = AuthorMiniSerializer(read_only=True)
department_name = serializers.CharField(
source="department.name",
read_only=True,
allow_null=True,
)
# удoбные представления дат
created_at_display = serializers.DateTimeField(
@ -71,6 +76,7 @@ class PostListSerializer(serializers.ModelSerializer):
"type",
"department",
"department_id",
"department_name",
"title",
"body",
"image",
@ -97,6 +103,7 @@ class PostListSerializer(serializers.ModelSerializer):
"author_id",
"author",
"department_id",
"department_name",
"is_liked",
)

View File

@ -30,6 +30,7 @@ from rest_framework.response import Response
from ..permissions import (
AdminOrActionOrModelPerms,
AdminOrDeptAllowed,
can_publish_department_posts,
)
from .serializers import PostListSerializer, PostLikerSerializer, PostSerializer
@ -57,8 +58,9 @@ class PostViewSet(viewsets.ModelViewSet):
- create:
* type=company → AdminOrActionOrModelPerms
+ required_perm_code="feed.publish_company_post"
* type=department → AdminOrDeptAllowed
с кодом DeptPerm.CREATE_POST|publish_department_post
* type=department → staff/superuser, руководитель отдела,
активный участник отдела или любой сотрудник
с активной ролью в этом отделе
- update/partial_update/destroy:
* company → как для company-create
* department → как для department-create
@ -102,6 +104,23 @@ class PostViewSet(viewsets.ModelViewSet):
return True # пропускаем к валидации сериализатора
return super().has_permission(request, view)
class DepartmentPostCreatePerm(IsAuthenticated):
"""Упрощённое право на создание новости отдела."""
def has_permission(self, request, view) -> bool:
if not super().has_permission(request, view):
return False
dept_id = _to_id(
request.data.get("department")
or request.data.get("department_id")
)
if dept_id is None:
# Даём сериализатору вернуть 400 по отсутствующему отделу.
return True
return can_publish_department_posts(request.user, dept_id)
def get_permissions(self) -> List[Any]:
"""Возвращает DRF-пермишены под текущий action.
@ -118,13 +137,9 @@ class PostViewSet(viewsets.ModelViewSet):
t = (self.request.data.get("type") or "").strip()
if t == TYPE_COMPANY:
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
# department → департаментное право на создание
dept_code = getattr(
DeptPerm, "CREATE_POST", "publish_department_post"
)
perm = self.PostDeptPerm()
perm.required_code = DeptPerm.CREATE_POST
return [IsAuthenticated(), perm]
if t == TYPE_DEPARTMENT:
return [self.DepartmentPostCreatePerm()]
return [IsAuthenticated()]
# --- UPDATE/PATCH/DELETE: определяем тип поста из БД,
# но без get_object() ---

View File

@ -5,11 +5,7 @@ from typing import Any, Dict, Iterable, Optional, Tuple
from django.contrib.auth.models import AbstractBaseUser
from employees.models import Department, EmployeeDepartment, RoleAssignment
from rest_framework.permissions import (
SAFE_METHODS,
BasePermission,
DjangoModelPermissions,
)
from rest_framework.permissions import SAFE_METHODS, BasePermission, DjangoModelPermissions
from rest_framework.request import Request
MANAGE_PERM = "manage_department"
@ -141,6 +137,39 @@ def has_dept_perm(
return link.role.scoped_permissions.filter(code=code).exists()
def can_publish_department_posts(
user: AbstractBaseUser, department_id: int
) -> bool:
"""Возвращает True, если пользователь может публиковать новости отдела.
Упрощённый продуктовый контракт:
- staff/superuser → всегда True
- руководитель отдела → True
- любой активный физический участник отдела → True
- любой сотрудник с активной ролью в отделе → True
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
if EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=department_id,
is_active=True,
).exists():
return True
return RoleAssignment.objects.filter(
employee_id=user.id,
role__department_id=department_id,
is_active=True,
).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ
по праву на конкретный отдел.

View File

@ -9,6 +9,7 @@ from employees.models import (
Department,
DepartmentPermission,
DepartmentRole,
EmployeeDepartment,
RoleAssignment,
)
from tests.conftest import _unique_phone
@ -78,3 +79,40 @@ def test_department_user_perms_resolves_role_based_feed_rights(api_client: APICl
assert resp.status_code == status.HTTP_200_OK
assert resp.json()["can_publish_posts"] is True
assert resp.json()["can_manage_feed"] is False
@pytest.mark.django_db
def test_department_user_perms_allows_active_member_to_publish(api_client: APIClient):
employee = make_user()
dept = Department.objects.create(name="Склад")
EmployeeDepartment.objects.create(
employee=employee,
department=dept,
is_active=True,
)
api_client.force_authenticate(user=employee)
url = reverse("api:v1:departments-user-perms", args=[dept.id])
resp = api_client.get(url)
assert resp.status_code == status.HTTP_200_OK
assert resp.json()["can_publish_posts"] is True
assert resp.json()["can_manage_feed"] is False
@pytest.mark.django_db
def test_department_user_perms_allows_role_only_to_publish_without_scoped_perm(
api_client: APIClient,
):
employee = make_user()
dept = Department.objects.create(name="Логистика")
role = DepartmentRole.objects.create(department=dept, name="Наблюдатель")
RoleAssignment.objects.create(employee=employee, role=role, is_active=True)
api_client.force_authenticate(user=employee)
url = reverse("api:v1:departments-user-perms", args=[dept.id])
resp = api_client.get(url)
assert resp.status_code == status.HTTP_200_OK
assert resp.json()["can_publish_posts"] is True
assert resp.json()["can_manage_feed"] is False

View File

@ -173,6 +173,30 @@ def test_department_post_requires_publish_permission(
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_active_department_member_can_create_department_post(
auth_client_factory,
make_user,
department_factory,
):
user = make_user("member-publisher@example.com")
department = department_factory(name="Operations")
EmployeeDepartment.objects.create(
employee=user,
department=department,
is_active=True,
)
client = auth_client_factory(user)
response = client.post(
API_POSTS_URL,
_department_post_payload(department.id),
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert Post.objects.filter(id=response.data["id"], department=department).exists()
def test_role_assignment_can_create_department_post(
auth_client_factory,
make_user,
@ -193,6 +217,34 @@ def test_role_assignment_can_create_department_post(
assert Post.objects.filter(id=response.data["id"], department=department).exists()
def test_any_role_assignment_can_create_department_post(
auth_client_factory,
make_user,
department_factory,
):
user = make_user("roleonly-publisher@example.com")
department = department_factory(name="Support")
role = DepartmentRole.objects.create(
department=department,
name="Observer",
)
RoleAssignment.objects.create(
employee=user,
role=role,
is_active=True,
)
client = auth_client_factory(user)
response = client.post(
API_POSTS_URL,
_department_post_payload(department.id),
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert Post.objects.filter(id=response.data["id"], department=department).exists()
def test_list_department_posts_filtered_by_department(
auth_client_factory,
make_user,

View File

@ -11,7 +11,7 @@ import {
ChevronRight,
Crown,
Link2,
MessageCircle,
MessageSquare,
PencilLine,
Plus,
Search,
@ -996,7 +996,7 @@ export default function DepartmentDetailPage() {
disabled={openingDepartmentChat}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<MessageCircle size={16} />
<MessageSquare size={16} />
Перейти в чат отдела
</button>
</div>

View File

@ -322,6 +322,12 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
color: var(--accent-primary-strong);
}
.app-badge-danger {
border-color: color-mix(in srgb, #ef4444 24%, var(--border-subtle));
background: var(--danger-soft);
color: #dc2626;
}
.app-status-pill {
display: inline-flex;
align-items: center;
@ -363,6 +369,15 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
color: #ffffff;
}
.app-counter-danger {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: #ef4444;
color: #ffffff;
}
.app-action-primary {
border: 1px solid transparent;
background: var(--accent-primary);

View File

@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/Providers";
import { getThemeInitScript } from "@/lib/theme";
import { getMobileNavPlacementInitScript } from "@/lib/mobileNavPlacement";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -36,6 +37,7 @@ export default function RootLayout({
<head>
<meta name="color-scheme" content="light dark" />
<script dangerouslySetInnerHTML={{ __html: getThemeInitScript() }} />
<script dangerouslySetInnerHTML={{ __html: getMobileNavPlacementInitScript() }} />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}

View File

@ -45,18 +45,18 @@ function getChatTypeIcon(chat: Chat) {
const chatType = chat.chat_type || chat.type;
switch (chatType) {
case 'global':
return <Globe size={10} className="text-white" />;
return <Globe size={10} className="app-accent-text" />;
case 'channel':
return <Radio size={10} className="text-white" />;
return <Radio size={10} className="app-accent-text" />;
case 'group':
return <Users size={10} className="text-white" />;
return <Users size={10} className="app-accent-text" />;
case 'private':
case 'direct':
return <MessageCircle size={10} className="text-white" />;
return <MessageCircle size={10} className="app-accent-text" />;
case 'announcement':
return <Megaphone size={10} className="text-white" />;
return <Megaphone size={10} className="app-accent-text" />;
case 'comments':
return <MessageSquare size={10} className="text-white" />;
return <MessageSquare size={10} className="app-accent-text" />;
default:
return null;
}
@ -422,7 +422,7 @@ export default function MessagesPage() {
>
<Filter size={16} />
{(filterUnread !== 'all' || filterPinned !== 'all') && (
<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">
<span className="app-badge app-badge-accent absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border px-1 text-[10px] font-bold">
{[filterUnread !== 'all', filterPinned !== 'all'].filter(Boolean).length}
</span>
)}
@ -537,12 +537,12 @@ export default function MessagesPage() {
)}
</div>
{/* Иконка типа чата */}
<span className="absolute -bottom-0.5 -left-0.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-sky-600 ring-2 ring-white">
<span className="app-badge app-badge-accent absolute -bottom-0.5 -left-0.5 z-10 flex h-4 w-4 items-center justify-center rounded-full border-2 border-[var(--surface-primary)]">
{getChatTypeIcon(chat)}
</span>
{/* Счетчик непрочитанных */}
{(chat.unread_count ?? 0) > 0 ? (
<span className="absolute -top-1 -right-1 z-10 flex h-5 min-w-[20px] items-center justify-center rounded-full bg-red-500 px-1.5 text-[10px] font-bold text-white ring-2 ring-white">
<span className="app-counter-danger absolute -top-1 -right-1 z-10 flex h-5 min-w-[20px] items-center justify-center border-2 border-[var(--surface-primary)] px-1.5 text-[10px] font-bold">
{chat.unread_count! > 99 ? '99+' : chat.unread_count}
</span>
) : null}

View File

@ -1,12 +1,15 @@
"use client";
import Link from "next/link";
import { ChevronRight, Pencil, Pin, Plus, Trash2 } from "lucide-react";
import { AppShell } from "../components/AppShell";
import { FeedPostCard } from "@/components/feed/FeedPostCard";
import { Modal } from "@/components/ui";
import { PostCommentsModal } from "@/components/feed/PostCommentsModal";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { useNotifications } from "@/contexts/NotificationsContext";
import { apiClient } from "@/lib/api";
import { userProfileLink } from "@/lib/shared";
import { resolveMediaUrl } from "@/lib/url";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
@ -21,6 +24,8 @@ type LikeUser = {
avatar?: string | null;
};
type PostSourceFilter = "all" | "company" | `department:${number}`;
export default function Home() {
return (
<Suspense fallback={<HomePageFallback />}>
@ -42,6 +47,7 @@ function HomePageFallback() {
function HomePageContent() {
const { user } = useUser();
const { notifications } = useNotifications();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@ -49,6 +55,7 @@ function HomePageContent() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [likeBusyId, setLikeBusyId] = useState<number | null>(null);
const [sourceFilter, setSourceFilter] = useState<PostSourceFilter>("all");
const [commentsOpen, setCommentsOpen] = useState(false);
const [activePost, setActivePost] = useState<Post | null>(null);
@ -135,9 +142,10 @@ function HomePageContent() {
hasPermission("publish_department_post") ||
hasPermission("feed.create_post") ||
hasPermission("create_post") ||
hasPermission("employees.manage_feed")
hasPermission("employees.manage_feed") ||
((user?.departments?.length || 0) > 0)
);
const canCreatePost = canCreateCompanyPost || canCreateDepartmentPost || ((user?.departments?.length || 0) > 0);
const canCreatePost = canCreateCompanyPost || canCreateDepartmentPost;
const userDepartments = useMemo(() => user?.departments || [], [user?.departments]);
const canManageAnyPost = Boolean(
auth?.is_staff ||
@ -181,11 +189,135 @@ function HomePageContent() {
return canDeleteAnyComments;
};
const renderPostOrigin = useCallback((post: Post) => {
if (post.type === "department") {
return (
<Link
href={post.department_id ? `/departments/${post.department_id}` : "/departments"}
className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium transition hover:border-[var(--border-strong)] hover:text-[var(--foreground)]"
>
{post.department_name ? `${post.department_name}` : "Отдел"}
</Link>
);
}
return (
<span className="app-badge app-badge-accent inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium">
Компания
</span>
);
}, []);
const refreshPosts = useCallback(async () => {
const response = await apiClient.getPosts();
setPosts(sortPostsPinnedFirst(response.results));
}, [sortPostsPinnedFirst]);
const postSourceCounts = useMemo(() => {
const departmentCounts = new Map<number, { id: number; name: string; total: number }>();
let companyCount = 0;
for (const post of posts) {
if (post.type === "department" && post.department_id) {
const current = departmentCounts.get(post.department_id);
if (current) {
current.total += 1;
} else {
departmentCounts.set(post.department_id, {
id: post.department_id,
name: post.department_name || "Отдел",
total: 1,
});
}
} else {
companyCount += 1;
}
}
const departments = Array.from(departmentCounts.values()).sort((left, right) => {
if (right.total !== left.total) return right.total - left.total;
return left.name.localeCompare(right.name, "ru");
});
return {
all: posts.length,
company: companyCount,
departments,
};
}, [posts]);
const postSourceUnreadCounts = useMemo(() => {
const departmentUnread = new Map<number, number>();
let companyUnread = 0;
for (const notification of notifications) {
if ((notification.verb || "") !== "feed_new_post") continue;
const rawData = notification.data;
const data = rawData && typeof rawData === "object"
? rawData as Record<string, unknown>
: null;
const postType = typeof data?.post_type === "string" ? data.post_type : "";
const departmentId =
typeof data?.department_id === "number"
? data.department_id
: typeof data?.department_id === "string" && data.department_id.trim()
? Number(data.department_id)
: null;
if (postType === "department" && Number.isFinite(departmentId) && departmentId !== null) {
departmentUnread.set(departmentId, (departmentUnread.get(departmentId) || 0) + 1);
} else {
companyUnread += 1;
}
}
const departmentEntries = Array.from(departmentUnread.entries()).map(([id, unread]) => ({
id,
unread,
}));
return {
all: companyUnread + departmentEntries.reduce((sum, entry) => sum + entry.unread, 0),
company: companyUnread,
departments: departmentEntries,
};
}, [notifications]);
const postSourceOptions = useMemo(() => {
const unreadByDepartment = new Map(
postSourceUnreadCounts.departments.map((entry) => [entry.id, entry.unread]),
);
const options: Array<{ key: PostSourceFilter; label: string; total: number; unread: number }> = [
{ key: "all", label: "Все", total: postSourceCounts.all, unread: postSourceUnreadCounts.all },
{ key: "company", label: "Компания", total: postSourceCounts.company, unread: postSourceUnreadCounts.company },
];
for (const department of postSourceCounts.departments) {
options.push({
key: `department:${department.id}`,
label: department.name,
total: department.total,
unread: unreadByDepartment.get(department.id) || 0,
});
}
return options.filter(({ key, total }) => key === "all" || total > 0);
}, [postSourceCounts, postSourceUnreadCounts]);
const filteredPosts = useMemo(() => {
if (sourceFilter === "company") {
return posts.filter((post) => post.type !== "department");
}
if (sourceFilter.startsWith("department:")) {
const departmentId = Number(sourceFilter.split(":")[1]);
return posts.filter((post) => post.department_id === departmentId);
}
return posts;
}, [posts, sourceFilter]);
useEffect(() => {
async function loadPosts() {
try {
@ -497,12 +629,53 @@ function HomePageContent() {
return (
<AppShell>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{postSourceOptions.map(({ key, label, total, unread }) => {
const active = sourceFilter === key;
return (
<button
key={key}
type="button"
onClick={() => setSourceFilter(key)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
active ? "app-pill-active" : "app-pill"
}`}
>
<span>{label}</span>
<span
className={`app-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold ${
active ? "app-pill-count-active" : "app-pill-count"
}`}
>
<span>{total}</span>
{unread > 0 ? (
<>
<span className="app-text-muted">•</span>
<span className="app-accent-text">{unread}</span>
</>
) : null}
</span>
</button>
);
})}
</div>
{posts.length === 0 ? (
<div className="app-surface-muted rounded-2xl p-8 text-center">
<p className="app-text-muted text-sm">Пока нет постов в ленте</p>
</div>
) : filteredPosts.length === 0 ? (
<div className="app-surface-muted rounded-2xl p-8 text-center">
<p className="text-sm font-medium text-[var(--foreground)]">
Публикации не найдены
</p>
<p className="app-text-muted mt-2 text-sm">
В выбранном источнике пока нет публикаций.
</p>
</div>
) : (
posts.map((post) => {
filteredPosts.map((post) => {
// Форматируем дату
const postDate = new Date(post.created_at);
const now = new Date();
@ -517,8 +690,14 @@ function HomePageContent() {
return (
<FeedPostCard
key={post.id}
authorHref={post.author ? userProfileLink(post.author, user?.id) : null}
post={post}
authorSubtitle={<span>{timeAgo}</span>}
authorSubtitle={
<>
<span>{timeAgo}</span>
{renderPostOrigin(post)}
</>
}
headerActions={(
(canEditPost(post) || canDeletePost(post)) ? (
<div

View File

@ -2,6 +2,8 @@
import Image from "next/image";
import {
ArrowDown,
ArrowUp,
Bell,
Check,
Clock3,
@ -19,6 +21,7 @@ import {
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
import {
formatSessionDateTime,
getSessionDeviceKind,
@ -34,6 +37,21 @@ const themeIcons = {
Monitor,
} as const;
const mobileNavCards = [
{
value: "top",
title: "Сверху",
description: "Мобильная панель навигации закреплена в верхней части экрана.",
Icon: ArrowUp,
},
{
value: "bottom",
title: "Снизу",
description: "Панель закреплена снизу, как в современных мобильных интерфейсах.",
Icon: ArrowDown,
},
] as const;
function getSessionDeviceIcon(deviceName?: string | null) {
return getSessionDeviceKind(deviceName) === "mobile" ? Smartphone : Monitor;
}
@ -107,6 +125,7 @@ function SectionCard({
}
export default function SettingsPage() {
const { mobileNavPlacement, setMobileNavPlacement } = useMobileNavPlacement();
const {
activeVerbCount,
avatarInputRef,
@ -337,6 +356,48 @@ export default function SettingsPage() {
<strong className="text-[var(--foreground)]">{resolvedTheme}</strong>.
</p>
</div>
<div className="app-surface-muted mt-4 rounded-2xl p-4">
<div className="mb-4">
<p className="text-sm font-medium text-[var(--foreground)]">Положение мобильной панели</p>
<p className="app-text-muted mt-1 text-sm">
Настраивает расположение мобильной панели навигации на этом устройстве.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
{mobileNavCards.map(({ value, title, description, Icon }) => {
const active = mobileNavPlacement === value;
return (
<button
key={value}
type="button"
onClick={() => setMobileNavPlacement(value)}
className={`rounded-2xl p-4 text-left transition ${
active ? "app-selected" : "app-surface hover:bg-[var(--surface-tertiary)]"
}`}
>
<div className="mb-4 flex items-start justify-between gap-3">
<span
className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl ${
active ? "app-action-primary text-white" : "bg-[var(--surface-elevated)] text-[var(--muted-foreground)]"
}`}
>
<Icon size={20} />
</span>
<span className={`app-badge inline-flex h-6 min-w-6 justify-center px-2 text-xs font-semibold ${active ? "app-badge-accent" : ""}`}>
{active ? <Check size={14} /> : value}
</span>
</div>
<p className="text-sm font-semibold text-[var(--foreground)]">{title}</p>
<p className="app-text-muted mt-1 text-sm">{description}</p>
</button>
);
})}
</div>
</div>
</SectionCard>
<SectionCard

View File

@ -1,9 +1,10 @@
"use client";
import { Building2, CalendarDays, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users, Wallet } from "lucide-react";
import { Building2, CalendarDays, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, startTransition, useEffect, useRef, useState, useMemo } from "react";
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
import { useUser } from "@/contexts/UserContext";
import { useNotifications } from "@/hooks/useApi";
import { getVerbCategory } from "@/lib/verbTranslations";
@ -25,6 +26,7 @@ type PageHeaderProps = {
};
type HeaderProps = {
mobileNavPlacement: "top" | "bottom";
onOpenLeftNav: () => void;
onOpenCalendar: () => void;
};
@ -45,7 +47,7 @@ const navItems = [
// { href: "/finances", label: "Финансы", icon: Wallet },
];
function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
function Header({ mobileNavPlacement, onOpenLeftNav, onOpenCalendar }: HeaderProps) {
const router = useRouter();
const { user, logout } = useUser();
const [searchQuery, setSearchQuery] = useState("");
@ -97,6 +99,8 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
? `${user.last_name} ${user.first_name}`.trim()
: 'Гость';
const isBottomMobileNav = mobileNavPlacement === "bottom";
const submitSearch = () => {
const query = searchQuery.trim();
if (!query) return;
@ -104,9 +108,19 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
};
return (
<header className="app-header sticky top-0 z-[40] border-b backdrop-blur">
<header
className={`app-header z-[40] backdrop-blur ${
isBottomMobileNav
? "fixed inset-x-0 bottom-0 border-t lg:sticky lg:top-0 lg:bottom-auto lg:border-b lg:border-t-0"
: "sticky top-0 border-b"
}`}
>
<div className="mx-auto max-w-6xl px-4 sm:px-8">
<div className="flex h-10 lg:h-14 items-center justify-between gap-3">
<div
className={`flex h-10 items-center justify-between gap-3 lg:h-14 ${
isBottomMobileNav ? "pb-[max(env(safe-area-inset-bottom),0px)]" : ""
}`}
>
<button
type="button"
onClick={onOpenLeftNav}
@ -135,7 +149,9 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
<img
src="/logo.png"
alt="Логотип"
className="mt-3 h-10 w-auto rounded bg-transparent pb-0.5 lg:mt-0 lg:h-11 lg:pb-0"
className={`h-10 w-auto rounded bg-transparent pb-0.5 lg:h-11 lg:pb-0 ${
isBottomMobileNav ? "mt-0" : "mt-3 lg:mt-0"
}`}
/>
</Link>
@ -214,9 +230,9 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
<div
className={`overflow-hidden transition-all duration-300 lg:hidden ${
isSearchOpen ? "max-h-20 pb-3" : "max-h-0"
}`}
} ${isBottomMobileNav ? "absolute inset-x-0 bottom-full px-4 sm:px-8" : ""}`}
>
<div className="relative">
<div className={`relative ${isBottomMobileNav ? "mx-auto max-w-6xl" : ""}`}>
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 app-text-muted" />
<input
ref={searchInputRef}
@ -243,10 +259,12 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
<div
className={`notification-panel-mobile overflow-hidden transition-all duration-300 lg:hidden ${
isNotificationsOpen ? "max-h-[70vh] pb-3" : "max-h-0"
}`}
} ${isBottomMobileNav ? "absolute inset-x-0 bottom-full px-4 sm:px-8" : ""}`}
>
{isNotificationsOpen && (
<NotificationPanel onClose={() => setIsNotificationsOpen(false)} />
<div className={isBottomMobileNav ? "mx-auto max-w-6xl" : ""}>
<NotificationPanel onClose={() => setIsNotificationsOpen(false)} />
</div>
)}
</div>
@ -349,6 +367,7 @@ export function PageHeader({ title, subtitle, badge, eyebrow }: PageHeaderProps
export function AppShell({ children }: AppShellProps) {
const { user, loading } = useUser();
const { mobileNavPlacement } = useMobileNavPlacement();
const router = useRouter();
const pathname = usePathname();
const isMessageDialogPage = pathname.startsWith('/messages/') &&
@ -427,8 +446,12 @@ export function AppShell({ children }: AppShellProps) {
}
return (
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'} app-shell flex flex-col`}>
<Header onOpenLeftNav={() => setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'} app-shell flex flex-col ${mobileNavPlacement === "bottom" ? "pb-[calc(env(safe-area-inset-bottom)+3.75rem)] lg:pb-0" : ""}`}>
<Header
mobileNavPlacement={mobileNavPlacement}
onOpenLeftNav={() => setIsMobileLeftNavOpen(true)}
onOpenCalendar={() => setIsMobileCalendarOpen(true)}
/>
<div className={`mx-auto flex w-full flex-1 min-h-0 max-w-6xl ${isMessageDialogPage ? 'gap-0 px-0 py-0 lg:gap-6 lg:px-8 lg:py-8' : '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>

View File

@ -196,11 +196,11 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
e.stopPropagation();
deleteNotification(notification.id);
}}
className="app-action-danger flex-shrink-0 rounded p-1.5 transition-all"
className="app-action-ghost flex-shrink-0 rounded p-1.5 transition-colors hover:bg-transparent"
aria-label="Удалить"
title="Удалить уведомление"
>
<Trash2 className="h-3.5 w-3.5 text-[var(--muted-foreground)] hover:text-red-500" />
<Trash2 className="h-3.5 w-3.5 text-[var(--muted-foreground)] transition-colors hover:text-[var(--danger-foreground)]" />
</button>
</div>
</li>

View File

@ -3,6 +3,7 @@
import { UserProvider } from "@/contexts/UserContext";
import { CalendarProvider } from "@/contexts/CalendarContext";
import { NotificationsProvider } from "@/contexts/NotificationsContext";
import { MobileNavPlacementProvider } from "@/contexts/MobileNavPlacementContext";
import { ThemeProvider, useTheme } from "@/contexts/ThemeContext";
import { ReactNode } from "react";
import { Toaster } from "sonner";
@ -16,14 +17,16 @@ function AppToaster() {
export function Providers({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<UserProvider>
<NotificationsProvider>
<CalendarProvider>
<AppToaster />
{children}
</CalendarProvider>
</NotificationsProvider>
</UserProvider>
<MobileNavPlacementProvider>
<UserProvider>
<NotificationsProvider>
<CalendarProvider>
<AppToaster />
{children}
</CalendarProvider>
</NotificationsProvider>
</UserProvider>
</MobileNavPlacementProvider>
</ThemeProvider>
);
}

View File

@ -19,6 +19,7 @@ import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { Modal } from "@/components/ui/Modal";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { userProfileLink } from "@/lib/shared";
import type { PaginatedResponse, Post } from "@/types/api";
type DepartmentFeedSectionProps = {
@ -456,6 +457,7 @@ export function DepartmentFeedSection({
return (
<FeedPostCard
key={post.id}
authorHref={post.author ? userProfileLink(post.author, currentUserId) : null}
post={post}
authorSubtitle={
<span>
@ -577,6 +579,7 @@ export function DepartmentFeedSection({
canDeletePost,
canEditPost,
canPinPost,
currentUserId,
error,
formatInitials,
formatUserName,

View File

@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
import { Heart, MessageSquare, Paperclip, Pin } from "lucide-react";
@ -9,6 +10,7 @@ import { resolveMediaUrl } from "@/lib/url";
import type { Post } from "@/types/api";
type FeedPostCardProps = {
authorHref?: string | null;
post: Post;
authorSubtitle: ReactNode;
attachmentButtonClassName?: string;
@ -47,6 +49,7 @@ function formatAuthorFallback(post: Post) {
}
export function FeedPostCard({
authorHref,
post,
authorSubtitle,
attachmentButtonClassName,
@ -88,9 +91,18 @@ export function FeedPostCard({
src={post.author?.avatar}
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--foreground)]">
{authorName}
</p>
{authorHref ? (
<Link
href={authorHref}
className="block truncate text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]"
>
{authorName}
</Link>
) : (
<p className="truncate text-sm font-semibold text-[var(--foreground)]">
{authorName}
</p>
)}
<div className="app-text-muted flex items-center gap-1.5 text-xs">
{authorSubtitle}
{pinnedStyle === "inline" && post.pinned ? (

View File

@ -6,7 +6,7 @@ import Image from "next/image";
import { ArrowLeft, Bell, BellOff, MoreHorizontal, Pin, Search } from "lucide-react";
import type { Chat } from "@/types/api";
import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
import { getChatAvatar, getChatInitials, getChatTitle, isDepartmentCommentsChat } from "@/lib/messages/chatUtils";
import { resolveMediaUrl } from "@/lib/url";
type ChatDialogHeaderProps = {
@ -33,7 +33,7 @@ function getChatSubtitle(chat: Chat): string {
case "global":
return "Глобальный чат";
case "comments":
return "Комментарии";
return isDepartmentCommentsChat(chat) ? "Чат отдела" : "Комментарии";
default:
return "Диалог";
}

View File

@ -51,7 +51,7 @@ export function RequestDetailModal({
<span className="app-badge app-badge-accent px-2.5 py-1 text-xs font-medium">
{typeLabel}
</span>
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${status.className}`}>
<span className={`app-status-pill ${status.className}`}>
{status.label}
</span>
</div>

View File

@ -188,7 +188,7 @@ export function RequestListItem({
<div className="shrink-0">
<div ref={isMenuOpen ? menuRef : null} className="flex items-center justify-end gap-2">
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${status.className}`}>
<span className={`app-status-pill ${status.className}`}>
{status.label}
</span>
{hasSecondaryActions ? (

View File

@ -1,30 +1,12 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { defaultStatusMeta, requestTypeLabels, statusMeta } from "@/hooks/useRequestsPage";
import type { Request, User } from "@/types/api";
import { Check, ChevronUp, Flame, ThumbsDown, ThumbsUp, Undo2, X } from "lucide-react";
import { Check, ChevronUp, Flame, ThumbsDown, ThumbsUp, Undo2 } from "lucide-react";
/* ── helpers ── */
const statusMeta: Record<string, { label: string; cls: string }> = {
draft: { label: "Черновик", cls: "bg-slate-100 text-slate-700" },
pending: { label: "На рассмотрении", cls: "bg-amber-50 text-amber-700" },
approved: { label: "Одобрено", cls: "bg-emerald-50 text-emerald-700" },
rejected: { label: "Отклонено", cls: "bg-rose-50 text-rose-700" },
cancelled: { label: "Отменено", cls: "bg-gray-100 text-gray-600" },
in_progress: { label: "В работе", cls: "bg-sky-50 text-sky-700" },
completed: { label: "Завершено", cls: "bg-violet-50 text-violet-700" },
};
const typeLabels: Record<string, string> = {
vacation: "Отпуск",
sick_leave: "Больничный",
day_off: "Отгул",
transfer: "Перевод",
dismissal: "Увольнение",
other: "Другое",
};
function fmt(d?: string | null) {
if (!d) return "—";
const dt = new Date(d);
@ -34,7 +16,8 @@ function fmt(d?: string | null) {
function userName(p?: User | null) {
if (!p) return "—";
const full = `${p.last_name || ""} ${p.first_name || ""}`.trim();
return full || (p as any)?.full_name || p.email || "Пользователь";
const fullName = "full_name" in p && typeof p.full_name === "string" ? p.full_name : "";
return full || fullName || p.email || "Пользователь";
}
/* ── types ── */
@ -59,7 +42,7 @@ const SWIPE_THRESHOLD = 80; // px
export default function SwipeApprovalMode({ requests, onApprove, onReject, onClose }: Props) {
const [queue, setQueue] = useState<Request[]>([]);
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [, setHistory] = useState<HistoryEntry[]>([]);
const [stats, setStats] = useState({ approved: 0, rejected: 0, skipped: 0 });
const [busy, setBusy] = useState(false);
const [undoToast, setUndoToast] = useState<HistoryEntry | null>(null);
@ -199,9 +182,9 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
const renderCard = (req: Request) => {
const st = String(req.status).toLowerCase();
const sMeta = statusMeta[st] ?? { label: st, cls: "app-badge" };
const sMeta = statusMeta[st] ?? defaultStatusMeta;
const typeKey = String(req.type || req.request_type || "").toLowerCase();
const typeLabel = typeLabels[typeKey] || typeKey || "Другое";
const typeLabel = requestTypeLabels[typeKey] || typeKey || "Другое";
const author = req.employee || req.created_by;
const summaryText = req.comment || req.description;
@ -209,7 +192,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
<>
{/* header */}
<div className="mb-3 flex items-center justify-between">
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset ${sMeta.cls}`}>
<span className={`app-status-pill ${sMeta.className}`}>
{sMeta.label}
</span>
<span className="app-text-muted text-xs">#{req.id}</span>

View File

@ -0,0 +1,67 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import {
initializeMobileNavPlacement,
readMobileNavPlacementPreference,
setMobileNavPlacementPreference,
subscribeToMobileNavPlacementChanges,
type MobileNavPlacement,
} from "@/lib/mobileNavPlacement";
type MobileNavPlacementContextValue = {
mobileNavPlacement: MobileNavPlacement;
setMobileNavPlacement: (placement: MobileNavPlacement) => void;
};
const MobileNavPlacementContext = createContext<MobileNavPlacementContextValue | null>(null);
export function MobileNavPlacementProvider({ children }: { children: ReactNode }) {
const [mobileNavPlacement, setMobileNavPlacementState] = useState<MobileNavPlacement>(() => {
if (typeof window === "undefined") {
return "top";
}
return initializeMobileNavPlacement();
});
useEffect(() => {
const sync = () => {
const preference = readMobileNavPlacementPreference();
setMobileNavPlacementState(preference);
};
initializeMobileNavPlacement();
return subscribeToMobileNavPlacementChanges(sync);
}, []);
const setMobileNavPlacement = useCallback((nextPlacement: MobileNavPlacement) => {
setMobileNavPlacementPreference(nextPlacement);
setMobileNavPlacementState(nextPlacement);
}, []);
const value = useMemo(
() => ({
mobileNavPlacement,
setMobileNavPlacement,
}),
[mobileNavPlacement, setMobileNavPlacement],
);
return (
<MobileNavPlacementContext.Provider value={value}>
{children}
</MobileNavPlacementContext.Provider>
);
}
export function useMobileNavPlacement() {
const context = useContext(MobileNavPlacementContext);
if (!context) {
throw new Error("useMobileNavPlacement must be used within MobileNavPlacementProvider");
}
return context;
}

View File

@ -34,16 +34,16 @@ export const createEmptyForm = (): RequestFormState => ({
});
export const statusMeta: Record<string, { label: string; className: string }> = {
draft: { label: "Черновик", className: "bg-slate-100 text-slate-700 ring-slate-200" },
pending: { label: "На рассмотрении", className: "bg-amber-50 text-amber-700 ring-amber-100" },
approved: { label: "Одобрено", className: "bg-emerald-50 text-emerald-700 ring-emerald-100" },
rejected: { label: "Отклонено", className: "bg-rose-50 text-rose-700 ring-rose-100" },
cancelled: { label: "Отменено", className: "bg-gray-100 text-gray-700 ring-gray-200" },
in_progress: { label: "В работе", className: "bg-sky-50 text-sky-700 ring-sky-100" },
completed: { label: "Завершено", className: "bg-violet-50 text-violet-700 ring-violet-100" },
draft: { label: "Черновик", className: "app-badge" },
pending: { label: "На рассмотрении", className: "app-feedback-warning" },
approved: { label: "Одобрено", className: "app-feedback-success" },
rejected: { label: "Отклонено", className: "app-feedback-danger" },
cancelled: { label: "Отменено", className: "app-badge" },
in_progress: { label: "В работе", className: "app-selected" },
completed: { label: "Завершено", className: "app-badge-accent" },
};
export const defaultStatusMeta = { label: "Неизвестный статус", className: "bg-gray-50 text-gray-700 ring-gray-200" };
export const defaultStatusMeta = { label: "Неизвестный статус", className: "app-badge" };
export const requestTypeLabels: Record<string, string> = {
vacation: "Отпуск",

View File

@ -1,4 +1,5 @@
import type { Chat, ChatMembership, User } from "@/types/api";
import { isDepartmentCommentsChat } from "@/lib/messages/chatUtils";
export type MemberSearchResult = Pick<User, "id" | "first_name" | "last_name" | "email" | "avatar"> & {
name?: string;
@ -21,7 +22,7 @@ export function getChatTypeLabel(chat: Chat): string {
case "announcement":
return "Канал объявлений";
case "comments":
return "Комментарии";
return isDepartmentCommentsChat(chat) ? "Чат отдела" : "Комментарии";
default:
return "Диалог";
}

View File

@ -89,6 +89,11 @@ export function getChatTitle(chat: Chat, currentUserId?: number, currentUser?: C
return rawName || "Диалог";
}
export function isDepartmentCommentsChat(chat: Chat): boolean {
const chatKind = chat.chat_type || chat.type;
return chatKind === "comments" && Boolean(chat.flags?.show_in_messages);
}
export function getChatAvatar(chat: Chat, currentUserId?: number): string {
const chatKind = chat.chat_type || chat.type;
if (chatKind === "direct" || chatKind === "private" || (chat.name || "").trim().toLowerCase() === "диалог") {
@ -155,4 +160,4 @@ export function mergeDisplayMessages(serverMessages: Message[], pendingMessages:
});
return combined.map(({ message }) => message);
}
}

View File

@ -0,0 +1,99 @@
export type MobileNavPlacement = "top" | "bottom";
export const MOBILE_NAV_PLACEMENT_STORAGE_KEY = "mobile-nav-placement";
export const DEFAULT_MOBILE_NAV_PLACEMENT: MobileNavPlacement = "top";
const VALID_MOBILE_NAV_PLACEMENTS = new Set<MobileNavPlacement>(["top", "bottom"]);
const MOBILE_NAV_PLACEMENT_EVENT = "eusrr-mobile-nav-placement-change";
function isMobileNavPlacement(value: string | null): value is MobileNavPlacement {
return Boolean(value) && VALID_MOBILE_NAV_PLACEMENTS.has(value as MobileNavPlacement);
}
export function readMobileNavPlacementPreference(): MobileNavPlacement {
if (typeof window === "undefined") {
return DEFAULT_MOBILE_NAV_PLACEMENT;
}
try {
const stored = window.localStorage.getItem(MOBILE_NAV_PLACEMENT_STORAGE_KEY);
return isMobileNavPlacement(stored) ? stored : DEFAULT_MOBILE_NAV_PLACEMENT;
} catch {
return DEFAULT_MOBILE_NAV_PLACEMENT;
}
}
export function applyMobileNavPlacement(preference: MobileNavPlacement): MobileNavPlacement {
if (typeof document === "undefined") {
return preference;
}
document.documentElement.setAttribute("data-mobile-nav-placement", preference);
return preference;
}
export function setMobileNavPlacementPreference(preference: MobileNavPlacement): MobileNavPlacement {
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(MOBILE_NAV_PLACEMENT_STORAGE_KEY, preference);
} catch {
// Ignore storage failures.
}
}
applyMobileNavPlacement(preference);
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(MOBILE_NAV_PLACEMENT_EVENT, { detail: { preference } }));
}
return preference;
}
export function initializeMobileNavPlacement(): MobileNavPlacement {
const preference = readMobileNavPlacementPreference();
applyMobileNavPlacement(preference);
return preference;
}
export function subscribeToMobileNavPlacementChanges(callback: () => void): () => void {
if (typeof window === "undefined") {
return () => {};
}
const onPlacementEvent = () => callback();
const onStorage = (event: StorageEvent) => {
if (!event.key || event.key === MOBILE_NAV_PLACEMENT_STORAGE_KEY) {
callback();
}
};
window.addEventListener(MOBILE_NAV_PLACEMENT_EVENT, onPlacementEvent);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(MOBILE_NAV_PLACEMENT_EVENT, onPlacementEvent);
window.removeEventListener("storage", onStorage);
};
}
export function getMobileNavPlacementInitScript(): string {
return `
(function () {
var storageKey = ${JSON.stringify(MOBILE_NAV_PLACEMENT_STORAGE_KEY)};
var defaultPreference = ${JSON.stringify(DEFAULT_MOBILE_NAV_PLACEMENT)};
var valid = { top: true, bottom: true };
var root = document.documentElement;
var preference = defaultPreference;
try {
var stored = window.localStorage.getItem(storageKey);
if (stored && valid[stored]) {
preference = stored;
}
} catch (error) {}
root.setAttribute("data-mobile-nav-placement", preference);
})();
`;
}

View File

@ -181,6 +181,7 @@ export interface Post {
type?: 'company' | 'department' | 'employee';
department?: number | null;
department_id?: number | null;
department_name?: string | null;
content?: string;
body?: string;
title?: string;