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

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:8080

7
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.next/
out/
.env
.env.local
.env.production
pnpm-lock.yaml

23
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-alpine
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "server.js"]

5
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

55
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// Docker 배포용 standalone 빌드
output: process.env.NODE_ENV === 'production' ? 'standalone' : undefined,
// API Gateway로 요청 프록시
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'}/api/:path*`,
},
]
},
// 구 Grails URL → 새 Next.js 경로 리다이렉트 (DB의 URL 값 그대로 유지)
async redirects() {
return [
// ── 게시판 ──────────────────────────────────
{ source: '/board_:id', destination: '/board/:id', permanent: false },
// ── 결재 (TAM0020: brunch 타입별 → 신청 목록) ──
{ source: '/tam0020/brunch/:type', destination: '/tam/0020', permanent: false },
{ source: '/tam0020', destination: '/tam/0020', permanent: false },
{ source: '/tam0010', destination: '/tam/0010', permanent: false },
{ source: '/tam0030', destination: '/tam/0030', permanent: false },
{ source: '/tam0040', destination: '/tam/0040', permanent: false },
// ── 근무계획 ─────────────────────────────────
{ source: '/wplan0010', destination: '/wplan/0010', permanent: false },
{ source: '/wplan0020', destination: '/wplan/0020', permanent: false },
{ source: '/wplan0030', destination: '/wplan/0030', permanent: false },
// ── 근무시간 ─────────────────────────────────
{ source: '/wtime0010', destination: '/wtime/0010', permanent: false },
{ source: '/wtime0030', destination: '/wtime/0030', permanent: false },
// ── 환경설정 ─────────────────────────────────
{ source: '/envset0010', destination: '/envset/users', permanent: false },
{ source: '/envset0020', destination: '/envset/codes', permanent: false },
{ source: '/envset0030/:path*', destination: '/envset/codes-view',permanent: false },
{ source: '/envset0040', destination: '/envset/workcd', permanent: false },
{ source: '/envset0050', destination: '/envset/menus', permanent: false },
// ── 비밀번호변경 ──────────────────────────────
{ source: '/main0020', destination: '/my/change-pw', permanent: false },
// ── FedEx ────────────────────────────────────
{ source: '/fedex0010', destination: '/fedex/0010', permanent: false },
]
},
}
export default nextConfig

49
frontend/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "gw-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
"@tanstack/react-query": "^5.64.1",
"@tanstack/react-query-devtools": "^5.64.1",
"@toast-ui/editor": "^3.2.2",
"@toast-ui/react-editor": "^3.2.3",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"next": "15.1.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"autoprefixer": "^10.4.20",
"eslint": "^9.18.0",
"eslint-config-next": "15.1.3",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
},
"packageManager": "pnpm@9.15.3"
}

View File

@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default config

View File

@@ -0,0 +1,24 @@
'use client';
import { use } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getPostInfoForEdit } from '@/lib/api/board';
import PostForm from '@/components/board/PostForm';
export default function BoardEditPage({
params,
}: {
params: Promise<{ boardType: string; postSno: string }>;
}) {
const { boardType, postSno } = use(params);
const { data, isLoading } = useQuery({
queryKey: ['board-edit', boardType, postSno],
queryFn: () => getPostInfoForEdit(boardType, Number(postSno)),
});
if (isLoading) return <div className="p-4 text-gray-400"> ...</div>;
if (!data) return <div className="p-4 text-gray-400"> .</div>;
return <PostForm boardType={boardType} initialData={data} />;
}

View File

