summaryrefslogtreecommitdiff
path: root/lib/basic-contract/agreement-comments/agreement-comment-list.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-19 06:15:43 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-19 06:15:43 +0000
commitc92bd1b8caa6ddabe6acee42018262febd5d91fb (patch)
tree833a62c9577894b0f77d3677d4d0274e1cb99385 /lib/basic-contract/agreement-comments/agreement-comment-list.tsx
parent9bf5b15734cdf87a02c68b2d2a25046a0678a037 (diff)
(임수민) 기본계약 코멘트, 법무검토 수정
Diffstat (limited to 'lib/basic-contract/agreement-comments/agreement-comment-list.tsx')
-rw-r--r--lib/basic-contract/agreement-comments/agreement-comment-list.tsx231
1 files changed, 204 insertions, 27 deletions
diff --git a/lib/basic-contract/agreement-comments/agreement-comment-list.tsx b/lib/basic-contract/agreement-comments/agreement-comment-list.tsx
index 8b9cdbea..bad5aee5 100644
--- a/lib/basic-contract/agreement-comments/agreement-comment-list.tsx
+++ b/lib/basic-contract/agreement-comments/agreement-comment-list.tsx
@@ -22,6 +22,8 @@ import {
Building2,
Loader2,
Download,
+ Send,
+ CheckCircle2,
} from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import {
@@ -30,6 +32,7 @@ import {
deleteAgreementComment,
uploadCommentAttachment,
deleteCommentAttachment,
+ completeNegotiation,
type AgreementCommentData,
type AgreementCommentAuthorType,
} from "./actions";
@@ -40,6 +43,8 @@ export interface AgreementCommentListProps {
readOnly?: boolean;
className?: string;
onCommentCountChange?: (count: number) => void;
+ isNegotiationCompleted?: boolean; // 협의 완료 여부
+ onNegotiationComplete?: () => void; // 협의 완료 콜백
}
export function AgreementCommentList({
@@ -48,6 +53,8 @@ export function AgreementCommentList({
readOnly = false,
className,
onCommentCountChange,
+ isNegotiationCompleted = false,
+ onNegotiationComplete,
}: AgreementCommentListProps) {
const [comments, setComments] = useState<AgreementCommentData[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -56,6 +63,8 @@ export function AgreementCommentList({
const [newAuthorName, setNewAuthorName] = useState('');
const [uploadingFiles, setUploadingFiles] = useState<Set<number>>(new Set());
const [isSaving, setIsSaving] = useState(false);
+ const [pendingFiles, setPendingFiles] = useState<File[]>([]); // 첨부 대기 중인 파일들
+ const [isCompletingNegotiation, setIsCompletingNegotiation] = useState(false);
// 코멘트 로드
const loadComments = useCallback(async () => {
@@ -77,8 +86,8 @@ export function AgreementCommentList({
loadComments();
}, [basicContractId]); // loadComments 대신 basicContractId만 의존
- // 코멘트 추가 핸들러
- const handleAddComment = useCallback(async () => {
+ // 코멘트 추가 핸들러 (저장만 - 이메일 발송 없음)
+ const handleAddComment = useCallback(async (shouldSendEmail: boolean = false) => {
if (!newComment.trim()) {
toast.error("코멘트를 입력해주세요.");
return;
@@ -90,13 +99,22 @@ export function AgreementCommentList({
basicContractId,
comment: newComment.trim(),
authorName: newAuthorName.trim() || undefined,
+ files: pendingFiles,
+ shouldSendEmail, // 이메일 발송 여부 전달
});
if (result.success) {
setNewComment('');
setNewAuthorName('');
+ setPendingFiles([]);
setIsAdding(false);
- toast.success("코멘트가 추가되었습니다.");
+
+ if (shouldSendEmail) {
+ toast.success("코멘트가 제출되었으며 상대방에게 이메일이 발송되었습니다.");
+ } else {
+ toast.success("코멘트가 저장되었습니다.");
+ }
+
await loadComments(); // 목록 새로고침
} else {
toast.error(result.error || "코멘트 추가에 실패했습니다.");
@@ -107,7 +125,22 @@ export function AgreementCommentList({
} finally {
setIsSaving(false);
}
- }, [newComment, newAuthorName, basicContractId]); // loadComments 제거
+ }, [newComment, newAuthorName, basicContractId, pendingFiles]); // pendingFiles 추가
+
+ // 파일 선택 핸들러
+ const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+ const files = Array.from(e.target.files || []);
+ if (files.length > 0) {
+ setPendingFiles(prev => [...prev, ...files]);
+ toast.success(`${files.length}개의 파일이 추가되었습니다.`);
+ }
+ e.target.value = ''; // 파일 입력 초기화
+ }, []);
+
+ // 대기 중인 파일 삭제 핸들러
+ const handleRemovePendingFile = useCallback((index: number) => {
+ setPendingFiles(prev => prev.filter((_, i) => i !== index));
+ }, []);
// 코멘트 삭제 핸들러
const handleDeleteComment = useCallback(async (commentId: number) => {
@@ -172,6 +205,31 @@ export function AgreementCommentList({
}
}, []); // loadComments 제거
+ // 협의 완료 핸들러
+ const handleCompleteNegotiation = useCallback(async () => {
+ if (!confirm("협의를 완료하시겠습니까?\n협의 완료 후에는 법무검토 요청이 가능합니다.")) {
+ return;
+ }
+
+ setIsCompletingNegotiation(true);
+ try {
+ const result = await completeNegotiation(basicContractId);
+ if (result.success) {
+ toast.success("협의가 완료되었습니다. 이제 법무검토 요청이 가능합니다.");
+ if (onNegotiationComplete) {
+ onNegotiationComplete();
+ }
+ } else {
+ toast.error(result.error || "협의 완료 처리에 실패했습니다.");
+ }
+ } catch (error) {
+ console.error('협의 완료 실패:', error);
+ toast.error("협의 완료 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsCompletingNegotiation(false);
+ }
+ }, [basicContractId, onNegotiationComplete]);
+
// 파일 크기 포맷팅
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B';
@@ -195,27 +253,51 @@ export function AgreementCommentList({
<div className="flex items-center space-x-2">
<MessageSquare className="h-5 w-5 text-blue-500" />
<h3 className="font-semibold text-gray-800">협의 코멘트</h3>
+ {isNegotiationCompleted && (
+ <Badge className="bg-green-500 text-white border-green-600">
+ 협의 완료
+ </Badge>
+ )}
</div>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200 h-8 px-3 text-sm flex items-center">
총 {comments.length}개
</Badge>
- {!readOnly && (
- <Button
- size="sm"
- onClick={() => setIsAdding(true)}
- disabled={isAdding}
- className="h-8"
- >
- <Plus className="h-4 w-4 mr-1" />
- 코멘트 추가
- </Button>
+ {!readOnly && !isNegotiationCompleted && (
+ <>
+ <Button
+ size="sm"
+ onClick={() => setIsAdding(true)}
+ disabled={isAdding}
+ className="h-8"
+ >
+ <Plus className="h-4 w-4 mr-1" />
+ 코멘트 추가
+ </Button>
+ {comments.length > 0 && currentUserType === 'SHI' && (
+ <Button
+ size="sm"
+ onClick={handleCompleteNegotiation}
+ disabled={isCompletingNegotiation}
+ className="h-8 bg-green-600 hover:bg-green-700"
+ >
+ {isCompletingNegotiation ? (
+ <Loader2 className="h-4 w-4 mr-1 animate-spin" />
+ ) : (
+ <CheckCircle2 className="h-4 w-4 mr-1" />
+ )}
+ 협의 완료
+ </Button>
+ )}
+ </>
)}
</div>
</div>
<p className="text-sm text-gray-600">
- SHI와 협력업체 간 기본계약서 협의 내용을 작성하고 공유합니다.
+ {isNegotiationCompleted
+ ? "협의가 완료되었습니다. 법무검토 요청이 가능합니다."
+ : "SHI와 협력업체 간 기본계약서 협의 내용을 작성하고 공유합니다."}
</p>
</div>
@@ -223,7 +305,7 @@ export function AgreementCommentList({
<ScrollArea className="flex-1">
<div className="p-3 space-y-3">
{/* 새 코멘트 입력 폼 */}
- {isAdding && !readOnly && (
+ {isAdding && !readOnly && !isNegotiationCompleted && (
<Card
className={cn(
"border-l-4",
@@ -289,7 +371,74 @@ export function AgreementCommentList({
/>
</div>
- <div className="flex items-center justify-end space-x-2">
+ {/* 파일 첨부 영역 */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label className="text-sm font-medium text-gray-700 flex items-center">
+ <Paperclip className="h-4 w-4 mr-1" />
+ 첨부파일 {pendingFiles.length > 0 ? `(${pendingFiles.length})` : ''}
+ </Label>
+ <label htmlFor="new-comment-files">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="h-8"
+ onClick={() => document.getElementById('new-comment-files')?.click()}
+ disabled={isSaving}
+ >
+ <Upload className="h-3 w-3 mr-1" />
+ 파일 추가
+ </Button>
+ <input
+ id="new-comment-files"
+ type="file"
+ multiple
+ className="hidden"
+ onChange={handleFileSelect}
+ accept="*/*"
+ />
+ </label>
+ </div>
+
+ {/* 대기 중인 파일 목록 */}
+ {pendingFiles.length > 0 && (
+ <div className="space-y-1.5 max-h-32 overflow-y-auto">
+ {pendingFiles.map((file, index) => (
+ <div
+ key={`${file.name}-${index}`}
+ className="flex items-center justify-between bg-white rounded p-2 border border-gray-200"
+ >
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ <FileText className="h-4 w-4 text-blue-500 flex-shrink-0" />
+ <div className="flex-1 min-w-0">
+ <p className="text-sm text-gray-700 truncate">
+ {file.name}
+ </p>
+ <p className="text-xs text-gray-500">
+ {formatFileSize(file.size)}
+ </p>
+ </div>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemovePendingFile(index)}
+ className="h-6 w-6 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <p className="text-xs text-gray-500">
+ 💡 파일을 먼저 추가한 후 저장 또는 제출 버튼을 눌러주세요.
+ </p>
+ </div>
+
+ <div className="flex items-center justify-end gap-2 pt-2 border-t">
<Button
variant="outline"
size="sm"
@@ -297,6 +446,7 @@ export function AgreementCommentList({
setIsAdding(false);
setNewComment('');
setNewAuthorName('');
+ setPendingFiles([]);
}}
disabled={isSaving}
>
@@ -304,8 +454,9 @@ export function AgreementCommentList({
취소
</Button>
<Button
+ variant="outline"
size="sm"
- onClick={handleAddComment}
+ onClick={() => handleAddComment(false)}
disabled={isSaving || !newComment.trim()}
>
{isSaving ? (
@@ -315,6 +466,19 @@ export function AgreementCommentList({
)}
저장
</Button>
+ <Button
+ size="sm"
+ onClick={() => handleAddComment(true)}
+ disabled={isSaving || !newComment.trim()}
+ className="bg-blue-600 hover:bg-blue-700"
+ >
+ {isSaving ? (
+ <Loader2 className="h-4 w-4 mr-1 animate-spin" />
+ ) : (
+ <Send className="h-4 w-4 mr-1" />
+ )}
+ 제출 (메일발송)
+ </Button>
</div>
</div>
</CardContent>
@@ -326,9 +490,11 @@ export function AgreementCommentList({
<div className="text-center py-8">
<MessageSquare className="h-12 w-12 text-gray-300 mx-auto mb-3" />
<p className="text-sm text-gray-500">
- 아직 코멘트가 없습니다.
+ {isNegotiationCompleted
+ ? "협의가 완료되어 더 이상 코멘트를 추가할 수 없습니다."
+ : "아직 코멘트가 없습니다."}
</p>
- {!readOnly && (
+ {!readOnly && !isNegotiationCompleted && (
<Button
variant="outline"
size="sm"
@@ -341,7 +507,11 @@ export function AgreementCommentList({
)}
</div>
) : (
- comments.map((comment) => (
+ comments.map((comment) => {
+ // 현재 사용자가 이 코멘트의 작성자인지 확인
+ const isCommentOwner = comment.authorType === currentUserType;
+
+ return (
<Card
key={comment.id}
className={cn(
@@ -384,7 +554,7 @@ export function AgreementCommentList({
)}
</div>
- {!readOnly && (
+ {!readOnly && isCommentOwner && (
<Button
variant="ghost"
size="sm"
@@ -404,14 +574,14 @@ export function AgreementCommentList({
</div>
{/* 첨부파일 */}
- {(comment.attachments.length > 0 || !readOnly) && (
+ {(comment.attachments.length > 0 || (!readOnly && isCommentOwner)) && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-gray-600 flex items-center">
<Paperclip className="h-3 w-3 mr-1" />
첨부파일 {comment.attachments.length > 0 ? `(${comment.attachments.length})` : ''}
</Label>
- {!readOnly && (
+ {!readOnly && isCommentOwner && (
<label htmlFor={`file-${comment.id}`}>
<Button
type="button"
@@ -469,11 +639,17 @@ export function AgreementCommentList({
asChild
className="h-6 w-6 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-50"
>
- <a href={attachment.filePath} download target="_blank" rel="noopener noreferrer">
+ <a
+ href={attachment.filePath}
+ download={attachment.fileName}
+ target="_blank"
+ rel="noopener noreferrer"
+ title={`${attachment.fileName} 다운로드`}
+ >
<Download className="h-3 w-3" />
</a>
</Button>
- {!readOnly && (
+ {!readOnly && isCommentOwner && (
<Button
variant="ghost"
size="sm"
@@ -505,7 +681,8 @@ export function AgreementCommentList({
</div>
</CardContent>
</Card>
- ))
+ );
+ })
)}
</div>
</ScrollArea>