동기화
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user