share job

This commit is contained in:
JAE SIK CHO
2026-04-09 11:12:12 +09:00
commit f8427ee1d0
193 changed files with 23830 additions and 0 deletions

View 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>
);
}

View 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,
);
}

View 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>
);
}

View 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>
)
}

View 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;

View 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,
);
}