Corp/frontend/src/components/finance/admin/PayrollCellCommentsModal.tsx

254 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}