share job
This commit is contained in:
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
7
frontend/.gitignore
vendored
Normal file
7
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
pnpm-lock.yaml
|
||||
23
frontend/Dockerfile
Normal file
23
frontend/Dockerfile
Normal 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
5
frontend/next-env.d.ts
vendored
Normal 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
55
frontend/next.config.ts
Normal 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
49
frontend/package.json
Normal 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"
|
||||
}
|
||||
8
frontend/postcss.config.mjs
Normal file
8
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -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} />;
|
||||
}
|
||||
178
frontend/src/app/(main)/board/[boardType]/[postSno]/page.tsx
Normal file
178
frontend/src/app/(main)/board/[boardType]/[postSno]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
frontend/src/app/(main)/board/[boardType]/page.tsx
Normal file
158
frontend/src/app/(main)/board/[boardType]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
frontend/src/app/(main)/board/[boardType]/write/page.tsx
Normal file
7
frontend/src/app/(main)/board/[boardType]/write/page.tsx
Normal 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} />;
|
||||
}
|
||||
500
frontend/src/app/(main)/dashboard/page.tsx
Normal file
500
frontend/src/app/(main)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
frontend/src/app/(main)/envset/codes-view/page.tsx
Normal file
115
frontend/src/app/(main)/envset/codes-view/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
frontend/src/app/(main)/envset/codes/page.tsx
Normal file
205
frontend/src/app/(main)/envset/codes/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
frontend/src/app/(main)/envset/layout.tsx
Normal file
32
frontend/src/app/(main)/envset/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
143
frontend/src/app/(main)/envset/menus/page.tsx
Normal file
143
frontend/src/app/(main)/envset/menus/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
frontend/src/app/(main)/envset/page.tsx
Normal file
5
frontend/src/app/(main)/envset/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function EnvsetPage() {
|
||||
redirect('/envset/users')
|
||||
}
|
||||
135
frontend/src/app/(main)/envset/user-auth/page.tsx
Normal file
135
frontend/src/app/(main)/envset/user-auth/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
234
frontend/src/app/(main)/envset/users/page.tsx
Normal file
234
frontend/src/app/(main)/envset/users/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
292
frontend/src/app/(main)/envset/workcd/page.tsx
Normal file
292
frontend/src/app/(main)/envset/workcd/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
frontend/src/app/(main)/fedex/0010/[sq]/page.tsx
Normal file
107
frontend/src/app/(main)/fedex/0010/[sq]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
frontend/src/app/(main)/fedex/0010/page.tsx
Normal file
121
frontend/src/app/(main)/fedex/0010/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
frontend/src/app/(main)/fedex/0010/write/page.tsx
Normal file
187
frontend/src/app/(main)/fedex/0010/write/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/app/(main)/layout.tsx
Normal file
16
frontend/src/app/(main)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
frontend/src/app/(main)/loading.tsx
Normal file
10
frontend/src/app/(main)/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
frontend/src/app/(main)/my/change-pw/page.tsx
Normal file
109
frontend/src/app/(main)/my/change-pw/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
frontend/src/app/(main)/tam/0010/page.tsx
Normal file
187
frontend/src/app/(main)/tam/0010/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
frontend/src/app/(main)/tam/0020/[docId]/page.tsx
Normal file
99
frontend/src/app/(main)/tam/0020/[docId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
frontend/src/app/(main)/tam/0020/list/page.tsx
Normal file
179
frontend/src/app/(main)/tam/0020/list/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/src/app/(main)/tam/0020/page.tsx
Normal file
28
frontend/src/app/(main)/tam/0020/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
frontend/src/app/(main)/tam/0020/write/page.tsx
Normal file
14
frontend/src/app/(main)/tam/0020/write/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
frontend/src/app/(main)/tam/0030/[docId]/page.tsx
Normal file
154
frontend/src/app/(main)/tam/0030/[docId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
frontend/src/app/(main)/tam/0030/page.tsx
Normal file
248
frontend/src/app/(main)/tam/0030/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
frontend/src/app/(main)/tam/0040/page.tsx
Normal file
123
frontend/src/app/(main)/tam/0040/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
frontend/src/app/(main)/wplan/0010/page.tsx
Normal file
211
frontend/src/app/(main)/wplan/0010/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
frontend/src/app/(main)/wplan/0020/page.tsx
Normal file
124
frontend/src/app/(main)/wplan/0020/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
frontend/src/app/(main)/wplan/0030/page.tsx
Normal file
237
frontend/src/app/(main)/wplan/0030/page.tsx
Normal 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">
|
||||
근무계획 > 전체근무계획
|
||||
</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)}월 | 전체 {list.length}건
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
frontend/src/app/(main)/wtime/0010/page.tsx
Normal file
143
frontend/src/app/(main)/wtime/0010/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
frontend/src/app/(main)/wtime/0030/page.tsx
Normal file
134
frontend/src/app/(main)/wtime/0030/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
frontend/src/app/error.tsx
Normal file
32
frontend/src/app/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/app/globals.css
Normal file
19
frontend/src/app/globals.css
Normal 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; }
|
||||
18
frontend/src/app/layout.tsx
Normal file
18
frontend/src/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
frontend/src/app/login/page.tsx
Normal file
17
frontend/src/app/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
frontend/src/app/not-found.tsx
Normal file
19
frontend/src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/page.tsx
Normal file
5
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function RootPage() {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
167
frontend/src/components/auth/LoginForm.tsx
Normal file
167
frontend/src/components/auth/LoginForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
frontend/src/components/board/PostForm.tsx
Normal file
109
frontend/src/components/board/PostForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
frontend/src/components/common/FileUpload.tsx
Normal file
161
frontend/src/components/common/FileUpload.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
AttachFileInfo,
|
||||
deleteFile,
|
||||
downloadFile,
|
||||
downloadZip,
|
||||
formatFileSize,
|
||||
uploadFiles,
|
||||
} from '@/lib/api/attach';
|
||||
|
||||
interface Props {
|
||||
atchNo?: string;
|
||||
division?: string;
|
||||
initialFiles?: AttachFileInfo[];
|
||||
onChange?: (atchNo: string, files: AttachFileInfo[]) => void;
|
||||
maxFiles?: number;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function FileUpload({
|
||||
atchNo: initialAtchNo,
|
||||
division,
|
||||
initialFiles = [],
|
||||
onChange,
|
||||
maxFiles = 10,
|
||||
readOnly = false,
|
||||
}: Props) {
|
||||
const [files, setFiles] = useState<AttachFileInfo[]>(initialFiles);
|
||||
const [atchNo, setAtchNo] = useState<string | undefined>(initialAtchNo);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async (selected: FileList | null) => {
|
||||
if (!selected || selected.length === 0) return;
|
||||
if (files.length + selected.length > maxFiles) {
|
||||
alert(`최대 ${maxFiles}개까지 업로드할 수 있습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await uploadFiles(Array.from(selected), atchNo, division);
|
||||
const newAtchNo = uploaded[0]?.atchNo ?? atchNo;
|
||||
const newFiles = [...files, ...uploaded];
|
||||
setAtchNo(newAtchNo);
|
||||
setFiles(newFiles);
|
||||
onChange?.(newAtchNo!, newFiles);
|
||||
} catch (e: any) {
|
||||
alert(e?.response?.data?.message ?? '업로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (atchfileNo: string) => {
|
||||
if (!confirm('파일을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await deleteFile(atchfileNo);
|
||||
const newFiles = files.filter((f) => f.atchfileNo !== atchfileNo);
|
||||
setFiles(newFiles);
|
||||
onChange?.(atchNo!, newFiles);
|
||||
} catch (e: any) {
|
||||
alert(e?.response?.data?.message ?? '삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 드롭존 */}
|
||||
{!readOnly && (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
|
||||
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-blue-400'}`}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
handleUpload(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleUpload(e.target.files)}
|
||||
/>
|
||||
{uploading ? (
|
||||
<p className="text-sm text-gray-500">업로드 중...</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
클릭하거나 파일을 드래그하세요 (최대 {maxFiles}개, 30MB 이하)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 목록 헤더 */}
|
||||
{files.length > 1 && atchNo && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => downloadZip(atchNo)}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
전체 다운로드 (ZIP)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 목록 */}
|
||||
{files.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{files.map((f) => (
|
||||
<li
|
||||
key={f.atchfileNo}
|
||||
className="flex items-center justify-between px-3 py-2 bg-gray-50 rounded border text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<FileIcon contentType={f.atchTypeNm} />
|
||||
<button
|
||||
onClick={() => downloadFile(f.atchfileNo, f.atchFileNm)}
|
||||
className="truncate text-blue-600 hover:underline text-left"
|
||||
>
|
||||
{f.atchFileNm}
|
||||
</button>
|
||||
<span className="text-gray-400 text-xs shrink-0">
|
||||
({formatFileSize(f.atchFileMg)})
|
||||
</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => handleDelete(f.atchfileNo)}
|
||||
className="ml-2 text-red-400 hover:text-red-600 shrink-0"
|
||||
title="삭제"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon({ contentType }: { contentType: string }) {
|
||||
const isImage = contentType?.startsWith('image');
|
||||
const isPdf = contentType === 'application/pdf';
|
||||
return (
|
||||
<span className="text-gray-400 shrink-0">
|
||||
{isImage ? '🖼' : isPdf ? '📄' : '📎'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/common/Modal.tsx
Normal file
78
frontend/src/components/common/Modal.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SIZE_CLASS = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
};
|
||||
|
||||
export default function Modal({ open, onClose, title, children, size = 'md', footer }: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
// body scroll 방지
|
||||
useEffect(() => {
|
||||
if (open) document.body.style.overflow = 'hidden';
|
||||
else document.body.style.overflow = '';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
|
||||
>
|
||||
<div className={`bg-white rounded-lg shadow-xl w-full ${SIZE_CLASS[size]} flex flex-col max-h-[90vh]`}>
|
||||
{/* 헤더 */}
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
|
||||
aria-label="닫기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
{footer && (
|
||||
<div className="px-5 py-3 border-t flex justify-end gap-2 bg-gray-50 rounded-b-lg">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/common/Pagination.tsx
Normal file
81
frontend/src/components/common/Pagination.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
export default function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
maxVisible = 10,
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
// 현재 페이지 주변 페이지 범위 계산
|
||||
const half = Math.floor(maxVisible / 2);
|
||||
let start = Math.max(1, currentPage - half);
|
||||
let end = Math.min(totalPages, start + maxVisible - 1);
|
||||
if (end - start + 1 < maxVisible) start = Math.max(1, end - maxVisible + 1);
|
||||
|
||||
const pages = Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center gap-1 mt-4 flex-wrap">
|
||||
{/* 처음 */}
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-2 py-1 text-sm rounded border hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
{/* 이전 */}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-2 py-1 text-sm rounded border hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
{start > 1 && <span className="px-1 text-gray-400 text-sm">…</span>}
|
||||
|
||||
{pages.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`px-3 py-1 text-sm rounded border transition-colors ${
|
||||
p === currentPage
|
||||
? 'bg-blue-600 text-white border-blue-600 font-medium'
|
||||
: 'hover:bg-gray-100 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{end < totalPages && <span className="px-1 text-gray-400 text-sm">…</span>}
|
||||
|
||||
{/* 다음 */}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-2 py-1 text-sm rounded border hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{/* 끝 */}
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-2 py-1 text-sm rounded border hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/common/Providers.tsx
Normal file
26
frontend/src/components/common/Providers.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
53
frontend/src/components/common/RichEditor.tsx
Normal file
53
frontend/src/components/common/RichEditor.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import type { Editor } from '@toast-ui/react-editor';
|
||||
import '@toast-ui/editor/dist/toastui-editor.css';
|
||||
|
||||
const ToastEditor = dynamic(
|
||||
() => import('@toast-ui/react-editor').then((m) => m.Editor),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export interface RichEditorRef {
|
||||
getMarkdown: () => string;
|
||||
getHTML: () => string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialValue?: string;
|
||||
height?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const RichEditor = forwardRef<RichEditorRef, Props>(
|
||||
({ initialValue = '', height = '400px', onChange }, ref) => {
|
||||
const editorRef = useRef<Editor>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => editorRef.current?.getInstance().getMarkdown() ?? '',
|
||||
getHTML: () => editorRef.current?.getInstance().getHTML() ?? '',
|
||||
}));
|
||||
|
||||
return (
|
||||
<ToastEditor
|
||||
ref={editorRef}
|
||||
initialValue={initialValue || ' '}
|
||||
previewStyle="vertical"
|
||||
height={height}
|
||||
initialEditType="wysiwyg"
|
||||
useCommandShortcut
|
||||
onChange={() => {
|
||||
if (onChange && editorRef.current) {
|
||||
onChange(editorRef.current.getInstance().getHTML());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RichEditor.displayName = 'RichEditor';
|
||||
|
||||
export default RichEditor;
|
||||
109
frontend/src/components/common/Toast.tsx
Normal file
109
frontend/src/components/common/Toast.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, createContext, useContext, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
// ─────────── Types ───────────
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (message: string, type?: ToastType, duration?: number) => void;
|
||||
success: (message: string, duration?: number) => void;
|
||||
error: (message: string, duration?: number) => void;
|
||||
info: (message: string, duration?: number) => void;
|
||||
warning: (message: string, duration?: number) => void;
|
||||
}
|
||||
|
||||
// ─────────── Context ───────────
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
let _idCounter = 0;
|
||||
|
||||
// ─────────── Provider ───────────
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const remove = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const toast = useCallback((message: string, type: ToastType = 'info', duration = 3000) => {
|
||||
const id = ++_idCounter;
|
||||
setToasts((prev) => [...prev, { id, type, message, duration }]);
|
||||
if (duration > 0) setTimeout(() => remove(id), duration);
|
||||
}, [remove]);
|
||||
|
||||
const ctx: ToastContextValue = {
|
||||
toast,
|
||||
success: (msg, dur) => toast(msg, 'success', dur),
|
||||
error: (msg, dur) => toast(msg, 'error', dur ?? 5000),
|
||||
info: (msg, dur) => toast(msg, 'info', dur),
|
||||
warning: (msg, dur) => toast(msg, 'warning', dur),
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={ctx}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onRemove={remove} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────── Hook ───────────
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ─────────── Container ───────────
|
||||
|
||||
const TYPE_STYLES: Record<ToastType, string> = {
|
||||
success: 'bg-green-600',
|
||||
error: 'bg-red-600',
|
||||
info: 'bg-blue-600',
|
||||
warning: 'bg-amber-500',
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<ToastType, string> = {
|
||||
success: '✓',
|
||||
error: '✕',
|
||||
info: 'ℹ',
|
||||
warning: '⚠',
|
||||
};
|
||||
|
||||
function ToastContainer({ toasts, onRemove }: { toasts: ToastItem[]; onRemove: (id: number) => void }) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(
|
||||
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 items-end pointer-events-none">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg text-white text-sm max-w-sm pointer-events-auto
|
||||
animate-in slide-in-from-right-4 fade-in duration-300 ${TYPE_STYLES[t.type]}`}
|
||||
>
|
||||
<span className="font-bold">{TYPE_ICONS[t.type]}</span>
|
||||
<span className="flex-1">{t.message}</span>
|
||||
<button
|
||||
onClick={() => onRemove(t.id)}
|
||||
className="ml-1 opacity-70 hover:opacity-100 text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/layout/Breadcrumb.tsx
Normal file
47
frontend/src/components/layout/Breadcrumb.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
frontend/src/components/layout/Header.tsx
Normal file
49
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
frontend/src/components/layout/Sidebar.tsx
Normal file
204
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
207
frontend/src/components/tam/ApproverSection.tsx
Normal file
207
frontend/src/components/tam/ApproverSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
frontend/src/components/tam/ApvdocInfo.tsx
Normal file
125
frontend/src/components/tam/ApvdocInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
frontend/src/components/tam/ApvreqForm.tsx
Normal file
129
frontend/src/components/tam/ApvreqForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
frontend/src/lib/api/attach.ts
Normal file
117
frontend/src/lib/api/attach.ts
Normal 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`;
|
||||
}
|
||||
17
frontend/src/lib/api/auth.ts
Normal file
17
frontend/src/lib/api/auth.ts
Normal 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'),
|
||||
}
|
||||
119
frontend/src/lib/api/board.ts
Normal file
119
frontend/src/lib/api/board.ts
Normal 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}`);
|
||||
}
|
||||
51
frontend/src/lib/api/client.ts
Normal file
51
frontend/src/lib/api/client.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
120
frontend/src/lib/api/envset.ts
Normal file
120
frontend/src/lib/api/envset.ts
Normal 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}`),
|
||||
}
|
||||
70
frontend/src/lib/api/fedex.ts
Normal file
70
frontend/src/lib/api/fedex.ts
Normal 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;
|
||||
}
|
||||
82
frontend/src/lib/api/main.ts
Normal file
82
frontend/src/lib/api/main.ts
Normal 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
198
frontend/src/lib/api/tam.ts
Normal 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;
|
||||
}
|
||||
89
frontend/src/lib/api/wplan.ts
Normal file
89
frontend/src/lib/api/wplan.ts
Normal 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;
|
||||
}
|
||||
89
frontend/src/lib/api/wtime.ts
Normal file
89
frontend/src/lib/api/wtime.ts
Normal 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;
|
||||
}
|
||||
53
frontend/src/lib/store/authStore.ts
Normal file
53
frontend/src/lib/store/authStore.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
18
frontend/src/lib/utils/date.ts
Normal file
18
frontend/src/lib/utils/date.ts
Normal 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);
|
||||
}
|
||||
24
frontend/src/middleware.ts
Normal file
24
frontend/src/middleware.ts
Normal 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).*)'],
|
||||
}
|
||||
41
frontend/src/types/auth.ts
Normal file
41
frontend/src/types/auth.ts
Normal 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
|
||||
18
frontend/src/types/common.ts
Normal file
18
frontend/src/types/common.ts
Normal 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
|
||||
}
|
||||
24
frontend/tailwind.config.ts
Normal file
24
frontend/tailwind.config.ts
Normal 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
23
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user