Refactor modals and add chat message search

This commit is contained in:
lizerk 2026-04-03 19:53:31 +03:00
parent e80412918d
commit 111ddb2bfd
32 changed files with 1510 additions and 1042 deletions

View File

@ -72,6 +72,40 @@ def _parse_bool_query_param(value, default=True):
return default
def _build_message_search_snippet(message, query, max_length=140):
query_normalized = (query or '').strip().lower()
content = (message.content or '').strip()
if content:
if not query_normalized:
return f"{content[:max_length].rstrip()}…" if len(content) > max_length else content
content_normalized = content.lower()
match_index = content_normalized.find(query_normalized)
if match_index < 0:
return f"{content[:max_length].rstrip()}…" if len(content) > max_length else content
context_before = max_length // 3
context_after = max_length - context_before - len(query_normalized)
start = max(0, match_index - context_before)
end = min(len(content), match_index + len(query_normalized) + context_after)
snippet = content[start:end].strip()
if start > 0:
snippet = f"…{snippet}"
if end < len(content):
snippet = f"{snippet}…"
return snippet
attachment_names = [a.file_name for a in getattr(message, 'attachments').all() if a.file_name]
if attachment_names:
joined_names = ', '.join(attachment_names)
return f"Файлы: {joined_names}"
return 'Сообщение без текста'
class ChatViewSet(viewsets.ModelViewSet):
"""
ViewSet для управления чатами
@ -855,6 +889,79 @@ class ChatViewSet(viewsets.ModelViewSet):
'has_more_after': len(after) > after_limit
})
@action(detail=True, methods=['get'], url_path='search-messages')
def search_messages(self, request, pk=None):
"""Поиск сообщений внутри одного чата с пагинацией и сниппетами."""
chat = self.get_object()
if not user_can_access_chat(chat, request.user):
return Response(
{'error': 'Access denied'},
status=status.HTTP_403_FORBIDDEN
)
query = (request.query_params.get('q') or '').strip()
try:
limit = min(max(int(request.query_params.get('limit', 20)), 1), 100)
except (ValueError, TypeError):
limit = 20
try:
offset = max(int(request.query_params.get('offset', 0)), 0)
except (ValueError, TypeError):
offset = 0
if len(query) < 2:
return Response({
'query': query,
'count': 0,
'offset': offset,
'next_offset': None,
'results': [],
})
queryset = chat.messages.filter(
is_deleted=False,
).select_related(
'author'
).prefetch_related(
'attachments'
).filter(
Q(content__icontains=query) | Q(attachments__file_name__icontains=query)
).distinct().order_by('-created_at', '-id')
total_count = queryset.count()
matches = list(queryset[offset:offset + limit])
next_offset = offset + limit if (offset + limit) < total_count else None
results = []
for message in matches:
author = message.author
author_name = (
f"{getattr(author, 'last_name', '')} {getattr(author, 'first_name', '')}".strip()
or getattr(author, 'username', '')
or 'Сотрудник'
)
attachments = list(message.attachments.all())
results.append({
'message_id': message.id,
'content': message.content or '',
'snippet': _build_message_search_snippet(message, query),
'author_name': author_name,
'created_at': message.created_at,
'attachments_count': len(attachments),
'has_attachments': bool(attachments),
})
return Response({
'query': query,
'count': total_count,
'offset': offset,
'next_offset': next_offset,
'results': results,
})
@action(detail=True, methods=['post'], url_path='mark-read')
def mark_read(self, request, pk=None):
"""

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -7,6 +7,7 @@ import { Archive, ArrowRightLeft, ArrowUpDown, ChevronDown, Filter, MessageSquar
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { formatDate, formatMoney } from "@/lib/shared";
import { useEquipmentPage } from "@/hooks/useEquipmentPage";
import { Modal } from "@/components/ui";
const listModeMeta = [
{ value: "all", label: "Весь реестр" },
@ -611,14 +612,14 @@ export default function EquipmentPage() {
)}
{/* ===== Modal create/edit ===== */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}>
<div className="relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl bg-white p-5 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-900">{modalMode === "create" ? "Добавить оборудование" : "Редактировать оборудование"}</h2>
<button type="button" onClick={closeModal} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button>
<Modal isOpen={isModalOpen} onClose={closeModal} title={modalMode === "create" ? "Добавить оборудование" : "Редактировать оборудование"} size="md" footer={
<div className="flex flex-wrap items-center justify-end gap-2">
<button type="button" onClick={closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
<button type="button" onClick={() => handleSave(modalMode)} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">
{modalMode === "create" ? "Добавить" : "Сохранить"}
</button>
</div>
}>
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
<div className="flex flex-col gap-3">
@ -740,30 +741,20 @@ export default function EquipmentPage() {
/>
</div>
</div>
</Modal>
{/* Buttons */}
<div className="mt-5 flex flex-wrap items-center justify-end gap-2 border-t border-gray-100 pt-4">
<button type="button" onClick={closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
<button type="button" onClick={() => handleSave(modalMode)} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">
{modalMode === "create" ? "Добавить" : "Сохранить"}
</button>
<Modal isOpen={!!operationModal && !!selectedEquipment} onClose={closeOperationModal} title={
operationModal === "transfer" ? "Перевод оборудования" :
operationModal === "writeoff" ? "Списание оборудования" :
"Добавить обслуживание"
} size="md" footer={
<div className="flex items-center justify-end gap-2">
<button type="button" onClick={closeOperationModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
{operationModal === "transfer" && <button type="button" onClick={() => { void handleTransfer(); }} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Перевести</button>}
{operationModal === "writeoff" && <button type="button" onClick={() => { void handleWriteOff(); }} disabled={busyKey !== null} className="rounded-lg bg-rose-600 px-3 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-60">Списать</button>}
{operationModal === "maintenance" && <button type="button" onClick={() => { void handleMaintenance(); }} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Добавить</button>}
</div>
</div>
</div>
)}
{operationModal && selectedEquipment && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) closeOperationModal(); }}>
<div className="relative w-full max-w-lg rounded-2xl bg-white p-5 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-gray-900">
{operationModal === "transfer" && "Перевод оборудования"}
{operationModal === "writeoff" && "Списание оборудования"}
{operationModal === "maintenance" && "Добавить обслуживание"}
</h3>
<button type="button" onClick={closeOperationModal} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button>
</div>
}>
{operationModal === "transfer" && (
<div className="space-y-3">
<SearchableSelectSingle
@ -821,16 +812,7 @@ export default function EquipmentPage() {
</div>
</div>
)}
<div className="mt-5 flex items-center justify-end gap-2 border-t border-gray-100 pt-4">
<button type="button" onClick={closeOperationModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
{operationModal === "transfer" && <button type="button" onClick={() => { void handleTransfer(); }} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Перевести</button>}
{operationModal === "writeoff" && <button type="button" onClick={() => { void handleWriteOff(); }} disabled={busyKey !== null} className="rounded-lg bg-rose-600 px-3 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-60">Списать</button>}
{operationModal === "maintenance" && <button type="button" onClick={() => { void handleMaintenance(); }} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Добавить</button>}
</div>
</div>
</div>
)}
</Modal>
{/* ===== Attachment preview removed (no attachment fields in model) ===== */}
</AppShell>

View File

@ -7,12 +7,13 @@ import { AppShell } from "../../../components/AppShell";
import ChatDialogHeader from "@/components/messages/ChatDialogHeader";
import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal";
import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem";
import ChatSearchPanel from "@/components/messages/ChatSearchPanel";
import MessageActionsMenu from "@/components/messages/MessageActionsMenu";
import MessageReadersModal from "@/components/messages/MessageReadersModal";
import MessageComposer from "@/components/messages/MessageComposer";
import ReactionPickerModal from "@/components/messages/ReactionPickerModal";
import { apiClient } from "@/lib/api";
import type { Message } from "@/types/api";
import type { ChatMessageSearchResponse, ChatMessageSearchResult, Message } from "@/types/api";
import { useUser } from "@/contexts/UserContext";
import { useChatFallbackSync } from "@/hooks/useChatFallbackSync";
import { useSilentChatReloadGuard } from "@/hooks/useSilentChatReloadGuard";
@ -69,6 +70,8 @@ export default function MessageDialogPage() {
const messagesViewportRef = useRef<ScrollableMessageListInner | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const messageInputRef = useRef<HTMLTextAreaElement | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const highlightTimerRef = useRef<NodeJS.Timeout | null>(null);
/* ──────────────────────────────────────────────────────
* HOOK: Chat messages (state, pagination, pending)
@ -141,6 +144,16 @@ export default function MessageDialogPage() {
const [isPinned, setIsPinned] = useState(false);
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
const [jumpingToDate, setJumpingToDate] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<ChatMessageSearchResult[]>([]);
const [searchTotalCount, setSearchTotalCount] = useState(0);
const [selectedSearchResultIndex, setSelectedSearchResultIndex] = useState(0);
const [searchLoading, setSearchLoading] = useState(false);
const [searchLoadingMore, setSearchLoadingMore] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [nextSearchOffset, setNextSearchOffset] = useState<number | null>(null);
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
// Sync settings from chat
useEffect(() => {
@ -150,6 +163,17 @@ export default function MessageDialogPage() {
}
}, [cm.chat]);
useEffect(() => {
if (!isSearchOpen) {
return;
}
requestAnimationFrame(() => {
searchInputRef.current?.focus();
searchInputRef.current?.select();
});
}, [isSearchOpen]);
/* ── membership & permissions ── */
const myMembership = useMemo(() => {
if (!cm.chat?.memberships || !user?.id) return null;
@ -313,6 +337,7 @@ export default function MessageDialogPage() {
/* ── cleanup timers ── */
useEffect(() => () => {
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
}, []);
useLayoutEffect(() => {
@ -602,6 +627,171 @@ export default function MessageDialogPage() {
} catch (e) { console.error("Ошибка переключения уведомлений:", e); }
};
const openSearchPanel = useCallback(() => {
setIsSearchOpen(true);
setExpandedReplyActionForId(null);
setActionsMenuAnchor(null);
setReactionPickerForMessageId(null);
setShowComposerEmojiPicker(false);
scroll.setIsDateNavigatorOpen(false);
}, [scroll]);
const closeSearchPanel = useCallback(() => {
setIsSearchOpen(false);
setSearchQuery("");
setSearchResults([]);
setSearchTotalCount(0);
setSelectedSearchResultIndex(0);
setSearchLoading(false);
setSearchLoadingMore(false);
setSearchError(null);
setNextSearchOffset(null);
}, []);
const highlightFoundMessage = useCallback((messageId: number) => {
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
setHighlightedMessageId(messageId);
highlightTimerRef.current = setTimeout(() => {
setHighlightedMessageId(null);
highlightTimerRef.current = null;
}, 2200);
}, []);
const runSearch = useCallback(async (query: string, options?: { append?: boolean; offset?: number }) => {
const normalizedQuery = query.trim();
if (!chatId || normalizedQuery.length < 2) {
setSearchResults([]);
setSearchTotalCount(0);
setSelectedSearchResultIndex(0);
setSearchError(null);
setNextSearchOffset(null);
return;
}
const append = Boolean(options?.append);
if (append) {
setSearchLoadingMore(true);
} else {
setSearchLoading(true);
}
setSearchError(null);
try {
const response: ChatMessageSearchResponse = await apiClient.searchChatMessages(chatId, {
q: normalizedQuery,
limit: 20,
offset: options?.offset ?? 0,
});
setSearchTotalCount(response.count);
setNextSearchOffset(response.next_offset);
setSearchResults((prev) => {
if (!append) {
return response.results;
}
const seen = new Set(prev.map((item) => item.message_id));
const merged = [...prev];
response.results.forEach((item: ChatMessageSearchResult) => {
if (!seen.has(item.message_id)) {
seen.add(item.message_id);
merged.push(item);
}
});
return merged;
});
if (!append) {
setSelectedSearchResultIndex(0);
}
} catch (e) {
console.error("Ошибка поиска по сообщениям:", e);
setSearchError("Не удалось выполнить поиск по сообщениям.");
} finally {
if (append) {
setSearchLoadingMore(false);
} else {
setSearchLoading(false);
}
}
}, [chatId]);
useEffect(() => {
if (!isSearchOpen) {
return;
}
const normalizedQuery = searchQuery.trim();
if (normalizedQuery.length < 2) {
setSearchResults([]);
setSearchTotalCount(0);
setSelectedSearchResultIndex(0);
setSearchError(null);
setNextSearchOffset(null);
setSearchLoading(false);
setSearchLoadingMore(false);
return;
}
const timer = setTimeout(() => {
void runSearch(normalizedQuery, { append: false, offset: 0 });
}, 250);
return () => clearTimeout(timer);
}, [isSearchOpen, runSearch, searchQuery]);
const handleSelectSearchResult = useCallback(async (index: number) => {
const result = searchResults[index];
if (!result) {
return;
}
setSelectedSearchResultIndex(index);
const jumped = await cm.jumpToMessage(result.message_id);
if (jumped) {
highlightFoundMessage(result.message_id);
setExpandedReplyActionForId(null);
setActionsMenuAnchor(null);
setReactionPickerForMessageId(null);
setIsReadersModalOpen(false);
}
}, [cm, highlightFoundMessage, searchResults]);
const handleSubmitSelectedSearchResult = useCallback(() => {
if (searchResults.length === 0) {
return;
}
void handleSelectSearchResult(selectedSearchResultIndex);
}, [handleSelectSearchResult, searchResults.length, selectedSearchResultIndex]);
const handlePreviousSearchResult = useCallback(() => {
if (searchResults.length === 0) {
return;
}
const nextIndex = selectedSearchResultIndex <= 0 ? searchResults.length - 1 : selectedSearchResultIndex - 1;
void handleSelectSearchResult(nextIndex);
}, [handleSelectSearchResult, searchResults.length, selectedSearchResultIndex]);
const handleNextSearchResult = useCallback(() => {
if (searchResults.length === 0) {
return;
}
const nextIndex = selectedSearchResultIndex >= searchResults.length - 1 ? 0 : selectedSearchResultIndex + 1;
void handleSelectSearchResult(nextIndex);
}, [handleSelectSearchResult, searchResults.length, selectedSearchResultIndex]);
const handleLoadMoreSearchResults = useCallback(() => {
const normalizedQuery = searchQuery.trim();
if (!normalizedQuery || nextSearchOffset === null || searchLoadingMore) {
return;
}
void runSearch(normalizedQuery, { append: true, offset: nextSearchOffset });
}, [nextSearchOffset, runSearch, searchLoadingMore, searchQuery]);
/* ── global click / escape dismissals ── */
useEffect(() => {
@ -628,11 +818,12 @@ export default function MessageDialogPage() {
setReactionPickerForMessageId(null);
setShowComposerEmojiPicker(false);
scroll.setIsDateNavigatorOpen(false);
if (isSearchOpen) closeSearchPanel();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [scroll]);
}, [closeSearchPanel, isSearchOpen, scroll]);
// Mobile keyboard viewport adjustment
useEffect(() => {
@ -655,7 +846,14 @@ export default function MessageDialogPage() {
if (!c?.containerRef?.current) return;
const v = c.containerRef.current;
const t = e.target as HTMLElement | null;
if (!t || v.contains(t) || t.closest("textarea,input,button,a,video,audio,[contenteditable='true']")) return;
if (
!t ||
v.contains(t) ||
t.closest("[data-overlay-root='true'], [role='dialog'], [aria-modal='true']") ||
t.closest("textarea,input,button,a,video,audio,[contenteditable='true']")
) {
return;
}
e.preventDefault();
v.scrollTop += e.deltaY;
};
@ -691,6 +889,29 @@ export default function MessageDialogPage() {
notificationsEnabled={notificationsEnabled}
onTogglePin={handleTogglePin}
onToggleNotifications={handleToggleNotifications}
onOpenSearch={openSearchPanel}
/>
<ChatSearchPanel
isOpen={isSearchOpen}
query={searchQuery}
loading={searchLoading}
loadingMore={searchLoadingMore}
error={searchError}
results={searchResults}
totalCount={searchTotalCount}
nextOffset={nextSearchOffset}
selectedIndex={selectedSearchResultIndex}
inputRef={searchInputRef}
onQueryChange={setSearchQuery}
onClose={closeSearchPanel}
onPrevious={handlePreviousSearchResult}
onNext={handleNextSearchResult}
onSelect={(index) => {
void handleSelectSearchResult(index);
}}
onLoadMore={handleLoadMoreSearchResults}
onSubmitSelection={handleSubmitSelectedSearchResult}
/>
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
@ -756,6 +977,7 @@ export default function MessageDialogPage() {
message={message}
currentUserId={user?.id}
repliedMessage={repliedMessage}
isHighlighted={highlightedMessageId === message.id}
isActionsOpen={expandedReplyActionForId === message.id}
canManage={!message.is_optimistic && canManageMessage(message)}
canReply={!message.is_deleted && !message.is_optimistic}

View File

@ -11,6 +11,7 @@ import Link from "next/link";
import Image from "next/image";
import { useUser } from "@/contexts/UserContext";
import { useRouter } from "next/navigation";
import { Modal } from "@/components/ui";
type UnreadFilter = "all" | "unread" | "read";
type PinnedFilter = "all" | "pinned" | "unpinned";
@ -661,19 +662,25 @@ export default function MessagesPage() {
)}
{/* Модальное окно создания чата */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-900">Создать чат</h2>
<button
onClick={() => setShowCreateModal(false)}
className="rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
>
<X size={20} />
</button>
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="Создать чат" size="sm" closeOnEsc={!creating} footer={
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
>
Отмена
</button>
<button
type="button"
onClick={handleCreateChat}
disabled={creating}
className="flex-1 rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-600 disabled:opacity-50"
>
{creating ? 'Создание...' : 'Создать'}
</button>
</div>
}>
<div className="space-y-4">
{/* Тип чата */}
<div>
@ -799,29 +806,8 @@ export default function MessagesPage() {
</label>
)}
</div>
{/* Кнопки */}
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
>
Отмена
</button>
<button
type="button"
onClick={handleCreateChat}
disabled={creating}
className="flex-1 rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-600 disabled:opacity-50"
>
{creating ? 'Создание...' : 'Создать'}
</button>
</div>
</div>
</div>
</div>
)}
</Modal>
</AppShell>
);
}

View File

@ -2,6 +2,7 @@
import { Heart, ImageIcon, MessageSquare, Paperclip, Pencil, Plus, Send, Trash2, X } from "lucide-react";
import { AppShell } from "../components/AppShell";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
import { useEffect, useRef, useState } from "react";
@ -683,152 +684,180 @@ export default function Home() {
)}
</div>
{createPostOpen ? (
<div className="fixed inset-0 z-[100]">
<button
type="button"
aria-label="Закрыть создание публикации"
onClick={() => setCreatePostOpen(false)}
className="absolute inset-0 bg-black/50"
/>
<Modal
isOpen={createPostOpen}
onClose={() => setCreatePostOpen(false)}
title={editingPostId ? "Редактировать публикацию" : "Создать публикацию"}
size="lg"
closeOnEsc={!createSubmitting}
footer={
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setCreatePostOpen(false)}
className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
>
Отмена
</button>
<button
type="button"
disabled={createSubmitting}
onClick={handleCreatePost}
className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-50"
>
{createSubmitting ? (editingPostId ? "Сохраняем..." : "Публикуем...") : (editingPostId ? "Сохранить" : "Опубликовать")}
</button>
</div>
}
>
<div className="space-y-4">
{createError ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{createError}</div>
) : null}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl max-h-[80vh] mx-auto px-3 flex flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200 sm:px-6">
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<p className="text-sm font-semibold text-gray-900">
{editingPostId ? "Редактировать публикацию" : "Создать публикацию"}
</p>
<button
type="button"
onClick={() => setCreatePostOpen(false)}
className="flex h-9 w-9 items-center justify-center rounded-full hover:bg-gray-100"
{(canCreateCompanyPost || canCreateDepartmentPost) ? (
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Тип публикации</label>
<select
value={createType}
onChange={(e) => setCreateType(e.target.value as "company" | "department")}
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
>
<X size={18} className="text-gray-600" />
</button>
{canCreateCompanyPost ? <option value="company">Новость компании</option> : null}
{canCreateDepartmentPost ? <option value="department">Новость отдела</option> : null}
</select>
</div>
) : null}
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="space-y-4">
{createError ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{createError}</div>
) : null}
{(canCreateCompanyPost || canCreateDepartmentPost) ? (
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Тип публикации</label>
<select
value={createType}
onChange={(e) => setCreateType(e.target.value as "company" | "department")}
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
>
{canCreateCompanyPost ? <option value="company">Новость компании</option> : null}
{canCreateDepartmentPost ? <option value="department">Новость отдела</option> : null}
</select>
</div>
) : null}
{createType === "department" ? (
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Отдел</label>
<select
value={createDepartmentId}
onChange={(e) => setCreateDepartmentId(e.target.value)}
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
>
<option value="">Выберите отдел</option>
{userDepartments.map((dept) => (
<option key={dept.id} value={String(dept.id)}>{dept.name}</option>
))}
</select>
</div>
) : null}
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Заголовок</label>
<input
type="text"
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Содержание</label>
<textarea
value={createBody}
onChange={(e) => setCreateBody(e.target.value)}
className="min-h-32 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Изображение</label>
<input
type="file"
accept="image/*"
onChange={(e) => setCreateImage(e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-sky-50 file:px-3 file:py-2 file:text-sm file:text-sky-700 hover:file:bg-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Вложение</label>
<input
type="file"
onChange={(e) => setCreateAttachment(e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-gray-100 file:px-3 file:py-2 file:text-sm file:text-gray-700 hover:file:bg-gray-200"
/>
</div>
</div>
{createType === "department" ? (
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Отдел</label>
<select
value={createDepartmentId}
onChange={(e) => setCreateDepartmentId(e.target.value)}
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
>
<option value="">Выберите отдел</option>
{userDepartments.map((dept) => (
<option key={dept.id} value={String(dept.id)}>{dept.name}</option>
))}
</select>
</div>
) : null}
<div className="border-t border-gray-100 px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setCreatePostOpen(false)}
className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
>
Отмена
</button>
<button
type="button"
disabled={createSubmitting}
onClick={handleCreatePost}
className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-50"
>
{createSubmitting ? (editingPostId ? "Сохраняем..." : "Публикуем...") : (editingPostId ? "Сохранить" : "Опубликовать")}
</button>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Заголовок</label>
<input
type="text"
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
className="h-10 w-full rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Содержание</label>
<textarea
value={createBody}
onChange={(e) => setCreateBody(e.target.value)}
className="min-h-32 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Изображение</label>
<input
type="file"
accept="image/*"
onChange={(e) => setCreateImage(e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-sky-50 file:px-3 file:py-2 file:text-sm file:text-sky-700 hover:file:bg-sky-100"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Вложение</label>
<input
type="file"
onChange={(e) => setCreateAttachment(e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-gray-100 file:px-3 file:py-2 file:text-sm file:text-gray-700 hover:file:bg-gray-200"
/>
</div>
</div>
) : null}
</Modal>
{commentsOpen && activePost && (
<div className="fixed inset-0 z-[100]">
<button
type="button"
aria-label="Закрыть комментарии"
onClick={() => setCommentsOpen(false)}
className="absolute inset-0 bg-black/50"
/>
<div className="absolute inset-x-2 sm:inset-x-3 top-2 sm:top-4 bottom-2 sm:bottom-4 mx-auto flex max-w-[95vw] sm:max-w-2xl flex-col overflow-hidden rounded-xl sm:rounded-2xl bg-white shadow-2xl ring-1 ring-gray-200">
<div className="flex items-center justify-between border-b border-gray-100 px-3 sm:px-4 py-2.5 sm:py-3">
<div>
<p className="text-xs sm:text-sm font-semibold text-gray-900">Комментарии</p>
<p className="text-[10px] sm:text-xs text-gray-500">Публикация #{activePost.id}</p>
<Modal
isOpen={commentsOpen && !!activePost}
onClose={() => setCommentsOpen(false)}
title="Комментарии"
size="lg"
noPadding
footer={
<div>
{(commentImage || commentAttachment) && (
<div className="mb-2 flex flex-wrap gap-2">
{commentImage && (
<div className="flex items-center gap-1.5 rounded-md bg-sky-50 px-2 py-1 text-xs text-sky-700 ring-1 ring-sky-200">
<ImageIcon size={12} />
<span className="max-w-[120px] truncate">{commentImage.name}</span>
<button type="button" onClick={() => setCommentImage(null)} className="ml-0.5 text-sky-400 hover:text-sky-700"><X size={12} /></button>
</div>
)}
{commentAttachment && (
<div className="flex items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-xs text-gray-700 ring-1 ring-gray-200">
<Paperclip size={12} />
<span className="max-w-[120px] truncate">{commentAttachment.name}</span>
<button type="button" onClick={() => setCommentAttachment(null)} className="ml-0.5 text-gray-400 hover:text-gray-700"><X size={12} /></button>
</div>
)}
</div>
)}
<div className="flex items-center gap-2">
<input ref={commentImageRef} type="file" accept="image/*" className="hidden" onChange={(e) => { setCommentImage(e.target.files?.[0] || null); e.target.value = ""; }} />
<input ref={commentAttachmentRef} type="file" className="hidden" onChange={(e) => { setCommentAttachment(e.target.files?.[0] || null); e.target.value = ""; }} />
<button
type="button"
onClick={() => setCommentsOpen(false)}
className="flex h-9 w-9 items-center justify-center rounded-full hover:bg-gray-100"
onClick={() => commentImageRef.current?.click()}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title="Прикрепить изображение"
>
<X size={18} className="text-gray-600" />
<ImageIcon size={18} />
</button>
<button
type="button"
onClick={() => commentAttachmentRef.current?.click()}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title="Прикрепить файл"
>
<Paperclip size={18} />
</button>
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleCreateComment();
}
}}
placeholder="Напишите комментарий..."
className="h-10 flex-1 rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
<button
type="button"
disabled={commentSending || (!newComment.trim() && !commentImage && !commentAttachment)}
onClick={handleCreateComment}
className="flex h-10 w-10 items-center justify-center rounded-lg bg-sky-500 text-white disabled:cursor-not-allowed disabled:opacity-50"
>
<Send size={16} />
</button>
</div>
</div>
}
>
{activePost && (
<>
<p className="px-4 pt-1 pb-2 text-[10px] sm:text-xs text-gray-500">Публикация #{activePost.id}</p>
<div className="flex-1 overflow-y-auto px-4 py-3">
{commentsLoading ? (
<p className="text-sm text-gray-500">Загрузка комментариев...</p>
@ -943,71 +972,9 @@ export default function Home() {
</div>
)}
</div>
<div className="border-t border-gray-100 px-4 py-3">
{(commentImage || commentAttachment) && (
<div className="mb-2 flex flex-wrap gap-2">
{commentImage && (
<div className="flex items-center gap-1.5 rounded-md bg-sky-50 px-2 py-1 text-xs text-sky-700 ring-1 ring-sky-200">
<ImageIcon size={12} />
<span className="max-w-[120px] truncate">{commentImage.name}</span>
<button type="button" onClick={() => setCommentImage(null)} className="ml-0.5 text-sky-400 hover:text-sky-700"><X size={12} /></button>
</div>
)}
{commentAttachment && (
<div className="flex items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-xs text-gray-700 ring-1 ring-gray-200">
<Paperclip size={12} />
<span className="max-w-[120px] truncate">{commentAttachment.name}</span>
<button type="button" onClick={() => setCommentAttachment(null)} className="ml-0.5 text-gray-400 hover:text-gray-700"><X size={12} /></button>
</div>
)}
</div>
)}
<div className="flex items-center gap-2">
<input ref={commentImageRef} type="file" accept="image/*" className="hidden" onChange={(e) => { setCommentImage(e.target.files?.[0] || null); e.target.value = ""; }} />
<input ref={commentAttachmentRef} type="file" className="hidden" onChange={(e) => { setCommentAttachment(e.target.files?.[0] || null); e.target.value = ""; }} />
<button
type="button"
onClick={() => commentImageRef.current?.click()}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title="Прикрепить изображение"
>
<ImageIcon size={18} />
</button>
<button
type="button"
onClick={() => commentAttachmentRef.current?.click()}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title="Прикрепить файл"
>
<Paperclip size={18} />
</button>
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleCreateComment();
}
}}
placeholder="Напишите комментарий..."
className="h-10 flex-1 rounded-lg border border-gray-300 px-3 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
<button
type="button"
disabled={commentSending || (!newComment.trim() && !commentImage && !commentAttachment)}
onClick={handleCreateComment}
className="flex h-10 w-10 items-center justify-center rounded-lg bg-sky-500 text-white disabled:cursor-not-allowed disabled:opacity-50"
>
<Send size={16} />
</button>
</div>
</div>
</div>
</div>
)}
</>
)}
</Modal>
</AppShell>
);
}

View File

@ -30,6 +30,7 @@ import {
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { formatDate, formatMoney } from "@/lib/shared";
import { useProcurementPage } from "@/hooks/useProcurementPage";
import { Modal } from "@/components/ui";
/* ══════════════════════════════════════════════════════
Constants & helpers
@ -596,13 +597,14 @@ export default function ProcurementPage() {
)}
{/* ══════════ Create / Edit modal ══════════ */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}>
<div className="relative max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl bg-white p-5 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-900">{modalMode === "create" ? "Новая заявка на закупку" : "Редактировать заявку"}</h2>
<button type="button" onClick={closeModal} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button>
<Modal isOpen={isModalOpen} onClose={closeModal} title={modalMode === "create" ? "Новая заявка на закупку" : "Редактировать заявку"} size="lg" footer={
<div className="flex flex-wrap items-center justify-end gap-2">
<button type="button" onClick={closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
<button type="button" onClick={handleSave} disabled={busyKey === "save"} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">
{modalMode === "create" ? "Создать черновик" : "Сохранить"}
</button>
</div>
}>
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
@ -723,17 +725,7 @@ export default function ProcurementPage() {
</div>
</div>
</div>
{/* ── modal buttons ── */}
<div className="mt-5 flex flex-wrap items-center justify-end gap-2 border-t border-gray-100 pt-4">
<button type="button" onClick={closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
<button type="button" onClick={handleSave} disabled={busyKey === "save"} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">
{modalMode === "create" ? "Создать черновик" : "Сохранить"}
</button>
</div>
</div>
</div>
)}
</Modal>
</AppShell>
);
}

View File

@ -2,7 +2,7 @@
/* eslint-disable react-hooks/refs -- All h.* values come from useState, not useRef. React compiler false positive. */
import { AppShell } from "../../components/AppShell";
import { Modal } from "@/components/ui/Modal";
import { Modal } from "@/components/ui";
import { useUser } from "@/contexts/UserContext";
import { canManageRequests, canProcessRequests } from "@/lib/permissions";
import { useRef } from "react";
@ -241,10 +241,13 @@ export default function RequestsPage() {
</Modal>
{/* Create/Edit modal */}
{h.isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) h.closeModal(); }}>
<div className="relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl bg-white p-5 shadow-xl">
<div className="mb-4 flex items-center justify-between"><h2 className="text-base font-semibold text-gray-900">{h.modalMode === "create" ? "Новое заявление" : "Редактировать заявление"}</h2><button type="button" onClick={h.closeModal} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button></div>
<Modal isOpen={h.isModalOpen} onClose={h.closeModal} title={h.modalMode === "create" ? "Новое заявление" : "Редактировать заявление"} size="md" footer={
<div className="flex flex-wrap items-center justify-end gap-2">
<button type="button" onClick={h.closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
<button type="button" onClick={() => h.handleCreateOrUpdate(h.modalMode, "draft")} disabled={h.busyKey !== null} className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60">Сохранить как черновик</button>
<button type="button" onClick={() => h.handleCreateOrUpdate(h.modalMode, "submitted")} disabled={h.busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">{h.modalMode === "create" ? "Создать" : "Сохранить"}</button>
</div>
}>
{h.actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{h.actionError}</p>}
<div className="flex flex-col gap-3">
<div><label className="mb-1 block text-xs font-medium text-gray-500">Тема заявления</label><input value={h.form.title} onChange={(e) => h.setForm((p) => ({ ...p, title: e.target.value }))} placeholder="Тема заявления" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100" /></div>
@ -257,26 +260,16 @@ export default function RequestsPage() {
<div><input ref={fileInputRef} type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={(e) => { const f = e.target.files?.[0] || null; if (f) { const ext = f.name.split(".").pop()?.toLowerCase() || ""; if (!["pdf","jpg","jpeg","png"].includes(ext)) return; } h.setForm((p) => ({ ...p, attachment: f })); }} /><button type="button" onClick={() => fileInputRef.current?.click()} className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"><Paperclip size={14} />{h.form.attachment ? h.form.attachment.name : "Прикрепить файл"}</button></div>
<label className="flex items-center gap-2 text-sm text-gray-700"><input type="checkbox" checked={h.form.sent_to_all_department} onChange={(e) => h.setForm((p) => ({ ...p, sent_to_all_department: e.target.checked }))} className="rounded border-gray-300" />Отправить всем сотрудникам выбранных отделов</label>
</div>
<div className="mt-5 flex flex-wrap items-center justify-end gap-2 border-t border-gray-100 pt-4">
<button type="button" onClick={h.closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
<button type="button" onClick={() => h.handleCreateOrUpdate(h.modalMode, "draft")} disabled={h.busyKey !== null} className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60">Сохранить как черновик</button>
<button type="button" onClick={() => h.handleCreateOrUpdate(h.modalMode, "submitted")} disabled={h.busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">{h.modalMode === "create" ? "Создать" : "Сохранить"}</button>
</div>
</div>
</div>
)}
</Modal>
{/* Attachment preview */}
{h.attachmentPreview && (() => {
const apUrl = h.attachmentPreview.url;
const apName = h.attachmentPreview.name;
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4" onClick={(e) => { if (e.target === e.currentTarget) h.setAttachmentPreview(null); }}>
<div className="relative flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3"><p className="truncate text-sm font-medium text-gray-800">{apName}</p><div className="flex items-center gap-2"><a href={apUrl} download className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50">Скачать</a><button type="button" onClick={() => h.setAttachmentPreview(null)} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X size={18} /></button></div></div>
<div className="flex-1 overflow-auto p-4">{(() => { const ext = apUrl.split(".").pop()?.toLowerCase() || ""; if (["jpg","jpeg","png","gif","webp","svg","bmp"].includes(ext)) return <img src={apUrl} alt={apName} className="mx-auto max-h-[70vh] rounded-lg object-contain" />; if (["mp4","webm","ogg","mov"].includes(ext)) return <video src={apUrl} controls className="mx-auto max-h-[70vh] rounded-lg" />; if (["mp3","wav","aac"].includes(ext)) return <audio src={apUrl} controls className="mx-auto mt-8" />; return <div className="flex flex-col items-center gap-3 py-12 text-center"><FileSignature size={40} className="text-gray-300" /><p className="text-sm text-gray-500">{apName}</p><a href={apUrl} download className="rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-600">Скачать файл</a></div>; })()}</div>
</div>
</div>
<Modal isOpen onClose={() => h.setAttachmentPreview(null)} title={apName} size="lg" footer={<a href={apUrl} download className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50">Скачать</a>}>
<div className="flex-1 overflow-auto">{(() => { const ext = apUrl.split(".").pop()?.toLowerCase() || ""; if (["jpg","jpeg","png","gif","webp","svg","bmp"].includes(ext)) return <img src={apUrl} alt={apName} className="mx-auto max-h-[70vh] rounded-lg object-contain" />; if (["mp4","webm","ogg","mov"].includes(ext)) return <video src={apUrl} controls className="mx-auto max-h-[70vh] rounded-lg" />; if (["mp3","wav","aac"].includes(ext)) return <audio src={apUrl} controls className="mx-auto mt-8" />; return <div className="flex flex-col items-center gap-3 py-12 text-center"><FileSignature size={40} className="text-gray-300" /><p className="text-sm text-gray-500">{apName}</p><a href={apUrl} download className="rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-600">Скачать файл</a></div>; })()}</div>
</Modal>
);
})()}
</AppShell>

View File

@ -3,6 +3,7 @@
import { useCallback, useState } from 'react';
import Cropper from 'react-easy-crop';
import type { Area, Point } from 'react-easy-crop';
import { Modal } from "@/components/ui";
interface AvatarCropperProps {
onCropComplete: (croppedImage: string) => void;
@ -59,8 +60,7 @@ export default function AvatarCropper({ onCropComplete, onCancel, initialImage }
}, [croppedAreaPixels, initialImage, onCropComplete]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl">
<Modal isOpen onClose={onCancel} noHeader noPadding size="lg">
{/* Заголовок */}
<div className="border-b border-gray-200 px-6 py-4">
<h3 className="text-lg font-semibold text-gray-900">
@ -174,7 +174,6 @@ export default function AvatarCropper({ onCropComplete, onCancel, initialImage }
</button>
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -1,7 +1,8 @@
"use client";
import { useState, useEffect } from "react";
import { X, Trash2 } from "lucide-react";
import { Trash2 } from "lucide-react";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import { useCalendar } from "@/contexts/CalendarContext";
@ -79,28 +80,39 @@ export function CalendarModal({ isOpen, onClose, calendar }: CalendarModalProps)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
onClose();
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4">
<div className="w-full max-w-[95vw] sm:max-w-md rounded-xl sm:rounded-2xl bg-white p-4 sm:p-6 shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
<div className="mb-3 sm:mb-4 flex items-center justify-between">
<h3 className="text-base sm:text-lg font-semibold text-gray-900">
{calendar?.id ? "Редактировать календарь" : "Создать календарь"}
</h3>
<Modal
isOpen={isOpen}
onClose={onClose}
title={calendar?.id ? "Редактировать календарь" : "Создать календарь"}
size="sm"
footer={
<div className="flex gap-2">
<button
onClick={onClose}
disabled={saving}
className="rounded-full p-1 hover:bg-gray-100 disabled:opacity-50"
onClick={handleSave}
disabled={!name.trim() || saving}
className="flex-1 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<X size={20} className="text-gray-600" />
{saving ? "Сохранение..." : calendar?.id ? "Сохранить" : "Создать"}
</button>
</div>
<div className="space-y-4">
{calendar?.id && (
<button
onClick={handleDelete}
disabled={saving}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100 disabled:opacity-50"
title="Удалить календарь"
>
<Trash2 size={16} />
</button>
)}
</div>
}
>
<div className="space-y-4">
{!calendar?.id && (
<p className="text-sm text-gray-600">
Создайте календарь для организации событий. Вы сможете добавлять встречи, задачи и напоминания.
@ -123,28 +135,7 @@ export function CalendarModal({ isOpen, onClose, calendar }: CalendarModalProps)
/>
</div>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={!name.trim() || saving}
className="flex-1 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Сохранение..." : calendar?.id ? "Сохранить" : "Создать"}
</button>
{calendar?.id && (
<button
onClick={handleDelete}
disabled={saving}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100 disabled:opacity-50"
title="Удалить календарь"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -3,6 +3,7 @@
import React, { useState, useEffect } from 'react';
import { X, UserPlus, Trash2 } from 'lucide-react';
import api from '@/lib/api';
import { Modal } from '@/components/ui';
interface Participant {
id: number;
@ -182,14 +183,8 @@ export default function CalendarParticipantsModal({
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-[95vw] sm:max-w-2xl rounded-xl sm:rounded-2xl bg-white shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<Modal isOpen={isOpen} onClose={onClose} size="md" noPadding>
<div className="overflow-y-auto">
<div className="flex items-center justify-between border-b px-4 sm:px-6 py-3 sm:py-4">
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900">Участники календаря</h2>
@ -397,6 +392,6 @@ export default function CalendarParticipantsModal({
</button>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -1,7 +1,8 @@
"use client";
import { useState, useEffect } from "react";
import { X, Check, Bell, BellOff, Eye, EyeOff, Loader2 } from "lucide-react";
import { Check, Bell, BellOff, Eye, EyeOff, Loader2 } from "lucide-react";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
interface Calendar {
@ -138,17 +139,11 @@ export function CalendarSubscriptionsModal({ isOpen, onClose, onUpdate }: Props)
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4">
<div className="w-full max-w-[95vw] sm:max-w-2xl rounded-xl sm:rounded-2xl bg-white shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
<Modal isOpen={isOpen} onClose={onClose} size="md" noPadding>
<div className="overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-100 px-4 sm:px-6 py-3 sm:py-4">
<h3 className="text-base sm:text-lg font-semibold text-gray-900">Подписки на календари</h3>
<button
onClick={onClose}
className="rounded-full p-1 hover:bg-gray-100"
>
<X size={20} className="text-gray-600" />
</button>
</div>
{/* Tabs */}
@ -296,6 +291,6 @@ export function CalendarSubscriptionsModal({ isOpen, onClose, onUpdate }: Props)
</button>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { X, Trash2 } from "lucide-react";
import { apiClient } from "@/lib/api";
import { Modal } from "@/components/ui";
// Нормализация byweekday в массив чисел
function normalizeByweekday(byweekday: any): number[] {
@ -377,25 +378,41 @@ export function EventModal({
}
};
if (!isOpen || !editingEvent) return null;
if (!editingEvent) return null;
const footerContent = (
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={!editingEvent.title?.trim() || saving}
className="flex-1 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Сохранение...' : (editingEvent.id ? 'Сохранить' : 'Создать')}
</button>
{editingEvent.id && (
<button
onClick={handleDelete}
disabled={saving}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100 disabled:opacity-50"
title="Удалить событие"
>
<Trash2 size={16} />
</button>
)}
</div>
);
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4">
<div className="w-full max-w-[95vw] sm:max-w-md rounded-xl sm:rounded-2xl bg-white p-4 sm:p-6 shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
<div className="mb-3 sm:mb-4 flex items-center justify-between">
<h3 className="text-base sm:text-lg font-semibold text-gray-900">
{editingEvent.id ? "Редактировать событие" : "Создать событие"}
</h3>
<button
onClick={onClose}
disabled={saving}
className="rounded-full p-1 hover:bg-gray-100 disabled:opacity-50"
>
<X size={20} className="text-gray-600" />
</button>
</div>
<div className="space-y-4">
<Modal
isOpen={isOpen}
onClose={onClose}
title={editingEvent.id ? "Редактировать событие" : "Создать событие"}
size="sm"
closeOnEsc={!saving}
footer={footerContent}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Название
@ -799,29 +816,7 @@ export function EventModal({
)}
</div>
)}
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={!editingEvent.title?.trim() || saving}
className="flex-1 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Сохранение...' : (editingEvent.id ? 'Сохранить' : 'Создать')}
</button>
{editingEvent.id && (
<button
onClick={handleDelete}
disabled={saving}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100 disabled:opacity-50"
title="Удалить событие"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -1,8 +1,9 @@
"use client";
import { useState, useEffect } from "react";
import { X, Search, Check, Users } from "lucide-react";
import { X, Search, Check } from "lucide-react";
import apiClient from "@/lib/api";
import { Modal } from "@/components/ui";
interface Employee {
id: number;
@ -102,164 +103,147 @@ export default function NewChatModal({ isOpen, onClose, onChatCreated }: NewChat
return `${emp.last_name} ${emp.first_name} ${emp.middle_name || ""}`.trim();
};
if (!isOpen) return null;
const isGroup = selectedEmployees.length > 1;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
<div className="bg-white rounded-xl sm:rounded-2xl shadow-xl max-w-[95vw] sm:max-w-2xl w-full max-h-[95vh] sm:max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-3 sm:p-4 border-b">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 sm:w-5 sm:h-5 text-gray-600" />
<h2 className="text-lg sm:text-xl font-semibold">Новый чат</h2>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Chat Name (for groups) */}
{isGroup && (
<div className="p-3 sm:p-4 border-b">
<input
type="text"
placeholder="Название группы (необязательно)"
value={chatName}
onChange={(e) => setChatName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base"
/>
</div>
)}
{/* Selected Employees */}
{selectedEmployees.length > 0 && (
<div className="p-3 sm:p-4 border-b bg-gray-50">
<div className="text-sm text-gray-600 mb-2">
Выбрано: {selectedEmployees.length}
</div>
<div className="flex flex-wrap gap-2">
{employees
.filter(emp => selectedEmployees.includes(emp.id))
.map(emp => (
<div
key={emp.id}
className="flex items-center gap-2 bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-sm"
>
<span>{getEmployeeFullName(emp)}</span>
<button
onClick={() => toggleEmployee(emp.id)}
className="hover:bg-blue-200 rounded-full p-0.5"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
{/* Search */}
<div className="p-3 sm:p-4 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Поиск сотрудников..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base"
/>
</div>
</div>
{/* Employee List */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="text-center py-8 text-gray-500">
Загрузка сотрудников...
</div>
) : employees.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Сотрудники не найдены
</div>
) : (
<div className="space-y-2">
{employees.map((emp) => {
const isSelected = selectedEmployees.includes(emp.id);
return (
<button
key={emp.id}
onClick={() => toggleEmployee(emp.id)}
className={`
w-full flex items-center gap-3 p-3 rounded-lg transition-colors
${isSelected
? "bg-blue-50 border-2 border-blue-500"
: "bg-gray-50 hover:bg-gray-100 border-2 border-transparent"
}
`}
>
{/* Avatar */}
<div className="relative flex-shrink-0">
{emp.avatar ? (
<img
src={emp.avatar}
alt={getEmployeeFullName(emp)}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white font-semibold">
{emp.first_name[0]}{emp.last_name[0]}
</div>
)}
{isSelected && (
<div className="absolute -top-1 -right-1 bg-blue-500 rounded-full p-0.5">
<Check className="w-3 h-3 text-white" />
</div>
)}
</div>
{/* Employee Info */}
<div className="flex-1 text-left">
<div className="font-medium text-gray-900">
{getEmployeeFullName(emp)}
</div>
{emp.position && (
<div className="text-sm text-gray-600">{emp.position}</div>
)}
{emp.department && (
<div className="text-xs text-gray-500">{emp.department.name}</div>
)}
</div>
</button>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t flex justify-end gap-2">
<button
onClick={onClose}
disabled={creating}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
>
Отмена
</button>
<button
onClick={handleCreateChat}
disabled={selectedEmployees.length === 0 || creating}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{creating ? "Создание..." : "Создать чат"}
</button>
</div>
</div>
const footerContent = (
<div className="flex justify-end gap-2">
<button
onClick={onClose}
disabled={creating}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
>
Отмена
</button>
<button
onClick={handleCreateChat}
disabled={selectedEmployees.length === 0 || creating}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{creating ? "Создание..." : "Создать чат"}
</button>
</div>
);
return (
<Modal isOpen={isOpen} onClose={onClose} title="Новый чат" size="md" closeOnEsc={!creating} noPadding footer={footerContent}>
{/* Chat Name (for groups) */}
{isGroup && (
<div className="p-3 sm:p-4 border-b">
<input
type="text"
placeholder="Название группы (необязательно)"
value={chatName}
onChange={(e) => setChatName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base"
/>
</div>
)}
{/* Selected Employees */}
{selectedEmployees.length > 0 && (
<div className="p-3 sm:p-4 border-b bg-gray-50">
<div className="text-sm text-gray-600 mb-2">
Выбрано: {selectedEmployees.length}
</div>
<div className="flex flex-wrap gap-2">
{employees
.filter(emp => selectedEmployees.includes(emp.id))
.map(emp => (
<div
key={emp.id}
className="flex items-center gap-2 bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-sm"
>
<span>{getEmployeeFullName(emp)}</span>
<button
onClick={() => toggleEmployee(emp.id)}
className="hover:bg-blue-200 rounded-full p-0.5"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
{/* Search */}
<div className="p-3 sm:p-4 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Поиск сотрудников..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base"
/>
</div>
</div>
{/* Employee List */}
<div className="max-h-[60vh] overflow-y-auto p-4">
{loading ? (
<div className="text-center py-8 text-gray-500">
Загрузка сотрудников...
</div>
) : employees.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Сотрудники не найдены
</div>
) : (
<div className="space-y-2">
{employees.map((emp) => {
const isSelected = selectedEmployees.includes(emp.id);
return (
<button
key={emp.id}
onClick={() => toggleEmployee(emp.id)}
className={`
w-full flex items-center gap-3 p-3 rounded-lg transition-colors
${isSelected
? "bg-blue-50 border-2 border-blue-500"
: "bg-gray-50 hover:bg-gray-100 border-2 border-transparent"
}
`}
>
{/* Avatar */}
<div className="relative flex-shrink-0">
{emp.avatar ? (
<img
src={emp.avatar}
alt={getEmployeeFullName(emp)}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white font-semibold">
{emp.first_name[0]}{emp.last_name[0]}
</div>
)}
{isSelected && (
<div className="absolute -top-1 -right-1 bg-blue-500 rounded-full p-0.5">
<Check className="w-3 h-3 text-white" />
</div>
)}
</div>
{/* Employee Info */}
<div className="flex-1 text-left">
<div className="font-medium text-gray-900">
{getEmployeeFullName(emp)}
</div>
{emp.position && (
<div className="text-sm text-gray-600">{emp.position}</div>
)}
{emp.department && (
<div className="text-xs text-gray-500">{emp.department.name}</div>
)}
</div>
</button>
);
})}
</div>
)}
</div>
</Modal>
);
}

View File

@ -1,6 +1,7 @@
"use client";
import { X, Plus, Clock, Calendar } from "lucide-react";
import { Plus, Clock, Calendar } from "lucide-react";
import { Modal } from "@/components/ui";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
@ -51,26 +52,27 @@ export function ViewDayEventsModal({
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4">
<div className="w-full max-w-[95vw] sm:max-w-md rounded-xl sm:rounded-2xl bg-white p-4 sm:p-6 shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
<div className="mb-3 sm:mb-4 flex items-center justify-between">
<div>
<h3 className="text-base sm:text-lg font-semibold text-gray-900">События на {capitalizedDate}</h3>
<p className="text-xs text-gray-500 mt-1">
{sortedEvents.length === 0
? "Нет событий"
: `${sortedEvents.length} ${sortedEvents.length === 1 ? "событие" : "событий"}`}
</p>
</div>
<button
onClick={onClose}
className="rounded-full p-1 hover:bg-gray-100"
>
<X size={20} className="text-gray-600" />
</button>
</div>
<div className="space-y-4">
<Modal
isOpen={isOpen}
onClose={onClose}
title={`События на ${capitalizedDate}`}
size="sm"
footer={
<button
onClick={onCreateEvent}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600"
>
<Plus size={16} />
Создать событие
</button>
}
>
<div className="space-y-4">
<p className="text-xs text-gray-500">
{sortedEvents.length === 0
? "Нет событий"
: `${sortedEvents.length} ${sortedEvents.length === 1 ? "событие" : "событий"}`}
</p>
{sortedEvents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Calendar size={40} className="text-gray-300 mb-2" />
@ -126,17 +128,7 @@ export function ViewDayEventsModal({
</div>
)}
<div className="mt-4">
<button
onClick={onCreateEvent}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600"
>
<Plus size={16} />
Создать событие
</button>
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -1,6 +1,7 @@
"use client";
import { X, Edit2, Trash2, Clock, Calendar, FileText, Users } from "lucide-react";
import { Edit2, Trash2, Clock, Calendar, FileText, Users } from "lucide-react";
import { Modal } from "@/components/ui";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import { useState, useEffect } from "react";
@ -98,37 +99,46 @@ export function ViewEventDetailsModal({
const capitalizedEnd = formatDateTime(event.end).charAt(0).toUpperCase() + formatDateTime(event.end).slice(1);
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4">
<div className="w-full max-w-[95vw] sm:max-w-md rounded-xl sm:rounded-2xl bg-white p-4 sm:p-6 shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 min-w-0 pr-3">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900">
{event.title}
</h3>
{event.rule && (
<span className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded flex-shrink-0">
⟲
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<div
className="h-2.5 w-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: event.color_event || "#3498db" }}
/>
<span className="truncate">Календарь #{event.calendar}</span>
</div>
</div>
<Modal
isOpen={isOpen}
onClose={onClose}
title={event.title}
size="sm"
footer={
<div className="flex gap-2">
<button
onClick={onClose}
className="rounded-full p-1 hover:bg-gray-100 flex-shrink-0"
onClick={onEdit}
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600"
>
<X size={20} className="text-gray-600" />
<Edit2 size={16} />
Редактировать
</button>
<button
onClick={onDelete}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100"
title="Удалить событие"
>
<Trash2 size={16} />
</button>
</div>
<div className="space-y-4">
}
>
<div className="space-y-4">
{/* Event meta */}
<div className="flex items-center gap-2">
{event.rule && (
<span className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded flex-shrink-0">
⟲
</span>
)}
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<div
className="h-2.5 w-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: event.color_event || "#3498db" }}
/>
<span className="truncate">Календарь #{event.calendar}</span>
</div>
</div>
{/* Time */}
<div className="flex items-start gap-2.5">
<Clock size={18} className="text-gray-400 mt-0.5 flex-shrink-0" />
@ -229,24 +239,7 @@ export function ViewEventDetailsModal({
</div>
)}
<div className="mt-4 flex gap-2">
<button
onClick={onEdit}
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-600"
>
<Edit2 size={16} />
Редактировать
</button>
<button
onClick={onDelete}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-100"
title="Удалить событие"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { X, Search, CheckCircle, AlertCircle, Users, Download } from "lucide-react";
import { apiClient } from "@/lib/api";
import { toast } from "sonner";
import { useModal, useClickOutside } from "@/hooks/useModal";
import { Modal } from "@/components/ui";
interface Employee {
id: number;
@ -46,17 +46,6 @@ export function DocumentAcknowledgementsReport({
const [search, setSearch] = useState("");
const [activeTab, setActiveTab] = useState<"all" | "acknowledged" | "unacknowledged">("all");
// Используем модальные хуки для улучшений UX
const contentRef = useModal({
isOpen: true,
onClose,
closeOnEsc: true,
trapFocus: true,
lockBodyScroll: true,
});
const backdropRef = useClickOutside(onClose, true);
useEffect(() => {
loadData();
}, [documentId, search]);
@ -129,14 +118,7 @@ export function DocumentAcknowledgementsReport({
const displayList = getDisplayList();
return (
<div
ref={backdropRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-2 sm:p-4 transition-opacity duration-200"
>
<div
ref={contentRef}
className="flex h-[95vh] sm:h-[90vh] w-full max-w-[95vw] sm:max-w-4xl flex-col rounded-xl sm:rounded-2xl bg-white shadow-xl transition-all duration-200"
>
<Modal isOpen onClose={onClose} noHeader noPadding size="xl" className="h-[95vh] sm:h-[90vh]">
{/* Header */}
<div className="flex shrink-0 items-start gap-2 sm:gap-3 justify-between border-b border-gray-200 p-3 sm:p-6">
<div className="min-w-0 flex-1">
@ -294,7 +276,6 @@ export function DocumentAcknowledgementsReport({
</div>
)}
</div>
</div>
</div>
</Modal>
);
}

View File

@ -5,6 +5,7 @@ import { Document as PDFDocument, Page, pdfjs } from "react-pdf";
import { ChevronLeft, ChevronRight, Download, X, FileText, Table } from "lucide-react";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
import { Modal } from "@/components/ui";
// Configure PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
@ -166,8 +167,7 @@ export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewP
const goToNextPage = () => setPageNumber((prev) => Math.min(prev + 1, numPages));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-2 sm:p-4">
<div className="relative flex h-full max-h-[95vh] sm:max-h-[90vh] w-full max-w-[95vw] sm:max-w-4xl flex-col rounded-xl sm:rounded-2xl bg-white shadow-2xl">
<Modal isOpen onClose={onClose ?? (() => {})} noHeader noPadding size="xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-3 sm:px-6 py-3 sm:py-4">
<h3 className="truncate text-sm sm:text-lg font-semibold text-gray-900">{fileName}</h3>
@ -419,7 +419,6 @@ export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewP
</button>
</div>
)}
</div>
</div>
</Modal>
);
}

View File

@ -1,9 +1,9 @@
'use client';
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { RelatedDocument, Document } from '@/types/api';
import { apiClient } from '@/lib/api';
import { Modal } from "@/components/ui";
interface RelatedDocumentsListProps {
documentId: number;
@ -170,35 +170,16 @@ export default function RelatedDocumentsList({ documentId }: RelatedDocumentsLis
)}
{/* Модальное окно добавления */}
{showAddModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-2 sm:p-4"
onClick={() => {
setShowAddModal(false);
setSearchQuery('');
setSearchResults([]);
}}
>
<div
className="w-full max-w-[95vw] sm:max-w-md rounded-xl sm:rounded-2xl bg-white shadow-xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-gray-200 px-4 sm:px-6 py-3 sm:py-4">
<h5 className="text-base sm:text-lg font-semibold text-gray-900">Добавить связанный документ</h5>
<button
type="button"
className="rounded-full p-1 hover:bg-gray-100"
onClick={() => {
setShowAddModal(false);
setSearchQuery('');
setSearchResults([]);
}}
>
<X size={20} className="text-gray-600" />
</button>
</div>
<div className="px-4 sm:px-6 py-4">
<Modal
isOpen={showAddModal}
onClose={() => {
setShowAddModal(false);
setSearchQuery('');
setSearchResults([]);
}}
title="Добавить связанный документ"
size="sm"
>
<div className="mb-4">
<input
type="text"
@ -241,10 +222,7 @@ export default function RelatedDocumentsList({ documentId }: RelatedDocumentsLis
))}
</div>
)}
</div>
</div>
</div>
)}
</Modal>
</div>
);
}

View File

@ -6,6 +6,7 @@ import { Users } from "lucide-react";
import { getParticipantInitials, type MemberSearchResult } from "@/lib/messages/chatSettingsUtils";
import { getUserFullName } from "@/lib/messages/chatUtils";
import { resolveMediaUrl } from "@/lib/url";
import { Modal } from "@/components/ui";
type AddChatMemberModalProps = {
open: boolean;
@ -28,83 +29,70 @@ export default function AddChatMemberModal({
onSearchChange,
onAddMember,
}: AddChatMemberModalProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">Добавить участника</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="mb-4">
<input
type="text"
value={memberSearchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Введите имя или email..."
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
autoFocus
/>
<p className="mt-1 text-xs text-gray-500">Минимум 2 символа для поиска</p>
</div>
<div className="max-h-96 overflow-y-auto">
{memberSearchLoading ? (
<div className="py-8 text-center">
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
<p className="text-sm text-gray-500">Поиск...</p>
</div>
) : memberSearchQuery.trim().length < 2 ? (
<div className="py-8 text-center">
<Users className="mx-auto mb-2 h-12 w-12 text-gray-300" />
<p className="text-sm text-gray-500">Начните вводить имя или email для поиска</p>
</div>
) : memberSearchResults.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-gray-500">Пользователи не найдены</p>
</div>
) : (
<div className="space-y-2">
{memberSearchResults.map((result) => (
<div key={result.id} className="flex items-center gap-3 rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{result.avatar ? (
<Image
src={resolveMediaUrl(result.avatar)}
alt={result.name || getUserFullName(result.last_name, result.first_name)}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
/>
) : (
<span>{getParticipantInitials(result.name || getUserFullName(result.last_name, result.first_name))}</span>
)}
</div>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{result.name || getUserFullName(result.last_name, result.first_name)}</p>
{result.email ? <p className="text-xs text-gray-500">{result.email}</p> : null}
</div>
<button
type="button"
onClick={() => onAddMember(result.id)}
disabled={actionLoading === `add-member-${result.id}`}
className="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-sky-700 disabled:opacity-50"
>
{actionLoading === `add-member-${result.id}` ? "..." : "Добавить"}
</button>
</div>
))}
</div>
)}
</div>
<Modal isOpen={open} onClose={onClose} title="Добавить участника" size="sm">
<div className="mb-4">
<input
type="text"
value={memberSearchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Введите имя или email..."
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
autoFocus
/>
<p className="mt-1 text-xs text-gray-500">Минимум 2 символа для поиска</p>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{memberSearchLoading ? (
<div className="py-8 text-center">
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
<p className="text-sm text-gray-500">Поиск...</p>
</div>
) : memberSearchQuery.trim().length < 2 ? (
<div className="py-8 text-center">
<Users className="mx-auto mb-2 h-12 w-12 text-gray-300" />
<p className="text-sm text-gray-500">Начните вводить имя или email для поиска</p>
</div>
) : memberSearchResults.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-gray-500">Пользователи не найдены</p>
</div>
) : (
<div className="space-y-2">
{memberSearchResults.map((result) => (
<div key={result.id} className="flex items-center gap-3 rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{result.avatar ? (
<Image
src={resolveMediaUrl(result.avatar)}
alt={result.name || getUserFullName(result.last_name, result.first_name)}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
/>
) : (
<span>{getParticipantInitials(result.name || getUserFullName(result.last_name, result.first_name))}</span>
)}
</div>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{result.name || getUserFullName(result.last_name, result.first_name)}</p>
{result.email ? <p className="text-xs text-gray-500">{result.email}</p> : null}
</div>
<button
type="button"
onClick={() => onAddMember(result.id)}
disabled={actionLoading === `add-member-${result.id}`}
className="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-sky-700 disabled:opacity-50"
>
{actionLoading === `add-member-${result.id}` ? "..." : "Добавить"}
</button>
</div>
))}
</div>
)}
</div>
</Modal>
);
}

View File

@ -1,8 +1,9 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { ArrowLeft, Bell, BellOff, Pin } from "lucide-react";
import { ArrowLeft, Bell, BellOff, MoreHorizontal, Pin, Search } from "lucide-react";
import type { Chat } from "@/types/api";
import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
@ -16,6 +17,7 @@ type ChatDialogHeaderProps = {
notificationsEnabled: boolean;
onTogglePin: () => void;
onToggleNotifications: () => void;
onOpenSearch: () => void;
};
function getChatSubtitle(chat: Chat): string {
@ -45,13 +47,53 @@ export default function ChatDialogHeader({
notificationsEnabled,
onTogglePin,
onToggleNotifications,
onOpenSearch,
}: ChatDialogHeaderProps) {
const avatar = getChatAvatar(chat, currentUserId);
const title = getChatTitle(chat, currentUserId);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [menuOpen]);
const subtitle = getChatSubtitle(chat);
return (
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-gray-100 px-4 pt-3 pb-3 lg:px-0 lg:pt-0">
<div className="flex items-center gap-3">
<header className="relative z-30 flex shrink-0 items-center gap-3 border-b border-gray-100 bg-white px-4 pt-3 pb-3 lg:px-0 lg:pt-0">
<Link
href="/messages"
aria-label="К списку чатов"
title="К списку чатов"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50 hover:text-sky-700"
>
<ArrowLeft size={16} />
</Link>
<Link href={`/messages/${chatId}/settings`} className="flex min-w-0 flex-1 items-center gap-3 transition hover:opacity-80">
<div className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{avatar ? (
<div className="relative h-full w-full">
@ -61,51 +103,68 @@ export default function ChatDialogHeader({
getChatInitials(chat, currentUserId)
)}
</div>
<Link href={`/messages/${chatId}/settings`} className="hover:opacity-80 transition">
<div>
<p className="text-sm font-semibold text-gray-900">{title}</p>
</div>
<p className="text-xs text-gray-500">{getChatSubtitle(chat)}</p>
</Link>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900">{title}</p>
<p className="truncate text-xs text-gray-500">{subtitle}</p>
</div>
</Link>
<div className="flex items-center gap-2">
<div ref={menuRef} className="relative z-40 shrink-0">
<button
type="button"
onClick={onTogglePin}
className={`flex h-10 w-10 items-center justify-center rounded-full border transition ${
isPinned
? "border-sky-500 bg-sky-50 text-sky-700 hover:bg-sky-100"
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-sky-700"
}`}
title={isPinned ? "Открепить чат" : "Закрепить чат"}
aria-label={isPinned ? "Открепить чат" : "Закрепить чат"}
>
<Pin size={16} className={isPinned ? "fill-current" : ""} />
</button>
<button
type="button"
onClick={onToggleNotifications}
className={`flex h-10 w-10 items-center justify-center rounded-full border transition ${
notificationsEnabled
? "border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-sky-700"
: "border-amber-500 bg-amber-50 text-amber-700 hover:bg-amber-100"
}`}
title={notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}
aria-label={notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}
>
{notificationsEnabled ? <Bell size={16} /> : <BellOff size={16} />}
</button>
<Link
href="/messages"
aria-label="К списку чатов"
title="К списку чатов"
onClick={() => setMenuOpen((prev) => !prev)}
className="flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50 hover:text-sky-700"
aria-label="Дополнительные действия"
aria-expanded={menuOpen}
aria-haspopup="menu"
title="Дополнительные действия"
>
<ArrowLeft size={16} />
</Link>
<MoreHorizontal size={16} />
</button>
{menuOpen ? (
<div className="absolute right-0 top-full z-[80] mt-2 w-56 overflow-hidden rounded-2xl border border-gray-200 bg-white py-1 shadow-lg shadow-gray-200/70 ring-1 ring-gray-100">
<button
type="button"
onClick={() => {
setMenuOpen(false);
onOpenSearch();
}}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-medium text-gray-700 transition hover:bg-gray-50"
>
<Search size={16} className="shrink-0 text-gray-500" />
<span>Поиск по сообщениям</span>
</button>
<button
type="button"
onClick={() => {
setMenuOpen(false);
onTogglePin();
}}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-medium text-gray-700 transition hover:bg-gray-50"
>
<Pin size={16} className={`shrink-0 ${isPinned ? "fill-current text-sky-700" : "text-gray-500"}`} />
<span>{isPinned ? "Открепить чат" : "Закрепить чат"}</span>
</button>
<button
type="button"
onClick={() => {
setMenuOpen(false);
onToggleNotifications();
}}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-medium text-gray-700 transition hover:bg-gray-50"
>
{notificationsEnabled ? (
<Bell size={16} className="shrink-0 text-gray-500" />
) : (
<BellOff size={16} className="shrink-0 text-amber-700" />
)}
<span>{notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}</span>
</button>
</div>
) : null}
</div>
</header>
);

View File

@ -12,7 +12,7 @@ type ChatMediaPreviewModalProps = {
export default function ChatMediaPreviewModal({ preview, onClose }: ChatMediaPreviewModalProps) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
<div data-overlay-root="true" className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
<button
type="button"
onClick={onClose}

View File

@ -1,7 +1,17 @@
"use client";
import Image from "next/image";
import { AlertCircle, Check, CheckCheck, ChevronRight, Clock3, FileText, Loader2 } from "lucide-react";
import {
AlertCircle,
Check,
CheckCheck,
ChevronRight,
Clock3,
FileText,
Image as ImageIcon,
Loader2,
PlayCircle,
} from "lucide-react";
import { resolveMediaUrl } from "@/lib/url";
import {
@ -22,10 +32,23 @@ export type MediaPreview = {
name: string;
};
function MediaTypeBadge({ type }: { type: "image" | "video" }) {
const isVideo = type === "video";
const Icon = isVideo ? PlayCircle : ImageIcon;
return (
<span className="absolute left-2 top-2 z-10 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-1 text-[11px] font-medium text-white backdrop-blur-sm">
<Icon size={12} />
{isVideo ? "Видео" : "Фото"}
</span>
);
}
type ChatMessageItemProps = {
message: Message;
currentUserId?: number;
repliedMessage?: Message | null;
isHighlighted?: boolean;
isActionsOpen: boolean;
canManage: boolean;
canReply: boolean;
@ -86,6 +109,7 @@ export default function ChatMessageItem({
message,
currentUserId,
repliedMessage,
isHighlighted = false,
isActionsOpen,
canManage,
canReply,
@ -132,7 +156,7 @@ export default function ChatMessageItem({
) : null}
<div className={`flex min-w-0 items-start gap-1 ${isMine ? "max-w-[88%] flex-row-reverse" : "max-w-[calc(100%-2.5rem)]"}`}>
<div className={`relative min-w-0 rounded-2xl px-3 py-2 pr-9 ${isMine ? "bg-sky-500 text-white" : "bg-white text-gray-900 ring-1 ring-gray-100"}`}>
<div className={`relative min-w-0 rounded-2xl px-3 py-2 pr-9 transition-shadow duration-300 ${isMine ? "bg-sky-500 text-white" : "bg-white text-gray-900 ring-1 ring-gray-100"} ${isHighlighted ? (isMine ? "shadow-[0_0_0_3px_rgba(125,211,252,0.45)]" : "ring-2 ring-sky-300 shadow-[0_0_0_4px_rgba(186,230,253,0.65)]") : ""}`}>
{hasActions ? (
<div className="absolute right-1 top-1 z-20">
<button
@ -212,8 +236,9 @@ export default function ChatMessageItem({
key={attachment.id}
type="button"
onClick={() => onOpenMediaPreview({ type: "image", src: fileUrl, name: attachment.file_name })}
className="block w-full overflow-hidden rounded-lg"
className="relative block w-full overflow-hidden rounded-lg"
>
<MediaTypeBadge type="image" />
<Image
src={imageSrc}
alt={attachment.file_name}
@ -256,8 +281,9 @@ export default function ChatMessageItem({
key={attachment.id}
type="button"
onClick={() => onOpenMediaPreview({ type: "video", src: fileUrl, name: attachment.file_name })}
className="block w-full overflow-hidden rounded-lg"
className="relative block w-full overflow-hidden rounded-lg"
>
<MediaTypeBadge type="video" />
<video
preload="metadata"
playsInline
@ -269,6 +295,11 @@ export default function ChatMessageItem({
onError={() => onAttachmentError(attachment.id)}
onLoadedData={() => onAttachmentLoad(attachment.id)}
/>
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm">
<PlayCircle size={26} fill="currentColor" strokeWidth={1.8} />
</span>
</span>
</button>
);
}

View File

@ -0,0 +1,189 @@
"use client";
import { ChevronDown, ChevronUp, Loader2, Paperclip, Search, X } from "lucide-react";
import type { ChatMessageSearchResult } from "@/types/api";
type ChatSearchPanelProps = {
isOpen: boolean;
query: string;
loading: boolean;
loadingMore: boolean;
error: string | null;
results: ChatMessageSearchResult[];
totalCount: number;
nextOffset: number | null;
selectedIndex: number;
inputRef: React.RefObject<HTMLInputElement | null>;
onQueryChange: (value: string) => void;
onClose: () => void;
onPrevious: () => void;
onNext: () => void;
onSelect: (index: number) => void;
onLoadMore: () => void;
onSubmitSelection: () => void;
};
function formatSearchDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export default function ChatSearchPanel({
isOpen,
query,
loading,
loadingMore,
error,
results,
totalCount,
nextOffset,
selectedIndex,
inputRef,
onQueryChange,
onClose,
onPrevious,
onNext,
onSelect,
onLoadMore,
onSubmitSelection,
}: ChatSearchPanelProps) {
if (!isOpen) {
return null;
}
const canNavigate = results.length > 0;
const selectedLabel = canNavigate ? `${selectedIndex + 1}/${totalCount}` : totalCount > 0 ? `0/${totalCount}` : "0";
return (
<div className="shrink-0 border-b border-gray-100 bg-white px-4 py-3 lg:px-0">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
onSubmitSelection();
}
if (event.key === "ArrowUp") {
event.preventDefault();
onPrevious();
}
if (event.key === "ArrowDown") {
event.preventDefault();
onNext();
}
}}
placeholder="Поиск по сообщениям"
className="h-10 w-full rounded-full border border-gray-200 bg-gray-50 pl-10 pr-4 text-sm text-gray-800 outline-none transition focus:border-sky-500 focus:bg-white focus:ring-2 focus:ring-sky-100"
/>
</div>
<div className="flex items-center gap-1 rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-500">
<button
type="button"
onClick={onPrevious}
disabled={!canNavigate}
className="inline-flex h-7 w-7 items-center justify-center rounded-full transition hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Предыдущее совпадение"
title="Предыдущее совпадение"
>
<ChevronUp size={14} />
</button>
<span className="min-w-10 text-center font-medium text-gray-600">{selectedLabel}</span>
<button
type="button"
onClick={onNext}
disabled={!canNavigate}
className="inline-flex h-7 w-7 items-center justify-center rounded-full transition hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Следующее совпадение"
title="Следующее совпадение"
>
<ChevronDown size={14} />
</button>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-500 transition hover:bg-gray-50 hover:text-sky-700"
aria-label="Закрыть поиск"
title="Закрыть поиск"
>
<X size={16} />
</button>
</div>
<div className="mt-3">
{query.trim().length < 2 ? (
<p className="text-xs text-gray-500">Введите минимум 2 символа для поиска по истории чата.</p>
) : loading && results.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 size={14} className="animate-spin" />
<span>Ищем совпадения...</span>
</div>
) : error ? (
<p className="text-sm text-red-600">{error}</p>
) : results.length === 0 ? (
<p className="text-sm text-gray-500">Совпадений не найдено.</p>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>Найдено: {totalCount}</span>
<span>Можно перейти Enter или кликом</span>
</div>
<div className="max-h-56 overflow-y-auto rounded-2xl border border-gray-100 bg-gray-50 p-2">
<div className="space-y-1.5">
{results.map((result, index) => (
<button
key={result.message_id}
type="button"
onClick={() => onSelect(index)}
className={`block w-full rounded-xl px-3 py-2 text-left transition ${index === selectedIndex ? "bg-sky-50 ring-1 ring-sky-200" : "bg-white hover:bg-gray-100"}`}
>
<div className="flex items-center justify-between gap-3">
<p className="truncate text-xs font-semibold text-gray-700">{result.author_name}</p>
<span className="shrink-0 text-[11px] text-gray-400">{formatSearchDate(result.created_at)}</span>
</div>
<p className="mt-1 line-clamp-2 text-sm text-gray-700">{result.snippet}</p>
{result.has_attachments ? (
<div className="mt-1 flex items-center gap-1 text-[11px] text-gray-400">
<Paperclip size={11} />
<span>Вложений: {result.attachments_count}</span>
</div>
) : null}
</button>
))}
</div>
</div>
{nextOffset !== null ? (
<button
type="button"
onClick={onLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
{loadingMore ? <Loader2 size={14} className="animate-spin" /> : null}
<span>{loadingMore ? "Загрузка..." : "Показать еще"}</span>
</button>
) : null}
</div>
)}
</div>
</div>
);
}

View File

@ -6,6 +6,7 @@ import Image from "next/image";
import { getChatAvatar, getChatInitials } from "@/lib/messages/chatUtils";
import { resolveMediaUrl } from "@/lib/url";
import type { Chat } from "@/types/api";
import { Modal } from "@/components/ui";
type EditChatModalProps = {
chat: Chat | null;
@ -36,15 +37,30 @@ export default function EditChatModal({
onAvatarChange,
onSave,
}: EditChatModalProps) {
if (!open || !chat) return null;
const avatar = getChatAvatar(chat, currentUserId);
const footerContent = (
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
disabled={actionLoading === "edit"}
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={onSave}
disabled={actionLoading === "edit" || !editName.trim()}
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
>
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
</button>
</div>
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h2 className="mb-4 text-xl font-bold text-gray-900">Редактировать чат</h2>
<Modal isOpen={open && !!chat} onClose={onClose} title="Редактировать чат" size="sm" closeOnEsc={actionLoading !== "edit"} footer={footerContent}>
{chat && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Аватар</label>
@ -52,8 +68,8 @@ export default function EditChatModal({
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-lg font-semibold text-white">
{editAvatarPreview ? (
<Image src={editAvatarPreview} alt="Preview" width={64} height={64} unoptimized className="h-full w-full object-cover" />
) : avatar ? (
<Image src={resolveMediaUrl(avatar)} alt="Avatar" width={64} height={64} unoptimized className="h-full w-full object-cover" />
) : getChatAvatar(chat, currentUserId) ? (
<Image src={resolveMediaUrl(getChatAvatar(chat, currentUserId)!)} alt="Avatar" width={64} height={64} unoptimized className="h-full w-full object-cover" />
) : (
getChatInitials(chat, currentUserId)
)}
@ -96,26 +112,7 @@ export default function EditChatModal({
/>
</div>
</div>
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={onClose}
disabled={actionLoading === "edit"}
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={onSave}
disabled={actionLoading === "edit" || !editName.trim()}
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
>
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
</button>
</div>
</div>
</div>
)}
</Modal>
);
}

View File

@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { apiClient } from "@/lib/api";
import type { ProcurementSupplier } from "@/types/api";
import { Pencil, Plus, Star, Trash2 } from "lucide-react";
import { Modal } from "@/components/ui";
type Props = {
canManage: boolean;
@ -215,11 +216,13 @@ export default function ProcurementSuppliersPanel({ canManage }: Props) {
</table>
</div>
{formOpen && canManage && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) closeForm(); }}>
<div className="w-full max-w-xl rounded-2xl bg-white p-5 shadow-xl">
<h3 className="text-base font-semibold text-gray-900">{editing ? "Редактировать поставщика" : "Новый поставщик"}</h3>
<div className="mt-4 grid gap-3">
<Modal isOpen={formOpen && canManage} onClose={closeForm} title={editing ? "Редактировать поставщика" : "Новый поставщик"} size="md" footer={
<div className="flex justify-end gap-2">
<button type="button" onClick={closeForm} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
<button type="button" onClick={handleSave} disabled={busy} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Сохранить</button>
</div>
}>
<div className="grid gap-3">
<input value={form.name} onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))} placeholder="Название *" className="rounded-lg border border-gray-300 px-3 py-2 text-sm" />
<div className="grid grid-cols-2 gap-3">
<input value={form.contact_person} onChange={(e) => setForm((prev) => ({ ...prev, contact_person: e.target.value }))} placeholder="Контактное лицо" className="rounded-lg border border-gray-300 px-3 py-2 text-sm" />
@ -239,13 +242,7 @@ export default function ProcurementSuppliersPanel({ canManage }: Props) {
<input type="checkbox" checked={form.is_active} onChange={(e) => setForm((prev) => ({ ...prev, is_active: e.target.checked }))} /> Активный поставщик
</label>
</div>
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={closeForm} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
<button type="button" onClick={handleSave} disabled={busy} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Сохранить</button>
</div>
</div>
</div>
)}
</Modal>
</div>
);
}

View File

@ -15,6 +15,10 @@ export interface ModalProps {
closeOnEsc?: boolean;
footer?: ReactNode;
className?: string;
/** Render children directly without padding wrappers (for custom layouts like flex-col) */
noPadding?: boolean;
/** Hide the built-in header (caller manages its own header inside children) */
noHeader?: boolean;
}
export function Modal({
@ -28,19 +32,18 @@ export function Modal({
closeOnEsc = true,
footer,
className = "",
noPadding = false,
noHeader = false,
}: ModalProps) {
const [isAnimating, setIsAnimating] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// Анимация появления
useEffect(() => {
if (isOpen) {
setIsAnimating(true);
// Блокируем прокрутку body
document.body.style.overflow = "hidden";
} else {
// Разблокируем прокрутку при закрытии
document.body.style.overflow = "";
}
@ -49,7 +52,6 @@ export function Modal({
};
}, [isOpen]);
// ESC для закрытия
useEffect(() => {
if (!isOpen || !closeOnEsc) return;
@ -63,7 +65,6 @@ export function Modal({
return () => document.removeEventListener("keydown", handleEsc);
}, [isOpen, closeOnEsc, onClose]);
// Focus trap
useEffect(() => {
if (!isOpen || !contentRef.current) return;
@ -76,20 +77,17 @@ export function Modal({
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Фокус на первый элемент при открытии
firstElement?.focus();
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
lastElement?.focus();
e.preventDefault();
}
} else {
// Tab
if (document.activeElement === lastElement) {
firstElement?.focus();
e.preventDefault();
@ -119,10 +117,13 @@ export function Modal({
const modalSizeClass = `w-full ${sizeClasses[size]} max-h-[95vh] sm:max-h-[90vh]`;
const showHeader = !noHeader && (title || showCloseButton);
return (
<div
ref={modalRef}
onClick={handleBackdropClick}
data-overlay-root="true"
className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-2 sm:p-4 transition-opacity duration-200 ${
isAnimating ? "opacity-100" : "opacity-0"
}`}
@ -132,48 +133,54 @@ export function Modal({
>
<div
ref={contentRef}
className={`flex flex-col rounded-xl sm:rounded-2xl bg-white shadow-xl overflow-y-auto transition-all duration-200 ${modalSizeClass} ${
className={`flex flex-col rounded-xl sm:rounded-2xl bg-white shadow-xl overflow-hidden transition-all duration-200 ${modalSizeClass} ${
isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0"
} ${className}`}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex shrink-0 items-center gap-2 sm:gap-3 mb-3 sm:mb-4 px-4 sm:px-6 pt-4 sm:pt-6">
{title && (
<h3
id="modal-title"
className="min-w-0 flex-1 text-sm sm:text-base lg:text-lg font-semibold text-gray-900 truncate"
title={title}
>
{title}
</h3>
{noPadding ? (
children
) : (
<>
{/* Header */}
{showHeader && (
<div className="flex shrink-0 items-center gap-2 sm:gap-3 mb-3 sm:mb-4 px-4 sm:px-6 pt-4 sm:pt-6">
{title && (
<h3
id="modal-title"
className="min-w-0 flex-1 text-sm sm:text-base lg:text-lg font-semibold text-gray-900 truncate"
title={title}
>
{title}
</h3>
)}
<div className="flex shrink-0 items-center gap-2">
{showCloseButton && (
<button
onClick={onClose}
className="rounded-full p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
title="Закрыть"
>
<X size={20} />
</button>
)}
</div>
</div>
)}
<div className="flex shrink-0 items-center gap-2">
{showCloseButton && (
<button
onClick={onClose}
className="rounded-full p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
title="Закрыть"
>
<X size={20} />
</button>
)}
{/* Content */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 sm:px-6">
{children}
</div>
</div>
{/* Footer */}
{footer && (
<div className="shrink-0 border-t border-gray-200 px-4 sm:px-6 py-3 sm:py-4">{footer}</div>
)}
{/* Bottom padding */}
<div className="pb-4 sm:pb-6" />
</>
)}
{/* Content */}
<div className="px-4 sm:px-6">
{children}
</div>
{/* Footer */}
{footer && (
<div className="shrink-0 border-t border-gray-200 px-4 sm:px-6 py-3 sm:py-4 mt-3 sm:mt-4">{footer}</div>
)}
{/* Bottom padding */}
<div className="pb-4 sm:pb-6" />
</div>
</div>
);

View File

@ -1,7 +1,7 @@
"use client";
import type { ChangeEvent } from "react";
import { X } from "lucide-react";
import { Modal } from "@/components/ui";
import type { User } from "@/types/api";
import type { UserProfileEditForm, UserProfileTextField } from "@/lib/users/userDetailUtils";
@ -32,25 +32,37 @@ export default function EditUserProfileModal({
onTextFieldChange,
person,
}: EditUserProfileModalProps) {
if (!isOpen || !person) {
return null;
}
const footerContent = (
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
disabled={actionLoading === "edit"}
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={onSave}
disabled={actionLoading === "edit" || !form.firstName.trim() || !form.lastName.trim() || !form.email.trim()}
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
>
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
</button>
</div>
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">Редактировать профиль</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
disabled={actionLoading === "edit"}
>
<X size={24} />
</button>
</div>
<Modal
isOpen={isOpen && !!person}
onClose={onClose}
title="Редактировать профиль"
size="lg"
closeOnEsc={actionLoading !== "edit"}
footer={footerContent}
>
{person && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
@ -212,26 +224,7 @@ export default function EditUserProfileModal({
</div>
</div>
</div>
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={onClose}
disabled={actionLoading === "edit"}
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={onSave}
disabled={actionLoading === "edit" || !form.firstName.trim() || !form.lastName.trim() || !form.email.trim()}
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
>
{actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
</button>
</div>
</div>
</div>
)}
</Modal>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { X } from "lucide-react";
import { Modal } from "@/components/ui";
import type { EmployeeActionField, EmployeeActionForm } from "@/lib/users/userDetailUtils";
type EmployeeActionOption = {
@ -27,94 +27,83 @@ export default function EmployeeActionModal({
onFieldChange,
onSave,
}: EmployeeActionModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">
{form.editingId ? "Редактировать событие" : "Кадровое событие"}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
disabled={actionLoading === "action"}
>
<X size={24} />
</button>
</div>
<div className="space-y-4">
<div>
<label htmlFor="action-type" className="mb-2 block text-sm font-medium text-gray-700">
Тип события *
</label>
<select
id="action-type"
value={form.type}
onChange={(event) => onFieldChange("type", event.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
>
<option value="">Выберите тип события</option>
{actionTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="action-date" className="mb-2 block text-sm font-medium text-gray-700">
Дата *
</label>
<input
id="action-date"
type="date"
value={form.date}
onChange={(event) => onFieldChange("date", event.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
/>
</div>
<div>
<label htmlFor="action-comment" className="mb-2 block text-sm font-medium text-gray-700">
Комментарий
</label>
<textarea
id="action-comment"
value={form.comment}
onChange={(event) => onFieldChange("comment", event.target.value)}
placeholder="Дополнительная информация..."
rows={3}
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
/>
</div>
</div>
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={onClose}
disabled={actionLoading === "action"}
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={onSave}
disabled={actionLoading === "action" || !form.type || !form.date}
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
>
{actionLoading === "action" ? "Сохранение..." : "Сохранить"}
</button>
</div>
</div>
const footerContent = (
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
disabled={actionLoading === "action"}
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={onSave}
disabled={actionLoading === "action" || !form.type || !form.date}
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-50"
>
{actionLoading === "action" ? "Сохранение..." : "Сохранить"}
</button>
</div>
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={form.editingId ? "Редактировать событие" : "Кадровое событие"}
size="sm"
closeOnEsc={actionLoading !== "action"}
footer={footerContent}
>
<div className="space-y-4">
<div>
<label htmlFor="action-type" className="mb-2 block text-sm font-medium text-gray-700">
Тип события *
</label>
<select
id="action-type"
value={form.type}
onChange={(event) => onFieldChange("type", event.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
>
<option value="">Выберите тип события</option>
{actionTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="action-date" className="mb-2 block text-sm font-medium text-gray-700">
Дата *
</label>
<input
id="action-date"
type="date"
value={form.date}
onChange={(event) => onFieldChange("date", event.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
/>
</div>
<div>
<label htmlFor="action-comment" className="mb-2 block text-sm font-medium text-gray-700">
Комментарий
</label>
<textarea
id="action-comment"
value={form.comment}
onChange={(event) => onFieldChange("comment", event.target.value)}
placeholder="Дополнительная информация..."
rows={3}
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
/>
</div>
</div>
</Modal>
);
}

View File

@ -70,6 +70,12 @@ export interface UseChatMessagesReturn {
anchorId: number | null;
anchorIndex: number | null;
} | null>;
/** Jump to a specific message by id and load surrounding context */
jumpToMessage: (messageId: number) => Promise<{
hasMoreAfter: boolean;
anchorId: number | null;
anchorIndex: number | null;
} | null>;
/** Set anchorRetryTick (used to retry anchor scroll) */
setAnchorRetryTick: React.Dispatch<React.SetStateAction<number>>;
/** Mark initial scroll as done */
@ -527,6 +533,40 @@ export function useChatMessages({
}
}, [chat?.last_read_message_id, chatId, hasMoreNewer, historyNavigationMode]);
const jumpToMessage = useCallback(async (messageId: number) => {
if (!chatId || Number.isNaN(chatId) || !messageId) return null;
try {
setMessagesLoading(true);
setHistoryNavigationMode(true);
const around = await apiClient.getChatMessagesAround(chatId, { limit: 30, around_id: messageId });
const norm = uniqueMessagesById(around.messages || []);
if (norm.length === 0) return null;
setMessages(norm);
setHasMoreOlder(typeof around.has_more_before === "boolean" ? around.has_more_before : norm.length >= 50);
setHasMoreNewer(Boolean(around.has_more_after));
setInitialAnchorId(around.anchor_id ?? messageId);
setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : norm.findIndex(m => m.id === (around.anchor_id ?? messageId)));
setAllowOneOlderProbe(Boolean(around.anchor_id ?? messageId));
initialScrolledRef.current = false;
setAnchorRetryTick(0);
return {
hasMoreAfter: Boolean(around.has_more_after),
anchorId: around.anchor_id ?? messageId,
anchorIndex: typeof around.anchor_index === "number" ? around.anchor_index : norm.findIndex(m => m.id === (around.anchor_id ?? messageId)),
};
} catch (e) {
console.error("Ошибка перехода к найденному сообщению:", e);
return null;
} finally {
setMessagesLoading(false);
}
}, [chatId]);
/* ── scroll tracking helpers ── */
const markInitialScrollDone = useCallback(() => { initialScrolledRef.current = true; }, []);
@ -567,6 +607,7 @@ export function useChatMessages({
markPendingFailed,
jumpToDate,
returnToUnread,
jumpToMessage,
setAnchorRetryTick,
markInitialScrollDone,
isInitialScrollPending,

View File

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ChatMessageSearchResponse } from '@/types/api';
import type { RequestFn, GetTokenFn } from './utils';
export function createMessagesApi(request: RequestFn, getToken: GetTokenFn) {
@ -87,6 +88,13 @@ export function createMessagesApi(request: RequestFn, getToken: GetTokenFn) {
const qs = qp.toString();
return request(`/api/v1/communications/chats/${chatId}/messages-around/${qs ? '?' + qs : ''}`);
},
searchChatMessages: (chatId: number | string, params: { q: string; limit?: number; offset?: number }) => {
const qp = new URLSearchParams();
qp.append('q', params.q);
if (params.limit) qp.append('limit', params.limit.toString());
if (typeof params.offset === 'number') qp.append('offset', params.offset.toString());
return request(`/api/v1/communications/chats/${chatId}/search-messages/?${qp.toString()}`) as Promise<ChatMessageSearchResponse>;
},
sendMessage: (chatId: number | string, text: string, replyTo?: number) => uploadMessage(chatId, text, [], replyTo),
sendMessageWithFiles: (chatId: number | string, text: string, files: File[], replyTo?: number) => uploadMessage(chatId, text, files, replyTo),
updateMessage: (messageId: number, text: string) => request(`/api/v1/communications/messages/${messageId}/`, { method: 'PATCH', body: JSON.stringify({ content: text }) }),

View File

@ -555,6 +555,24 @@ export interface MessageAttachment {
is_local?: boolean;
}
export interface ChatMessageSearchResult {
message_id: number;
content: string;
snippet: string;
author_name: string;
created_at: string;
attachments_count: number;
has_attachments: boolean;
}
export interface ChatMessageSearchResponse {
query: string;
count: number;
offset: number;
next_offset: number | null;
results: ChatMessageSearchResult[];
}
// Calendar types (django-scheduler)
export interface CalendarEvent {
id: number;