feat: 신규 페이지 구현 및 HR/설정 기능 개선

신규 페이지:
- 회계관리: 거래처, 예상비용, 청구서, 발주서
- 게시판: 공지사항, 자료실, 커뮤니티
- 고객센터: 문의/FAQ
- 설정: 계정, 알림, 출퇴근, 팝업, 구독, 결제내역
- 리포트 (차트 시각화)
- 개발자 테스트 URL 페이지

기능 개선:
- HR 직원관리/휴가관리/카드관리 강화
- IntegratedListTemplateV2 확장
- AuthenticatedLayout 패딩 표준화
- 로그인 페이지 UI 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-19 19:12:34 +09:00
parent d742c0ce26
commit c6b605200d
213 changed files with 32644 additions and 775 deletions

View File

@@ -0,0 +1,182 @@
'use client';
/**
* 개별 댓글 컴포넌트
*
* 디자인 스펙 기준:
* - 프로필 이미지, 부서명 이름 직책, 등록일시, 댓글 내용
* - 수정/삭제 버튼 (본인 댓글만)
* - 수정 클릭 시 인풋박스에 기존 댓글 내용 입력 상태로 변경
* - 삭제 클릭 시 "정말 삭제하시겠습니까?" 확인 Alert
*/
import { useState, useCallback } from 'react';
import { format } from 'date-fns';
import { User, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Comment } from '../types';
interface CommentItemProps {
comment: Comment;
currentUserId: string;
onUpdate: (id: string, content: string) => void;
onDelete: (id: string) => void;
}
export function CommentItem({
comment,
currentUserId,
onUpdate,
onDelete,
}: CommentItemProps) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(comment.content);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const isMyComment = comment.authorId === currentUserId;
// 수정 모드 토글
const handleEditClick = useCallback(() => {
setIsEditing(true);
setEditContent(comment.content);
}, [comment.content]);
// 수정 취소
const handleCancelEdit = useCallback(() => {
setIsEditing(false);
setEditContent(comment.content);
}, [comment.content]);
// 수정 저장
const handleSaveEdit = useCallback(() => {
if (editContent.trim()) {
onUpdate(comment.id, editContent.trim());
setIsEditing(false);
}
}, [comment.id, editContent, onUpdate]);
// 삭제 확인
const handleConfirmDelete = useCallback(() => {
onDelete(comment.id);
setShowDeleteDialog(false);
}, [comment.id, onDelete]);
// 작성자 정보 포맷
const authorInfo = [
comment.authorDepartment,
comment.authorName,
comment.authorPosition,
].filter(Boolean).join(' ');
return (
<div className="flex gap-3 py-4 border-b border-gray-100 last:border-0">
{/* 프로필 이미지 */}
<div className="flex-shrink-0">
{comment.authorProfileImage ? (
<img
src={comment.authorProfileImage}
alt={comment.authorName}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
<User className="w-5 h-5 text-gray-500" />
</div>
)}
</div>
{/* 댓글 내용 */}
<div className="flex-1 min-w-0">
{/* 작성자 정보 + 날짜 + 버튼 */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-gray-900">{authorInfo}</span>
<span className="text-gray-400">
{format(new Date(comment.createdAt), 'yyyy-MM-dd HH:mm')}
</span>
</div>
{/* 본인 댓글만 수정/삭제 버튼 표시 */}
{isMyComment && !isEditing && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gray-500 hover:text-gray-700"
onClick={handleEditClick}
>
<Pencil className="h-3 w-3 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-red-500 hover:text-red-600"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="h-3 w-3 mr-1" />
</Button>
</div>
)}
</div>
{/* 댓글 본문 또는 수정 폼 */}
{isEditing ? (
<div className="space-y-2">
<Textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
placeholder="댓글을 입력해주세요"
className="min-h-[80px]"
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleSaveEdit}>
</Button>
<Button size="sm" variant="outline" onClick={handleCancelEdit}>
</Button>
</div>
</div>
) : (
<p className="text-gray-700 whitespace-pre-wrap">{comment.content}</p>
)}
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
export default CommentItem;

View File

@@ -0,0 +1,90 @@
'use client';
/**
* 댓글 섹션 컴포넌트
*
* 디자인 스펙 기준:
* - 댓글 등록 Textarea + 버튼
* - 댓글 수 표시 ("댓글 N")
* - 댓글 목록
*/
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { CommentItem } from './CommentItem';
import type { Comment } from '../types';
interface CommentSectionProps {
postId: string;
comments: Comment[];
currentUserId: string;
onAddComment: (content: string) => void;
onUpdateComment: (id: string, content: string) => void;
onDeleteComment: (id: string) => void;
}
export function CommentSection({
postId,
comments,
currentUserId,
onAddComment,
onUpdateComment,
onDeleteComment,
}: CommentSectionProps) {
const [newComment, setNewComment] = useState('');
// 댓글 등록 핸들러
const handleSubmit = useCallback(() => {
if (newComment.trim()) {
onAddComment(newComment.trim());
setNewComment('');
}
}, [newComment, onAddComment]);
return (
<div className="space-y-6">
{/* 댓글 등록 */}
<div className="space-y-3">
<h3 className="text-lg font-semibold flex items-center gap-2">
<span className="w-2 h-2 bg-red-500 rounded-full" />
</h3>
<Textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="댓글을 입력해주세요"
className="min-h-[100px]"
/>
<div className="flex justify-end">
<Button onClick={handleSubmit}></Button>
</div>
</div>
{/* 댓글 목록 */}
{comments.length > 0 && (
<div className="space-y-3">
<h3 className="text-lg font-semibold flex items-center gap-2">
<span className="w-2 h-2 bg-red-500 rounded-full" />
{comments.length}
</h3>
<div className="bg-white rounded-lg border border-gray-200">
<div className="p-4">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
currentUserId={currentUserId}
onUpdate={onUpdateComment}
onDelete={onDeleteComment}
/>
))}
</div>
</div>
</div>
)}
</div>
);
}
export default CommentSection;