254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||
import { Loader2, MessageSquare, Send } from "lucide-react";
|
||
|
||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||
import { CommentDeleteButton } from "@/components/shared/CommentControls";
|
||
import { Modal } from "@/components/ui/Modal";
|
||
import { apiClient } from "@/lib/api";
|
||
import type { PayrollCellComment } from "@/lib/api/finance";
|
||
import { formatDateTime } from "@/lib/shared";
|
||
import { getPayrollAdminError } from "@/lib/payroll-admin";
|
||
|
||
export type PayrollCommentCell = {
|
||
periodId: number;
|
||
employeeId: number;
|
||
employeeName: string;
|
||
columnId: string;
|
||
columnLabel: string;
|
||
commentsCount: number;
|
||
};
|
||
|
||
type PayrollCellCommentsModalProps = {
|
||
cell: PayrollCommentCell | null;
|
||
currentUserId?: number | null;
|
||
onClose: () => void;
|
||
onCommentCountChange: (cell: PayrollCommentCell, count: number) => void;
|
||
periodId: number;
|
||
};
|
||
|
||
function authorFallback(comment: PayrollCellComment) {
|
||
return (
|
||
comment.author.last_name?.[0] ||
|
||
comment.author.first_name?.[0] ||
|
||
comment.author.email?.[0] ||
|
||
"К"
|
||
).toUpperCase();
|
||
}
|
||
|
||
export function PayrollCellCommentsModal({
|
||
cell,
|
||
currentUserId,
|
||
onClose,
|
||
onCommentCountChange,
|
||
periodId,
|
||
}: PayrollCellCommentsModalProps) {
|
||
const [comments, setComments] = useState<PayrollCellComment[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [draft, setDraft] = useState("");
|
||
const [sending, setSending] = useState(false);
|
||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [composerExpanded, setComposerExpanded] = useState(false);
|
||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
const loadComments = useCallback(async (activeCell: PayrollCommentCell) => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
const next = await apiClient.getPayrollAdminCellComments(
|
||
periodId,
|
||
activeCell.employeeId,
|
||
activeCell.columnId,
|
||
);
|
||
setComments(next);
|
||
onCommentCountChange(activeCell, next.length);
|
||
} catch (loadError) {
|
||
setComments([]);
|
||
setError(getPayrollAdminError(loadError, "Не удалось загрузить комментарии."));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [onCommentCountChange, periodId]);
|
||
|
||
useEffect(() => {
|
||
if (!cell) return;
|
||
setComments([]);
|
||
setDraft("");
|
||
setError(null);
|
||
setDeletingId(null);
|
||
void loadComments(cell);
|
||
}, [cell, loadComments]);
|
||
|
||
useEffect(() => {
|
||
if (!cell || loading) return;
|
||
requestAnimationFrame(() => bottomRef.current?.scrollIntoView({ block: "end" }));
|
||
}, [cell, comments.length, loading]);
|
||
|
||
useLayoutEffect(() => {
|
||
const input = inputRef.current;
|
||
if (!input) return;
|
||
const minHeight = 38;
|
||
const maxHeight = 128;
|
||
input.style.height = "0px";
|
||
const resolvedHeight = Math.max(Math.min(input.scrollHeight, maxHeight), minHeight);
|
||
input.style.height = `${resolvedHeight}px`;
|
||
input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden";
|
||
setComposerExpanded(resolvedHeight > minHeight + 2);
|
||
}, [draft]);
|
||
|
||
const sendComment = useCallback(async () => {
|
||
if (!cell || !draft.trim()) return;
|
||
try {
|
||
setSending(true);
|
||
setError(null);
|
||
const saved = await apiClient.addPayrollAdminCellComment(
|
||
periodId,
|
||
cell.employeeId,
|
||
cell.columnId,
|
||
draft.trim(),
|
||
);
|
||
const next = [...comments, saved];
|
||
setComments(next);
|
||
setDraft("");
|
||
onCommentCountChange(cell, next.length);
|
||
} catch (sendError) {
|
||
setError(getPayrollAdminError(sendError, "Не удалось отправить комментарий."));
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}, [cell, comments, draft, onCommentCountChange, periodId]);
|
||
|
||
const deleteComment = useCallback(async (comment: PayrollCellComment) => {
|
||
if (!cell) return;
|
||
try {
|
||
setDeletingId(comment.id);
|
||
setError(null);
|
||
await apiClient.deletePayrollAdminCellComment(
|
||
periodId,
|
||
cell.employeeId,
|
||
cell.columnId,
|
||
comment.id,
|
||
);
|
||
const next = comments.filter((item) => item.id !== comment.id);
|
||
setComments(next);
|
||
onCommentCountChange(cell, next.length);
|
||
} catch (deleteError) {
|
||
setError(getPayrollAdminError(deleteError, "Не удалось удалить комментарий."));
|
||
} finally {
|
||
setDeletingId(null);
|
||
}
|
||
}, [cell, comments, onCommentCountChange, periodId]);
|
||
|
||
return (
|
||
<Modal
|
||
isOpen={Boolean(cell)}
|
||
onClose={onClose}
|
||
title="Комментарии к ячейке"
|
||
size="lg"
|
||
noPadding
|
||
>
|
||
{cell ? (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="shrink-0 px-4 pb-4 sm:px-6">
|
||
<div className="app-surface-muted rounded-xl px-4 py-3">
|
||
<p className="text-sm font-semibold text-[var(--foreground)]">{cell.employeeName}</p>
|
||
<p className="app-text-muted mt-1 text-xs">Колонка: {cell.columnLabel}</p>
|
||
</div>
|
||
{error ? <div className="app-feedback-danger mt-3 rounded-xl p-3 text-sm">{error}</div> : null}
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 sm:px-6">
|
||
<section className="app-surface rounded-xl p-4">
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<MessageSquare size={16} className="app-text-muted" />
|
||
<p className="app-card-caption">Обсуждение · {comments.length}</p>
|
||
</div>
|
||
{loading ? (
|
||
<div className="py-8 text-center">
|
||
<Loader2 className="app-text-muted mx-auto animate-spin" size={22} />
|
||
<p className="app-text-muted mt-3 text-sm">Загрузка комментариев...</p>
|
||
</div>
|
||
) : comments.length === 0 ? (
|
||
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
|
||
<p className="text-sm font-medium text-[var(--foreground)]">Комментариев пока нет</p>
|
||
<p className="app-text-muted mt-2 text-sm">Добавьте пояснение к значению в этой ячейке.</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{comments.map((comment) => (
|
||
<article key={comment.id} className="app-surface-muted rounded-xl p-3">
|
||
<div className="flex items-start gap-3">
|
||
<RequestAvatar
|
||
alt={comment.author.display_name}
|
||
fallback={authorFallback(comment)}
|
||
size="lg"
|
||
src={comment.author.avatar}
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{comment.author.display_name}</p>
|
||
<p className="app-text-muted mt-0.5 text-xs">{formatDateTime(comment.created_at)}</p>
|
||
</div>
|
||
{comment.author.id === currentUserId ? (
|
||
<CommentDeleteButton
|
||
disabled={deletingId === comment.id}
|
||
onClick={() => deleteComment(comment)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<p className="app-text-wrap mt-2 text-sm leading-6 text-[var(--foreground)]">{comment.text}</p>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
))}
|
||
<div ref={bottomRef} />
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
|
||
<div className="app-surface-elevated shrink-0 border-t border-[var(--border-primary)] px-4 py-4 sm:px-6">
|
||
<div className="flex items-end gap-2">
|
||
<div className={`app-input min-w-0 flex-1 overflow-hidden ${composerExpanded ? "rounded-[1.5rem]" : "h-10 rounded-full"}`}>
|
||
<textarea
|
||
ref={inputRef}
|
||
value={draft}
|
||
onChange={(event) => {
|
||
setDraft(event.target.value);
|
||
if (error) setError(null);
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" && !event.shiftKey) {
|
||
event.preventDefault();
|
||
void sendComment();
|
||
}
|
||
}}
|
||
rows={1}
|
||
maxLength={10000}
|
||
placeholder="Введите комментарий..."
|
||
aria-label="Поле ввода комментария"
|
||
className="w-full resize-none bg-transparent px-4 py-2.5 text-sm leading-5 text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)]"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void sendComment()}
|
||
disabled={sending || !draft.trim()}
|
||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500 text-white shadow-sm transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:border disabled:border-[var(--border-subtle)] disabled:bg-[var(--surface-secondary)] disabled:text-[var(--muted-foreground)]"
|
||
title="Отправить комментарий"
|
||
aria-label="Отправить комментарий"
|
||
>
|
||
{sending ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</Modal>
|
||
);
|
||
}
|