동기화

This commit is contained in:
GA Pro
2026-05-14 01:21:27 +09:00
parent acea2afce5
commit 958fe6b980
379 changed files with 21786 additions and 423 deletions
@@ -0,0 +1,59 @@
import { Steps, Typography } from 'antd';
import { IconCheck, IconX, IconClock } from '@tabler/icons-react';
import { ApprovalRequestStep } from '@/api/approval';
import { COLOR_SUCCESS, COLOR_ERROR, COLOR_PRIMARY, GRAY } from '@/theme/tokens';
const { Text } = Typography;
interface Props {
steps: ApprovalRequestStep[];
currentStep: number;
}
function stepIcon(status: string) {
if (status === 'APPROVED') return <IconCheck size={14} style={{ color: COLOR_SUCCESS }} />;
if (status === 'REJECTED') return <IconX size={14} style={{ color: COLOR_ERROR }} />;
return <IconClock size={14} style={{ color: COLOR_PRIMARY }} />;
}
function stepStatus(status: string): 'finish' | 'error' | 'process' | 'wait' {
if (status === 'APPROVED') return 'finish';
if (status === 'REJECTED') return 'error';
if (status === 'IN_PROGRESS') return 'process';
return 'wait';
}
/**
* 결재선 진행 시각화 컴포넌트.
* Steps 수평 렌더링 + 단계별 상태 색상.
*/
export default function ApprovalProgress({ steps, currentStep }: Props) {
const items = steps.map((s) => ({
title: (
<Text style={{ fontSize: 12, fontWeight: 600, color: GRAY[700] }}>
{s.approverName}
</Text>
),
description: (
<div style={{ fontSize: 11, color: GRAY[500] }}>
{s.comment && <div style={{ marginTop: 2, color: GRAY[600] }}>{s.comment}</div>}
{s.processedAt && (
<div style={{ marginTop: 2 }}>
{s.processedAt.slice(0, 10)}
</div>
)}
</div>
),
icon: stepIcon(s.status),
status: stepStatus(s.status),
}));
return (
<Steps
size="small"
current={currentStep - 1}
items={items}
style={{ marginTop: 8 }}
/>
);
}
@@ -0,0 +1,122 @@
import { useState } from 'react';
import { Button, List, Progress, Space, Typography, Upload, message } from 'antd';
import { IconFile, IconTrash, IconUpload } from '@tabler/icons-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { attachmentApi, AttachmentMeta } from '@/api/attachment';
import { GRAY, RADIUS, SHADOW, COLOR_ERROR } from '@/theme/tokens';
const { Text } = Typography;
const { Dragger } = Upload;
interface Props {
refType: string;
refId: number;
}
/**
* 공용 첨부파일 업로드/목록/삭제 컴포넌트.
* refType + refId 로 연결. Drag+Drop 지원.
*/
export default function AttachmentUpload({ refType, refId }: Props) {
const qc = useQueryClient();
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const { data: files = [], isLoading } = useQuery({
queryKey: ['attachments', refType, refId],
queryFn: () => attachmentApi.list(refType, refId),
enabled: !!refId,
});
const handleUpload = async (file: File) => {
setUploading(true);
setProgress(0);
try {
await attachmentApi.upload(refType, refId, file, setProgress);
message.success(`${file.name} 업로드 완료`);
qc.invalidateQueries({ queryKey: ['attachments', refType, refId] });
} catch {
message.error('업로드 실패');
} finally {
setUploading(false);
setProgress(0);
}
return false; // prevent antd auto-upload
};
const handleDelete = async (id: number, name: string) => {
try {
await attachmentApi.delete(id);
message.success(`${name} 삭제 완료`);
qc.invalidateQueries({ queryKey: ['attachments', refType, refId] });
} catch {
message.error('삭제 실패');
}
};
const fmtSize = (bytes: number) => {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
};
return (
<div>
<Dragger
multiple={false}
showUploadList={false}
beforeUpload={handleUpload}
disabled={uploading}
style={{
borderRadius: RADIUS.md,
borderColor: GRAY[200],
background: GRAY[50],
padding: '12px 0',
}}
>
<p style={{ margin: 0, color: GRAY[500], fontSize: 13 }}>
<IconUpload size={20} style={{ verticalAlign: 'middle', marginRight: 8 }} />
( 50MB)
</p>
</Dragger>
{uploading && (
<Progress percent={progress} status="active" style={{ marginTop: 8 }} />
)}
{!isLoading && files.length > 0 && (
<List
size="small"
style={{
marginTop: 12,
background: '#fff',
border: `1px solid ${GRAY[100]}`,
borderRadius: RADIUS.md,
boxShadow: SHADOW.sm,
}}
dataSource={files}
renderItem={(f: AttachmentMeta) => (
<List.Item
actions={[
<Button
key="del"
type="text"
size="small"
icon={<IconTrash size={14} />}
style={{ color: COLOR_ERROR }}
onClick={() => handleDelete(f.attachmentId, f.originalName)}
/>,
]}
>
<Space>
<IconFile size={14} style={{ color: GRAY[400] }} />
<Text style={{ fontSize: 13 }}>{f.originalName}</Text>
<Text style={{ fontSize: 12, color: GRAY[400] }}>({fmtSize(f.fileSize)})</Text>
</Space>
</List.Item>
)}
/>
)}
</div>
);
}
@@ -0,0 +1,116 @@
import { Badge, Button, Dropdown, Empty, Space, Typography } from 'antd';
import { IconBell } from '@tabler/icons-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { notificationApi, UserNotificationRow } from '@/api/notification';
import { GRAY, COLOR_PRIMARY, RADIUS, SHADOW } from '@/theme/tokens';
const { Text } = Typography;
/**
* MainLayout 우측 상단 알림 벨.
* 미읽음 카운트 빨간 dot + Dropdown 최근 10개 리스트.
*/
export default function NotificationBell() {
const qc = useQueryClient();
const { data: count = 0 } = useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: notificationApi.unreadCount,
refetchInterval: 30_000,
});
const { data: page } = useQuery({
queryKey: ['notifications', 'list'],
queryFn: () => notificationApi.list({ pageSize: 10 }),
});
const notifications = page?.list ?? [];
const handleRead = async (id: number) => {
await notificationApi.markRead(id);
qc.invalidateQueries({ queryKey: ['notifications'] });
};
const handleReadAll = async () => {
await notificationApi.markAllRead();
qc.invalidateQueries({ queryKey: ['notifications'] });
};
const overlay = (
<div style={{
width: 340,
background: '#fff',
borderRadius: RADIUS.lg,
boxShadow: SHADOW.lg,
border: `1px solid ${GRAY[100]}`,
overflow: 'hidden',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: `1px solid ${GRAY[100]}`,
}}>
<Text style={{ fontSize: 14, fontWeight: 700, color: GRAY[800] }}></Text>
{count > 0 && (
<Button type="link" size="small" onClick={handleReadAll}
style={{ fontSize: 12, padding: 0 }}>
</Button>
)}
</div>
{notifications.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="알림이 없습니다"
style={{ padding: '24px 0' }} />
) : (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{notifications.map((n: UserNotificationRow) => (
<div
key={n.notificationId}
onClick={() => !n.isRead && handleRead(n.notificationId)}
style={{
padding: '10px 16px',
cursor: n.isRead ? 'default' : 'pointer',
background: n.isRead ? '#fff' : '#EBF3FF',
borderBottom: `1px solid ${GRAY[100]}`,
}}
>
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Text style={{
fontSize: 13,
fontWeight: n.isRead ? 400 : 600,
color: GRAY[800],
}}>
{n.title}
</Text>
<Text style={{ fontSize: 12, color: GRAY[500] }}>{n.content}</Text>
<Text style={{ fontSize: 11, color: GRAY[400] }}>
{n.createdAt?.slice(0, 16)}
</Text>
</Space>
</div>
))}
</div>
)}
</div>
);
return (
<Dropdown
dropdownRender={() => overlay}
placement="bottomRight"
trigger={['click']}
>
<Badge count={count} size="small" offset={[-3, 3]}>
<Button
type="text"
icon={<IconBell size={18} />}
style={{
color: GRAY[600],
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
/>
</Badge>
</Dropdown>
);
}