@@ -0,0 +1,178 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getPostInfo,
deletePost,
getCommentList,
insertComment,
deleteComment,
} from '@/lib/api/board';
import { downloadFile, downloadZip } from '@/lib/api/attach';
import { useAuthStore } from '@/lib/store/authStore';
export default function BoardDetailPage() {
const params = useParams();
const router = useRouter();
const qc = useQueryClient();
const usrId = useAuthStore((s) => s.user?.usrId);
const boardType = params.boardType as string;
const postSno = Number(params.postSno);
const [cmmtInput, setCmmtInput] = useState('');
const { data: post, isLoading } = useQuery({
queryKey: ['board-detail', boardType, postSno],
queryFn: () => getPostInfo(boardType, postSno),
});
const { data: comments = [] } = useQuery({
queryKey: ['board-comments', boardType, postSno],
queryFn: () => getCommentList(boardType, postSno),
});
const addCommentMut = useMutation({
mutationFn: () => insertComment(boardType, postSno, cmmtInput),
onSuccess: () => {
setCmmtInput('');
qc.invalidateQueries({ queryKey: ['board-comments', boardType, postSno] });
qc.invalidateQueries({ queryKey: ['board-detail', boardType, postSno] });
},
});
const delCommentMut = useMutation({
mutationFn: (cmmtSno: number) => deleteComment(boardType, postSno, cmmtSno),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['board-comments', boardType, postSno] });
},
});
const handleDeletePost = async () => {
if (!confirm('게시물을 삭제하시겠습니까?')) return;
try {
await deletePost(boardType, postSno);
router.push(`/board/${boardType}`);
} catch (e: any) {
alert(e?.response?.data?.message ?? '삭제 중 오류가 발생했습니다.');
}
};
if (isLoading) return <div className="p-4 text-gray-400"> ...</div>;
if (!post) return <div className="p-4 text-gray-400"> .</div>;
const isAuthor = usrId === post.ctusrId;
return (
<div className="p-4 max-w-4xl">
{/* 제목 영역 */}
<div className="border-b pb-3 mb-4">
<h2 className="text-xl font-semibold">{post.bbsTitleNm}</h2>
<div className="flex gap-4 text-sm text-gray-500 mt-2">
<span>: {post.ctusrNm}</span>
<span>: {post.rgstDate}</span>
<span>: {post.inqrCnt}</span>
</div>
</div>
{/* 본문 */}
<div
className="board-content min-h-40 mb-6 text-sm leading-relaxed"
dangerouslySetInnerHTML={{ __html: post.bbsCn }}
/>
{/* 첨부파일 */}
{post.etcFileList && post.etcFileList.length > 0 && (
<div className="mb-4">
<p className="text-sm font-medium mb-1"></p>
<ul className="space-y-1">
{post.etcFileList.map((f: any) => (
<li key={f.atchfileNo}>
<button
onClick={() => downloadFile(f.atchfileNo, f.atchFileNm)}
className="text-sm text-blue-600 hover:underline"
>
📎 {f.atchFileNm}
</button>
</li>
))}
</ul>
{post.etcFileList.length > 1 && (
<button
onClick={() => downloadZip(post.etcAtchNo)}
className="mt-1 text-xs text-gray-500 hover:underline"
>
(ZIP)
</button>
)}
</div>
)}
{/* 버튼 */}
<div className="flex gap-2 mb-6">
<button
onClick={() => { qc.invalidateQueries({ queryKey: ['board', boardType] }); router.push(`/board/${boardType}`); }}
className="px-4 py-1.5 border text-sm rounded hover:bg-gray-100"
>
</button>
{isAuthor && (
<>
<button
onClick={() => router.push(`/board/${boardType}/${postSno}/edit`)}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</button>
<button
onClick={handleDeletePost}
className="px-4 py-1.5 bg-red-500 text-white text-sm rounded hover:bg-red-600"
>
</button>
</>
)}
</div>
{/* 댓글 */}
<div className="border-t pt-4">
<h3 className="text-sm font-semibold mb-3"> {comments.length}</h3>
<ul className="space-y-2 mb-4">
{comments.map((c) => (
<li key={c.cmmtSno} className="flex justify-between items-start bg-gray-50 px-3 py-2 rounded text-sm">
<div>
<span className="font-medium mr-2">{c.cmmtCtusrNm}</span>
<span className="text-gray-400 text-xs mr-3">{c.rgstDate}</span>
<span>{c.cmmtCn}</span>
</div>
{usrId === c.cmmtCtusrId && (
<button
onClick={() => delCommentMut.mutate(c.cmmtSno)}
className="text-red-400 hover:text-red-600 text-xs ml-2 shrink-0"
>
</button>
)}
</li>
))}
</ul>
<div className="flex gap-2">
<textarea
value={cmmtInput}
onChange={(e) => setCmmtInput(e.target.value)}
placeholder="댓글을 입력하세요"
className="flex-1 border rounded px-3 py-2 text-sm resize-none h-16"
/>
<button
onClick={() => cmmtInput.trim() && addCommentMut.mutate()}
disabled={addCommentMut.isPending}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { getPostList, deletePost, PostListItem } from '@/lib/api/board';
import { useAuthStore } from '@/lib/store/authStore';
const PAGE_SIZE = 20;
export default function BoardListPage() {
const params = useParams();
const router = useRouter();
const usrId = useAuthStore((s) => s.user?.usrId);
const boardType = params.boardType as string;
const [searchText, setSearchText] = useState('');
const [inputText, setInputText] = useState('');
const [pageNo, setPageNo] = useState(1);
const { data, isLoading, refetch } = useQuery({
queryKey: ['board', boardType, searchText, pageNo],
queryFn: () => getPostList(boardType, { searchText, pageNo, pageSize: PAGE_SIZE }),
});
const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 1;
const handleSearch = () => {
if (inputText === searchText && pageNo === 1) {
refetch();
} else {
setSearchText(inputText);
setPageNo(1);
}
};
const handleDelete = async (item: PostListItem) => {
if (!confirm('삭제하시겠습니까?')) return;
try {
await deletePost(boardType, item.untyBbsSno);
refetch();
} catch (e: any) {
alert(e?.response?.data?.message ?? '삭제 중 오류가 발생했습니다.');
}
};
return (
<div className="p-4">
{/* 검색 */}
<div className="flex gap-2 mb-4">
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="제목 또는 내용 검색"
className="border rounded px-3 py-1.5 text-sm w-64"
/>
<button
onClick={handleSearch}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</button>
<button
onClick={() => router.push(`/board/${boardType}/write`)}
className="ml-auto px-4 py-1.5 bg-green-600 text-white text-sm rounded hover:bg-green-700"
>
</button>
</div>
{/* 목록 테이블 */}
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100 text-left">
<th className="border px-3 py-2 w-16 text-center"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2 w-28 text-center"></th>
<th className="border px-3 py-2 w-28 text-center"></th>
<th className="border px-3 py-2 w-16 text-center"></th>
<th className="border px-3 py-2 w-24 text-center"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-400">
...
</td>
</tr>
) : !data?.list?.length ? (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-400">
.
</td>
</tr>
) : (
data.list.map((item, idx) => (
<tr key={item.untyBbsSno} className="hover:bg-gray-50">
<td className="border px-3 py-2 text-center text-gray-500">
{data.total - (pageNo - 1) * PAGE_SIZE - idx}
</td>
<td className="border px-3 py-2">
<button
onClick={() => router.push(`/board/${boardType}/${item.untyBbsSno}`)}
className="text-blue-600 hover:underline text-left"
>
{item.bbsTitleNm}
</button>
{item.cmmtCnt > 0 && (
<span className="ml-1 text-red-500 text-xs">[{item.cmmtCnt}]</span>
)}
</td>
<td className="border px-3 py-2 text-center">{item.ctusrNm}</td>
<td className="border px-3 py-2 text-center">
{item.rgstDt?.length >= 8 ? `${item.rgstDt.slice(0,4)}-${item.rgstDt.slice(4,6)}-${item.rgstDt.slice(6,8)}` : item.rgstDt}
</td>
<td className="border px-3 py-2 text-center">{item.inqrCnt}</td>
<td className="border px-3 py-2 text-center">
{item.ctusrId === usrId && (
<button
onClick={() => handleDelete(item)}
className="px-2 py-0.5 text-xs border border-red-300 text-red-500 rounded hover:bg-red-50"
>
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex justify-center gap-1 mt-4">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => setPageNo(p)}
className={`px-3 py-1 text-sm rounded border ${
p === pageNo ? 'bg-blue-600 text-white border-blue-600' : 'hover:bg-gray-100'
}`}
>
{p}
</button>
))}
</div>
)}
<div className="mt-2 text-sm text-gray-500 text-right">
{data?.total ?? 0}
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { use } from 'react';
import PostForm from '@/components/board/PostForm';
export default function BoardWritePage({ params }: { params: Promise<{ boardType: string }> }) {
const { boardType } = use(params);
return <PostForm boardType={boardType} />;
}

View File

@@ -0,0 +1,500 @@
'use client';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getDashboard, clockIn, clockOut,
type PostItem, type WorkRecord,
type DocSentSummaryItem, type DocReceivedSummaryItem,
type TodayWorkSummaryItem, type LateItem,
} from '@/lib/api/main';
import { minToHhmm } from '@/lib/api/wtime';
import { useAuthStore } from '@/lib/store/authStore';
import Link from 'next/link';
import { useState } from 'react';
import { localYmd } from '@/lib/utils/date';
// ─────────── 결재상태 매핑 ───────────
const SENT_STATUS: Record<string, { label: string; cls: string }> = {
'0001': { label: '작성중', cls: 'bg-gray-100 text-gray-600' },
'0002': { label: '결재중', cls: 'bg-blue-100 text-blue-700' },
'0003': { label: '승인', cls: 'bg-green-100 text-green-700' },
'0004': { label: '반려', cls: 'bg-red-100 text-red-700' },
};
const RECV_STATUS: Record<string, { label: string; cls: string }> = {
'WAIT': { label: '미확인', cls: 'bg-gray-100 text-gray-500' },
'0002': { label: '미결재', cls: 'bg-yellow-100 text-yellow-700'},
'0003': { label: '승인', cls: 'bg-green-100 text-green-700' },
'0004': { label: '반려', cls: 'bg-red-100 text-red-700' },
};
// ─────────── 위젯 래퍼 ───────────
function Widget({ title, href, children }: { title: string; href?: string; children: React.ReactNode }) {
return (
<div className="bg-white border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
<h2 className="text-sm font-semibold text-gray-700">{title}</h2>
{href && (
<Link href={href} className="text-xs text-blue-600 hover:underline">
</Link>
)}
</div>
<div className="p-3">{children}</div>
</div>
);
}
// ─────────── 게시판 위젯 ───────────
function BoardWidget({ list, bbsCd }: { list: PostItem[]; bbsCd: string }) {
if (!list.length) return <p className="text-xs text-gray-400 py-2"> .</p>;
return (
<ul className="divide-y">
{list.map((p, i) => (
<li key={p.untyBbsSno ?? i} className="py-1.5 flex items-center gap-2">
{p.isNew === 'Y' && (
<span className="text-xs font-bold text-red-500 shrink-0">N</span>
)}
<Link
href={`/board/${bbsCd}/${p.untyBbsSno}`}
className="text-sm text-gray-800 hover:text-blue-600 truncate flex-1"
>
{p.bbsTitleNm}
</Link>
<span className="text-xs text-gray-400 shrink-0">
{p.rgstDt?.slice(0, 10)}
</span>
</li>
))}
</ul>
);
}
// ─────────── 출퇴근 위젯 ───────────
function ClockWidget({ todayWork }: { todayWork: WorkRecord | null }) {
const router = useRouter();
const queryClient = useQueryClient();
const [msg, setMsg] = useState<string | null>(null);
const today = localYmd();
const inMut = useMutation({
mutationFn: () => clockIn(today),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
if (data.lateMin > 0) {
setMsg(`출근 처리 완료 (지각 ${data.lateMin}분)`);
if (confirm(`${data.lateMin}분 지각입니다. 지각계를 작성하시겠습니까?`)) {
router.push('/tam/0020?kind=A');
}
} else {
setMsg('출근 처리 완료');
}
},
onError: (e: any) => setMsg(e.response?.data?.message ?? '오류가 발생했습니다.'),
});
const outMut = useMutation({
mutationFn: () => clockOut(today),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
if (data.earlyMin > 0) {
setMsg(`퇴근 처리 완료 (조퇴 ${data.earlyMin}분)`);
if (confirm(`${data.earlyMin}분 조퇴입니다. 조퇴계를 작성하시겠습니까?`)) {
router.push('/tam/0020?kind=B');
}
} else {
setMsg('퇴근 처리 완료');
}
},
onError: (e: any) => setMsg(e.response?.data?.message ?? '오류가 발생했습니다.'),
});
const hasIn = !!todayWork?.workStartDt;
const hasOut = !!todayWork?.workEndDt;
const busy = inMut.isPending || outMut.isPending;
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="text-center">
<p className="text-xs text-gray-400 mb-1"></p>
<p className="text-lg font-bold text-gray-800">
{todayWork?.workStartDt?.slice(11, 16) || '--:--'}
</p>
</div>
<div className="text-center">
<p className="text-xs text-gray-400 mb-1"></p>
<p className="text-lg font-bold text-gray-800">
{todayWork?.workEndDt?.slice(11, 16) || '--:--'}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => { setMsg(null); inMut.mutate(); }}
disabled={hasIn || busy}
className={`py-2 rounded text-sm font-semibold transition-colors ${
hasIn ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{busy && !hasIn ? '처리중...' : '출근'}
</button>
<button
onClick={() => { setMsg(null); outMut.mutate(); }}
disabled={!hasIn || hasOut || busy}
className={`py-2 rounded text-sm font-semibold transition-colors ${
!hasIn || hasOut ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-orange-500 text-white hover:bg-orange-600'
}`}
>
{busy && hasIn && !hasOut ? '처리중...' : '퇴근'}
</button>
</div>
{msg && (
<p className={`text-xs text-center px-2 py-1 rounded ${
msg.includes('오류') || msg.includes('않') || msg.includes('없')
? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-700'
}`}>{msg}</p>
)}
{todayWork && (
<dl className="grid grid-cols-3 gap-x-2 gap-y-1 pt-1 border-t">
{([
['근무코드', todayWork.realWorkCd || '-'],
['총 근무', minToHhmm(todayWork.totWorkMin)],
['지각', minToHhmm(todayWork.lateMin)],
] as [string, string][]).map(([label, val]) => (
<div key={label} className="text-center">
<dt className="text-xs text-gray-400">{label}</dt>
<dd className="text-xs font-medium text-gray-700">{val}</dd>
</div>
))}
</dl>
)}
</div>
);
}
// ─────────── 미니 달력 ───────────
function MiniCalendar() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth();
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const todayDate = today.getDate();
const cells: (number | null)[] = [
...Array(firstDay).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
// pad to full weeks
while (cells.length % 7 !== 0) cells.push(null);
const weeks: (number | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
const monthLabel = `${year}${month + 1}`;
return (
<div>
<p className="text-xs font-semibold text-gray-600 text-center mb-2">{monthLabel}</p>
<table className="w-full text-xs">
<thead>
<tr>
{['일','월','화','수','목','금','토'].map((d, i) => (
<th key={d} className={`py-0.5 text-center font-medium ${i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-500'}`}>
{d}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week, wi) => (
<tr key={wi}>
{week.map((day, di) => (
<td key={di} className="py-0.5 text-center">
{day ? (
<span className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-xs
${day === todayDate
? 'bg-blue-600 text-white font-bold'
: di === 0 ? 'text-red-400' : di === 6 ? 'text-blue-400' : 'text-gray-700'
}`}>
{day}
</span>
) : null}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// ─────────── 최근 근무 이력 ───────────
function WorkRangeWidget({ list }: { list: WorkRecord[] }) {
if (!list.length) return <p className="text-xs text-gray-400 py-2"> .</p>;
const todayStr = localYmd();
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500 border-b">
<th className="py-1 text-left font-medium"></th>
<th className="py-1 text-left font-medium"></th>
<th className="py-1 text-right font-medium"></th>
<th className="py-1 text-right font-medium"></th>
<th className="py-1 text-right font-medium"></th>
<th className="py-1 text-right font-medium">OT</th>
</tr>
</thead>
<tbody>
{list.map((r, i) => {
const isFuture = r.workPlanYymmdd > todayStr;
const dateStr = r.workPlanYymmdd
? `${r.workPlanYymmdd.slice(0,4)}-${r.workPlanYymmdd.slice(4,6)}-${r.workPlanYymmdd.slice(6,8)}`
: '-';
// 미래는 계획 출/퇴근 시간 표시
const inTime = isFuture
? (r.gotoworkTmNm ? `${r.gotoworkTmNm.slice(0,2)}:${r.gotoworkTmNm.slice(2,4)}` : '-')
: (r.workStartDt?.slice(11, 16) || '-');
const outTime = isFuture
? (r.getoffworkTmNm ? `${r.getoffworkTmNm.slice(0,2)}:${r.getoffworkTmNm.slice(2,4)}` : '-')
: (r.workEndDt?.slice(11, 16) || '-');
return (
<tr key={r.workPlanYymmdd ?? i} className={`border-b last:border-0 ${isFuture ? 'text-gray-400' : ''}`}>
<td className="py-1">{dateStr}</td>
<td className="py-1">{r.realWorkCd || '-'}</td>
<td className="py-1 text-right">{inTime}</td>
<td className="py-1 text-right">{outTime}</td>
<td className="py-1 text-right">{isFuture ? '-' : minToHhmm(r.totWorkMin)}</td>
<td className="py-1 text-right">{isFuture ? '-' : minToHhmm(r.otWorkMin)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ─────────── 결재 문서 요약 위젯 ───────────
function DocSummaryWidget({
sent,
received,
}: {
sent: DocSentSummaryItem[];
received: DocReceivedSummaryItem[];
}) {
const sentMap: Record<string, number> = {};
const recvMap: Record<string, number> = {};
sent.forEach(({ aprvlStusCd, cnt }) => { sentMap[aprvlStusCd] = cnt; });
received.forEach(({ apprStusCd, cnt }) => { recvMap[apprStusCd] = cnt; });
const sentRows: [string, string][] = [
['0001','작성중'],['0002','결재중'],['0003','승인'],['0004','반려'],
];
const recvRows: [string, string][] = [
['0002','미결재'],['WAIT','미확인'],['0003','승인'],['0004','반려'],
];
return (
<div className="grid grid-cols-2 gap-3 text-xs">
{/* 내가 올린 */}
<div>
<p className="font-semibold text-gray-600 mb-2 text-center"> </p>
<div className="space-y-1">
{sentRows.map(([cd, label]) => {
const cnt = sentMap[cd] ?? 0;
const st = SENT_STATUS[cd];
return (
<Link
key={cd}
href={`/tam/0020?status=${cd}`}
className="flex items-center justify-between px-2 py-1 rounded hover:bg-gray-50"
>
<span className={`px-1.5 py-0.5 rounded text-xs ${st.cls}`}>{label}</span>
<span className={`font-bold ${cnt > 0 ? 'text-gray-800' : 'text-gray-300'}`}>{cnt}</span>
</Link>
);
})}
</div>
</div>
{/* 내가 받은 */}
<div>
<p className="font-semibold text-gray-600 mb-2 text-center"> </p>
<div className="space-y-1">
{recvRows.map(([cd, label]) => {
const cnt = recvMap[cd] ?? 0;
const st = RECV_STATUS[cd];
return (
<Link
key={cd}
href={`/tam/0030?status=${cd}`}
className="flex items-center justify-between px-2 py-1 rounded hover:bg-gray-50"
>
<span className={`px-1.5 py-0.5 rounded text-xs ${st.cls}`}>{label}</span>
<span className={`font-bold ${cnt > 0 ? 'text-gray-800' : 'text-gray-300'}`}>{cnt}</span>
</Link>
);
})}
</div>
</div>
</div>
);
}
// ─────────── 오늘의 근무 정보 (전체 직원 근무코드별 현황) ───────────
function TodayWorkSummaryWidget({ list }: { list: TodayWorkSummaryItem[] }) {
if (!list.length) return <p className="text-xs text-gray-400 py-2"> .</p>;
const fmtTm = (tm?: string) =>
tm && tm.length >= 4 ? `${tm.slice(0, 2)}:${tm.slice(2, 4)}` : '-';
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500 border-b">
<th className="py-1.5 text-left font-medium"></th>
<th className="py-1.5 text-center font-medium"></th>
<th className="py-1.5 text-center font-medium"></th>
<th className="py-1.5 text-center font-medium"></th>
<th className="py-1.5 text-center font-medium"></th>
<th className="py-1.5 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{list.map((r) => (
<tr key={r.realWorkCd} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-1.5">
<span className="font-mono text-blue-700 mr-1">{r.realWorkCd}</span>
<span className="text-gray-500">{r.workCdTitleNm}</span>
</td>
<td className="py-1.5 text-center text-gray-600">{fmtTm(r.gotoworkTmNm)}</td>
<td className="py-1.5 text-center text-gray-600">{fmtTm(r.getoffworkTmNm)}</td>
<td className="py-1.5 text-center font-medium">{r.totalCnt}</td>
<td className="py-1.5 text-center text-blue-600 font-medium">{r.inCnt}</td>
<td className="py-1.5 text-center text-orange-500 font-medium">{r.outCnt}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ─────────── 지각자 목록 위젯 ───────────
function LateListWidget({ list }: { list: LateItem[] }) {
if (!list.length) return <p className="text-xs text-gray-400 py-2"> .</p>;
return (
<ul className="divide-y">
{list.map((item, i) => (
<li key={i} className="py-1.5 flex items-center justify-between">
<span className="text-sm text-gray-800">{item.usrNm}</span>
<span className="text-xs text-red-500 font-medium">{item.lateMin} </span>
</li>
))}
</ul>
);
}
// ─────────── 대시보드 메인 ───────────
export default function DashboardPage() {
const { user } = useAuthStore();
const isAdmin = user?.roleCode === 'BIZM' || user?.roleCode === 'MNGR';
const { data, isLoading } = useQuery({
queryKey: ['dashboard'],
queryFn: getDashboard,
staleTime: 60_000,
});
const pendingCnt = data?.pendingAppr?.pendingCnt ?? 0;
return (
<div className="p-4 space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
{isLoading && <span className="text-sm text-gray-400"> ...</span>}
</div>
{/* 결재 대기 배너 */}
{pendingCnt > 0 && (
<Link
href="/tam/0030"
className="flex items-center gap-3 bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 hover:bg-blue-100 transition-colors"
>
<span className="flex items-center justify-center w-8 h-8 bg-blue-600 text-white rounded-full text-sm font-bold">
{pendingCnt}
</span>
<div>
<p className="text-sm font-semibold text-blue-800"> .</p>
<p className="text-xs text-blue-600"> </p>
</div>
</Link>
)}
{/* 위젯 그리드 */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{/* 출퇴근 */}
<Widget title="출퇴근" href="/wtime/0010">
<ClockWidget todayWork={data?.todayWork ?? null} />
</Widget>
{/* 달력 */}
<Widget title="이번 달">
<MiniCalendar />
</Widget>
{/* 공지사항 */}
<Widget title="공지사항" href="/board/0002">
<BoardWidget list={data?.noticeList ?? []} bbsCd="0002" />
</Widget>
{/* 자유게시판 */}
<Widget title="자유게시판" href="/board/0001">
<BoardWidget list={data?.boardList ?? []} bbsCd="0001" />
</Widget>
{/* 업무메뉴얼 */}
<Widget title="업무메뉴얼" href="/board/0004">
<BoardWidget list={data?.manualList ?? []} bbsCd="0004" />
</Widget>
{/* 최근 근무 이력 */}
<Widget title="최근 근무 이력 (9일)" href="/wtime/0010">
<WorkRangeWidget list={data?.workRangeList ?? []} />
</Widget>
{/* 내 결재 문서 */}
<Widget title="내 결재 문서" href="/tam/0020">
<DocSummaryWidget
sent={data?.myDocSent ?? []}
received={data?.myDocReceived ?? []}
/>
</Widget>
{/* 오늘의 근무 정보 */}
<Widget title="오늘의 근무 정보" href="/wtime/0010">
<TodayWorkSummaryWidget list={data?.todayWorkSummary ?? []} />
</Widget>
{/* 오늘 지각자 (관리자만) */}
{isAdmin && (
<Widget title="오늘 지각자">
<LateListWidget list={data?.todayLateList ?? []} />
</Widget>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
interface CodeItem {
code: string;
name: string;
useYn: string;
}
async function getCodeList(commClCd: string): Promise<CodeItem[]> {
if (!commClCd) return [];
const res = await apiClient.get('/code', { params: { commClCd } });
return res.data.data ?? [];
}
export default function CodesViewPage() {
const [commClCd, setCommClCd] = useState('');
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('');
const { data: list = [], isLoading } = useQuery({
queryKey: ['codes-view', filter],
queryFn: () => getCodeList(filter),
enabled: !!filter,
});
const handleSearch = () => setFilter(commClCd.trim());
const filtered = search
? list.filter(c =>
c.code.toLowerCase().includes(search.toLowerCase()) ||
c.name.includes(search))
: list;
return (
<div className="p-4">
<h2 className="text-lg font-semibold mb-4"> </h2>
{/* 검색 */}
<div className="flex gap-2 mb-4">
<input
type="text"
value={commClCd}
onChange={e => setCommClCd(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
placeholder="코드분류 (예: SX001)"
className="border rounded px-3 py-1.5 text-sm w-44"
/>
<button
onClick={handleSearch}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</button>
{list.length > 0 && (
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="코드/코드명 검색"
className="border rounded px-3 py-1.5 text-sm w-48 ml-4"
/>
)}
</div>
{/* 결과 */}
{isLoading ? (
<p className="text-sm text-gray-400"> ...</p>
) : filter && (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50 border-b">
<th className="px-3 py-2 text-left font-medium text-gray-600 w-32"></th>
<th className="px-3 py-2 text-left font-medium text-gray-600"></th>
<th className="px-3 py-2 text-center font-medium text-gray-600 w-20"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={3} className="px-3 py-4 text-center text-gray-400">
{filter ? '해당 코드분류에 데이터가 없습니다.' : '코드분류를 입력하여 조회하세요.'}
</td>
</tr>
) : (
filtered.map(c => (
<tr key={c.code} className="border-b hover:bg-gray-50">
<td className="px-3 py-2 font-mono text-xs">{c.code}</td>
<td className="px-3 py-2">{c.name}</td>
<td className="px-3 py-2 text-center">
<span className={`text-xs px-1.5 py-0.5 rounded ${
c.useYn === 'Y'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}>
{c.useYn === 'Y' ? '사용' : '미사용'}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
{filtered.length > 0 && (
<p className="text-xs text-gray-400 mt-2">{filtered.length}</p>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,205 @@
'use client'
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { envsetApi, type CodeIndex, type Code } from '@/lib/api/envset'
export default function CodesPage() {
const qc = useQueryClient()
const [selectedCd, setSelectedCd] = useState<CodeIndex | null>(null)
const [cdidxSearch, setCdidxSearch] = useState('')
const [codeSearch, setCodeSearch] = useState('')
// 코드인덱스 목록
const { data: cdidxData } = useQuery({
queryKey: ['cdidx', cdidxSearch],
queryFn: () => envsetApi.getCdidxList(cdidxSearch),
})
const cdidxList: CodeIndex[] = cdidxData?.data?.data ?? []
// 코드 목록 (선택된 코드인덱스의 코드들)
const { data: codeData, isLoading: codeLoading } = useQuery({
queryKey: ['codes', selectedCd?.commClCd, codeSearch],
queryFn: () => envsetApi.getCodeList(selectedCd!.commClCd, codeSearch),
enabled: !!selectedCd,
})
const codeList: Code[] = codeData?.data?.data ?? []
// 편집 상태 (로컬)
const [editCdidxList, setEditCdidxList] = useState<CodeIndex[]>([])
const [editCodeList, setEditCodeList] = useState<Code[]>([])
const saveCdidxMutation = useMutation({
mutationFn: (list: CodeIndex[]) => envsetApi.saveCdidxList(list.filter(i => i.rowStatus)),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['cdidx'] }); setEditCdidxList([]) },
})
const saveCodeMutation = useMutation({
mutationFn: (list: Code[]) =>
envsetApi.saveCodeList(selectedCd!.commClCd, list.filter(i => i.rowStatus)),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['codes'] }); setEditCodeList([])
},
})
const mergedCdidx = cdidxList.map(item => {
const edited = editCdidxList.find(e => e.commClCd === item.commClCd)
return edited ?? item
}).concat(editCdidxList.filter(e => e.rowStatus === 'I'))
const mergedCodes = codeList.map(item => {
const edited = editCodeList.find(e => e.commCd === item.commCd)
return edited ?? item
}).concat(editCodeList.filter(e => e.rowStatus === 'I'))
const addCdidxRow = () => {
setEditCdidxList(prev => [...prev, {
commClCd: '', commClCdNm: '', commClCdUseYn: 'Y',
commClCdDscrpt: '', dsplyOrdr: 0, rowStatus: 'I'
}])
}
const addCodeRow = () => {
if (!selectedCd) return
setEditCodeList(prev => [...prev, {
commClCd: selectedCd.commClCd, commCd: '', commCdNm: '',
commCdUseYn: 'Y', commCdDscrpt: '', commCdDsplyOrdr: 0, rowStatus: 'I'
}])
}
return (
<div className="flex gap-4">
{/* 코드인덱스 패널 */}
<div className="w-96 shrink-0">
<div className="flex gap-2 mb-3">
<input value={cdidxSearch} onChange={e => setCdidxSearch(e.target.value)}
placeholder="코드분류 검색" className="flex-1 border rounded px-3 py-1.5 text-sm" />
</div>
<div className="flex justify-between mb-2">
<span className="text-sm font-medium text-gray-700"></span>
<div className="flex gap-2">
<button onClick={addCdidxRow} className="text-xs border px-2 py-1 rounded hover:bg-gray-50">+ </button>
<button onClick={() => saveCdidxMutation.mutate(editCdidxList)}
disabled={editCdidxList.length === 0}
className="text-xs bg-primary-600 text-white px-2 py-1 rounded hover:bg-primary-700 disabled:opacity-40">
</button>
</div>
</div>
<div className="border rounded overflow-auto" style={{ maxHeight: '65vh' }}>
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-2 py-2 text-left text-xs text-gray-600"></th>
<th className="px-2 py-2 text-left text-xs text-gray-600"></th>
<th className="px-2 py-2 text-center text-xs text-gray-600"></th>
</tr>
</thead>
<tbody>
{mergedCdidx.map((item, idx) => (
<tr key={item.commClCd || `new-${idx}`}
onClick={() => !item.rowStatus && setSelectedCd(item)}
className={`border-t ${!item.rowStatus ? 'cursor-pointer hover:bg-gray-50' : ''}
${selectedCd?.commClCd === item.commClCd ? 'bg-primary-50' : ''}
${item.rowStatus === 'D' ? 'opacity-40 line-through' : ''}`}>
<td className="px-2 py-1.5">
{item.rowStatus ? (
<input value={item.commClCd}
onChange={e => setEditCdidxList(prev => prev.map((p, i) =>
i === idx ? { ...p, commClCd: e.target.value } : p))}
className="border rounded px-1 w-full text-xs" />
) : item.commClCd}
</td>
<td className="px-2 py-1.5">
{item.rowStatus ? (
<input value={item.commClCdNm}
onChange={e => setEditCdidxList(prev => prev.map((p, i) =>
i === idx ? { ...p, commClCdNm: e.target.value } : p))}
className="border rounded px-1 w-full text-xs" />
) : item.commClCdNm}
</td>
<td className="px-2 py-1.5 text-center">
<span className={`text-xs ${item.commClCdUseYn === 'Y' ? 'text-green-600' : 'text-gray-400'}`}>
{item.commClCdUseYn}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 코드 패널 */}
<div className="flex-1">
{selectedCd ? (
<>
<div className="flex gap-2 mb-3">
<span className="text-sm font-medium text-gray-700 flex items-center">
{selectedCd.commClCdNm} ({selectedCd.commClCd})
</span>
<input value={codeSearch} onChange={e => setCodeSearch(e.target.value)}
placeholder="코드 검색" className="border rounded px-3 py-1.5 text-sm ml-auto w-40" />
<button onClick={addCodeRow} className="text-xs border px-2 py-1 rounded hover:bg-gray-50">+ </button>
<button onClick={() => saveCodeMutation.mutate(editCodeList)}
disabled={editCodeList.length === 0}
className="text-xs bg-primary-600 text-white px-2 py-1 rounded hover:bg-primary-700 disabled:opacity-40">
</button>
</div>
<div className="border rounded overflow-auto" style={{ maxHeight: '65vh' }}>
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
{['코드', '코드명', '사용', '정렬', '설명'].map(h => (
<th key={h} className="px-3 py-2 text-left text-xs text-gray-600">{h}</th>
))}
</tr>
</thead>
<tbody>
{codeLoading ? (
<tr><td colSpan={5} className="px-3 py-4 text-center text-gray-400"> ...</td></tr>
) : mergedCodes.map((item, idx) => (
<tr key={item.commCd || `new-${idx}`}
className={`border-t ${item.rowStatus === 'D' ? 'opacity-40' : ''}`}>
<td className="px-2 py-1">
{item.rowStatus === 'I' ? (
<input value={item.commCd}
onChange={e => setEditCodeList(prev => prev.map((p, i) =>
i === idx - codeList.length ? { ...p, commCd: e.target.value } : p))}
className="border rounded px-1 w-24 text-xs" />
) : item.commCd}
</td>
<td className="px-2 py-1">
<input defaultValue={item.commCdNm}
onBlur={e => {
if (e.target.value !== item.commCdNm) {
setEditCodeList(prev => {
const existing = prev.find(p => p.commCd === item.commCd)
if (existing) return prev.map(p => p.commCd === item.commCd ? { ...p, commCdNm: e.target.value, rowStatus: 'U' } : p)
return [...prev, { ...item, commCdNm: e.target.value, rowStatus: 'U' }]
})
}
}}
className="border rounded px-1 w-32 text-xs" />
</td>
<td className="px-2 py-1 text-center">
<span className={`text-xs ${item.commCdUseYn === 'Y' ? 'text-green-600' : 'text-gray-400'}`}>
{item.commCdUseYn}
</span>
</td>
<td className="px-2 py-1 text-center text-xs">{item.commCdDsplyOrdr}</td>
<td className="px-2 py-1 text-xs text-gray-500">{item.commCdDscrpt}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
<div className="flex items-center justify-center h-40 text-gray-400 text-sm border rounded">
.
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import Link from 'next/link'
const tabs = [
{ href: '/envset/users', label: '직원정보' },
{ href: '/envset/codes', label: '공통코드' },
{ href: '/envset/codes-view', label: '코드조회' },
{ href: '/envset/workcd', label: '근무코드' },
{ href: '/envset/menus', label: '메뉴관리' },
{ href: '/envset/user-auth', label: '사용자권한' },
]
export default function EnvsetLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<div className="mb-6">
<h1 className="text-xl font-bold text-gray-900 mb-4"></h1>
<nav className="flex gap-1 border-b">
{tabs.map((tab) => (
<Link
key={tab.href}
href={tab.href}
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-primary-600 border-b-2 border-transparent hover:border-primary-600 transition-colors"
>
{tab.label}
</Link>
))}
</nav>
</div>
{children}
</div>
)
}

View File

@@ -0,0 +1,143 @@
'use client'
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { envsetApi, type MenuManage, type AuthMenu } from '@/lib/api/envset'
const ROLE_OPTIONS = [
{ value: 'BIZM', label: '업무관리자' },
{ value: 'MNGR', label: '시스템관리자' },
{ value: 'USER', label: '일반사용자' },
{ value: 'FEDE', label: 'FedEx' },
]
export default function MenusPage() {
const qc = useQueryClient()
const [selectedRole, setSelectedRole] = useState<string>('BIZM')
const [editMenuList, setEditMenuList] = useState<MenuManage[]>([])
const [editAuthList, setEditAuthList] = useState<AuthMenu[]>([])
const { data: menuData } = useQuery({
queryKey: ['menus'],
queryFn: () => envsetApi.getMenuList(),
})
const menus: MenuManage[] = menuData?.data?.data ?? []
const { data: authData } = useQuery({
queryKey: ['auth-menus', selectedRole],
queryFn: () => envsetApi.getAuthMenuList(selectedRole),
enabled: !!selectedRole,
})
const authMenus: AuthMenu[] = authData?.data?.data ?? []
const saveMenuMutation = useMutation({
mutationFn: (list: MenuManage[]) => envsetApi.saveMenuList(list.filter(i => i.rowStatus)),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['menus'] }); setEditMenuList([]) },
})
const saveAuthMutation = useMutation({
mutationFn: (list: AuthMenu[]) =>
envsetApi.saveAuthMenuList(selectedRole, list.filter(i => i.rowStatus)),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['auth-menus'] }); setEditAuthList([]) },
})
const toggleAuth = (menu: AuthMenu) => {
setEditAuthList(prev => {
const existing = prev.find(p => p.menuNo === menu.menuNo)
const newYn = menu.menuAuthYn === 'Y' ? 'N' : 'Y'
if (existing) return prev.map(p => p.menuNo === menu.menuNo ? { ...p, menuAuthYn: newYn, rowStatus: 'U' } : p)
return [...prev, { ...menu, menuAuthYn: newYn, rowStatus: 'U' }]
})
}
const getAuthYn = (menu: AuthMenu) => {
const edited = editAuthList.find(e => e.menuNo === menu.menuNo)
return edited?.menuAuthYn ?? menu.menuAuthYn
}
return (
<div className="flex gap-4">
{/* 메뉴 목록 */}
<div className="w-80 shrink-0">
<div className="flex justify-between mb-3">
<span className="text-sm font-medium text-gray-700"> </span>
<button onClick={() => saveMenuMutation.mutate(editMenuList)}
disabled={editMenuList.length === 0}
className="text-xs bg-primary-600 text-white px-2 py-1 rounded disabled:opacity-40">
</button>
</div>
<div className="border rounded overflow-auto" style={{ maxHeight: '70vh' }}>
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-2 py-2 text-left text-xs text-gray-600"></th>
<th className="px-2 py-2 text-center text-xs text-gray-600"></th>
<th className="px-2 py-2 text-center text-xs text-gray-600"></th>
</tr>
</thead>
<tbody>
{menus.map((item) => (
<tr key={item.menuNo} className="border-t hover:bg-gray-50">
<td className="px-2 py-1.5 text-xs" style={{ paddingLeft: `${(item.menuLvl ?? 1) * 12}px` }}>
{item.menuNm}
</td>
<td className="px-2 py-1.5 text-center text-xs text-gray-500">{item.menuOrdr}</td>
<td className="px-2 py-1.5 text-center">
<span className={`text-xs ${item.menuUseYn === 'Y' ? 'text-green-600' : 'text-gray-400'}`}>
{item.menuUseYn}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 권한별 메뉴 */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<span className="text-sm font-medium text-gray-700"> </span>
<select value={selectedRole} onChange={e => setSelectedRole(e.target.value)}
className="border rounded px-2 py-1 text-sm">
{ROLE_OPTIONS.map(r => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
<button onClick={() => saveAuthMutation.mutate(editAuthList)}
disabled={editAuthList.length === 0}
className="text-xs bg-primary-600 text-white px-3 py-1 rounded disabled:opacity-40 ml-auto">
</button>
</div>
<div className="border rounded overflow-auto" style={{ maxHeight: '70vh' }}>
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-xs text-gray-600"></th>
<th className="px-3 py-2 text-center text-xs text-gray-600">URL</th>
<th className="px-3 py-2 text-center text-xs text-gray-600"></th>
</tr>
</thead>
<tbody>
{authMenus.map((item) => (
<tr key={item.menuNo} className="border-t hover:bg-gray-50">
<td className="px-2 py-1.5 text-xs" style={{ paddingLeft: `${(item.lvl ?? 1) * 12}px` }}>
{item.menuNm}
</td>
<td className="px-3 py-1.5 text-center text-xs text-gray-400">{item.url}</td>
<td className="px-3 py-1.5 text-center">
<input type="checkbox"
checked={getAuthYn(item) === 'Y'}
onChange={() => toggleAuth(item)}
className="accent-primary-600" />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function EnvsetPage() {
redirect('/envset/users')
}

View File

@@ -0,0 +1,135 @@
'use client'
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { envsetApi, type UserAuth } from '@/lib/api/envset'
const ROLE_OPTIONS = [
{ value: '', label: '전체' },
{ value: 'BIZM', label: '업무관리자' },
{ value: 'MNGR', label: '시스템관리자' },
{ value: 'USER', label: '일반사용자' },
{ value: 'FEDE', label: 'FedEx' },
]
export default function UserAuthPage() {
const qc = useQueryClient()
const [menuAuthCd, setMenuAuthCd] = useState('')
const [usrNm, setUsrNm] = useState('')
const [addUsrId, setAddUsrId] = useState('')
const [addRole, setAddRole] = useState('USER')
const [keyword, setKeyword] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['user-auth', menuAuthCd, usrNm],
queryFn: () => envsetApi.getUserMauthList({ menuAuthCd: menuAuthCd || undefined, usrNm: usrNm || undefined }),
})
const list: UserAuth[] = data?.data?.data ?? []
// 사용자 검색 autocomplete
const { data: searchData } = useQuery({
queryKey: ['search-user', keyword],
queryFn: () => envsetApi.searchUser(keyword),
enabled: keyword.length > 0,
})
const searchResults = searchData?.data?.data ?? []
const saveMutation = useMutation({
mutationFn: (items: UserAuth[]) => envsetApi.saveUserMauthList(items),
onSuccess: () => qc.invalidateQueries({ queryKey: ['user-auth'] }),
})
const handleAdd = () => {
if (!addUsrId || !addRole) return
saveMutation.mutate([{ usrId: addUsrId, menuAuthCd: addRole, rowStatus: 'I' } as UserAuth])
setAddUsrId(''); setKeyword('')
}
const handleDelete = (item: UserAuth) => {
if (!confirm(`${item.usrNm}${item.menuAuthCd} 권한을 삭제하시겠습니까?`)) return
saveMutation.mutate([{ ...item, rowStatus: 'D' }])
}
return (
<div>
{/* 필터 */}
<div className="flex gap-3 mb-4 items-end">
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<select value={menuAuthCd} onChange={e => setMenuAuthCd(e.target.value)}
className="border rounded px-3 py-1.5 text-sm">
{ROLE_OPTIONS.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input value={usrNm} onChange={e => setUsrNm(e.target.value)}
placeholder="직원명 검색" className="border rounded px-3 py-1.5 text-sm w-40" />
</div>
<span className="text-sm text-gray-500 self-center"> {list.length}</span>
</div>
{/* 권한 추가 */}
<div className="flex gap-2 mb-4 p-3 bg-gray-50 rounded border">
<div className="relative">
<input value={keyword} onChange={e => { setKeyword(e.target.value) }}
placeholder="직원명으로 검색" className="border rounded px-3 py-1.5 text-sm w-48" />
{searchResults.length > 0 && keyword && (
<div className="absolute top-full left-0 bg-white border rounded shadow-lg z-10 w-48">
{searchResults.map((u) => (
<div key={u.usrId} onClick={() => { setAddUsrId(u.usrId); setKeyword(u.usrNm) }}
className="px-3 py-2 text-sm hover:bg-gray-50 cursor-pointer">
{u.usrNm} ({u.usrId})
</div>
))}
</div>
)}
</div>
<select value={addRole} onChange={e => setAddRole(e.target.value)}
className="border rounded px-3 py-1.5 text-sm">
{ROLE_OPTIONS.filter(r => r.value).map(r => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
<button onClick={handleAdd}
className="text-sm bg-primary-600 text-white px-3 py-1.5 rounded hover:bg-primary-700">
</button>
</div>
{/* 목록 */}
<div className="border rounded overflow-auto" style={{ maxHeight: '55vh' }}>
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
{['직원ID', '직원명', '권한코드', '팀', '직위', '퇴직여부', ''].map(h => (
<th key={h} className="px-3 py-2 text-left text-xs text-gray-600">{h}</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={7} className="text-center py-4 text-gray-400"> ...</td></tr>
) : list.length === 0 ? (
<tr><td colSpan={7} className="text-center py-4 text-gray-400"> </td></tr>
) : list.map((item, i) => (
<tr key={`${item.usrId}-${item.menuAuthCd}-${i}`}
className={`border-t hover:bg-gray-50 ${item.retirementDate ? 'text-gray-400' : ''}`}>
<td className="px-3 py-2">{item.usrId}</td>
<td className="px-3 py-2">{item.usrNm}</td>
<td className="px-3 py-2 font-mono text-xs">{item.menuAuthCd}</td>
<td className="px-3 py-2 text-xs">{item.teamCd}</td>
<td className="px-3 py-2 text-xs">{item.dutyCd}</td>
<td className="px-3 py-2 text-xs">{item.retirementDate ? '퇴직' : '-'}</td>
<td className="px-3 py-2">
<button onClick={() => handleDelete(item)}
className="text-xs text-red-500 hover:text-red-700"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,234 @@
'use client'
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { envsetApi, type UserListItem, type UserSaveRequest } from '@/lib/api/envset'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import FileUpload from '@/components/common/FileUpload'
const saveSchema = z.object({
usrNm: z.string().min(1, '직원명은 필수입니다.'),
loginId: z.string().optional(),
pw: z.string().optional(),
dutyCd: z.string().optional(),
teamCd: z.string().optional(),
usrTelno: z.string().optional(),
mtelNo: z.string().optional(),
email: z.string().optional(),
joinCpDate: z.string().optional(),
retirementDate: z.string().optional(),
apprYn: z.string().optional(),
})
type SaveForm = z.infer<typeof saveSchema>
export default function UsersPage() {
const qc = useQueryClient()
const [page, setPage] = useState(1)
const [keyword, setKeyword] = useState('')
const [includeRetire, setIncludeRetire] = useState('N')
const [selected, setSelected] = useState<UserListItem | null>(null)
const [mode, setMode] = useState<'list' | 'view' | 'edit' | 'new'>('list')
const { data, isLoading } = useQuery({
queryKey: ['users', page, keyword, includeRetire],
queryFn: () => envsetApi.getUserList({ page, size: 50, keyword, includeRetireYn: includeRetire }),
})
const { data: detailData } = useQuery({
queryKey: ['user-detail', selected?.usrId],
queryFn: () => envsetApi.getUserDetail(selected!.usrId),
enabled: !!selected && (mode === 'view' || mode === 'edit'),
})
const { register, handleSubmit, reset, formState: { errors } } = useForm<SaveForm>({
resolver: zodResolver(saveSchema),
})
const insertMutation = useMutation({
mutationFn: (data: UserSaveRequest) => envsetApi.insertUser(data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['users'] }); setMode('list') },
})
const updateMutation = useMutation({
mutationFn: ({ usrId, data }: { usrId: string; data: UserSaveRequest }) =>
envsetApi.updateUser(usrId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] })
qc.invalidateQueries({ queryKey: ['user-detail', selected?.usrId] })
setMode('view')
},
})
const deleteMutation = useMutation({
mutationFn: (usrId: string) => envsetApi.deleteUser(usrId),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['users'] }); setMode('list'); setSelected(null) },
})
const list = data?.data?.data?.list ?? []
const total = data?.data?.data?.total ?? 0
const detail = detailData?.data?.data
const onSubmit = (form: SaveForm) => {
if (mode === 'new') {
insertMutation.mutate(form)
} else if (mode === 'edit' && selected) {
updateMutation.mutate({ usrId: selected.usrId, data: form })
}
}
const photoMutation = useMutation({
mutationFn: ({ usrId, photoAtchfileNo }: { usrId: string; photoAtchfileNo: string }) =>
envsetApi.updateUser(usrId, { photoAtchfileNo } as UserSaveRequest),
onSuccess: () => qc.invalidateQueries({ queryKey: ['user-detail', selected?.usrId] }),
})
const openNew = () => { reset({}); setMode('new') }
const openEdit = () => {
if (detail) reset({ usrNm: detail.usrNm, loginId: detail.loginId, dutyCd: detail.dutyCd,
teamCd: detail.teamCd, usrTelno: detail.usrTelno, mtelNo: detail.mtelNo,
email: detail.email, joinCpDate: detail.joinCpDate, retirementDate: detail.retirementDate,
apprYn: detail.apprYn })
setMode('edit')
}
return (
<div className="flex gap-4 h-full">
{/* 목록 패널 */}
<div className="w-80 shrink-0">
{/* 검색 */}
<div className="flex gap-2 mb-3">
<input
type="text"
placeholder="직원명 검색"
value={keyword}
onChange={e => setKeyword(e.target.value)}
className="flex-1 border rounded px-3 py-1.5 text-sm"
/>
<label className="flex items-center gap-1 text-sm text-gray-600 whitespace-nowrap">
<input type="checkbox" checked={includeRetire === 'Y'}
onChange={e => setIncludeRetire(e.target.checked ? 'Y' : 'N')} />
</label>
</div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-500"> {total}</span>
<button onClick={openNew}
className="text-sm bg-primary-600 text-white px-3 py-1 rounded hover:bg-primary-700">
+
</button>
</div>
<div className="border rounded overflow-auto" style={{ maxHeight: '60vh' }}>
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-600"></th>
<th className="px-3 py-2 text-left font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={2} className="px-3 py-4 text-center text-gray-400"> ...</td></tr>
) : list.length === 0 ? (
<tr><td colSpan={2} className="px-3 py-4 text-center text-gray-400"> </td></tr>
) : list.map((item) => (
<tr key={item.usrId}
onClick={() => { setSelected(item); setMode('view') }}
className={`cursor-pointer border-t hover:bg-gray-50 ${
selected?.usrId === item.usrId ? 'bg-primary-50' : ''
} ${item.retirementDate ? 'text-gray-400' : ''}`}>
<td className="px-3 py-2">{item.usrNm}</td>
<td className="px-3 py-2">{item.dutyCd}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 상세/편집 패널 */}
<div className="flex-1 border rounded p-4">
{mode === 'list' && (
<div className="flex items-center justify-center h-40 text-gray-400 text-sm">
.
</div>
)}
{(mode === 'view') && detail && (
<div>
<div className="flex justify-between mb-4">
<h2 className="font-bold text-lg">{detail.usrNm}</h2>
<div className="flex gap-2">
<button onClick={openEdit}
className="text-sm border px-3 py-1 rounded hover:bg-gray-50"></button>
<button onClick={() => { if(confirm('삭제하시겠습니까?')) deleteMutation.mutate(detail.usrId) }}
className="text-sm border border-red-300 text-red-600 px-3 py-1 rounded hover:bg-red-50"></button>
</div>
</div>
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm mb-4">
{[['아이디', detail.usrId], ['로그인ID', detail.loginId], ['직위', detail.dutyCd],
['팀', detail.teamCd], ['연락처', detail.usrTelno], ['휴대폰', detail.mtelNo],
['이메일', detail.email], ['입사일', detail.joinCpDate], ['퇴직일', detail.retirementDate]
].map(([label, value]) => (
<div key={label} className="flex gap-2">
<dt className="text-gray-500 w-20 shrink-0">{label}</dt>
<dd className="text-gray-900">{value || '-'}</dd>
</div>
))}
</dl>
{/* 직원 사진 업로드 */}
<div className="border-t pt-4">
<p className="text-sm font-medium text-gray-700 mb-2"> </p>
<FileUpload
atchNo={detail.photoAtchfileNo || undefined}
division="USER_PHOTO"
maxFiles={1}
onChange={(atchNo) => photoMutation.mutate({ usrId: detail.usrId, photoAtchfileNo: atchNo })}
/>
</div>
</div>
)}
{(mode === 'edit' || mode === 'new') && (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex justify-between mb-4">
<h2 className="font-bold text-lg">{mode === 'new' ? '직원 등록' : '직원 수정'}</h2>
<div className="flex gap-2">
<button type="submit"
className="text-sm bg-primary-600 text-white px-3 py-1 rounded hover:bg-primary-700">
</button>
<button type="button" onClick={() => setMode(selected ? 'view' : 'list')}
className="text-sm border px-3 py-1 rounded hover:bg-gray-50"></button>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
{[
{ label: '직원명*', key: 'usrNm', required: true },
{ label: '로그인ID', key: 'loginId' },
{ label: mode === 'new' ? '비밀번호*' : '새 비밀번호', key: 'pw', type: 'password' },
{ label: '직위코드', key: 'dutyCd' },
{ label: '팀코드', key: 'teamCd' },
{ label: '연락처', key: 'usrTelno' },
{ label: '휴대폰', key: 'mtelNo' },
{ label: '이메일', key: 'email' },
{ label: '입사일', key: 'joinCpDate', type: 'date' },
{ label: '퇴직일', key: 'retirementDate', type: 'date' },
].map(({ label, key, type = 'text' }) => (
<div key={key}>
<label className="block text-gray-600 mb-1">{label}</label>
<input {...register(key as keyof SaveForm)} type={type}
className="w-full border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary-500" />
{errors[key as keyof SaveForm] && (
<p className="text-red-500 text-xs mt-0.5">
{errors[key as keyof SaveForm]?.message}
</p>
)}
</div>
))}
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,292 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { envsetApi, type WorkCd } from '@/lib/api/envset';
import { useToast } from '@/components/common/Toast';
import Modal from '@/components/common/Modal';
import { useForm } from 'react-hook-form';
type FormValues = {
workCd: string;
workCdTitleNm: string;
gotoworkTmNm: string;
getoffworkTmNm: string;
regularWorkTm: number;
restTm: number;
otStartTm: string;
ngtStartTm: string;
ngtEndTm: string;
workCdUseYn: string;
holidayYn: string;
workCn: string;
};
const DEFAULT_FORM: FormValues = {
workCd: '', workCdTitleNm: '', gotoworkTmNm: '', getoffworkTmNm: '',
regularWorkTm: 480, restTm: 60, otStartTm: '', ngtStartTm: '',
ngtEndTm: '', workCdUseYn: 'Y', holidayYn: 'N', workCn: '',
};
export default function WorkCdPage() {
const qc = useQueryClient();
const { success, error: toastError } = useToast();
const [searchText, setSearchText] = useState('');
const [inputText, setInputText] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editTarget, setEditTarget] = useState<WorkCd | null>(null);
const { data: list = [], isLoading } = useQuery({
queryKey: ['envset', 'workcd', searchText],
queryFn: async () => {
const res = await envsetApi.getWorkCdList(searchText);
return res.data.data as WorkCd[];
},
});
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormValues>({
defaultValues: DEFAULT_FORM,
});
const openCreate = () => {
setEditTarget(null);
reset(DEFAULT_FORM);
setModalOpen(true);
};
const openEdit = (item: WorkCd) => {
setEditTarget(item);
reset(item as unknown as FormValues);
setModalOpen(true);
};
const saveMut = useMutation({
mutationFn: async (data: FormValues) => {
if (editTarget) {
await envsetApi.updateWorkCd(editTarget.workCd, data);
} else {
await envsetApi.insertWorkCd(data);
}
},
onSuccess: () => {
success(editTarget ? '수정되었습니다.' : '등록되었습니다.');
qc.invalidateQueries({ queryKey: ['envset', 'workcd'] });
setModalOpen(false);
},
onError: () => toastError('저장 중 오류가 발생했습니다.'),
});
const delMut = useMutation({
mutationFn: (workCd: string) => envsetApi.deleteWorkCd(workCd),
onSuccess: () => {
success('삭제되었습니다.');
qc.invalidateQueries({ queryKey: ['envset', 'workcd'] });
},
onError: () => toastError('삭제 중 오류가 발생했습니다.'),
});
const handleDelete = (item: WorkCd) => {
if (!confirm(`근무코드 [${item.workCd}] ${item.workCdTitleNm}을(를) 삭제하시겠습니까?`)) return;
delMut.mutate(item.workCd);
};
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold"> </h1>
<button
onClick={openCreate}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
+
</button>
</div>
{/* 검색 */}
<div className="flex gap-2 mb-4">
<input
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { setSearchText(inputText); } }}
placeholder="코드명 검색"
className="border rounded px-3 py-1.5 text-sm w-56 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={() => setSearchText(inputText)}
className="px-3 py-1.5 bg-gray-700 text-white text-sm rounded hover:bg-gray-800"
>
</button>
</div>
{/* 목록 */}
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50 border-y">
<th className="px-3 py-2 text-left font-medium text-gray-600 w-24"></th>
<th className="px-3 py-2 text-left font-medium text-gray-600"></th>
<th className="px-3 py-2 text-center font-medium text-gray-600 w-24"></th>
<th className="px-3 py-2 text-center font-medium text-gray-600 w-24"></th>
<th className="px-3 py-2 text-center font-medium text-gray-600 w-20">()</th>
<th className="px-3 py-2 text-center font-medium text-gray-600 w-20">()</th>
<th className="px-3 py-2 text-center font-medium text-gray-600 w-16"></th>
<th className="px-3 py-2 text-center font-medium text-gray-600 w-16"></th>
<th className="px-3 py-2 text-center font-medium text-gray-600 w-24"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={9} className="text-center py-8 text-gray-400"> ...</td></tr>
) : list.length === 0 ? (
<tr><td colSpan={9} className="text-center py-8 text-gray-400"> .</td></tr>
) : (
list.map((item) => (
<tr key={item.workCd} className="border-b hover:bg-gray-50">
<td className="px-3 py-2 font-mono text-blue-700">{item.workCd}</td>
<td className="px-3 py-2">{item.workCdTitleNm}</td>
<td className="px-3 py-2 text-center">{item.gotoworkTmNm}</td>
<td className="px-3 py-2 text-center">{item.getoffworkTmNm}</td>
<td className="px-3 py-2 text-center">{item.regularWorkTm}</td>
<td className="px-3 py-2 text-center">{item.restTm}</td>
<td className="px-3 py-2 text-center">
<span className={`text-xs px-2 py-0.5 rounded-full ${item.workCdUseYn === 'Y' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{item.workCdUseYn === 'Y' ? '사용' : '미사용'}
</span>
</td>
<td className="px-3 py-2 text-center">
<span className={`text-xs ${item.holidayYn === 'Y' ? 'text-red-500' : 'text-gray-400'}`}>
{item.holidayYn === 'Y' ? '휴일' : '-'}
</span>
</td>
<td className="px-3 py-2 text-center">
<div className="flex justify-center gap-1">
<button
onClick={() => openEdit(item)}
className="px-2 py-0.5 text-xs border rounded hover:bg-gray-100"
>
</button>
<button
onClick={() => handleDelete(item)}
className="px-2 py-0.5 text-xs border border-red-200 text-red-600 rounded hover:bg-red-50"
>
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 등록/수정 모달 */}
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={editTarget ? '근무코드 수정' : '근무코드 등록'}
size="lg"
footer={
<>
<button
onClick={() => setModalOpen(false)}
className="px-4 py-2 text-sm border rounded hover:bg-gray-50"
>
</button>
<button
onClick={handleSubmit((d) => saveMut.mutate(d))}
disabled={saveMut.isPending}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{saveMut.isPending ? '저장 중...' : '저장'}
</button>
</>
}
>
<div className="space-y-4 text-sm">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block font-medium text-gray-700 mb-1"> *</label>
<input
{...register('workCd', { required: '근무코드를 입력하세요.' })}
disabled={!!editTarget}
className="w-full border rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
/>
{errors.workCd && <p className="text-red-500 text-xs mt-1">{errors.workCd.message}</p>}
</div>
<div>
<label className="block font-medium text-gray-700 mb-1"> *</label>
<input
{...register('workCdTitleNm', { required: '코드명을 입력하세요.' })}
className="w-full border rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.workCdTitleNm && <p className="text-red-500 text-xs mt-1">{errors.workCdTitleNm.message}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block font-medium text-gray-700 mb-1"> (HHMM)</label>
<input {...register('gotoworkTmNm')} placeholder="0900" className="w-full border rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block font-medium text-gray-700 mb-1"> (HHMM)</label>
<input {...register('getoffworkTmNm')} placeholder="1800" className="w-full border rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block font-medium text-gray-700 mb-1">()</label>
<input type="number" {...register('regularWorkTm', { valueAsNumber: true })} className="w-full border rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">()</label>
<input type="number" {...register('restTm', { valueAsNumber: true })} className="w-full border rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">OT </label>
<input {...register('otStartTm')} placeholder="1800" className="w-full border rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block font-medium text-gray-700 mb-1"> </label>
<input {...register('ngtStartTm')} placeholder="2200" className="w-full border rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block font-medium text-gray-700 mb-1"> </label>
<input {...register('ngtEndTm')} placeholder="0600" className="w-full border rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block font-medium text-gray-700 mb-1"></label>
<select {...register('workCdUseYn')} className="w-full border rounded px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="Y"></option>
<option value="N"></option>
</select>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1"></label>
<select {...register('holidayYn')} className="w-full border rounded px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="N"></option>
<option value="Y"></option>
</select>
</div>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1"></label>
<textarea {...register('workCn')} rows={2} className="w-full border rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none" />
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import { use } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { getFedexDetail, updateFedexAttach } from '@/lib/api/fedex';
import FileUpload from '@/components/common/FileUpload';
import Link from 'next/link';
function Field({ label, value }: { label: string; value?: string | number | null }) {
return (
<div>
<dt className="text-xs font-medium text-gray-500 mb-0.5">{label}</dt>
<dd className="text-sm text-gray-900">{value ?? '-'}</dd>
</div>
);
}
export default function Fedex0010DetailPage({
params,
}: {
params: Promise<{ sq: string }>;
}) {
const { sq } = use(params);
const router = useRouter();
const qc = useQueryClient();
const { data: item, isLoading } = useQuery({
queryKey: ['fedex', 'detail', sq],
queryFn: () => getFedexDetail(Number(sq)),
enabled: !!sq,
});
const attachMut = useMutation({
mutationFn: (atchNo: string) => updateFedexAttach(Number(sq), atchNo),
onSuccess: () => qc.invalidateQueries({ queryKey: ['fedex', 'detail', sq] }),
});
if (isLoading) return <div className="p-4 text-gray-400"> ...</div>;
if (!item) return <div className="p-4 text-gray-400"> .</div>;
return (
<div className="p-4 max-w-2xl">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold"> </h1>
<div className="flex gap-2">
<Link
href="/fedex/0010"
className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50"
>
</Link>
</div>
</div>
<div className="bg-white border rounded-lg p-5 space-y-5">
{/* 신고 정보 */}
<section>
<h2 className="text-sm font-semibold text-gray-700 mb-3 pb-1 border-b"> </h2>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
<Field label="신고번호" value={item.singoNo} />
<Field label="업체명" value={item.comNm} />
<Field label="원신고일" value={item.jSingoDt} />
<Field label="정정신고일" value={item.sSingoDt} />
<Field label="관세청 구분" value={item.jGwiDes || item.jGwiCd} />
<Field label="신고 구분" value={item.ieGbn === 'I' ? '수입' : '수출'} />
</dl>
</section>
{/* 정정 정보 */}
<section>
<h2 className="text-sm font-semibold text-gray-700 mb-3 pb-1 border-b"> </h2>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
<Field label="처리상태" value={item.fStDes || item.fStCd} />
<Field label="정정사유" value={item.fJjDes1 || item.fJjCd} />
<Field label="담당자" value={item.fJjNm} />
<Field label="처리일" value={item.fJjRegDt} />
<div className="col-span-2">
<dt className="text-xs font-medium text-gray-500 mb-0.5"></dt>
<dd className="text-sm text-gray-900 whitespace-pre-wrap bg-gray-50 rounded p-3 min-h-16">
{item.fJjContents || '-'}
</dd>
</div>
</dl>
</section>
{/* 등록자 */}
<section>
<h2 className="text-sm font-semibold text-gray-700 mb-3 pb-1 border-b"> </h2>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
<Field label="등록자 ID" value={item.regUserId} />
</dl>
</section>
{/* 첨부파일 */}
<section>
<h2 className="text-sm font-semibold text-gray-700 mb-3 pb-1 border-b"></h2>
<FileUpload
atchNo={item.attachNo || undefined}
division="FEDEX"
onChange={(atchNo) => attachMut.mutate(atchNo)}
/>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getFedexList, type FedexItem } from '@/lib/api/fedex';
import Pagination from '@/components/common/Pagination';
import Link from 'next/link';
export default function Fedex0010Page() {
const [searchText, setSearchText] = useState('');
const [inputText, setInputText] = useState('');
const [pageNo, setPageNo] = useState(1);
const pageSize = 20;
const { data, isLoading } = useQuery({
queryKey: ['fedex', 'list', searchText, pageNo],
queryFn: () => getFedexList({ searchText, pageNo, pageSize }),
});
const list: FedexItem[] = data?.list ?? [];
const total = data?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
const handleSearch = () => {
setSearchText(inputText);
setPageNo(1);
};
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold"> </h1>
<Link
href="/fedex/0010/write"
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</Link>
</div>
{/* 검색 */}
<div className="flex gap-2 mb-4">
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="업체명 검색"
className="border rounded px-3 py-1.5 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleSearch}
className="px-3 py-1.5 bg-gray-700 text-white text-sm rounded hover:bg-gray-800"
>
</button>
</div>
{/* 목록 */}
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50 border-y">
<th className="px-3 py-2 text-left font-medium text-gray-600"></th>
<th className="px-3 py-2 text-left font-medium text-gray-600"></th>
<th className="px-3 py-2 text-left font-medium text-gray-600"></th>
<th className="px-3 py-2 text-left font-medium text-gray-600"></th>
<th className="px-3 py-2 text-left font-medium text-gray-600"></th>
<th className="px-3 py-2 text-left font-medium text-gray-600"></th>
<th className="px-3 py-2 text-left font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-400">
...
</td>
</tr>
) : list.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-400">
.
</td>
</tr>
) : (
list.map((item) => (
<tr key={item.sq} className="border-b hover:bg-gray-50">
<td className="px-3 py-2">
<Link
href={`/fedex/0010/${item.sq}`}
className="text-blue-600 hover:underline"
>
{item.singoNo}
</Link>
</td>
<td className="px-3 py-2">{item.comNm}</td>
<td className="px-3 py-2">{item.jSingoDt}</td>
<td className="px-3 py-2">{item.sSingoDt}</td>
<td className="px-3 py-2">
<span className="px-2 py-0.5 rounded-full text-xs bg-gray-100 text-gray-700">
{item.fStDes || item.fStCd}
</span>
</td>
<td className="px-3 py-2">{item.fJjDes1 || item.fJjCd}</td>
<td className="px-3 py-2">{item.regUserId}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between mt-2 text-sm text-gray-500">
<span> {total.toLocaleString()}</span>
</div>
<Pagination currentPage={pageNo} totalPages={totalPages} onPageChange={setPageNo} />
</div>
);
}

View File

@@ -0,0 +1,187 @@
'use client';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { getFedexCodes, insertFedex } from '@/lib/api/fedex';
import { useToast } from '@/components/common/Toast';
interface FormValues {
ieGbn: string;
sCode: string;
sYear: string;
sJechl: string;
comNm: string;
jSingoDt: string;
sSingoDt: string;
jGwiCd: string;
fStCd: string;
fJjCd: string;
fJjContents: string;
fJjNm: string;
fJjRegDt: string;
fJjRegGbn: string;
}
export default function Fedex0010WritePage() {
const router = useRouter();
const { success, error: toastError } = useToast();
const { data: codes } = useQuery({
queryKey: ['fedex', 'codes'],
queryFn: getFedexCodes,
});
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
defaultValues: { fJjRegGbn: '1' },
});
const saveMut = useMutation({
mutationFn: (data: FormValues) => insertFedex(data as Record<string, unknown>),
onSuccess: (sq) => {
success('등록되었습니다.');
router.push(`/fedex/0010/${sq}`);
},
onError: () => toastError('등록 중 오류가 발생했습니다.'),
});
return (
<div className="p-4 max-w-2xl">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold"> </h1>
<button
onClick={() => router.back()}
className="text-sm text-gray-500 hover:text-gray-700"
>
</button>
</div>
<form onSubmit={handleSubmit((d) => saveMut.mutate(d))} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> </label>
<select
{...register('ieGbn')}
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="I"></option>
<option value="E"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> *</label>
<input
{...register('comNm', { required: '업체명을 입력하세요.' })}
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.comNm && <p className="text-red-500 text-xs mt-1">{errors.comNm.message}</p>}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> </label>
<input {...register('sCode')} className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input {...register('sYear')} placeholder="2024" className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input {...register('sJechl')} className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input type="date" {...register('jSingoDt')} className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input type="date" {...register('sSingoDt')} className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> </label>
<select
{...register('jGwiCd')}
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value=""></option>
{codes?.gwiList.map((g) => (
<option key={g.jGwiCd} value={g.jGwiCd}>{g.jGwiDes}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<select
{...register('fStCd')}
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value=""></option>
{codes?.fstList.map((f) => (
<option key={f.fStCd} value={f.fStCd}>{f.fStDes}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<select
{...register('fJjCd')}
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value=""></option>
{codes?.fjjList.map((f) => (
<option key={f.fJjCd} value={f.fJjCd}>{f.fJjDes1}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<textarea
{...register('fJjContents')}
rows={4}
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input {...register('fJjNm')} className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input type="date" {...register('fJjRegDt')} className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t">
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 text-sm border rounded hover:bg-gray-50"
>
</button>
<button
type="submit"
disabled={saveMut.isPending}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{saveMut.isPending ? '저장 중...' : '저장'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Sidebar } from '@/components/layout/Sidebar'
import { Header } from '@/components/layout/Header'
import { Breadcrumb } from '@/components/layout/Breadcrumb'
export default function MainLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen bg-gray-50">
<Sidebar />
<div className="flex flex-col flex-1 overflow-hidden">
<Header />
<Breadcrumb />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-3 text-gray-400">
<div className="w-8 h-8 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin" />
<p className="text-sm"> ...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/lib/store/authStore';
export default function ChangePwPage() {
const router = useRouter();
const { clearAuth } = useAuthStore();
const [form, setForm] = useState({ oldPw: '', newPw: '', confirmPw: '' });
const [msg, setMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setMsg(null);
if (form.newPw !== form.confirmPw) {
setMsg({ type: 'err', text: '새 비밀번호와 확인이 일치하지 않습니다.' });
return;
}
if (form.newPw.length < 4) {
setMsg({ type: 'err', text: '새 비밀번호는 4자 이상이어야 합니다.' });
return;
}
setLoading(true);
try {
await apiClient.post('/auth/change-pw', {
oldPw: form.oldPw,
newPw: form.newPw,
});
setMsg({ type: 'ok', text: '비밀번호가 변경되었습니다. 다시 로그인하세요.' });
setTimeout(() => {
clearAuth();
document.cookie = 'accessToken=; path=/; max-age=0; SameSite=Lax';
router.replace('/login');
}, 1500);
} catch (err: any) {
setMsg({ type: 'err', text: err.response?.data?.message ?? '오류가 발생했습니다.' });
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md mx-auto mt-12 p-6 bg-white border rounded-lg">
<h1 className="text-lg font-bold mb-6"> </h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> </label>
<input
type="password"
value={form.oldPw}
onChange={e => setForm(f => ({ ...f, oldPw: e.target.value }))}
required
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> </label>
<input
type="password"
value={form.newPw}
onChange={e => setForm(f => ({ ...f, newPw: e.target.value }))}
required
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> </label>
<input
type="password"
value={form.confirmPw}
onChange={e => setForm(f => ({ ...f, confirmPw: e.target.value }))}
required
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
</div>
{msg && (
<p className={`text-sm px-3 py-2 rounded ${
msg.type === 'ok' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'
}`}>{msg.text}</p>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => router.back()}
className="flex-1 py-2 border rounded text-sm text-gray-600 hover:bg-gray-50"
>
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '처리중...' : '변경'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,187 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getYyvctList, saveYyvct, deleteYyvct, YyvctItem } from '@/lib/api/tam';
const CURRENT_YEAR = new Date().getFullYear().toString();
export default function Tam0010Page() {
const qc = useQueryClient();
const [yyvctYy, setYyvctYy] = useState(CURRENT_YEAR);
const [usrNm, setUsrNm] = useState('');
const [teamCd, setTeamCd] = useState('');
const [incRetire, setIncRetire] = useState('N');
// 검색 확정값
const [filter, setFilter] = useState({ yyvctYy: CURRENT_YEAR, usrNm: '', teamCd: '', includeRetireYn: 'N' });
// 인라인 편집 상태
const [editKey, setEditKey] = useState<string | null>(null);
const [editCnt, setEditCnt] = useState<number>(0);
const { data = [], isLoading } = useQuery({
queryKey: ['yyvct', filter],
queryFn: () => getYyvctList(filter),
});
const saveMut = useMutation({
mutationFn: (item: { yyvctYy: string; usrId: string; yyvctCnt: number }) => saveYyvct(item),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['yyvct'] });
setEditKey(null);
},
onError: (e: any) => alert(e?.response?.data?.message ?? '저장 오류'),
});
const delMut = useMutation({
mutationFn: ({ yy, id }: { yy: string; id: string }) => deleteYyvct(yy, id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['yyvct'] }),
onError: (e: any) => alert(e?.response?.data?.message ?? '삭제 오류'),
});
const handleSearch = () => {
setFilter({ yyvctYy, usrNm, teamCd, includeRetireYn: incRetire });
};
const startEdit = (item: YyvctItem) => {
setEditKey(`${item.yyvctYy}-${item.usrId}`);
setEditCnt(item.yyvctCnt);
};
const handleSave = (item: YyvctItem) => {
saveMut.mutate({ yyvctYy: item.yyvctYy, usrId: item.usrId, yyvctCnt: editCnt });
};
const handleDelete = (item: YyvctItem) => {
if (!confirm(`${item.usrNm}${item.yyvctYy}년 연차를 삭제하시겠습니까?`)) return;
delMut.mutate({ yy: item.yyvctYy, id: item.usrId });
};
return (
<div className="p-4">
<h2 className="text-lg font-semibold mb-4"> </h2>
{/* 검색 */}
<div className="flex flex-wrap gap-2 mb-4 items-center">
<label className="text-sm"></label>
<input
type="text" value={yyvctYy} maxLength={4}
onChange={(e) => setYyvctYy(e.target.value)}
className="border rounded px-2 py-1.5 text-sm w-20"
/>
<label className="text-sm"></label>
<input
type="text" value={usrNm} placeholder="직원명"
onChange={(e) => setUsrNm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="border rounded px-2 py-1.5 text-sm w-32"
/>
<label className="text-sm"></label>
<input
type="text" value={teamCd} placeholder="부서코드"
onChange={(e) => setTeamCd(e.target.value)}
className="border rounded px-2 py-1.5 text-sm w-28"
/>
<label className="flex items-center gap-1 text-sm cursor-pointer">
<input type="checkbox" checked={incRetire === 'Y'}
onChange={(e) => setIncRetire(e.target.checked ? 'Y' : 'N')}
/>
</label>
<button
onClick={handleSearch}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</button>
</div>
{/* 테이블 */}
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100 text-center">
<th className="border px-3 py-2">ID</th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2 w-28"></th>
<th className="border px-3 py-2 w-24"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={7} className="text-center py-8 text-gray-400"> ...</td></tr>
) : data.length === 0 ? (
<tr><td colSpan={7} className="text-center py-8 text-gray-400"> .</td></tr>
) : (
data.map((item) => {
const key = `${item.yyvctYy}-${item.usrId}`;
const isEditing = editKey === key;
return (
<tr key={key} className="hover:bg-gray-50 text-center">
<td className="border px-3 py-2">{item.usrId}</td>
<td className="border px-3 py-2">{item.usrNm}</td>
<td className="border px-3 py-2">{item.teamCd}</td>
<td className="border px-3 py-2">{item.dutyCd}</td>
<td className="border px-3 py-2">{item.yyvctYy}</td>
<td className="border px-3 py-2">
{isEditing ? (
<input
type="number" min={0} max={365}
value={editCnt}
onChange={(e) => setEditCnt(Number(e.target.value))}
className="border rounded px-2 py-1 text-sm w-20 text-center"
autoFocus
/>
) : (
item.yyvctCnt
)}
</td>
<td className="border px-3 py-2">
{isEditing ? (
<div className="flex justify-center gap-1">
<button
onClick={() => handleSave(item)}
disabled={saveMut.isPending}
className="px-2 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:opacity-50"
>
</button>
<button
onClick={() => setEditKey(null)}
className="px-2 py-1 bg-gray-400 text-white text-xs rounded hover:bg-gray-500"
>
</button>
</div>
) : (
<div className="flex justify-center gap-1">
<button
onClick={() => startEdit(item)}
className="px-2 py-1 bg-amber-500 text-white text-xs rounded hover:bg-amber-600"
>
</button>
<button
onClick={() => handleDelete(item)}
className="px-2 py-1 bg-red-500 text-white text-xs rounded hover:bg-red-600"
>
</button>
</div>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
<div className="mt-2 text-sm text-gray-500 text-right"> {data.length}</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
import { use } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getApvreqDetail, requestApvreq, deleteApvreq,
APRVL_STUS, APRVL_STUS_LABEL, APRVL_KIND_LABEL,
} from '@/lib/api/tam';
import { useAuthStore } from '@/lib/store/authStore';
import ApvreqForm from '@/components/tam/ApvreqForm';
import ApproverSection from '@/components/tam/ApproverSection';
import ApvdocInfo from '@/components/tam/ApvdocInfo';
export default function Tam0020DetailPage({ params }: { params: Promise<{ docId: string }> }) {
const { docId } = use(params);
const router = useRouter();
const qc = useQueryClient();
const usrId = useAuthStore((s) => s.user?.usrId);
const { data, isLoading } = useQuery({
queryKey: ['apvreq', docId],
queryFn: () => getApvreqDetail(docId),
});
const requestMut = useMutation({
mutationFn: (apprList: Array<{ apprId: string }>) => requestApvreq(docId, apprList),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['apvreq'] });
qc.invalidateQueries({ queryKey: ['apvreq', docId] });
alert('결재 상신이 완료되었습니다.');
},
onError: (e: any) => alert(e?.response?.data?.message ?? '상신 오류'),
});
const delMut = useMutation({
mutationFn: () => deleteApvreq(docId),
onSuccess: () => router.push('/tam/0020/list'),
onError: (e: any) => alert(e?.response?.data?.message ?? '삭제 오류'),
});
if (isLoading) return <div className="p-4 text-gray-400"> ...</div>;
if (!data) return <div className="p-4 text-gray-400"> .</div>;
const isOwner = data.aplntId === usrId;
const isWriting = data.aprvlStusCd === APRVL_STUS.WRITING;
const handleRequest = (apprList: Array<{ apprId: string }>) => {
if (apprList.length < 2) { alert('결재자를 2명 이상 지정해주세요.'); return; }
if (!confirm(`결재자 ${apprList.length}명으로 상신하시겠습니까?`)) return;
requestMut.mutate(apprList);
};
const handleDelete = () => {
if (!confirm('신청서를 삭제하시겠습니까?')) return;
delMut.mutate();
};
return (
<div className="p-4 max-w-3xl">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"> </h2>
<button onClick={() => router.back()} className="text-sm text-gray-500 hover:underline"> </button>
</div>
{/* 문서 정보 (읽기전용 or 편집) */}
{isOwner && isWriting ? (
<ApvreqForm
initialData={data}
onSuccess={() => qc.invalidateQueries({ queryKey: ['apvreq', docId] })}
onCancel={() => router.back()}
/>
) : (
<ApvdocInfo data={data} />
)}
{/* 결재선 */}
<ApproverSection
apprList={data.apprList}
editable={isOwner && isWriting}
onRequest={handleRequest}
requesting={requestMut.isPending}
/>
{/* 버튼 영역 */}
{isOwner && isWriting && (
<div className="flex gap-2 mt-4">
<button
onClick={handleDelete}
disabled={delMut.isPending}
className="px-4 py-2 bg-red-500 text-white text-sm rounded hover:bg-red-600 disabled:opacity-50"
>
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,179 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getApvreqList, deleteApvreq,
APRVL_STUS, APRVL_STUS_LABEL, APRVL_KIND_LABEL,
ApvreqListItem,
} from '@/lib/api/tam';
import { useAuthStore } from '@/lib/store/authStore';
import { localYmd, localYmdDaysAgo } from '@/lib/utils/date';
const PAGE_SIZE = 20;
const TODAY = localYmd();
const YEAR_AGO = localYmdDaysAgo(365);
export default function Tam0020ListPage() {
const router = useRouter();
const qc = useQueryClient();
const usrId = useAuthStore((s) => s.user?.usrId);
const [staYmd, setStaYmd] = useState(YEAR_AGO);
const [endYmd, setEndYmd] = useState(TODAY);
const [aprvlStusCd, setStatus] = useState('');
const [pageNo, setPageNo] = useState(1);
// 문서결재내역은 기안(0007)만 조회
const [filter, setFilter] = useState({
staYmd: YEAR_AGO, endYmd: TODAY, aprvlStusCd: '', aprvlKindCd: '0007', pageNo: 1,
});
const { data, isLoading, refetch } = useQuery({
queryKey: ['apvreq', filter],
queryFn: () => getApvreqList({ ...filter, pageSize: PAGE_SIZE }),
});
const delMut = useMutation({
mutationFn: (id: string) => deleteApvreq(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apvreq'] }),
onError: (e: any) => alert(e?.response?.data?.message ?? '삭제 오류'),
});
const handleSearch = () => {
const f = { staYmd, endYmd, aprvlStusCd, aprvlKindCd: '0007', pageNo: 1 };
const same = JSON.stringify(f) === JSON.stringify(filter);
setFilter(f);
setPageNo(1);
if (same) refetch();
};
const handlePageChange = (p: number) => {
setPageNo(p);
setFilter((prev) => ({ ...prev, pageNo: p }));
};
const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 1;
const statusBadge = (cd: string) => {
const colors: Record<string, string> = {
[APRVL_STUS.WRITING]: 'bg-gray-100 text-gray-600',
[APRVL_STUS.APPRING]: 'bg-blue-100 text-blue-700',
[APRVL_STUS.APPROVED]: 'bg-green-100 text-green-700',
[APRVL_STUS.REJECTED]: 'bg-red-100 text-red-700',
};
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${colors[cd] ?? ''}`}>
{APRVL_STUS_LABEL[cd] ?? cd}
</span>
);
};
return (
<div className="p-4">
<h2 className="text-lg font-semibold mb-4"> </h2>
{/* 검색 */}
<div className="flex flex-wrap gap-2 mb-4 items-center">
<input type="date" value={staYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
onChange={(e) => setStaYmd(e.target.value.replace(/-/g, ''))}
className="border rounded px-2 py-1.5 text-sm"
/>
<span className="text-sm">~</span>
<input type="date" value={endYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
onChange={(e) => setEndYmd(e.target.value.replace(/-/g, ''))}
className="border rounded px-2 py-1.5 text-sm"
/>
<select value={aprvlStusCd} onChange={(e) => setStatus(e.target.value)}
className="border rounded px-2 py-1.5 text-sm"
>
<option value=""> </option>
{Object.entries(APRVL_STUS_LABEL).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<button onClick={handleSearch}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</button>
<button onClick={() => router.push('/tam/0020')}
className="ml-auto px-4 py-1.5 bg-green-600 text-white text-sm rounded hover:bg-green-700"
>
</button>
</div>
{/* 테이블 */}
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100 text-center">
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2 w-20"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={6} className="text-center py-8 text-gray-400"> ...</td></tr>
) : !data?.list?.length ? (
<tr><td colSpan={6} className="text-center py-8 text-gray-400"> .</td></tr>
) : (
data.list.map((item) => (
<tr key={item.aprvlDocId} className="hover:bg-gray-50 text-center">
<td className="border px-3 py-2">
{APRVL_KIND_LABEL[item.aprvlKindCd] ?? item.aprvlKindCd}
</td>
<td className="border px-3 py-2">{statusBadge(item.aprvlStusCd)}</td>
<td className="border px-3 py-2">{item.offerDt}</td>
<td className="border px-3 py-2">{item.cmplDt || '-'}</td>
<td className="border px-3 py-2">
{item.aprvlCmplSno} / {item.finalAprvlSno}
</td>
<td className="border px-3 py-2">
<div className="flex justify-center gap-1">
<button
onClick={() => router.push(`/tam/0020/${item.aprvlDocId}`)}
className="px-2 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600"
>
</button>
{item.aprvlStusCd === APRVL_STUS.WRITING && item.aplntId === usrId && (
<button
onClick={() => { if (!confirm('신청서를 삭제하시겠습니까?')) return; delMut.mutate(item.aprvlDocId); }}
className="px-2 py-1 bg-red-500 text-white text-xs rounded hover:bg-red-600"
>
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex justify-center gap-1 mt-4">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button key={p} onClick={() => handlePageChange(p)}
className={`px-3 py-1 text-sm rounded border ${
p === pageNo ? 'bg-blue-600 text-white border-blue-600' : 'hover:bg-gray-100'
}`}
>
{p}
</button>
))}
</div>
)}
<div className="mt-2 text-sm text-gray-500 text-right"> {data?.total ?? 0}</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import { useRouter } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createApvreq } from '@/lib/api/tam';
import ApvreqForm from '@/components/tam/ApvreqForm';
export default function Tam0020Page() {
const router = useRouter();
const qc = useQueryClient();
const handleSuccess = (aprvlDocId?: string) => {
qc.invalidateQueries({ queryKey: ['apvreq'] });
// 저장 후 상세 페이지로 이동 → 결재자 지정 및 상신
if (aprvlDocId) {
router.push(`/tam/0020/${aprvlDocId}`);
} else {
router.push('/tam/0020/list');
}
};
return (
<div className="p-4 max-w-3xl">
<h2 className="text-lg font-semibold mb-4"> </h2>
<ApvreqForm onSuccess={handleSuccess} onCancel={() => router.push('/tam/0020/list')} />
</div>
);
}

View File

@@ -0,0 +1,14 @@
'use client';
import { useRouter } from 'next/navigation';
import ApvreqForm from '@/components/tam/ApvreqForm';
export default function Tam0020WritePage() {
const router = useRouter();
return (
<div className="p-4">
<h2 className="text-lg font-semibold mb-4"> </h2>
<ApvreqForm onSuccess={() => router.push('/tam/0020/list')} onCancel={() => router.push('/tam/0020/list')} />
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { use, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getApvappDetail, approveApvdoc, rejectApvdoc,
APRVL_STUS, APRVL_STUS_LABEL, APRVL_KIND_LABEL,
} from '@/lib/api/tam';
import { useAuthStore } from '@/lib/store/authStore';
import ApvdocInfo from '@/components/tam/ApvdocInfo';
export default function Tam0030DetailPage({ params }: { params: Promise<{ docId: string }> }) {
const { docId } = use(params);
const router = useRouter();
const qc = useQueryClient();
const usrId = useAuthStore((s) => s.user?.usrId);
const [comment, setComment] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['apvapp', docId],
queryFn: () => getApvappDetail(docId),
});
const approveMut = useMutation({
mutationFn: ({ aprvlSno, apprCn }: { aprvlSno: number; apprCn: string }) =>
approveApvdoc(docId, aprvlSno, apprCn),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['apvapp'] });
qc.invalidateQueries({ queryKey: ['apvapp', docId] });
alert('승인 처리되었습니다.');
router.push('/tam/0030');
},
onError: (e: any) => alert(e?.response?.data?.message ?? '승인 오류'),
});
const rejectMut = useMutation({
mutationFn: ({ aprvlSno, apprCn }: { aprvlSno: number; apprCn: string }) =>
rejectApvdoc(docId, aprvlSno, apprCn),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['apvapp'] });
qc.invalidateQueries({ queryKey: ['apvapp', docId] });
alert('반려 처리되었습니다.');
router.push('/tam/0030');
},
onError: (e: any) => alert(e?.response?.data?.message ?? '반려 오류'),
});
if (isLoading) return <div className="p-4 text-gray-400"> ...</div>;
if (!data) return <div className="p-4 text-gray-400"> .</div>;
// 현재 로그인 사용자가 결재해야 할 차수 찾기
const myAppr = data.apprList?.find(
(a) => a.apprId === usrId && a.apprStusCd === APRVL_STUS.APPRING,
);
const isAppring = data.aprvlStusCd === APRVL_STUS.APPRING;
const handleApprove = () => {
if (!myAppr || isPending) return;
if (!confirm('승인 처리하시겠습니까?')) return;
approveMut.mutate({ aprvlSno: myAppr.aprvlSno, apprCn: comment });
};
const handleReject = () => {
if (!myAppr || isPending) return;
if (!comment.trim()) { alert('반려 사유를 입력해주세요.'); return; }
if (!confirm('반려 처리하시겠습니까?')) return;
rejectMut.mutate({ aprvlSno: myAppr.aprvlSno, apprCn: comment });
};
const isPending = approveMut.isPending || rejectMut.isPending || approveMut.isSuccess || rejectMut.isSuccess;
return (
<div className="p-4 max-w-3xl">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"> </h2>
<button onClick={() => router.back()} className="text-sm text-gray-500 hover:underline"> </button>
</div>
{/* 문서 정보 */}
<ApvdocInfo data={data} />
{/* 결재선 */}
<div className="mt-4 border rounded p-3">
<h3 className="text-sm font-semibold mb-2"></h3>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 text-center">
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
</tr>
</thead>
<tbody>
{data.apprList?.map((a) => (
<tr key={a.aprvlSno} className={`text-center ${a.apprId === usrId ? 'bg-yellow-50' : ''}`}>
<td className="border px-3 py-2">{a.aprvlSno}</td>
<td className="border px-3 py-2">{a.apprNm} ({a.apprId})</td>
<td className="border px-3 py-2">
<span className={`px-2 py-0.5 rounded text-xs ${
a.apprStusCd === APRVL_STUS.APPROVED ? 'bg-green-100 text-green-700' :
a.apprStusCd === APRVL_STUS.APPRING ? 'bg-blue-100 text-blue-700' :
a.apprStusCd === APRVL_STUS.REJECTED ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
}`}>
{APRVL_STUS_LABEL[a.apprStusCd] ?? a.apprStusCd}
</span>
</td>
<td className="border px-3 py-2">{a.aprvlDt || '-'}</td>
<td className="border px-3 py-2 text-left">{a.apprCn || '-'}</td>
<td className="border px-3 py-2">{a.finalApprYn === 'Y' ? '✓' : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 승인/반려 처리 영역 */}
{myAppr && isAppring && (
<div className="mt-4 border rounded p-3 bg-yellow-50">
<h3 className="text-sm font-semibold mb-2"> ({myAppr.aprvlSno} )</h3>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="의견을 입력하세요 (반려 시 필수)"
rows={3}
className="w-full border rounded px-3 py-2 text-sm mb-3 resize-none"
/>
<div className="flex gap-2">
<button
onClick={handleApprove}
disabled={isPending}
className="px-4 py-2 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
>
</button>
<button
onClick={handleReject}
disabled={isPending}
className="px-4 py-2 bg-red-500 text-white text-sm rounded hover:bg-red-600 disabled:opacity-50"
>
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,248 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getApvappList, multiApprove, multiReject,
APRVL_STUS_LABEL, APRVL_KIND_LABEL, ApvreqListItem,
} from '@/lib/api/tam';
import { localYmd, localYmdDaysAgo } from '@/lib/utils/date';
const PAGE_SIZE = 20;
const TODAY = localYmd();
const MONTH_AGO = localYmdDaysAgo(30);
export default function Tam0030Page() {
const router = useRouter();
const qc = useQueryClient();
const [staYmd, setStaYmd] = useState(MONTH_AGO);
const [endYmd, setEndYmd] = useState(TODAY);
const [pageNo, setPageNo] = useState(1);
const [filter, setFilter] = useState({ staYmd: MONTH_AGO, endYmd: TODAY, pageNo: 1 });
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchComment, setBatchComment] = useState('');
const { data, isLoading, refetch } = useQuery({
queryKey: ['apvapp', filter],
queryFn: () => getApvappList({ ...filter, pageSize: PAGE_SIZE }),
});
const handleSearch = () => {
const f = { staYmd, endYmd, pageNo: 1 };
const same = JSON.stringify(f) === JSON.stringify(filter);
setFilter(f);
setPageNo(1);
setSelected(new Set());
if (same) refetch();
};
const handlePageChange = (p: number) => {
setPageNo(p);
setFilter((prev) => ({ ...prev, pageNo: p }));
setSelected(new Set());
};
// 결재중인 항목만 체크박스 대상
const appringItems = (data?.list ?? []).filter((item) => item.aprvlStusCd === '0002');
const toggleAll = () => {
if (selected.size === appringItems.length) {
setSelected(new Set());
} else {
setSelected(new Set(appringItems.map((i) => i.aprvlDocId)));
}
};
const toggleOne = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const getSelectedItems = () =>
(data?.list ?? [])
.filter((i) => selected.has(i.aprvlDocId))
.map((i) => ({ aprvlDocId: i.aprvlDocId, aprvlSno: i.aprvlCmplSno + 1 }));
const approveMut = useMutation({
mutationFn: () => multiApprove(getSelectedItems(), batchComment),
onSuccess: () => {
alert(`${selected.size}건 일괄 승인 처리되었습니다.`);
setSelected(new Set());
setBatchComment('');
qc.invalidateQueries({ queryKey: ['apvapp'] });
},
onError: (e: any) => alert(e?.response?.data?.message ?? '일괄 승인 오류'),
});
const rejectMut = useMutation({
mutationFn: () => multiReject(getSelectedItems(), batchComment),
onSuccess: () => {
alert(`${selected.size}건 일괄 반려 처리되었습니다.`);
setSelected(new Set());
setBatchComment('');
qc.invalidateQueries({ queryKey: ['apvapp'] });
},
onError: (e: any) => alert(e?.response?.data?.message ?? '일괄 반려 오류'),
});
const handleBatchApprove = () => {
if (selected.size === 0) { alert('선택된 항목이 없습니다.'); return; }
if (!confirm(`${selected.size}건을 일괄 승인하시겠습니까?`)) return;
approveMut.mutate();
};
const handleBatchReject = () => {
if (selected.size === 0) { alert('선택된 항목이 없습니다.'); return; }
if (!batchComment.trim()) { alert('반려 사유를 입력해주세요.'); return; }
if (!confirm(`${selected.size}건을 일괄 반려하시겠습니까?`)) return;
rejectMut.mutate();
};
const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 1;
const isPending = approveMut.isPending || rejectMut.isPending;
const statusBadge = (cd: string) => {
const colors: Record<string, string> = {
'0001': 'bg-gray-100 text-gray-600',
'0002': 'bg-blue-100 text-blue-700',
'0003': 'bg-green-100 text-green-700',
'0004': 'bg-red-100 text-red-700',
};
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${colors[cd] ?? ''}`}>
{APRVL_STUS_LABEL[cd] ?? cd}
</span>
);
};
return (
<div className="p-4">
<h2 className="text-lg font-semibold mb-4"> </h2>
{/* 검색 */}
<div className="flex flex-wrap gap-2 mb-4 items-center">
<input type="date" value={staYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
onChange={(e) => setStaYmd(e.target.value.replace(/-/g, ''))}
className="border rounded px-2 py-1.5 text-sm"
/>
<span className="text-sm">~</span>
<input type="date" value={endYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
onChange={(e) => setEndYmd(e.target.value.replace(/-/g, ''))}
className="border rounded px-2 py-1.5 text-sm"
/>
<button onClick={handleSearch}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</button>
</div>
{/* 일괄처리 바 */}
{selected.size > 0 && (
<div className="flex items-center gap-2 mb-3 p-2 bg-yellow-50 border border-yellow-200 rounded">
<span className="text-sm font-medium text-yellow-800">{selected.size} </span>
<input
type="text"
value={batchComment}
onChange={(e) => setBatchComment(e.target.value)}
placeholder="의견 (반려 시 필수)"
className="flex-1 border rounded px-2 py-1 text-sm"
/>
<button onClick={handleBatchApprove} disabled={isPending}
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
>
</button>
<button onClick={handleBatchReject} disabled={isPending}
className="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 disabled:opacity-50"
>
</button>
</div>
)}
{/* 테이블 */}
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100 text-center">
<th className="border px-2 py-2 w-8">
<input type="checkbox"
checked={appringItems.length > 0 && selected.size === appringItems.length}
onChange={toggleAll}
/>
</th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2 w-16"></th>
<th className="border px-3 py-2 w-24"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={8} className="text-center py-8 text-gray-400"> ...</td></tr>
) : !data?.list?.length ? (
<tr><td colSpan={8} className="text-center py-8 text-gray-400"> .</td></tr>
) : (
data.list.map((item: ApvreqListItem) => {
const isAppring = item.aprvlStusCd === '0002';
const isChecked = selected.has(item.aprvlDocId);
return (
<tr key={item.aprvlDocId}
className={`hover:bg-gray-50 text-center ${isChecked ? 'bg-blue-50' : ''}`}
>
<td className="border px-2 py-2">
{isAppring && (
<input type="checkbox" checked={isChecked}
onChange={() => toggleOne(item.aprvlDocId)}
/>
)}
</td>
<td className="border px-3 py-2">{item.aplntId}</td>
<td className="border px-3 py-2">{APRVL_KIND_LABEL[item.aprvlKindCd] ?? item.aprvlKindCd}</td>
<td className="border px-3 py-2">{statusBadge(item.aprvlStusCd)}</td>
<td className="border px-3 py-2">{item.offerDt}</td>
<td className="border px-3 py-2">{item.cmplDt || '-'}</td>
<td className="border px-3 py-2">{item.aprvlCmplSno} / {item.finalAprvlSno}</td>
<td className="border px-3 py-2">
<button
onClick={() => router.push(`/tam/0030/${item.aprvlDocId}`)}
className="px-2 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600"
>
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex justify-center gap-1 mt-4">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button key={p} onClick={() => handlePageChange(p)}
className={`px-3 py-1 text-sm rounded border ${
p === pageNo ? 'bg-blue-600 text-white border-blue-600' : 'hover:bg-gray-100'
}`}
>
{p}
</button>
))}
</div>
)}
<div className="mt-2 text-sm text-gray-500 text-right"> {data?.total ?? 0}</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getTamStatusList, APRVL_KIND_LABEL } from '@/lib/api/tam';
import { localYmd } from '@/lib/utils/date';
const CURRENT_YEAR = new Date().getFullYear().toString();
const TODAY = localYmd();
const YEAR_START = CURRENT_YEAR + '0101';
export default function Tam0040Page() {
const [yyvctYy, setYyvctYy] = useState(CURRENT_YEAR);
const [teamCd, setTeamCd] = useState('');
const [staYmd, setStaYmd] = useState(YEAR_START);
const [endYmd, setEndYmd] = useState(TODAY);
const [incRetire, setIncRetire] = useState('N');
const [filter, setFilter] = useState({
yyvctYy: CURRENT_YEAR, teamCd: '', staYmd: YEAR_START, endYmd: TODAY, includeRetireYn: 'N',
});
const { data = [], isLoading } = useQuery({
queryKey: ['tamStatus', filter],
queryFn: () => getTamStatusList(filter),
});
const handleSearch = () => {
setFilter({ yyvctYy, teamCd, staYmd, endYmd, includeRetireYn: incRetire });
};
// 결재 종류 컬럼 동적 추출
const kindCodes = Object.keys(APRVL_KIND_LABEL);
return (
<div className="p-4">
<h2 className="text-lg font-semibold mb-4"> </h2>
{/* 검색 */}
<div className="flex flex-wrap gap-2 mb-4 items-center">
<label className="text-sm"></label>
<input type="text" value={yyvctYy} maxLength={4}
onChange={(e) => setYyvctYy(e.target.value)}
className="border rounded px-2 py-1.5 text-sm w-20"
/>
<label className="text-sm"></label>
<input type="text" value={teamCd} placeholder="부서코드"
onChange={(e) => setTeamCd(e.target.value)}
className="border rounded px-2 py-1.5 text-sm w-28"
/>
<input type="date" value={staYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
onChange={(e) => setStaYmd(e.target.value.replace(/-/g, ''))}
className="border rounded px-2 py-1.5 text-sm"
/>
<span className="text-sm">~</span>
<input type="date" value={endYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
onChange={(e) => setEndYmd(e.target.value.replace(/-/g, ''))}
className="border rounded px-2 py-1.5 text-sm"
/>
<label className="flex items-center gap-1 text-sm cursor-pointer">
<input type="checkbox" checked={incRetire === 'Y'}
onChange={(e) => setIncRetire(e.target.checked ? 'Y' : 'N')}
/>
</label>
<button onClick={handleSearch}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</button>
</div>
{/* 테이블 */}
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse whitespace-nowrap">
<thead>
<tr className="bg-gray-100 text-center">
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2">()</th>
<th className="border px-3 py-2">()</th>
<th className="border px-3 py-2">()</th>
{kindCodes.map((cd) => (
<th key={cd} className="border px-3 py-2">{APRVL_KIND_LABEL[cd]}</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={6 + kindCodes.length} className="text-center py-8 text-gray-400"> ...</td></tr>
) : data.length === 0 ? (
<tr><td colSpan={6 + kindCodes.length} className="text-center py-8 text-gray-400"> .</td></tr>
) : (
data.map((row: any, i: number) => (
<tr key={i} className="hover:bg-gray-50 text-center">
<td className="border px-3 py-2">{row.USR_NM ?? row.usrNm}</td>
<td className="border px-3 py-2">{row.TEAM_NM ?? row.teamNm ?? row.TEAM_CD ?? row.teamCd}</td>
<td className="border px-3 py-2">{row.DUTY_NM ?? row.dutyNm ?? row.DUTY_CD ?? row.dutyCd}</td>
<td className="border px-3 py-2">{row.YYVCT_CNT ?? row.yyvctCnt ?? 0}</td>
<td className="border px-3 py-2">{row.USE_CNT ?? row.useCnt ?? 0}</td>
<td className="border px-3 py-2">{row.REMAIN_CNT ?? row.remainCnt ?? 0}</td>
{kindCodes.map((cd) => {
const key = `KIND_${cd}`;
const altKey = `kind${cd}`;
return (
<td key={cd} className="border px-3 py-2">
{row[key] ?? row[altKey] ?? 0}
</td>
);
})}
</tr>
))
)}
</tbody>
</table>
</div>
<div className="mt-2 text-sm text-gray-500 text-right"> {data.length}</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { getWplanList, getWorkCodeList, saveWplanList, WplanItem, WplanSaveItem } from '@/lib/api/wplan';
const now = new Date();
const DEFAULT_YM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
// 주어진 월의 날짜 수
function daysInMonth(yyyymm: string): number {
const y = parseInt(yyyymm.slice(0, 4));
const m = parseInt(yyyymm.slice(4, 6));
return new Date(y, m, 0).getDate();
}
// 날짜배열 ['01'..'31']
function buildDays(yyyymm: string): string[] {
return Array.from({ length: daysInMonth(yyyymm) }, (_, i) => String(i + 1).padStart(2, '0'));
}
// 요일명
const DOW = ['일', '월', '화', '수', '목', '금', '토'];
function getDow(yyyymm: string, dd: string): string {
const d = new Date(`${yyyymm.slice(0, 4)}-${yyyymm.slice(4, 6)}-${dd}`);
return DOW[d.getDay()];
}
export default function Wplan0010Page() {
const [workPlanYymm, setYymm] = useState(DEFAULT_YM);
const [searchText, setSearch] = useState('');
const [teamCd, setTeamCd] = useState('');
const [incRetire, setIncRetire] = useState('N');
const [filter, setFilter] = useState({ workPlanYymm: DEFAULT_YM, searchText: '', teamCd: '', includeRetireYn: 'N' });
// 수정사항 Map: key = `${usrId}_${dd}`, value = planWorkCd
const [changes, setChanges] = useState<Record<string, string>>({});
const { data: rawList = [], isLoading } = useQuery({
queryKey: ['wplan0010', filter],
queryFn: () => getWplanList(filter),
});
const { data: workCodes = [] } = useQuery({
queryKey: ['workCodes'],
queryFn: getWorkCodeList,
});
const saveMut = useMutation({
mutationFn: (saveList: WplanSaveItem[]) => saveWplanList(saveList),
onSuccess: () => { alert('저장되었습니다.'); setChanges({}); },
onError: (e: any) => alert(e?.response?.data?.message ?? '저장 오류'),
});
const days = useMemo(() => buildDays(filter.workPlanYymm), [filter.workPlanYymm]);
// rawList를 {usrId → {dd → planWorkCd}} 피벗으로 변환
const pivoted = useMemo(() => {
const map: Record<string, { info: WplanItem; plan: Record<string, string> }> = {};
rawList.forEach((row) => {
if (!map[row.usrId]) {
map[row.usrId] = { info: row, plan: {} };
}
if (row.workPlanYymmdd) {
const dd = row.workPlanYymmdd.slice(-2);
map[row.usrId].plan[dd] = row.planWorkCd ?? '';
}
});
return Object.values(map);
}, [rawList]);
const getCell = (usrId: string, dd: string, originalCd: string) => {
const key = `${usrId}_${dd}`;
return key in changes ? changes[key] : (originalCd ?? '');
};
const handleChange = (usrId: string, dd: string, value: string) => {
const key = `${usrId}_${dd}`;
setChanges((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
if (!Object.keys(changes).length) { alert('변경된 내용이 없습니다.'); return; }
const saveList: WplanSaveItem[] = Object.entries(changes).map(([key, planWorkCd]) => {
const [usrId, dd] = key.split('_');
return { usrId, workPlanYymmdd: filter.workPlanYymm + dd, planWorkCd };
});
saveMut.mutate(saveList);
};
return (
<div className="p-4">
<h2 className="text-lg font-semibold mb-4"> </h2>
{/* 검색 */}
<div className="flex flex-wrap gap-2 mb-4 items-center">
<label className="text-sm"></label>
<input type="month"
value={`${workPlanYymm.slice(0, 4)}-${workPlanYymm.slice(4, 6)}`}
onChange={(e) => setYymm(e.target.value.replace('-', ''))}
className="border rounded px-2 py-1.5 text-sm"
/>
<label className="text-sm"></label>
<input type="text" value={searchText} placeholder="직원명"
onChange={(e) => setSearch(e.target.value)}
className="border rounded px-2 py-1.5 text-sm w-28"
/>
<label className="text-sm"></label>
<input type="text" value={teamCd} placeholder="부서코드"
onChange={(e) => setTeamCd(e.target.value)}
className="border rounded px-2 py-1.5 text-sm w-28"
/>
<label className="flex items-center gap-1 text-sm cursor-pointer">
<input type="checkbox" checked={incRetire === 'Y'}
onChange={(e) => setIncRetire(e.target.checked ? 'Y' : 'N')}
/>
</label>
<button
onClick={() => { setFilter({ workPlanYymm, searchText, teamCd, includeRetireYn: incRetire }); setChanges({}); }}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</button>
<button
onClick={handleSave}
disabled={saveMut.isPending || !Object.keys(changes).length}
className="ml-auto px-4 py-1.5 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
>
</button>
</div>
{/* 근무코드 범례 */}
{workCodes.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3 text-xs">
{workCodes.map((wc) => (
<span key={wc.workCd} className="px-2 py-0.5 bg-gray-100 rounded border">
{wc.workCd}: {wc.workCdTitleNm}
</span>
))}
</div>
)}
{/* 피벗 테이블 */}
<div className="overflow-x-auto">
<table className="text-xs border-collapse whitespace-nowrap">
<thead>
<tr className="bg-gray-100">
<th className="border px-2 py-1.5 sticky left-0 bg-gray-100 z-10 min-w-20"></th>
{days.map((dd) => {
const dow = getDow(filter.workPlanYymm, dd);
return (
<th key={dd}
className={`border px-1 py-1.5 w-16 text-center ${
dow === '일' ? 'text-red-500' : dow === '토' ? 'text-blue-500' : ''
}`}
>
<div>{dd}</div>
<div className="text-gray-400">{dow}</div>
</th>
);
})}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={days.length + 1} className="text-center py-8 text-gray-400"> ...</td></tr>
) : pivoted.length === 0 ? (
<tr><td colSpan={days.length + 1} className="text-center py-8 text-gray-400"> .</td></tr>
) : (
pivoted.map(({ info, plan }) => (
<tr key={info.usrId} className="hover:bg-gray-50">
<td className="border px-2 py-1 sticky left-0 bg-white z-10 font-medium">
{info.usrNm}
</td>
{days.map((dd) => {
const val = getCell(info.usrId, dd, plan[dd]);
const dow = getDow(filter.workPlanYymm, dd);
return (
<td key={dd} className={`border p-0.5 ${
dow === '일' ? 'bg-red-50' : dow === '토' ? 'bg-blue-50' : ''
}`}>
<select
value={val}
onChange={(e) => handleChange(info.usrId, dd, e.target.value)}
className="w-14 text-xs border-0 bg-transparent py-0.5 text-center appearance-none cursor-pointer hover:bg-gray-100"
>
<option value=""></option>
{workCodes.map((wc) => (
<option key={wc.workCd} value={wc.workCd}>{wc.workCd}</option>
))}
</select>
</td>
);
})}
</tr>
))
)}
</tbody>
</table>
</div>
{Object.keys(changes).length > 0 && (
<div className="mt-2 text-sm text-amber-600">
{Object.keys(changes).length} ()
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getMyWplanList, getWorkCodeList, MyWplanItem } from '@/lib/api/wplan';
const now = new Date();
const DEFAULT_YM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
const DOW = ['일', '월', '화', '수', '목', '금', '토'];
function getDow(yyyymmdd: string): string {
const d = new Date(`${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`);
return DOW[d.getDay()];
}
export default function Wplan0020Page() {
const [workPlanYymm, setYymm] = useState(DEFAULT_YM);
const [filter, setFilter] = useState(DEFAULT_YM);
const { data: planList = [], isLoading } = useQuery({
queryKey: ['myWplan', filter],
queryFn: () => getMyWplanList(filter),
});
const { data: workCodes = [] } = useQuery({
queryKey: ['workCodes'],
queryFn: getWorkCodeList,
});
const codeMap = useMemo(() => {
const m: Record<string, string> = {};
workCodes.forEach((wc) => { m[wc.workCd] = wc.workCdTitleNm; });
return m;
}, [workCodes]);
// 달력 빌드
const calendarData = useMemo(() => {
const planMap: Record<string, MyWplanItem> = {};
planList.forEach((p) => { planMap[p.workPlanYymmdd] = p; });
const y = parseInt(filter.slice(0, 4));
const m = parseInt(filter.slice(4, 6));
const totalDays = new Date(y, m, 0).getDate();
const firstDow = new Date(y, m - 1, 1).getDay();
const weeks: (string | null)[][] = [];
let week: (string | null)[] = Array(firstDow).fill(null);
for (let d = 1; d <= totalDays; d++) {
const dd = String(d).padStart(2, '0');
week.push(`${filter}${dd}`);
if (week.length === 7) { weeks.push(week); week = []; }
}
if (week.length > 0) {
while (week.length < 7) week.push(null);
weeks.push(week);
}
return { weeks, planMap };
}, [filter, planList]);
return (
<div className="p-4 max-w-3xl">
<h2 className="text-lg font-semibold mb-4"> </h2>
{/* 년월 선택 */}
<div className="flex gap-2 mb-4 items-center">
<input type="month"
value={`${workPlanYymm.slice(0, 4)}-${workPlanYymm.slice(4, 6)}`}
onChange={(e) => setYymm(e.target.value.replace('-', ''))}
className="border rounded px-2 py-1.5 text-sm"
/>
<button onClick={() => setFilter(workPlanYymm)}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</button>
</div>
{/* 달력 */}
<table className="w-full border-collapse text-sm">
<thead>
<tr>
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
<th key={d} className={`border px-2 py-2 text-center font-medium ${
i === 0 ? 'text-red-500' : i === 6 ? 'text-blue-500' : 'text-gray-700'
}`}>{d}</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={7} className="text-center py-8 text-gray-400"> ...</td></tr>
) : (
calendarData.weeks.map((week, wi) => (
<tr key={wi}>
{week.map((yyyymmdd, di) => {
if (!yyyymmdd) return <td key={di} className="border p-2 bg-gray-50" />;
const plan = calendarData.planMap[yyyymmdd];
const day = parseInt(yyyymmdd.slice(-2));
const isWeekend = di === 0 || di === 6;
const workCd = plan?.workCd || plan?.planWorkCd;
return (
<td key={di} className={`border p-2 min-h-16 align-top ${
isWeekend ? 'bg-gray-50' : ''
}`}>
<div className={`text-xs mb-1 font-medium ${
di === 0 ? 'text-red-500' : di === 6 ? 'text-blue-500' : 'text-gray-700'
}`}>{day}</div>
{workCd && (
<div className="text-xs px-1 py-0.5 bg-blue-100 text-blue-700 rounded truncate">
{workCd}
<div className="text-gray-500 truncate">{codeMap[workCd] ?? ''}</div>
</div>
)}
</td>
);
})}
</tr>
))
)}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,237 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getAllWplanList, getWorkCodeList, AllWplanItem } from '@/lib/api/wplan';
const now = new Date();
const DEFAULT_YM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
// ─── 날짜 계산 ───────────────────────────────────────────
type DayCell = { date: number; dow: number; yymmdd: string };
function buildCalendar(yymm: string): DayCell[] {
const y = parseInt(yymm.slice(0, 4));
const m = parseInt(yymm.slice(4, 6)) - 1;
const total = new Date(y, m + 1, 0).getDate();
return Array.from({ length: total }, (_, i) => {
const d = i + 1;
return {
date: d,
dow: new Date(y, m, d).getDay(),
yymmdd: `${y}${String(m + 1).padStart(2, '0')}${String(d).padStart(2, '0')}`,
};
});
}
// ─── 데이터 그룹핑 ────────────────────────────────────────
type WorkGroup = { workCd: string; label: string; names: string[] };
type DayMap = Map<string, WorkGroup[]>;
function buildDayMap(list: AllWplanItem[], workCdType: string): DayMap {
// Map<yymmdd, Map<workCd, WorkGroup>>
const tmp = new Map<string, Map<string, WorkGroup>>();
list.forEach((item) => {
const day = item.workPlanYymmdd ?? '';
const cd = workCdType === 'PLAN_WORK_CD'
? (item.planWorkCd || '')
: (item.workCd || '');
const s = item.planGotoworkTm || '';
const e = item.planGetoffworkTm || '';
const label = `${cd}(${s}~${e})`;
if (!tmp.has(day)) tmp.set(day, new Map());
const dayGroups = tmp.get(day)!;
if (!dayGroups.has(cd)) {
dayGroups.set(cd, { workCd: cd, label, names: [] });
}
dayGroups.get(cd)!.names.push(item.usrNm);
});
const result: DayMap = new Map();
tmp.forEach((groups, day) => {
result.set(day, Array.from(groups.values()));
});
return result;
}
// ─── 주별 배열 빌드 ────────────────────────────────────────
function buildWeeks(cells: DayCell[]): (DayCell | null)[][] {
if (cells.length === 0) return [];
const weeks: (DayCell | null)[][] = [];
let week: (DayCell | null)[] = Array(cells[0].dow).fill(null);
cells.forEach((cell) => {
week.push(cell);
if (week.length === 7) { weeks.push(week); week = []; }
});
if (week.length > 0) {
while (week.length < 7) week.push(null);
weeks.push(week);
}
return weeks;
}
// ─── 페이지 ───────────────────────────────────────────────
export default function Wplan0030Page() {
const [yymm, setYymm] = useState(DEFAULT_YM);
const [workCdType, setType] = useState('PLAN_WORK_CD');
const [teamCd, setTeamCd] = useState('');
const [dutyCd, setDutyCd] = useState('');
const [workCdFlt, setWorkCdFlt] = useState('');
const [filter, setFilter] = useState({
workPlanYymm: DEFAULT_YM, workCdType: 'PLAN_WORK_CD',
teamCd: '', dutyCd: '', workCd: '',
});
const { data: list = [], isLoading } = useQuery({
queryKey: ['allWplan', filter],
queryFn: () => getAllWplanList({
workPlanYymm: filter.workPlanYymm,
teamCd: filter.teamCd || undefined,
dutyCd: filter.dutyCd || undefined,
workCd: filter.workCd || undefined,
workCdType: filter.workCdType,
}),
});
const { data: workCodes = [] } = useQuery({
queryKey: ['workCodes'],
queryFn: getWorkCodeList,
});
const handleSearch = () => {
setFilter({ workPlanYymm: yymm, workCdType, teamCd, dutyCd, workCd: workCdFlt });
};
const cells = buildCalendar(filter.workPlanYymm);
const weeks = buildWeeks(cells);
const dayMap = buildDayMap(list, filter.workCdType);
const yyyyStr = filter.workPlanYymm.slice(0, 4);
const mmStr = filter.workPlanYymm.slice(4, 6);
const DOW_LABELS = ['일', '월', '화', '수', '목', '금', '토'];
return (
<div className="p-4">
<h2 className="text-lg font-semibold mb-3"></h2>
{/* 검색 */}
<div className="flex flex-wrap gap-2 mb-4 items-center">
<select
value={yymm.slice(0, 4)}
onChange={(e) => setYymm(e.target.value + yymm.slice(4))}
className="border rounded px-2 py-1.5 text-sm"
>
{[2024, 2025, 2026, 2027].map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
<select
value={yymm.slice(4)}
onChange={(e) => setYymm(yymm.slice(0, 4) + e.target.value)}
className="border rounded px-2 py-1.5 text-sm"
>
{Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0')).map((m) => (
<option key={m} value={m}>{parseInt(m)}</option>
))}
</select>
<select value={workCdType} onChange={(e) => setType(e.target.value)}
className="border rounded px-2 py-1.5 text-sm"
>
<option value="PLAN_WORK_CD"></option>
<option value="WORK_CD"></option>
</select>
<input type="text" value={teamCd} placeholder="팀 코드"
onChange={(e) => setTeamCd(e.target.value)}
className="border rounded px-2 py-1.5 text-sm w-24"
/>
<input type="text" value={dutyCd} placeholder="직책 코드"
onChange={(e) => setDutyCd(e.target.value)}
className="border rounded px-2 py-1.5 text-sm w-24"
/>
<select value={workCdFlt} onChange={(e) => setWorkCdFlt(e.target.value)}
className="border rounded px-2 py-1.5 text-sm"
>
<option value="">-- --</option>
{workCodes.map((wc) => (
<option key={wc.workCd} value={wc.workCd}>
{wc.workCd} - {wc.workCdTitleNm}
</option>
))}
</select>
<button onClick={handleSearch}
className="px-4 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700"
>
Search
</button>
</div>
{/* 캘린더 */}
{isLoading ? (
<div className="text-center py-12 text-gray-400"> ...</div>
) : (
<>
<div className="text-sm font-semibold text-orange-600 mb-2">
&gt;
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr>
{DOW_LABELS.map((d, i) => (
<th key={d}
className={`border px-1 py-1.5 text-center font-semibold w-[14.28%] ${
i === 0 ? 'text-red-600' : i === 6 ? 'text-blue-600' : ''
}`}
>
{d}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week, wi) => (
<tr key={wi} className="align-top">
{week.map((cell, di) => {
if (!cell) {
return <td key={di} className="border bg-gray-50" />;
}
const groups = dayMap.get(cell.yymmdd) ?? [];
const isSun = cell.dow === 0;
const isSat = cell.dow === 6;
return (
<td key={di} className="border px-1 py-1 align-top min-h-[80px]">
{/* 날짜 숫자 */}
<div className={`font-bold mb-0.5 ${
isSun ? 'text-red-600' : isSat ? 'text-blue-600' : ''
}`}>
{cell.date}
</div>
{/* 근무코드 그룹 */}
{groups.map((g) => (
<div key={g.workCd} className="mb-1">
<div className="text-gray-600 font-medium leading-tight">
{g.label}
</div>
<div className="text-gray-800 leading-tight">
{g.names.join(' ')}
</div>
</div>
))}
</td>
);
})}
</tr>
))}
</tbody>
</table>
<div className="mt-2 text-xs text-gray-500 text-right">
{yyyyStr} {parseInt(mmStr)} &nbsp;|&nbsp; {list.length}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getWtimeList, minToHhmm } from '@/lib/api/wtime';
import { getExcelUrl } from '@/lib/api/attach';
const now = new Date();
const Y = now.getFullYear();
const M = String(now.getMonth() + 1).padStart(2, '0');
const D = String(now.getDate()).padStart(2, '0');
const TODAY = `${Y}${M}${D}`;
const MON_START = `${Y}${M}01`;
export default function Wtime0010Page() {
const [staYmd, setStaYmd] = useState(MON_START);
const [endYmd, setEndYmd] = useState(TODAY);
const [usrNm, setUsrNm] = useState('');
const [teamCd, setTeamCd] = useState('');
const [incRetire, setIncRetire] = useState('N');
const [incWorkOnly, setIncWorkOnly] = useState('');
const [filter, setFilter] = useState({
staYmd: MON_START, endYmd: TODAY, usrNm: '', teamCd: '',
includeRetireYn: 'N', includeWorkYn: '',
});
const { data: list = [], isLoading } = useQuery({
queryKey: ['wtime0010', filter],
queryFn: () => getWtimeList(filter),
});
const handleSearch = () => {
setFilter({ staYmd, endYmd, usrNm, teamCd, includeRetireYn: incRetire, includeWorkYn: incWorkOnly });
};
const fmtDate = (s: string) => s?.slice(0, 16) ?? '';
return (
<div className="p-4">
<h2 className="text-lg font-semibold mb-4"> </h2>
{/* 검색 */}
<div className="flex flex-wrap gap-2 mb-4 items-center">
<input type="date" value={staYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
onChange={(e) => setStaYmd(e.target.value.replace(/-/g, ''))}
className="border rounded px-2 py-1.5 text-sm"
/>
<span className="text-sm">~</span>
<input type="date" value={endYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
onChange={(e) => setEndYmd(e.target.value.replace(/-/g, ''))}
className="border rounded px-2 py-1.5 text-sm"
/>
<input type="text" value={usrNm} placeholder="직원명"
onChange={(e) => setUsrNm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="border rounded px-2 py-1.5 text-sm w-28"
/>
<input type="text" value={teamCd} placeholder="부서코드"
onChange={(e) => setTeamCd(e.target.value)}
className="border rounded px-2 py-1.5 text-sm w-28"
/>
<label className="flex items-center gap-1 text-sm cursor-pointer">
<input type="checkbox" checked={incRetire === 'Y'}
onChange={(e) => setIncRetire(e.target.checked ? 'Y' : 'N')}
/>
</label>
<label className="flex items-center gap-1 text-sm cursor-pointer">
<input type="checkbox" checked={incWorkOnly === 'Y'}
onChange={(e) => setIncWorkOnly(e.target.checked ? 'Y' : '')}
/>
</label>
<button onClick={handleSearch}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</button>
<a
href={getExcelUrl('wtime', { ...filter })}
className="px-4 py-1.5 bg-green-600 text-white text-sm rounded hover:bg-green-700"
download
>
Excel
</a>
</div>
{/* 테이블 */}
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse whitespace-nowrap">
<thead>
<tr className="bg-gray-100 text-center">
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2">OT</th>
<th className="border px-2 py-2">(h)</th>
<th className="border px-2 py-2">(h)</th>
<th className="border px-2 py-2">OT(h)</th>
<th className="border px-2 py-2">(h)</th>
<th className="border px-2 py-2">(h)</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={12} className="text-center py-8 text-gray-400"> ...</td></tr>
) : list.length === 0 ? (
<tr><td colSpan={12} className="text-center py-8 text-gray-400"> .</td></tr>
) : (
list.map((row, i) => {
const isAbti = row.abtiYn === 'Y' || (row.ABTI_YN === 'Y');
return (
<tr key={i} className={`hover:bg-gray-50 text-center ${isAbti ? 'bg-red-50' : ''}`}>
<td className="border px-2 py-1.5 text-left">{row.usrNm ?? row.USR_NM}</td>
<td className="border px-2 py-1.5">
{(row.workPlanYymmdd ?? row.WORK_PLAN_YYMMDD ?? '').replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
</td>
<td className="border px-2 py-1.5">{row.realWorkCd ?? row.REAL_WORK_CD}</td>
<td className="border px-2 py-1.5">{fmtDate(row.workStartDt ?? row.WORK_START_DT ?? '')}</td>
<td className="border px-2 py-1.5">{fmtDate(row.workEndDt ?? row.WORK_END_DT ?? '')}</td>
<td className="border px-2 py-1.5">{isAbti ? '●' : ''}</td>
<td className="border px-2 py-1.5">{(row.otRctnYn ?? row.OT_RCTN_YN) === 'Y' ? '✓' : ''}</td>
<td className="border px-2 py-1.5">{minToHhmm(row.totWorkMin ?? row.TOT_WORK_MIN ?? 0)}</td>
<td className="border px-2 py-1.5">{minToHhmm(row.lateMin ?? row.LATE_MIN ?? 0)}</td>
<td className="border px-2 py-1.5">{minToHhmm(row.otWorkMin ?? row.OT_WORK_MIN ?? 0)}</td>
<td className="border px-2 py-1.5">{minToHhmm(row.ngtOtMin ?? row.NGT_OT_MIN ?? 0)}</td>
<td className="border px-2 py-1.5">{minToHhmm(row.outingMin ?? row.OUTING_MIN ?? 0)}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="mt-2 text-sm text-gray-500 text-right"> {list.length}</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getWstatList, minToHhmm } from '@/lib/api/wtime';
import { getExcelUrl } from '@/lib/api/attach';
const now = new Date();
const Y = now.getFullYear();
const M = String(now.getMonth() + 1).padStart(2, '0');
const TODAY = `${Y}${M}${String(now.getDate()).padStart(2, '0')}`;
const MON_START = `${Y}${M}01`;
export default function Wtime0030Page() {
const [staYmd, setStaYmd] = useState(MON_START);
const [endYmd, setEndYmd] = useState(TODAY);
const [usrNm, setUsrNm] = useState('');
const [teamCd, setTeamCd] = useState('');
const [incRetire, setIncRetire] = useState('N');
const [filter, setFilter] = useState({
staYmd: MON_START, endYmd: TODAY, usrNm: '', teamCd: '', includeRetireYn: 'N',
});
const { data: list = [], isLoading } = useQuery({
queryKey: ['wstat', filter],
queryFn: () => getWstatList(filter),
});
const handleSearch = () => {
setFilter({ staYmd, endYmd, usrNm, teamCd, includeRetireYn: incRetire });
};
return (
<div className="p-4">
<h2 className="text-lg font-semibold mb-4"> </h2>
{/* 검색 */}
<div className="flex flex-wrap gap-2 mb-4 items-center">
<input type="date" value={staYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
onChange={(e) => setStaYmd(e.target.value.replace(/-/g, ''))}
className="border rounded px-2 py-1.5 text-sm"
/>
<span className="text-sm">~</span>
<input type="date" value={endYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
onChange={(e) => setEndYmd(e.target.value.replace(/-/g, ''))}
className="border rounded px-2 py-1.5 text-sm"
/>
<input type="text" value={usrNm} placeholder="직원명"
onChange={(e) => setUsrNm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="border rounded px-2 py-1.5 text-sm w-28"
/>
<input type="text" value={teamCd} placeholder="부서코드"
onChange={(e) => setTeamCd(e.target.value)}
className="border rounded px-2 py-1.5 text-sm w-28"
/>
<label className="flex items-center gap-1 text-sm cursor-pointer">
<input type="checkbox" checked={incRetire === 'Y'}
onChange={(e) => setIncRetire(e.target.checked ? 'Y' : 'N')}
/>
</label>
<button onClick={handleSearch}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</button>
<a
href={getExcelUrl('wstat', { ...filter })}
className="px-4 py-1.5 bg-green-600 text-white text-sm rounded hover:bg-green-700"
download
>
Excel
</a>
</div>
{/* 테이블 */}
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse whitespace-nowrap">
<thead>
<tr className="bg-gray-100 text-center">
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2">()</th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2">(h)</th>
<th className="border px-2 py-2">(h)</th>
<th className="border px-2 py-2">OT(h)</th>
<th className="border px-2 py-2">(h)</th>
<th className="border px-2 py-2">()</th>
<th className="border px-2 py-2">(h)</th>
<th className="border px-2 py-2"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={16} className="text-center py-8 text-gray-400"> ...</td></tr>
) : list.length === 0 ? (
<tr><td colSpan={16} className="text-center py-8 text-gray-400"> .</td></tr>
) : (
list.map((row, i) => (
<tr key={i} className="hover:bg-gray-50 text-center">
<td className="border px-2 py-1.5 text-left">{row.usrNm ?? row.USR_NM}</td>
<td className="border px-2 py-1.5">{row.teamCd ?? row.TEAM_CD}</td>
<td className="border px-2 py-1.5">{row.dutyCd ?? row.DUTY_CD}</td>
<td className="border px-2 py-1.5">{row.workDcnt ?? row.WORK_DCNT ?? 0}</td>
<td className="border px-2 py-1.5">{row.abtiDcnt ?? row.ABTI_DCNT ?? 0}</td>
<td className="border px-2 py-1.5">{row.yyctDeduDcnt ?? row.YYCT_DEDU_DCNT ?? 0}</td>
<td className="border px-2 py-1.5">{row.lateDcnt ?? row.LATE_DCNT ?? 0}</td>
<td className="border px-2 py-1.5">{row.skipoffDcnt ?? row.SKIPOFF_DCNT ?? 0}</td>
<td className="border px-2 py-1.5">{row.sickleaveDcnt ?? row.SICKLEAVE_DCNT ?? 0}</td>
<td className="border px-2 py-1.5">{minToHhmm(row.totWorkMin ?? row.TOT_WORK_MIN ?? 0)}</td>
<td className="border px-2 py-1.5">{minToHhmm(row.lateMin ?? row.LATE_MIN ?? 0)}</td>
<td className="border px-2 py-1.5">{minToHhmm(row.otWorkMin ?? row.OT_WORK_MIN ?? 0)}</td>
<td className="border px-2 py-1.5">{minToHhmm(row.ngtOtMin ?? row.NGT_OT_MIN ?? 0)}</td>
<td className="border px-2 py-1.5">{row.outingDcnt ?? row.OUTING_DCNT ?? 0}</td>
<td className="border px-2 py-1.5">{minToHhmm(row.outingMin ?? row.OUTING_MIN ?? 0)}</td>
<td className="border px-2 py-1.5">{row.yyvctCnt ?? row.YYVCT_CNT ?? 0}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="mt-2 text-sm text-gray-500 text-right"> {list.length}</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import { useEffect } from 'react';
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-800 mb-2"> </h1>
<p className="text-gray-500 mb-6">
{error.message || '예기치 않은 오류가 발생했습니다.'}
</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Board / rich-text content rendering */
.board-content h1 { font-size: 1.5rem; font-weight: 700; margin: 0.75rem 0; }
.board-content h2 { font-size: 1.25rem; font-weight: 600; margin: 0.75rem 0; }
.board-content h3 { font-size: 1.1rem; font-weight: 600; margin: 0.5rem 0; }
.board-content p { margin: 0.5rem 0; }
.board-content ul { list-style: disc; padding-left: 1.5rem; margin: 0.5rem 0; }
.board-content ol { list-style: decimal; padding-left: 1.5rem; margin: 0.5rem 0; }
.board-content blockquote { border-left: 3px solid #d1d5db; padding-left: 0.75rem; color: #6b7280; margin: 0.5rem 0; }
.board-content a { color: #2563eb; text-decoration: underline; }
.board-content img { max-width: 100%; height: auto; }
.board-content table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
.board-content th, .board-content td { border: 1px solid #d1d5db; padding: 0.4rem 0.75rem; }
.board-content th { background: #f9fafb; font-weight: 600; }
.board-content code { background: #f3f4f6; padding: 0.1rem 0.3rem; border-radius: 3px; font-family: monospace; font-size: 0.85em; }
.board-content pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 6px; overflow-x: auto; margin: 0.5rem 0; }

View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next'
import './globals.css'
import { Providers } from '@/components/common/Providers'
export const metadata: Metadata = {
title: '그룹웨어',
description: '사내 그룹웨어 시스템',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body suppressHydrationWarning>
<Providers>{children}</Providers>
</body>
</html>
)
}

View File

@@ -0,0 +1,17 @@
import { LoginForm } from '@/components/auth/LoginForm'
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-xl shadow-md w-full max-w-sm">
{/* 로고 / 타이틀 */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1"> </p>
</div>
<LoginForm />
</div>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import Link from 'next/link';
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-gray-300 mb-4">404</h1>
<h2 className="text-xl font-semibold text-gray-700 mb-2"> </h2>
<p className="text-gray-500 mb-6"> .</p>
<Link
href="/"
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function RootPage() {
redirect('/dashboard')
}

View File

@@ -0,0 +1,167 @@
'use client'
import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useRouter } from 'next/navigation'
import { authApi } from '@/lib/api/auth'
import { useAuthStore } from '@/lib/store/authStore'
import { ROLES } from '@/types/auth'
const SAVE_ID_KEY = 'gw_save_id'
const SAVED_ID_KEY = 'gw_saved_login_id'
const schema = z.object({
loginId: z.string().min(1, '로그인 아이디를 입력하세요.'),
loginPw: z.string().min(1, '비밀번호를 입력하세요.'),
menuAuthCd: z.string().min(1, '권한을 선택하세요.'),
})
type FormValues = z.infer<typeof schema>
// 기존 시스템과 동일한 권한 목록 (BizConstants.groovy 기준, 기본값: BIZM)
const ROLE_OPTIONS = [
{ value: ROLES.USER, label: '일반사용자' },
{ value: ROLES.BIZM, label: '업무관리자' },
{ value: ROLES.MNGR, label: '시스템관리자' },
{ value: ROLES.FEDEX, label: 'FedEx 사용자' },
]
export function LoginForm() {
const router = useRouter()
const setAuth = useAuthStore((s) => s.setAuth)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [saveId, setSaveId] = useState(false)
const { register, handleSubmit, setValue, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { menuAuthCd: ROLES.USER },
})
// 저장된 아이디 불러오기
useEffect(() => {
const savedFlag = localStorage.getItem(SAVE_ID_KEY)
const savedId = localStorage.getItem(SAVED_ID_KEY)
if (savedFlag === 'Y' && savedId) {
setValue('loginId', savedId)
setSaveId(true)
}
}, [setValue])
const onSubmit = async (data: FormValues) => {
setError(null)
setLoading(true)
// 아이디 저장 처리
if (saveId) {
localStorage.setItem(SAVE_ID_KEY, 'Y')
localStorage.setItem(SAVED_ID_KEY, data.loginId)
} else {
localStorage.removeItem(SAVE_ID_KEY)
localStorage.removeItem(SAVED_ID_KEY)
}
try {
const res = await authApi.login(data)
const { accessToken, refreshToken, userInfo, menuList } = res.data.data
setAuth(userInfo, accessToken, refreshToken, menuList)
// 미들웨어(서버사이드)가 읽을 수 있도록 쿠키에도 저장
document.cookie = `accessToken=${accessToken}; path=/; SameSite=Lax`
router.replace('/dashboard')
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
'로그인 중 오류가 발생했습니다.'
setError(message)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* 에러 메시지 */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded">
{error}
</div>
)}
{/* 아이디 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
{...register('loginId')}
type="text"
autoComplete="username"
placeholder="로그인 아이디"
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
{errors.loginId && (
<p className="text-red-500 text-xs mt-1">{errors.loginId.message}</p>
)}
</div>
{/* 비밀번호 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
{...register('loginPw')}
type="password"
autoComplete="current-password"
placeholder="비밀번호"
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
{errors.loginPw && (
<p className="text-red-500 text-xs mt-1">{errors.loginPw.message}</p>
)}
</div>
{/* 권한 선택 (기존 MENU_AUTH_CD) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
{...register('menuAuthCd')}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white"
>
{ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{errors.menuAuthCd && (
<p className="text-red-500 text-xs mt-1">{errors.menuAuthCd.message}</p>
)}
</div>
{/* 아이디 저장 */}
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer select-none">
<input
type="checkbox"
checked={saveId}
onChange={(e) => setSaveId(e.target.checked)}
className="rounded"
/>
</label>
{/* 로그인 버튼 */}
<button
type="submit"
disabled={loading}
className="w-full bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 rounded-md text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '로그인 중...' : '로그인'}
</button>
</form>
)
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { PostDetail, PostSaveRequest, insertPost, updatePost } from '@/lib/api/board';
import FileUpload from '@/components/common/FileUpload';
import RichEditor, { RichEditorRef } from '@/components/common/RichEditor';
import { AttachFileInfo } from '@/lib/api/attach';
interface Props {
boardType: string;
initialData?: PostDetail;
}
export default function PostForm({ boardType, initialData }: Props) {
const router = useRouter();
const qc = useQueryClient();
const isEdit = !!initialData;
const editorRef = useRef<RichEditorRef>(null);
const [title, setTitle] = useState(initialData?.bbsTitleNm ?? '');
const [content, setContent] = useState(initialData?.bbsCn ?? '');
const [etcAtchNo, setEtcAtchNo] = useState<string | undefined>(initialData?.etcAtchNo);
const [submitting, setSubmitting] = useState(false);
const handleFileChange = (atchNo: string, _files: AttachFileInfo[]) => {
setEtcAtchNo(atchNo);
};
const handleSubmit = async () => {
if (!title.trim()) { alert('제목을 입력하세요.'); return; }
// ref 우선, state fallback (dynamic import 환경에서 ref가 늦게 연결될 수 있음)
const html = editorRef.current?.getHTML() || content;
if (!html || html === '<p><br></p>' || html.trim() === '') { alert('내용을 입력하세요.'); return; }
const data: PostSaveRequest = { bbsTitleNm: title, bbsCn: html, etcAtchNo };
setSubmitting(true);
try {
if (isEdit && initialData) {
await updatePost(boardType, initialData.untyBbsSno, data);
qc.invalidateQueries({ queryKey: ['board', boardType] });
qc.invalidateQueries({ queryKey: ['board-detail', boardType, initialData.untyBbsSno] });
qc.invalidateQueries({ queryKey: ['board-edit', boardType, String(initialData.untyBbsSno)] });
router.push(`/board/${boardType}/${initialData.untyBbsSno}`);
} else {
const res = await insertPost(boardType, data);
qc.invalidateQueries({ queryKey: ['board', boardType] });
router.push(`/board/${boardType}/${res.untyBbsSno}`);
}
} catch (e: any) {
alert(e?.response?.data?.message ?? '저장 중 오류가 발생했습니다.');
} finally {
setSubmitting(false);
}
};
return (
<div className="p-4 max-w-4xl space-y-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full border rounded px-3 py-2 text-sm"
placeholder="제목을 입력하세요"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<RichEditor
ref={editorRef}
initialValue={initialData?.bbsCn ?? ''}
height="400px"
onChange={setContent}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<FileUpload
atchNo={etcAtchNo}
division={`board_${boardType}`}
initialFiles={initialData?.etcFileList ?? []}
onChange={handleFileChange}
/>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => router.back()}
className="px-4 py-2 border text-sm rounded hover:bg-gray-100"
>
</button>
<button
onClick={handleSubmit}
disabled={submitting}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? '저장 중...' : isEdit ? '수정' : '등록'}
</button>
</div>
</div>
);
}

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

View File

@@ -0,0 +1,47 @@
'use client'
import { useAuthStore } from '@/lib/store/authStore'
import { buildTree, type TreeNode } from './Sidebar'
function findTrail(nodes: TreeNode[], targetMenuNo: string | null): TreeNode[] {
if (!targetMenuNo) return []
for (const node of nodes) {
if (node.menuNo === targetMenuNo) return [node]
const childTrail = findTrail(node.children, targetMenuNo)
if (childTrail.length > 0) return [node, ...childTrail]
}
return []
}
export function Breadcrumb() {
const menuList = useAuthStore((s) => s.menuList)
const selectedMenuNo = useAuthStore((s) => s.selectedMenuNo)
const tree = buildTree(menuList)
const trail = findTrail(tree, selectedMenuNo)
if (trail.length === 0) return null
const current = trail[trail.length - 1]
return (
<div className="bg-white border-b px-6 py-2.5 flex items-center gap-3 shrink-0">
<h1 className="text-sm font-semibold text-gray-800">{current.menuNm}</h1>
{trail.length > 1 && (
<>
<span className="text-gray-200">|</span>
<nav className="flex items-center gap-1 text-xs text-gray-400">
{trail.map((item, i) => (
<span key={item.menuNo} className="flex items-center gap-1">
{i > 0 && <span className="text-gray-300"></span>}
<span className={i === trail.length - 1 ? 'text-blue-600 font-medium' : ''}>
{item.menuNm}
</span>
</span>
))}
</nav>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useAuthStore } from '@/lib/store/authStore'
import { authApi } from '@/lib/api/auth'
export function Header() {
const router = useRouter()
const { user, clearAuth } = useAuthStore()
const handleLogout = async () => {
try {
await authApi.logout()
} catch {
// 실패해도 로컬 상태는 초기화
} finally {
clearAuth()
document.cookie = 'accessToken=; path=/; max-age=0; SameSite=Lax'
router.replace('/login')
}
}
return (
<header className="h-16 bg-white border-b flex items-center justify-between px-6 shrink-0">
<div />
<div className="flex items-center gap-4">
{user && (
<span className="text-sm text-gray-600">
{user.usrNm}
<span className="ml-2 text-xs text-gray-400">({user.roleCode})</span>
</span>
)}
<Link
href="/my/change-pw"
className="text-sm text-gray-500 hover:text-gray-800 transition-colors"
>
</Link>
<button
onClick={handleLogout}
className="text-sm text-gray-500 hover:text-gray-800 transition-colors"
>
</button>
</div>
</header>
)
}

View File

@@ -0,0 +1,204 @@
'use client'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { clsx } from 'clsx'
import { useAuthStore } from '@/lib/store/authStore'
import type { MenuDto } from '@/types/auth'
/** DB 구 Grails URL → 실제 Next.js 경로 (next.config.ts redirects와 동일 규칙) */
export function resolveFinalPath(url: string | null | undefined): string {
if (!url || !url.trim()) return ''
const u = url.trim()
const p = u.startsWith('/') ? u : `/${u}`
if (/^\/board_(\w+)$/.test(p)) return `/board/${p.replace('/board_', '')}`
// brunch 패턴: D=문서결재신청, DD=문서결재내역, 나머지는 해당 타입 신청폼
if (p === '/tam0020/brunch/D') return '/tam/0020' // 문서결재신청
if (p === '/tam0020/brunch/DD') return '/tam/0020/list' // 문서결재내역
if (p === '/tam0020/brunch/A') return '/tam/0020?kind=A' // 지각계
if (p === '/tam0020/brunch/B') return '/tam/0020?kind=B' // 조퇴계
if (p === '/tam0020/brunch/C') return '/tam/0020?kind=C' // 외출계
if (p === '/tam0020/brunch/E') return '/tam/0020?kind=E' // 연차등신청
if (p === '/tam0020/brunch/F') return '/tam/0020?kind=F' // 근무변경신청
if (p === '/tam0020/brunch/G') return '/tam/0020?kind=G' // 출퇴근미기록
if (p === '/tam0020/brunch/H') return '/tam/0020?kind=H' // 시간외근무신청
if (p === '/tam0020/brunch/HH') return '/tam/0020?kind=HH' // 시간외근무상세
if (p === '/tam0020/brunch/ZZ') return '/tam/0020?kind=ZZ' // 출퇴근상세내역
if (p === '/tam0020/apvdoc_add') return '/tam/0020'
if (p.startsWith('/tam0020')) return '/tam/0020/list'
if (p === '/tam0010') return '/tam/0010'
if (p === '/tam0030') return '/tam/0030'
if (p === '/tam0040') return '/tam/0040'
if (p === '/wplan0010') return '/wplan/0010'
if (p === '/wplan0020') return '/wplan/0020'
if (p === '/wplan0030') return '/wplan/0030'
if (p === '/wtime0010') return '/wtime/0010'
if (p === '/wtime0030') return '/wtime/0030'
if (p === '/envset0010') return '/envset/users'
if (p === '/envset0020') return '/envset/codes'
if (p.startsWith('/envset0030')) return '/envset/codes-view'
if (p === '/envset0040') return '/envset/workcd'
if (p === '/envset0050') return '/envset/menus'
if (p === '/main0020') return '/my/change-pw'
if (p === '/fedex0010') return '/fedex/0010'
return p
}
export interface TreeNode extends MenuDto {
children: TreeNode[]
}
export function buildTree(list: MenuDto[]): TreeNode[] {
const map = new Map<string, TreeNode>()
list.forEach((m) => map.set(m.menuNo, { ...m, children: [] }))
const roots: TreeNode[] = []
map.forEach((node) => {
if (node.upperMenuNo && map.has(node.upperMenuNo)) {
map.get(node.upperMenuNo)!.children.push(node)
} else {
roots.push(node)
}
})
const sort = (nodes: TreeNode[]) => {
nodes.sort((a, b) => a.menuOrdr - b.menuOrdr)
nodes.forEach((n) => sort(n.children))
}
sort(roots)
return roots
}
/** 선택된 menuNo가 이 노드의 자손인지 확인 */
function hasSelectedDescendant(node: TreeNode, selectedMenuNo: string | null): boolean {
if (!selectedMenuNo) return false
return node.children.some(
(c) => c.menuNo === selectedMenuNo || hasSelectedDescendant(c, selectedMenuNo)
)
}
function MenuNode({ node, depth = 0 }: { node: TreeNode; depth?: number }) {
const selectedMenuNo = useAuthStore((s) => s.selectedMenuNo)
const setSelectedMenuNo = useAuthStore((s) => s.setSelectedMenuNo)
const href = resolveFinalPath(node.url)
const hasChildren = node.children.length > 0
const isLeaf = !hasChildren && !!href
const isActive = isLeaf && node.menuNo === selectedMenuNo
const isAncestorActive = hasChildren && hasSelectedDescendant(node, selectedMenuNo)
// 서버/클라이언트 hydration 불일치 방지: 초기값은 항상 false, mount 후 localStorage 기반으로 설정
const [open, setOpen] = useState(false)
useEffect(() => {
setOpen(hasSelectedDescendant(node, selectedMenuNo))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const indent = depth === 0 ? 'px-3' : depth === 1 ? 'px-5' : 'px-7'
/* ── 리프 메뉴 (실제 페이지 링크) ── */
if (isLeaf) {
return (
<Link
href={href}
onClick={() => setSelectedMenuNo(node.menuNo)}
className={clsx(
'flex items-center gap-1.5 py-1.5 text-sm rounded transition-colors mx-1',
indent,
isActive
? 'bg-blue-600 text-white font-medium'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
)}
>
<span className={clsx('text-xs', isActive ? 'text-blue-200' : 'text-gray-300')}></span>
{node.menuNm}
</Link>
)
}
/* ── 그룹 메뉴 (폴더) ── */
if (hasChildren) {
// depth=0 : 항상 펼침, 클릭 토글 없음
if (depth === 0) {
return (
<div className="mb-1">
<div className={clsx(
'flex items-center py-1.5 text-xs font-bold uppercase tracking-wider mx-1',
indent,
isAncestorActive ? 'text-blue-600' : 'text-gray-400'
)}>
{node.menuNm}
</div>
<div>
{node.children.map((child) => (
<MenuNode key={child.menuNo} node={child} depth={depth + 1} />
))}
</div>
</div>
)
}
// depth>0 : 토글 가능
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className={clsx(
'w-full flex items-center justify-between py-1.5 text-sm rounded transition-colors mx-1',
indent,
'hover:bg-gray-100',
isAncestorActive ? 'text-blue-500 font-medium' : 'text-gray-500'
)}
>
<span>{node.menuNm}</span>
<span className="mr-2 text-[10px] text-gray-400">{open ? '▾' : '▸'}</span>
</button>
{open && (
<div>
{node.children.map((child) => (
<MenuNode key={child.menuNo} node={child} depth={depth + 1} />
))}
</div>
)}
</div>
)
}
/* ── URL·자식 없는 비활성 카테고리 ── */
return (
<span className={clsx('flex items-center py-1.5 text-xs text-gray-400 tracking-wide', indent)}>
{node.menuNm}
</span>
)
}
export function Sidebar() {
const menuList = useAuthStore((s) => s.menuList)
const setSelectedMenuNo = useAuthStore((s) => s.setSelectedMenuNo)
const tree = buildTree(menuList)
return (
<aside className="w-56 bg-white border-r flex flex-col shrink-0">
<div className="h-14 flex items-center justify-center border-b shrink-0">
<Link
href="/dashboard"
onClick={() => setSelectedMenuNo('')}
className="font-bold text-base text-blue-600 hover:text-blue-700 transition-colors"
>
</Link>
</div>
<nav className="flex-1 overflow-y-auto py-2 space-y-0.5">
{tree.length > 0 ? (
tree.map((node) => (
<MenuNode key={node.menuNo} node={node} depth={0} />
))
) : (
<div className="px-4 py-3 text-sm text-gray-400"> </div>
)}
</nav>
</aside>
)
}

View File

@@ -0,0 +1,207 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApprUserList, APRVL_STUS_LABEL, ApprInfo, ApprUserItem } from '@/lib/api/tam';
interface Props {
apprList: ApprInfo[];
editable: boolean;
onRequest: (apprList: Array<{ apprId: string }>) => void;
requesting: boolean;
}
export default function ApproverSection({ apprList, editable, onRequest, requesting }: Props) {
const [localApprList, setLocalApprList] = useState<ApprUserItem[]>(
apprList?.map((a) => ({ usrId: a.apprId, usrNm: a.apprNm ?? a.apprId, teamCd: '', dutyCd: '' })) ?? [],
);
const [keyword, setKeyword] = useState('');
const [open, setOpen] = useState(false);
const [submitted, setSubmitted] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const { data: apprUsers = [] } = useQuery({
queryKey: ['apprUsers'],
queryFn: getApprUserList,
enabled: editable,
});
// 키워드로 필터
const filtered = keyword.trim()
? apprUsers.filter((u) =>
u.usrNm.includes(keyword) || u.usrId.includes(keyword),
)
: apprUsers;
// 외부 클릭 시 드롭다운 닫기
useEffect(() => {
const handler = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const addApprover = (user: ApprUserItem) => {
if (localApprList.some((a) => a.usrId === user.usrId)) {
alert('이미 추가된 결재자입니다.');
return;
}
setLocalApprList((prev) => [...prev, user]);
setKeyword('');
setOpen(false);
};
const removeApprover = (idx: number) =>
setLocalApprList((prev) => prev.filter((_, i) => i !== idx));
const moveUp = (idx: number) => {
if (idx === 0) return;
setLocalApprList((prev) => {
const arr = [...prev];
[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
return arr;
});
};
const moveDown = (idx: number) =>
setLocalApprList((prev) => {
if (idx >= prev.length - 1) return prev;
const arr = [...prev];
[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]];
return arr;
});
return (
<div className="mt-4 border rounded p-3">
<h3 className="text-sm font-semibold mb-2"> </h3>
{/* ── 읽기 전용 ── */}
{!editable ? (
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 text-center">
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2"></th>
</tr>
</thead>
<tbody>
{apprList?.map((a) => (
<tr key={a.aprvlSno} className="text-center">
<td className="border px-3 py-2">{a.aprvlSno}</td>
<td className="border px-3 py-2">{a.apprNm} ({a.apprId})</td>
<td className="border px-3 py-2">
<span className={`px-2 py-0.5 rounded text-xs ${
a.apprStusCd === '0003' ? 'bg-green-100 text-green-700' :
a.apprStusCd === '0002' ? 'bg-blue-100 text-blue-700' :
a.apprStusCd === '0004' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
}`}>
{APRVL_STUS_LABEL[a.apprStusCd] ?? a.apprStusCd}
</span>
</td>
<td className="border px-3 py-2">{a.aprvlDt || '-'}</td>
<td className="border px-3 py-2 text-left">{a.apprCn || '-'}</td>
</tr>
))}
</tbody>
</table>
) : (
<>
{/* ── 결재자 검색 드롭다운 ── */}
<div ref={wrapRef} className="relative mb-3 w-72">
<input
type="text"
value={keyword}
placeholder="이름 또는 ID로 검색"
onChange={(e) => { setKeyword(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
className="w-full border rounded px-3 py-1.5 text-sm"
/>
{open && (
<ul className="absolute z-10 w-full bg-white border rounded shadow-lg max-h-52 overflow-y-auto mt-0.5">
{filtered.length === 0 ? (
<li className="px-3 py-2 text-xs text-gray-400"> </li>
) : (
filtered.map((u) => (
<li
key={u.usrId}
onMouseDown={() => addApprover(u)}
className="px-3 py-2 text-sm cursor-pointer hover:bg-blue-50 flex justify-between"
>
<span>{u.usrNm}</span>
<span className="text-xs text-gray-400">{u.usrId}</span>
</li>
))
)}
</ul>
)}
</div>
{/* ── 선택된 결재자 목록 ── */}
{localApprList.length > 0 && (
<table className="w-full text-sm mb-3">
<thead>
<tr className="bg-gray-50 text-center">
<th className="border px-3 py-2 w-12"></th>
<th className="border px-3 py-2"></th>
<th className="border px-3 py-2 w-28"></th>
<th className="border px-3 py-2 w-12"></th>
</tr>
</thead>
<tbody>
{localApprList.map((a, i) => (
<tr key={a.usrId} className="text-center">
<td className="border px-3 py-2">{i + 1}</td>
<td className="border px-3 py-2 text-left">
{a.usrNm} ({a.usrId})
</td>
<td className="border px-3 py-2">
<div className="flex justify-center gap-1">
<button onClick={() => moveUp(i)} disabled={i === 0}
className="px-1.5 py-0.5 text-xs border rounded hover:bg-gray-100 disabled:opacity-30"
></button>
<button onClick={() => moveDown(i)} disabled={i === localApprList.length - 1}
className="px-1.5 py-0.5 text-xs border rounded hover:bg-gray-100 disabled:opacity-30"
></button>
</div>
</td>
<td className="border px-3 py-2">
<button onClick={() => removeApprover(i)}
className="text-red-400 hover:text-red-600 text-xs"
></button>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* ── 상신 버튼 ── */}
{!submitted && (
<div className="flex items-center gap-3">
<button
onClick={() => {
onRequest(localApprList.map((a) => ({ apprId: a.usrId })));
if (!requesting) setSubmitted(true);
}}
disabled={requesting || submitted || localApprList.length < 2}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50"
>
{requesting ? '처리중...' : '결재 상신'}
</button>
{localApprList.length < 2 && (
<span className="text-xs text-red-500"> 2 </span>
)}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import { ApvdocDetail, APRVL_STUS_LABEL, APRVL_KIND_LABEL } from '@/lib/api/tam';
interface Props {
data: ApvdocDetail;
}
export default function ApvdocInfo({ data }: Props) {
return (
<div className="border rounded overflow-hidden">
<table className="w-full text-sm">
<tbody>
<tr className="border-b">
<th className="bg-gray-50 px-4 py-2 text-right w-32 font-medium"></th>
<td className="px-4 py-2">{data.aprvlDocId}</td>
<th className="bg-gray-50 px-4 py-2 text-right w-24 font-medium"></th>
<td className="px-4 py-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
data.aprvlStusCd === '0001' ? 'bg-gray-100 text-gray-600' :
data.aprvlStusCd === '0002' ? 'bg-blue-100 text-blue-700' :
data.aprvlStusCd === '0003' ? 'bg-green-100 text-green-700' :
'bg-red-100 text-red-700'
}`}>
{APRVL_STUS_LABEL[data.aprvlStusCd] ?? data.aprvlStusCd}
</span>
</td>
</tr>
<tr className="border-b">
<th className="bg-gray-50 px-4 py-2 text-right font-medium"> </th>
<td className="px-4 py-2">{APRVL_KIND_LABEL[data.aprvlKindCd] ?? data.aprvlKindCd}</td>
<th className="bg-gray-50 px-4 py-2 text-right font-medium"></th>
<td className="px-4 py-2">{data.aplntNm} ({data.aplntId})</td>
</tr>
<tr className="border-b">
<th className="bg-gray-50 px-4 py-2 text-right font-medium"></th>
<td className="px-4 py-2">{data.offerDt}</td>
<th className="bg-gray-50 px-4 py-2 text-right font-medium"></th>
<td className="px-4 py-2">{data.cmplDt || '-'}</td>
</tr>
<tr className="border-b">
<th className="bg-gray-50 px-4 py-2 text-right font-medium"> </th>
<td colSpan={3} className="px-4 py-2">{data.aplntCn}</td>
</tr>
{data.bzCn && (
<tr className="border-b">
<th className="bg-gray-50 px-4 py-2 text-right font-medium"> </th>
<td colSpan={3} className="px-4 py-2">{data.bzCn}</td>
</tr>
)}
{data.offerCn && (
<tr className="border-b">
<th className="bg-gray-50 px-4 py-2 text-right font-medium"> </th>
<td colSpan={3} className="px-4 py-2">{data.offerCn}</td>
</tr>
)}
{data.bzDeputyId && (
<tr className="border-b">
<th className="bg-gray-50 px-4 py-2 text-right font-medium"></th>
<td colSpan={3} className="px-4 py-2">{data.bzDeputyId}</td>
</tr>
)}
</tbody>
</table>
{/* 변경근무 목록 */}
{data.workChangeList?.length > 0 && (
<div className="border-t p-3">
<h4 className="text-sm font-medium mb-2"> </h4>
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-gray-50 text-center">
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
</tr>
</thead>
<tbody>
{data.workChangeList.map((wc: any, i: number) => (
<tr key={i} className="text-center">
<td className="border px-2 py-1">{wc.WRK_YMD ?? wc.wrkYmd}</td>
<td className="border px-2 py-1">{wc.BEFORE_WRK_CD ?? wc.beforeWrkCd}</td>
<td className="border px-2 py-1">{wc.AFTER_WRK_CD ?? wc.afterWrkCd}</td>
<td className="border px-2 py-1">{wc.CHG_RSN ?? wc.chgRsn}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* OT 정보 */}
{data.otInfo && (
<div className="border-t p-3">
<h4 className="text-sm font-medium mb-2"> </h4>
<div className="text-sm grid grid-cols-2 gap-2">
<span>: {data.otInfo.OT_YMD ?? data.otInfo.otYmd}</span>
<span>: {data.otInfo.OT_HR ?? data.otInfo.otHr} {data.otInfo.OT_MIN ?? data.otInfo.otMin}</span>
</div>
</div>
)}
{/* 첨부파일 */}
{data.attachFileList?.length > 0 && (
<div className="border-t p-3">
<h4 className="text-sm font-medium mb-2"></h4>
<ul className="space-y-1">
{data.attachFileList.map((f: any) => (
<li key={f.atchfileNo ?? f.ATCHFILE_NO}>
<a
href={`/api/attach/download/${f.atchfileNo ?? f.ATCHFILE_NO}`}
className="text-sm text-blue-600 hover:underline"
download
>
{f.atchFileNm ?? f.ATCH_FILE_NM}
</a>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import {
createApvreq, updateApvreq, ApvdocDetail, APRVL_KIND_LABEL,
} from '@/lib/api/tam';
import { AttachFileInfo } from '@/lib/api/attach';
import FileUpload from '@/components/common/FileUpload';
interface Props {
initialData?: ApvdocDetail;
onSuccess: (aprvlDocId?: string) => void;
onCancel: () => void;
}
export default function ApvreqForm({ initialData, onSuccess, onCancel }: Props) {
const isEdit = !!initialData;
// 문서결재신청은 '기안'(코드 0007)만 허용 (DB: SX009)
const [aprvlKindCd, setKind] = useState(initialData?.aprvlKindCd ?? '0007');
const [aplntCn, setAplntCn] = useState(initialData?.aplntCn ?? '');
const [bzCn, setBzCn] = useState(initialData?.bzCn ?? '');
const [atchNo, setAtchNo] = useState<string | undefined>(initialData?.atchNo);
const [files, setFiles] = useState<AttachFileInfo[]>(initialData?.attachFileList ?? []);
const buildBody = () => ({
aprvlKindCd,
aplntCn,
bzCn,
atchNo: atchNo ?? null,
});
const createMut = useMutation({
mutationFn: () => createApvreq(buildBody()),
onSuccess: (res) => { alert('신청서가 저장되었습니다.'); onSuccess(res.aprvlDocId); },
onError: (e: any) => alert(e?.response?.data?.message ?? '저장 오류'),
});
const updateMut = useMutation({
mutationFn: () => updateApvreq(initialData!.aprvlDocId, buildBody()),
onSuccess: () => { alert('수정되었습니다.'); onSuccess(); },
onError: (e: any) => alert(e?.response?.data?.message ?? '수정 오류'),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!aplntCn.trim()) { alert('신청 내용을 입력해주세요.'); return; }
isEdit ? updateMut.mutate() : createMut.mutate();
};
const isPending = createMut.isPending || updateMut.isPending;
return (
<form onSubmit={handleSubmit} className="border rounded overflow-hidden">
<table className="w-full text-sm">
<tbody>
{/* 결재문서번호 */}
<tr className="border-b">
<th className="bg-gray-50 px-4 py-2 text-right w-36 font-medium"></th>
<td className="px-4 py-2 text-gray-500">
{isEdit ? initialData!.aprvlDocId : '저장 후 자동 생성'}
</td>
</tr>
{/* 결재종류 - 기안만 허용 */}
<tr className="border-b">
<th className="bg-gray-50 px-4 py-2 text-right font-medium"> *</th>
<td className="px-4 py-2">
<span className="text-sm"></span>
<input type="hidden" value={aprvlKindCd} readOnly />
</td>
</tr>
{/* 신청내용 */}
<tr className="border-b">
<th className="bg-gray-50 px-4 py-2 text-right font-medium"> *</th>
<td className="px-4 py-2">
<textarea
value={aplntCn} onChange={(e) => setAplntCn(e.target.value)}
rows={4} className="w-full border rounded px-3 py-2 text-sm resize-none"
placeholder="신청 내용을 입력하세요"
/>
</td>
</tr>
{/* 업무내용 */}
<tr className="border-b">
<th className="bg-gray-50 px-4 py-2 text-right font-medium"> </th>
<td className="px-4 py-2">
<textarea
value={bzCn} onChange={(e) => setBzCn(e.target.value)}
rows={2} className="w-full border rounded px-3 py-2 text-sm resize-none"
placeholder="업무 내용"
/>
</td>
</tr>
{/* 첨부파일 */}
<tr className="border-b">
<th className="bg-gray-50 px-4 py-2 text-right font-medium align-top pt-3"></th>
<td className="px-4 py-2">
<FileUpload
atchNo={atchNo}
division="tam"
initialFiles={files}
onChange={(no, fileList) => { setAtchNo(no); setFiles(fileList); }}
/>
</td>
</tr>
</tbody>
</table>
{/* 버튼 */}
<div className="border-t p-3 flex gap-2 justify-end bg-gray-50">
<button type="button" onClick={onCancel}
className="px-4 py-2 text-sm border rounded hover:bg-gray-100"
>
</button>
<button type="submit" disabled={isPending}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? '처리중...' : isEdit ? '수정' : '저장'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,117 @@
import { apiClient } from './client';
export interface AttachFileInfo {
atchfileNo: string;
atchNo: string;
atchFilePathNm: string;
atchFileNm: string;
atchTypeNm: string;
atchFileMg: number;
tblColNm?: string;
}
/** 단일 파일 업로드 */
export async function uploadFile(
file: File,
atchNo?: string,
division?: string,
): Promise<AttachFileInfo> {
const form = new FormData();
form.append('file', file);
if (atchNo) form.append('atchNo', atchNo);
if (division) form.append('division', division);
const res = await apiClient.post('/attach/upload', form, {
headers: { 'Content-Type': undefined },
});
return res.data.data;
}
/** 다중 파일 업로드 */
export async function uploadFiles(
files: File[],
atchNo?: string,
division?: string,
): Promise<AttachFileInfo[]> {
const form = new FormData();
files.forEach((f) => form.append('files', f));
if (atchNo) form.append('atchNo', atchNo);
if (division) form.append('division', division);
const res = await apiClient.post('/attach/upload/multi', form, {
headers: { 'Content-Type': undefined },
});
return res.data.data;
}
/** 에디터 이미지 업로드 */
export async function uploadEditorImage(
file: File,
division?: string,
): Promise<{ url: string }> {
const form = new FormData();
form.append('file', file);
if (division) form.append('division', division);
const res = await apiClient.post('/attach/upload/image', form, {
headers: { 'Content-Type': undefined },
});
return res.data.data;
}
/** 첨부번호로 파일 목록 조회 */
export async function getFileList(atchNo: string): Promise<AttachFileInfo[]> {
const res = await apiClient.get(`/attach/list/${atchNo}`);
return res.data.data;
}
/** 파일 다운로드 (JWT 토큰 포함, blob 방식) */
export async function downloadFile(atchfileNo: string, fileName?: string): Promise<void> {
const res = await apiClient.get(`/attach/download/${atchfileNo}`, {
responseType: 'blob',
});
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = fileName || atchfileNo;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** ZIP 일괄 다운로드 (JWT 토큰 포함, blob 방식) */
export async function downloadZip(atchNo: string): Promise<void> {
const res = await apiClient.get(`/attach/download-all/${atchNo}`, {
responseType: 'blob',
});
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = '첨부파일.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** 파일 삭제 */
export async function deleteFile(atchfileNo: string): Promise<void> {
await apiClient.delete(`/attach/${atchfileNo}`);
}
/** Excel 내보내기 URL 생성 (쿼리스트링 포함) */
export function getExcelUrl(endpoint: string, params: Record<string, string | number | undefined>): string {
const qs = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&');
return `/api/excel/${endpoint}${qs ? '?' + qs : ''}`;
}
/** 파일 크기를 읽기 쉬운 단위로 변환 */
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,17 @@
import { apiClient } from './client'
import type { ApiResponse } from '@/types/common'
import type { LoginRequest, LoginResponse } from '@/types/auth'
export const authApi = {
login: (data: LoginRequest) =>
apiClient.post<ApiResponse<LoginResponse>>('/auth/login', data),
logout: () =>
apiClient.post<ApiResponse<null>>('/auth/logout'),
refresh: (refreshToken: string) =>
apiClient.post<ApiResponse<string>>('/auth/refresh', { refreshToken }),
me: () =>
apiClient.get<ApiResponse<string>>('/auth/me'),
}

View File

@@ -0,0 +1,119 @@
import { apiClient } from './client';
export interface PostListItem {
untyBbsSno: number;
bbsTitleNm: string;
inqrCnt: number;
cmmtCnt: number;
ctusrId: string;
ctusrNm: string;
rgstDt: string;
}
export interface PostDetail extends PostListItem {
untyBbsCd: string;
bbsCn: string;
etcAtchNo?: string;
photoAtchNo?: string;
rgstrId: string;
rgstDate: string;
updDate: string;
etcFileList?: any[];
photoFileList?: any[];
}
export interface Comment {
cmmtSno: number;
cmmtCn: string;
cmmtCtusrId: string;
cmmtCtusrNm: string;
rgstDate: string;
}
export interface PostListResponse {
list: PostListItem[];
total: number;
pageNo: number;
pageSize: number;
}
export interface PostSaveRequest {
bbsTitleNm: string;
bbsCn: string;
etcAtchNo?: string;
photoAtchNo?: string;
}
/** 게시물 목록 */
export async function getPostList(
untyBbsCd: string,
params: { searchText?: string; pageNo?: number; pageSize?: number },
): Promise<PostListResponse> {
const res = await apiClient.get(`/board/${untyBbsCd}`, { params });
return res.data.data;
}
/** 게시물 상세 (조회수 증가) */
export async function getPostInfo(untyBbsCd: string, untyBbsSno: number): Promise<PostDetail> {
const res = await apiClient.get(`/board/${untyBbsCd}/${untyBbsSno}`);
return res.data.data;
}
/** 게시물 상세 (수정용) */
export async function getPostInfoForEdit(
untyBbsCd: string,
untyBbsSno: number,
): Promise<PostDetail> {
const res = await apiClient.get(`/board/${untyBbsCd}/${untyBbsSno}/edit`);
return res.data.data;
}
/** 게시물 등록 */
export async function insertPost(
untyBbsCd: string,
data: PostSaveRequest,
): Promise<{ untyBbsSno: number }> {
const res = await apiClient.post(`/board/${untyBbsCd}`, data);
return res.data.data;
}
/** 게시물 수정 */
export async function updatePost(
untyBbsCd: string,
untyBbsSno: number,
data: PostSaveRequest,
): Promise<void> {
await apiClient.put(`/board/${untyBbsCd}/${untyBbsSno}`, data);
}
/** 게시물 삭제 */
export async function deletePost(untyBbsCd: string, untyBbsSno: number): Promise<void> {
await apiClient.delete(`/board/${untyBbsCd}/${untyBbsSno}`);
}
/** 댓글 목록 */
export async function getCommentList(
untyBbsCd: string,
untyBbsSno: number,
): Promise<Comment[]> {
const res = await apiClient.get(`/board/${untyBbsCd}/${untyBbsSno}/comments`);
return res.data.data;
}
/** 댓글 등록 */
export async function insertComment(
untyBbsCd: string,
untyBbsSno: number,
cmmtCn: string,
): Promise<void> {
await apiClient.post(`/board/${untyBbsCd}/${untyBbsSno}/comments`, { cmmtCn });
}
/** 댓글 삭제 */
export async function deleteComment(
untyBbsCd: string,
untyBbsSno: number,
cmmtSno: number,
): Promise<void> {
await apiClient.delete(`/board/${untyBbsCd}/${untyBbsSno}/comments/${cmmtSno}`);
}

View File

@@ -0,0 +1,51 @@
import axios from 'axios'
import { useAuthStore } from '@/lib/store/authStore'
export const apiClient = axios.create({
baseURL: '/api',
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
})
// 요청 인터셉터 - JWT 자동 첨부
apiClient.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 응답 인터셉터 - 401 시 Refresh Token으로 재발급 시도
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
const { refreshToken, setAccessToken, clearAuth } = useAuthStore.getState()
if (refreshToken) {
try {
const res = await axios.post('/api/auth/refresh', { refreshToken })
const newAccessToken: string = res.data.data
setAccessToken(newAccessToken)
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
return apiClient(originalRequest)
} catch {
clearAuth()
document.cookie = 'accessToken=; path=/; max-age=0; SameSite=Lax'
window.location.href = '/login'
}
} else {
clearAuth()
document.cookie = 'accessToken=; path=/; max-age=0; SameSite=Lax'
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)

View File

@@ -0,0 +1,120 @@
import { apiClient } from './client'
import type { ApiResponse, PaginationParams } from '@/types/common'
// ===== 직원 관리 =====
export interface UserListItem {
usrId: string; usrNm: string; teamCd: string; dutyCd: string
usrTelno: string; mtelNo: string; retirementDate: string
}
export interface UserDetail {
corpNo: string; usrId: string; usrNm: string; loginId: string; dutyCd: string
brthDyDate: string; usrTelno: string; mtelNo: string; email: string
baseAdrs: string; gusoAdrs: string; gusoTelno: string; joinCpDate: string
teamCd: string; dismlDate: string; retirementDate: string; spkltArtcCn: string
photoAtchfileNo: string; spmtEmail: string; apprYn: string; finalSchspNm: string
}
export interface UserSaveRequest {
usrNm?: string; loginId?: string; pw?: string; dutyCd?: string; teamCd?: string
usrTelno?: string; mtelNo?: string; email?: string; joinCpDate?: string
retirementDate?: string; apprYn?: string; photoAtchfileNo?: string
}
// ===== 코드 관리 =====
export interface CodeIndex {
commClCd: string; commClCdNm: string; commClCdUseYn: string
commClCdDscrpt: string; dsplyOrdr: number; rowStatus?: string
}
export interface Code {
commClCd: string; commCd: string; commCdNm: string; commCdUseYn: string
commCdDscrpt: string; commCdDsplyOrdr: number; rowStatus?: string
}
// ===== 메뉴 관리 =====
export interface MenuManage {
menuNo: string; upperMenuNo: string; menuNm: string; url: string
menuProp: string; menuOrdr: number; menuUseYn: string
controller: string; menuLvl: number; rowStatus?: string
}
export interface AuthMenu {
menuNo: string; upperMenuNo: string; menuNm: string; lvl: number
menuAuthYn: string; menuAuthCd: string; funcAuthCn: string; rowStatus?: string
}
export interface UserAuth {
usrId: string; usrNm: string; menuAuthCd: string
teamCd: string; dutyCd: string; retirementDate: string; rowStatus?: string
}
// ===== 근무코드 =====
export interface WorkCd {
workCd: string;
workCdTitleNm: string;
gotoworkTmNm: string;
getoffworkTmNm: string;
regularWorkTm: number;
restTm: number;
otStartTm: string;
ngtStartTm: string;
ngtEndTm: string;
workCdUseYn: string;
holidayYn: string;
workCn: string;
[key: string]: unknown;
}
export const envsetApi = {
// 직원
getUserList: (params: PaginationParams & { userOrderBy?: string; includeRetireYn?: string; teamCd?: string; dutyCd?: string }) =>
apiClient.get<ApiResponse<{ list: UserListItem[]; total: number }>>('/envset/users', { params }),
getUserDetail: (usrId: string) =>
apiClient.get<ApiResponse<UserDetail>>(`/envset/users/${usrId}`),
insertUser: (data: UserSaveRequest) =>
apiClient.post<ApiResponse<null>>('/envset/users', data),
updateUser: (usrId: string, data: UserSaveRequest) =>
apiClient.put<ApiResponse<null>>(`/envset/users/${usrId}`, data),
deleteUser: (usrId: string) =>
apiClient.delete<ApiResponse<null>>(`/envset/users/${usrId}`),
// 코드인덱스
getCdidxList: (searchText?: string) =>
apiClient.get<ApiResponse<CodeIndex[]>>('/envset/codes', { params: { searchText } }),
saveCdidxList: (list: CodeIndex[]) =>
apiClient.post<ApiResponse<null>>('/envset/codes', list),
// 코드
getCodeList: (commClCd: string, searchText?: string) =>
apiClient.get<ApiResponse<Code[]>>(`/envset/codes/${commClCd}`, { params: { searchText } }),
saveCodeList: (commClCd: string, list: Code[]) =>
apiClient.post<ApiResponse<null>>(`/envset/codes/${commClCd}`, list),
// 메뉴
getMenuList: () =>
apiClient.get<ApiResponse<MenuManage[]>>('/envset/menus'),
saveMenuList: (list: MenuManage[]) =>
apiClient.post<ApiResponse<null>>('/envset/menus', list),
// 권한-메뉴
getAuthMenuList: (menuAuthCd: string) =>
apiClient.get<ApiResponse<AuthMenu[]>>('/envset/menus/auth', { params: { menuAuthCd } }),
saveAuthMenuList: (menuAuthCd: string, list: AuthMenu[]) =>
apiClient.post<ApiResponse<null>>('/envset/menus/auth', list, { params: { menuAuthCd } }),
// 사용자-권한
getUserMauthList: (params?: { menuAuthCd?: string; usrNm?: string }) =>
apiClient.get<ApiResponse<UserAuth[]>>('/envset/menus/user-auth', { params }),
saveUserMauthList: (list: UserAuth[]) =>
apiClient.post<ApiResponse<null>>('/envset/menus/user-auth', list),
// 사용자 검색
searchUser: (keyword: string) =>
apiClient.get<ApiResponse<{ usrId: string; usrNm: string }[]>>('/envset/menus/search-user', { params: { keyword } }),
// 근무코드
getWorkCdList: (searchText?: string) =>
apiClient.get<ApiResponse<WorkCd[]>>('/envset/workcd', { params: { searchText } }),
insertWorkCd: (data: Partial<WorkCd>) =>
apiClient.post<ApiResponse<null>>('/envset/workcd', data),
updateWorkCd: (workCd: string, data: Partial<WorkCd>) =>
apiClient.put<ApiResponse<null>>(`/envset/workcd/${workCd}`, data),
deleteWorkCd: (workCd: string) =>
apiClient.delete<ApiResponse<null>>(`/envset/workcd/${workCd}`),
}

View File

@@ -0,0 +1,70 @@
import { apiClient } from './client';
export interface FedexItem {
sq: number;
ieGbn: string;
sCode: string;
sYear: string;
sJechl: string;
singoNo: string;
comNm: string;
jSingoDt: string;
sSingoDt: string;
regUserId: string;
fStCd: string;
fJjCd: string;
fJjContents: string;
fJjNm: string;
fJjRegDt: string;
fJjRegGbn: string;
attachNo: string;
fStDes: string;
jGwiDes: string;
fJjDes1: string;
}
export interface FedexDetail extends FedexItem {
jGwiCd: string;
uTitle: string;
uRes: string;
uRegDt: string;
}
export interface FedexListResponse {
list: FedexItem[];
total: number;
}
export interface FedexCodes {
fstList: { fStCd: string; fStDes: string }[];
gwiList: { jGwiCd: string; jGwiDes: string }[];
fjjList: { fJjCd: string; fJjDes1: string }[];
}
export async function getFedexList(params: {
searchText?: string;
pageNo?: number;
pageSize?: number;
}): Promise<FedexListResponse> {
const res = await apiClient.get('/fedex/0010', { params });
return res.data.data;
}
export async function getFedexDetail(sq: number): Promise<FedexDetail> {
const res = await apiClient.get(`/fedex/0010/${sq}`);
return res.data.data;
}
export async function insertFedex(body: Record<string, unknown>): Promise<number> {
const res = await apiClient.post('/fedex/0010', body);
return res.data.data;
}
export async function updateFedexAttach(sq: number, attachNo: string): Promise<void> {
await apiClient.patch(`/fedex/0010/${sq}/attach`, { attachNo });
}
export async function getFedexCodes(): Promise<FedexCodes> {
const res = await apiClient.get('/fedex/codes');
return res.data.data;
}

View File

@@ -0,0 +1,82 @@
import { apiClient } from './client';
export interface PostItem {
untyBbsSno: number;
untyBbsCd: string;
bbsTitleNm: string;
inqrCnt: number;
cmmtCnt: number;
rgstDt: string;
rgstrNm: string;
isNew: string;
}
export interface WorkRecord {
workPlanYymmdd: string;
workStartDt: string;
workEndDt: string;
totWorkMin: number;
lateMin: number;
otWorkMin: number;
realWorkCd: string;
gotoworkTmNm: string;
getoffworkTmNm: string;
}
/** 내가 올린 결재: APRVL_STUS_CD = 0001/0002/0003/0004 */
export interface DocSentSummaryItem {
aprvlStusCd: string;
cnt: number;
}
/** 내가 받은 결재: APPR_STUS_CD = 0002/0003/0004/WAIT */
export interface DocReceivedSummaryItem {
apprStusCd: string;
cnt: number;
}
export interface LateItem {
usrNm: string;
teamCd: string;
dutyCd: string;
lateMin: number;
workStartDt: string;
}
export interface TodayWorkSummaryItem {
realWorkCd: string;
workCdTitleNm: string;
gotoworkTmNm: string;
getoffworkTmNm: string;
totalCnt: number;
inCnt: number;
outCnt: number;
}
export interface DashboardData {
noticeList: PostItem[];
boardList: PostItem[];
manualList: PostItem[];
workRangeList: WorkRecord[];
todayWork: WorkRecord | null;
pendingAppr: { pendingCnt: number };
myDocSent: DocSentSummaryItem[];
myDocReceived: DocReceivedSummaryItem[];
todayWorkSummary: TodayWorkSummaryItem[];
todayLateList: LateItem[];
}
export async function getDashboard(): Promise<DashboardData> {
const res = await apiClient.get('/main/dashboard');
return res.data.data;
}
export async function clockIn(workPlanYymmdd: string): Promise<{ lateMin: number }> {
const res = await apiClient.post('/main/workstart', { workPlanYymmdd });
return res.data.data;
}
export async function clockOut(workPlanYymmdd: string): Promise<{ earlyMin: number }> {
const res = await apiClient.post('/main/workend', { workPlanYymmdd });
return res.data.data;
}

198
frontend/src/lib/api/tam.ts Normal file
View File

@@ -0,0 +1,198 @@
import { apiClient } from './client';
// ─────────── 결재 상태 코드 ───────────
export const APRVL_STUS = {
WRITING: '0001',
APPRING: '0002',
APPROVED: '0003',
REJECTED: '0004',
} as const;
export const APRVL_STUS_LABEL: Record<string, string> = {
'0001': '작성중',
'0002': '결재중',
'0003': '결재완료',
'0004': '반려',
};
// 결재 종류 코드 (DB: SX_CO0010 COMM_CL_CD='SX009')
export const APRVL_KIND_LABEL: Record<string, string> = {
'0007': '기안',
};
// ─────────── Types ───────────
export interface ApvreqListItem {
aprvlDocId: string;
aprvlStusCd: string;
aprvlKindCd: string;
aplntId: string;
offerDt: string;
cmplDt: string;
aprvlCmplSno: number;
finalAprvlSno: number;
}
export interface ApprInfo {
aprvlSno: number;
apprId: string;
apprNm: string;
apprStusCd: string;
aprvlDt: string;
apprCn: string;
finalApprYn: string;
}
export interface ApvdocDetail {
aprvlDocId: string;
aprvlStusCd: string;
aprvlKindCd: string;
aplntId: string;
aplntNm: string;
aplntCn: string;
bzCn: string;
offerCn: string;
bzDeputyId: string;
offerDt: string;
cmplDt: string;
atchNo: string;
apprList: ApprInfo[];
attachFileList: any[];
workChangeList: any[];
otInfo: any;
}
export interface YyvctItem {
usrId: string;
usrNm: string;
teamCd: string;
dutyCd: string;
yyvctYy: string;
yyvctCnt: number;
}
// ─────────── TAM0010 연차 API ───────────
export async function getYyvctList(params: {
yyvctYy: string;
usrNm?: string;
teamCd?: string;
includeRetireYn?: string;
}): Promise<YyvctItem[]> {
const res = await apiClient.get('/tam/yyvct', { params });
return res.data.data;
}
export async function saveYyvct(data: {
yyvctYy: string;
usrId: string;
yyvctCnt: number;
}): Promise<void> {
await apiClient.post('/tam/yyvct', data);
}
export async function deleteYyvct(yyvctYy: string, usrId: string): Promise<void> {
await apiClient.delete('/tam/yyvct', { params: { yyvctYy, usrId } });
}
// ─────────── TAM0020 결재신청 API ───────────
export async function getApvreqList(params: Record<string, any>): Promise<{
list: ApvreqListItem[];
total: number;
}> {
const res = await apiClient.get('/tam/apvreq', { params });
return res.data.data;
}
export async function getApvreqDetail(aprvlDocId: string): Promise<ApvdocDetail> {
const res = await apiClient.get(`/tam/apvreq/${aprvlDocId}`);
return res.data.data;
}
export async function createApvreq(data: Record<string, any>): Promise<{ aprvlDocId: string }> {
const res = await apiClient.post('/tam/apvreq', data);
return res.data.data;
}
export async function updateApvreq(aprvlDocId: string, data: Record<string, any>): Promise<void> {
await apiClient.put(`/tam/apvreq/${aprvlDocId}`, data);
}
export async function requestApvreq(
aprvlDocId: string,
apprList: Array<{ apprId: string }>,
): Promise<void> {
await apiClient.post(`/tam/apvreq/${aprvlDocId}/request`, { apprList });
}
export async function deleteApvreq(aprvlDocId: string): Promise<void> {
await apiClient.delete(`/tam/apvreq/${aprvlDocId}`);
}
export async function getLatestApprList(): Promise<any[]> {
const res = await apiClient.get('/tam/apvreq/latest-appr');
return res.data.data;
}
export interface ApprUserItem {
usrId: string;
usrNm: string;
teamCd: string;
dutyCd: string;
}
/** 결재 가능 사용자 목록 (APPR_YN = 'Y', 재직자만) */
export async function getApprUserList(): Promise<ApprUserItem[]> {
const res = await apiClient.get('/envset/users', {
params: { apprYn: 'Y', includeRetireYn: 'N', size: 500, page: 1, userOrderBy: 'USR_NM' },
});
return res.data.data?.list ?? [];
}
// ─────────── TAM0030 결재처리 API ───────────
export async function getApvappList(params: Record<string, any>): Promise<{
list: ApvreqListItem[];
total: number;
}> {
const res = await apiClient.get('/tam/apvapp', { params });
return res.data.data;
}
export async function getApvappDetail(aprvlDocId: string): Promise<ApvdocDetail> {
const res = await apiClient.get(`/tam/apvapp/${aprvlDocId}`);
return res.data.data;
}
export async function approveApvdoc(
aprvlDocId: string,
aprvlSno: number,
apprCn: string,
): Promise<void> {
await apiClient.post(`/tam/apvapp/${aprvlDocId}/approve`, { aprvlSno, apprCn });
}
export async function rejectApvdoc(
aprvlDocId: string,
aprvlSno: number,
apprCn: string,
): Promise<void> {
await apiClient.post(`/tam/apvapp/${aprvlDocId}/reject`, { aprvlSno, apprCn });
}
export async function multiApprove(
items: Array<{ aprvlDocId: string; aprvlSno: number }>,
apprCn: string,
): Promise<void> {
await apiClient.post('/tam/apvapp/multi-approve', { items, apprCn });
}
export async function multiReject(
items: Array<{ aprvlDocId: string; aprvlSno: number }>,
apprCn: string,
): Promise<void> {
await apiClient.post('/tam/apvapp/multi-reject', { items, apprCn });
}
// ─────────── TAM0040 현황 API ───────────
export async function getTamStatusList(params: Record<string, any>): Promise<any[]> {
const res = await apiClient.get('/tam/status', { params });
return res.data.data;
}

View File

@@ -0,0 +1,89 @@
import { apiClient } from './client';
// ─────────── Types ───────────
export interface WorkCode {
workCd: string;
workCdTitleNm: string;
gotoworkTmNm: string;
getoffworkTmNm: string;
holidayYn: string;
}
export interface WplanItem {
usrId: string;
usrNm: string;
teamCd: string;
dutyCd: string;
workPlanYymmdd: string | null;
planWorkCd: string | null;
workCd: string | null;
sortOdr: number;
}
export interface MyWplanItem {
workPlanYymmdd: string;
planWorkCd: string;
workCd: string;
workCdTitleNm: string;
gotoworkTmNm: string;
getoffworkTmNm: string;
}
export interface AllWplanItem {
usrId: string;
usrNm: string;
teamCd: string;
dutyCd: string;
workPlanYymmdd: string;
planWorkCd: string;
workCd: string;
planWorkNm: string;
workNm: string;
planGotoworkTm: string;
planGetoffworkTm: string;
}
export interface WplanSaveItem {
usrId: string;
workPlanYymmdd: string;
planWorkCd: string;
sortOdr?: number;
}
// ─────────── API ───────────
export async function getWorkCodeList(): Promise<WorkCode[]> {
const res = await apiClient.get('/wplan/workcode');
return res.data.data;
}
export async function getWplanList(params: {
workPlanYymm: string;
searchText?: string;
teamCd?: string;
includeRetireYn?: string;
}): Promise<WplanItem[]> {
const res = await apiClient.get('/wplan/0010', { params });
return res.data.data;
}
export async function saveWplanList(saveList: WplanSaveItem[]): Promise<void> {
await apiClient.post('/wplan/0010', { saveList });
}
export async function getMyWplanList(workPlanYymm: string): Promise<MyWplanItem[]> {
const res = await apiClient.get('/wplan/0020', { params: { workPlanYymm } });
return res.data.data;
}
export async function getAllWplanList(params: {
workPlanYymm: string;
teamCd?: string;
dutyCd?: string;
workCd?: string;
workCdType?: string;
}): Promise<AllWplanItem[]> {
const res = await apiClient.get('/wplan/0030', { params });
return res.data.data;
}

View File

@@ -0,0 +1,89 @@
import { apiClient } from './client';
// ─────────── Types ───────────
export interface WtimeItem {
workPlanYymmdd: string;
planWorkCd: string;
workCd: string;
realWorkCd: string;
workStartDt: string;
workEndDt: string;
abtiYn: string;
otRctnYn: string;
elRctnYn: string;
totWorkMin: number;
lateMin: number;
skipoffRemnBzMin: number;
incluWorkOtMin: number;
otWorkMin: number;
ngtOtMin: number;
outingMin: number;
holidayLateMin: number;
realStartDt: string;
realEndDt: string;
getoffworkTmNm: string;
usrId: string;
usrNm: string;
teamCd: string;
dutyCd: string;
}
export interface WstatItem {
usrId: string;
usrNm: string;
teamCd: string;
dutyCd: string;
workDcnt: number;
abtiDcnt: number;
yyctDeduDcnt: number;
lateDcnt: number;
skipoffDcnt: number;
sickleaveDcnt: number;
totWorkMin: number;
lateMin: number;
skipoffRemnBzMin: number;
incluWorkOtMin: number;
otWorkMin: number;
ngtOtMin: number;
outingDcnt: number;
outingMin: number;
yyvctCnt: number;
}
// 분 → hh:mm
export function minToHhmm(min: number): string {
if (!min) return '0:00';
const h = Math.floor(min / 60);
const m = min % 60;
return `${h}:${String(m).padStart(2, '0')}`;
}
// ─────────── API ───────────
export async function getWtimeList(params: {
staYmd: string;
endYmd: string;
usrId?: string;
usrNm?: string;
teamCd?: string;
dutyCd?: string;
includeRetireYn?: string;
includeWorkYn?: string;
}): Promise<WtimeItem[]> {
const res = await apiClient.get('/wtime/0010', { params });
return res.data.data;
}
export async function getWstatList(params: {
staYmd: string;
endYmd: string;
usrId?: string;
usrNm?: string;
teamCd?: string;
dutyCd?: string;
includeRetireYn?: string;
}): Promise<WstatItem[]> {
const res = await apiClient.get('/wtime/0030', { params });
return res.data.data;
}

View File

@@ -0,0 +1,53 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { UserInfo, MenuDto } from '@/types/auth'
interface AuthState {
user: UserInfo | null
accessToken: string | null
refreshToken: string | null
menuList: MenuDto[]
isAuthenticated: boolean
selectedMenuNo: string | null
setAuth: (user: UserInfo, accessToken: string, refreshToken: string, menuList: MenuDto[]) => void
setAccessToken: (token: string) => void
setSelectedMenuNo: (menuNo: string) => void
clearAuth: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
menuList: [],
isAuthenticated: false,
selectedMenuNo: null,
setAuth: (user, accessToken, refreshToken, menuList) =>
set({ user, accessToken, refreshToken, menuList, isAuthenticated: true }),
setAccessToken: (token) =>
set({ accessToken: token }),
setSelectedMenuNo: (menuNo) =>
set({ selectedMenuNo: menuNo }),
clearAuth: () =>
set({ user: null, accessToken: null, refreshToken: null, menuList: [], isAuthenticated: false, selectedMenuNo: null }),
}),
{
name: 'gw-auth',
// accessToken만 localStorage에서 제외하고 싶다면 partialize 사용
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
menuList: state.menuList,
isAuthenticated: state.isAuthenticated,
selectedMenuNo: state.selectedMenuNo,
}),
}
)
)

View File

@@ -0,0 +1,18 @@
/** 로컬 날짜를 YYYYMMDD 형식으로 반환 (toISOString은 UTC 기준이라 한국 오전 9시 이전에 전날 날짜가 반환되는 문제 방지) */
export function localYmd(date: Date = new Date()): string {
return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`;
}
/** N일 전 로컬 날짜를 YYYYMMDD 형식으로 반환 */
export function localYmdDaysAgo(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return localYmd(d);
}
/** N년 전 로컬 날짜를 YYYYMMDD 형식으로 반환 */
export function localYmdYearsAgo(years: number): string {
const d = new Date();
d.setFullYear(d.getFullYear() - years);
return localYmd(d);
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
const PUBLIC_PATHS = ['/login']
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
const token = request.cookies.get('accessToken')?.value
if (!isPublic && !token) {
return NextResponse.redirect(new URL('/login', request.url))
}
if (isPublic && token) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
}

View File

@@ -0,0 +1,41 @@
export interface LoginRequest {
loginId: string
loginPw: string
menuAuthCd: string
}
export interface UserInfo {
usrId: string
usrNm: string
loginId: string
corpNo: string
dutyCd: string
roleCode: string
roles: string[]
}
export interface MenuDto {
menuNo: string
upperMenuNo: string
menuNm: string
url: string
menuProp: string
menuOrdr: number
menuUseYn: string
lvl: number
}
export interface LoginResponse {
accessToken: string
refreshToken: string
userInfo: UserInfo
menuList: MenuDto[]
}
// 기존 시스템과 동일한 ROLE 코드 (BizConstants.groovy 기준)
export const ROLES = {
USER: 'USER', // 일반사용자
BIZM: 'BIZM', // 업무관리자
MNGR: 'MNGR', // 시스템관리자
FEDEX: 'FEDE', // FEDEX
} as const

View File

@@ -0,0 +1,18 @@
export interface ApiResponse<T> {
success: boolean
data: T
message?: string
pagination?: PaginationInfo
}
export interface PaginationInfo {
page: number
size: number
total: number
}
export interface PaginationParams {
page?: number
size?: number
keyword?: string
}

View File

@@ -0,0 +1,24 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
},
},
},
},
plugins: [],
}
export default config

23
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}