refactor(ui): polish department feed and mobile nav placement
This commit is contained in:
parent
beb4b8b86b
commit
70d2c55348
@ -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
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
@ -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() ---
|
||||
|
||||
@ -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):
|
||||
"""Комбинированный пермишен: админский доступ ИЛИ доступ
|
||||
по праву на конкретный отдел.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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`}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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 "Диалог";
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
|
||||
67
frontend/src/contexts/MobileNavPlacementContext.tsx
Normal file
67
frontend/src/contexts/MobileNavPlacementContext.tsx
Normal 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;
|
||||
}
|
||||
@ -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: "Отпуск",
|
||||
|
||||
@ -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 "Диалог";
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
99
frontend/src/lib/mobileNavPlacement.ts
Normal file
99
frontend/src/lib/mobileNavPlacement.ts
Normal 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);
|
||||
})();
|
||||
`;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user