share job
This commit is contained in:
161
frontend/src/components/common/FileUpload.tsx
Normal file
161
frontend/src/components/common/FileUpload.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
AttachFileInfo,
|
||||
deleteFile,
|
||||
downloadFile,
|
||||
downloadZip,
|
||||
formatFileSize,
|
||||
uploadFiles,
|
||||
} from '@/lib/api/attach';
|
||||
|
||||
interface Props {
|
||||
atchNo?: string;
|
||||
division?: string;
|
||||
initialFiles?: AttachFileInfo[];
|
||||
onChange?: (atchNo: string, files: AttachFileInfo[]) => void;
|
||||
maxFiles?: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function FileUpload({
|
||||
atchNo: initialAtchNo,
|
||||
division,
|
||||
initialFiles = [],
|
||||
onChange,
|
||||
maxFiles = 10,
|
||||
readOnly = false,
|
||||
}: Props) {
|
||||
const [files, setFiles] = useState<AttachFileInfo[]>(initialFiles);
|
||||
const [atchNo, setAtchNo] = useState<string | undefined>(initialAtchNo);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async (selected: FileList | null) => {
|
||||
if (!selected || selected.length === 0) return;
|
||||
if (files.length + selected.length > maxFiles) {
|
||||
alert(`최대 ${maxFiles}개까지 업로드할 수 있습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await uploadFiles(Array.from(selected), atchNo, division);
|
||||
const newAtchNo = uploaded[0]?.atchNo ?? atchNo;
|
||||
const newFiles = [...files, ...uploaded];
|
||||
setAtchNo(newAtchNo);
|
||||
setFiles(newFiles);
|
||||
onChange?.(newAtchNo!, newFiles);
|
||||
} catch (e: any) {
|
||||
alert(e?.response?.data?.message ?? '업로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (atchfileNo: string) => {
|
||||
if (!confirm('파일을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await deleteFile(atchfileNo);
|
||||
const newFiles = files.filter((f) => f.atchfileNo !== atchfileNo);
|
||||
setFiles(newFiles);
|
||||
onChange?.(atchNo!, newFiles);
|
||||
} catch (e: any) {
|
||||
alert(e?.response?.data?.message ?? '삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 드롭존 */}
|
||||
{!readOnly && (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
|
||||
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-blue-400'}`}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
handleUpload(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleUpload(e.target.files)}
|
||||
/>
|
||||
{uploading ? (
|
||||
<p className="text-sm text-gray-500">업로드 중...</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
클릭하거나 파일을 드래그하세요 (최대 {maxFiles}개, 30MB 이하)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 목록 헤더 */}
|
||||
{files.length > 1 && atchNo && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => downloadZip(atchNo)}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
전체 다운로드 (ZIP)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 목록 */}
|
||||
{files.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{files.map((f) => (
|
||||
<li
|
||||
key={f.atchfileNo}
|
||||
className="flex items-center justify-between px-3 py-2 bg-gray-50 rounded border text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<FileIcon contentType={f.atchTypeNm} />
|
||||
<button
|
||||
onClick={() => downloadFile(f.atchfileNo, f.atchFileNm)}
|
||||
className="truncate text-blue-600 hover:underline text-left"
|
||||
>
|
||||
{f.atchFileNm}
|
||||
</button>
|
||||
<span className="text-gray-400 text-xs shrink-0">
|
||||
({formatFileSize(f.atchFileMg)})
|
||||
</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => handleDelete(f.atchfileNo)}
|
||||
className="ml-2 text-red-400 hover:text-red-600 shrink-0"
|
||||
title="삭제"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon({ contentType }: { contentType: string }) {
|
||||
const isImage = contentType?.startsWith('image');
|
||||
const isPdf = contentType === 'application/pdf';
|
||||
return (
|
||||
<span className="text-gray-400 shrink-0">
|
||||
{isImage ? '🖼' : isPdf ? '📄' : '📎'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/common/Modal.tsx
Normal file
78
frontend/src/components/common/Modal.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SIZE_CLASS = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
};
|
||||
|
||||
export default function Modal({ open, onClose, title, children, size = 'md', footer }: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
// body scroll 방지
|
||||
useEffect(() => {
|
||||
if (open) document.body.style.overflow = 'hidden';
|
||||
else document.body.style.overflow = '';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
|
||||
>
|
||||
<div className={`bg-white rounded-lg shadow-xl w-full ${SIZE_CLASS[size]} flex flex-col max-h-[90vh]`}>
|
||||
{/* 헤더 */}
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
|
||||
aria-label="닫기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
{footer && (
|
||||
<div className="px-5 py-3 border-t flex justify-end gap-2 bg-gray-50 rounded-b-lg">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/common/Pagination.tsx
Normal file
81
frontend/src/components/common/Pagination.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
export default function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
maxVisible = 10,
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
// 현재 페이지 주변 페이지 범위 계산
|
||||
const half = Math.floor(maxVisible / 2);
|
||||
let start = Math.max(1, currentPage - half);
|
||||
let end = Math.min(totalPages, start + maxVisible - 1);
|
||||
if (end - start + 1 < maxVisible) start = Math.max(1, end - maxVisible + 1);
|
||||
|
||||
const pages = Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center gap-1 mt-4 flex-wrap">
|
||||
{/* 처음 */}
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-2 py-1 text-sm rounded border hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
{/* 이전 */}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-2 py-1 text-sm rounded border hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
{start > 1 && <span className="px-1 text-gray-400 text-sm">…</span>}
|
||||
|
||||
{pages.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`px-3 py-1 text-sm rounded border transition-colors ${
|
||||
p === currentPage
|
||||
? 'bg-blue-600 text-white border-blue-600 font-medium'
|
||||
: 'hover:bg-gray-100 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{end < totalPages && <span className="px-1 text-gray-400 text-sm">…</span>}
|
||||
|
||||
{/* 다음 */}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-2 py-1 text-sm rounded border hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{/* 끝 */}
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-2 py-1 text-sm rounded border hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/common/Providers.tsx
Normal file
26
frontend/src/components/common/Providers.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
53
frontend/src/components/common/RichEditor.tsx
Normal file
53
frontend/src/components/common/RichEditor.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import type { Editor } from '@toast-ui/react-editor';
|
||||
import '@toast-ui/editor/dist/toastui-editor.css';
|
||||
|
||||
const ToastEditor = dynamic(
|
||||
() => import('@toast-ui/react-editor').then((m) => m.Editor),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export interface RichEditorRef {
|
||||
getMarkdown: () => string;
|
||||
getHTML: () => string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialValue?: string;
|
||||
height?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const RichEditor = forwardRef<RichEditorRef, Props>(
|
||||
({ initialValue = '', height = '400px', onChange }, ref) => {
|
||||
const editorRef = useRef<Editor>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => editorRef.current?.getInstance().getMarkdown() ?? '',
|
||||
getHTML: () => editorRef.current?.getInstance().getHTML() ?? '',
|
||||
}));
|
||||
|
||||
return (
|
||||
<ToastEditor
|
||||
ref={editorRef}
|
||||
initialValue={initialValue || ' '}
|
||||
previewStyle="vertical"
|
||||
height={height}
|
||||
initialEditType="wysiwyg"
|
||||
useCommandShortcut
|
||||
onChange={() => {
|
||||
if (onChange && editorRef.current) {
|
||||
onChange(editorRef.current.getInstance().getHTML());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RichEditor.displayName = 'RichEditor';
|
||||
|
||||
export default RichEditor;
|
||||
109
frontend/src/components/common/Toast.tsx
Normal file
109
frontend/src/components/common/Toast.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, createContext, useContext, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
// ─────────── Types ───────────
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (message: string, type?: ToastType, duration?: number) => void;
|
||||
success: (message: string, duration?: number) => void;
|
||||
error: (message: string, duration?: number) => void;
|
||||
info: (message: string, duration?: number) => void;
|
||||
warning: (message: string, duration?: number) => void;
|
||||
}
|
||||
|
||||
// ─────────── Context ───────────
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
let _idCounter = 0;
|
||||
|
||||
// ─────────── Provider ───────────
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const remove = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const toast = useCallback((message: string, type: ToastType = 'info', duration = 3000) => {
|
||||
const id = ++_idCounter;
|
||||
setToasts((prev) => [...prev, { id, type, message, duration }]);
|
||||
if (duration > 0) setTimeout(() => remove(id), duration);
|
||||
}, [remove]);
|
||||
|
||||
const ctx: ToastContextValue = {
|
||||
toast,
|
||||
success: (msg, dur) => toast(msg, 'success', dur),
|
||||
error: (msg, dur) => toast(msg, 'error', dur ?? 5000),
|
||||
info: (msg, dur) => toast(msg, 'info', dur),
|
||||
warning: (msg, dur) => toast(msg, 'warning', dur),
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={ctx}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onRemove={remove} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────── Hook ───────────
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ─────────── Container ───────────
|
||||
|
||||
const TYPE_STYLES: Record<ToastType, string> = {
|
||||
success: 'bg-green-600',
|
||||
error: 'bg-red-600',
|
||||
info: 'bg-blue-600',
|
||||
warning: 'bg-amber-500',
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<ToastType, string> = {
|
||||
success: '✓',
|
||||
error: '✕',
|
||||
info: 'ℹ',
|
||||
warning: '⚠',
|
||||
};
|
||||
|
||||
function ToastContainer({ toasts, onRemove }: { toasts: ToastItem[]; onRemove: (id: number) => void }) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(
|
||||
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 items-end pointer-events-none">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg text-white text-sm max-w-sm pointer-events-auto
|
||||
animate-in slide-in-from-right-4 fade-in duration-300 ${TYPE_STYLES[t.type]}`}
|
||||
>
|
||||
<span className="font-bold">{TYPE_ICONS[t.type]}</span>
|
||||
<span className="flex-1">{t.message}</span>
|
||||
<button
|
||||
onClick={() => onRemove(t.id)}
|
||||
className="ml-1 opacity-70 hover:opacity-100 text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user