(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 */}
-
+
{sMeta.label}
#{req.id}
diff --git a/frontend/src/contexts/MobileNavPlacementContext.tsx b/frontend/src/contexts/MobileNavPlacementContext.tsx
new file mode 100644
index 00000000..519f90ce
--- /dev/null
+++ b/frontend/src/contexts/MobileNavPlacementContext.tsx
@@ -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(null);
+
+export function MobileNavPlacementProvider({ children }: { children: ReactNode }) {
+ const [mobileNavPlacement, setMobileNavPlacementState] = useState(() => {
+ 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 (
+
+ {children}
+
+ );
+}
+
+export function useMobileNavPlacement() {
+ const context = useContext(MobileNavPlacementContext);
+
+ if (!context) {
+ throw new Error("useMobileNavPlacement must be used within MobileNavPlacementProvider");
+ }
+
+ return context;
+}
diff --git a/frontend/src/hooks/useRequestsPage.ts b/frontend/src/hooks/useRequestsPage.ts
index d8589983..aa061ebf 100644
--- a/frontend/src/hooks/useRequestsPage.ts
+++ b/frontend/src/hooks/useRequestsPage.ts
@@ -34,16 +34,16 @@ export const createEmptyForm = (): RequestFormState => ({
});
export const statusMeta: Record = {
- 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 = {
vacation: "Отпуск",
diff --git a/frontend/src/lib/messages/chatSettingsUtils.ts b/frontend/src/lib/messages/chatSettingsUtils.ts
index 9d89cbfd..f7f8be86 100644
--- a/frontend/src/lib/messages/chatSettingsUtils.ts
+++ b/frontend/src/lib/messages/chatSettingsUtils.ts
@@ -1,4 +1,5 @@
import type { Chat, ChatMembership, User } from "@/types/api";
+import { isDepartmentCommentsChat } from "@/lib/messages/chatUtils";
export type MemberSearchResult = Pick & {
name?: string;
@@ -21,7 +22,7 @@ export function getChatTypeLabel(chat: Chat): string {
case "announcement":
return "Канал объявлений";
case "comments":
- return "Комментарии";
+ return isDepartmentCommentsChat(chat) ? "Чат отдела" : "Комментарии";
default:
return "Диалог";
}
diff --git a/frontend/src/lib/messages/chatUtils.ts b/frontend/src/lib/messages/chatUtils.ts
index f7353000..fa7c35ba 100644
--- a/frontend/src/lib/messages/chatUtils.ts
+++ b/frontend/src/lib/messages/chatUtils.ts
@@ -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);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/lib/mobileNavPlacement.ts b/frontend/src/lib/mobileNavPlacement.ts
new file mode 100644
index 00000000..56cca007
--- /dev/null
+++ b/frontend/src/lib/mobileNavPlacement.ts
@@ -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(["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);
+ })();
+ `;
+}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index ed7447a8..b9b9d0f9 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -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;