feat: 신규 페이지 구현 및 HR/설정 기능 개선

신규 페이지:
- 회계관리: 거래처, 예상비용, 청구서, 발주서
- 게시판: 공지사항, 자료실, 커뮤니티
- 고객센터: 문의/FAQ
- 설정: 계정, 알림, 출퇴근, 팝업, 구독, 결제내역
- 리포트 (차트 시각화)
- 개발자 테스트 URL 페이지

기능 개선:
- HR 직원관리/휴가관리/카드관리 강화
- IntegratedListTemplateV2 확장
- AuthenticatedLayout 패딩 표준화
- 로그인 페이지 UI 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-19 19:12:34 +09:00
parent d742c0ce26
commit c6b605200d
213 changed files with 32644 additions and 775 deletions

View File

@@ -0,0 +1,11 @@
'use client';
import { useParams } from 'next/navigation';
import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail';
export default function EditBadDebtPage() {
const params = useParams();
const recordId = params.id as string;
return <BadDebtDetail mode="edit" recordId={recordId} />;
}

View File

@@ -0,0 +1,11 @@
'use client';
import { useParams } from 'next/navigation';
import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail';
export default function BadDebtDetailPage() {
const params = useParams();
const recordId = params.id as string;
return <BadDebtDetail mode="view" recordId={recordId} />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail';
export default function NewBadDebtPage() {
return <BadDebtDetail mode="new" />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { BadDebtCollection } from '@/components/accounting/BadDebtCollection';
export default function BadDebtCollectionPage() {
return <BadDebtCollection />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { BankTransactionInquiry } from '@/components/accounting/BankTransactionInquiry';
export default function BankTransactionsPage() {
return <BankTransactionInquiry />;
}

View File

@@ -0,0 +1,13 @@
'use client';
import { useParams, useSearchParams } from 'next/navigation';
import { BillDetail } from '@/components/accounting/BillManagement/BillDetail';
export default function BillDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const billId = params.id as string;
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
return <BillDetail billId={billId} mode={mode} />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { BillDetail } from '@/components/accounting/BillManagement/BillDetail';
export default function BillNewPage() {
return <BillDetail billId="new" mode="new" />;
}

View File

@@ -0,0 +1,12 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { BillManagement } from '@/components/accounting/BillManagement';
export default function BillsPage() {
const searchParams = useSearchParams();
const vendorId = searchParams.get('vendorId') || undefined;
const billType = searchParams.get('type') || undefined;
return <BillManagement initialVendorId={vendorId} initialBillType={billType} />;
}

View File

@@ -0,0 +1,5 @@
import { CardTransactionInquiry } from '@/components/accounting/CardTransactionInquiry';
export default function CardTransactionsPage() {
return <CardTransactionInquiry />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { DailyReport } from '@/components/accounting/DailyReport';
export default function DailyReportPage() {
return <DailyReport />;
}

View File

@@ -0,0 +1,13 @@
'use client';
import { useParams, useSearchParams } from 'next/navigation';
import { DepositDetail } from '@/components/accounting/DepositManagement/DepositDetail';
export default function DepositDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const depositId = params.id as string;
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
return <DepositDetail depositId={depositId} mode={mode} />;
}

View File

@@ -0,0 +1,5 @@
import { DepositManagement } from '@/components/accounting/DepositManagement';
export default function DepositsPage() {
return <DepositManagement />;
}

View File

@@ -0,0 +1,5 @@
import { ExpectedExpenseManagement } from '@/components/accounting/ExpectedExpenseManagement';
export default function ExpectedExpensesPage() {
return <ExpectedExpenseManagement />;
}

View File

@@ -0,0 +1,13 @@
'use client';
import { useParams, useSearchParams } from 'next/navigation';
import { PurchaseDetail } from '@/components/accounting/PurchaseManagement/PurchaseDetail';
export default function PurchaseDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const purchaseId = params.id as string;
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
return <PurchaseDetail purchaseId={purchaseId} mode={mode} />;
}

View File

@@ -0,0 +1,5 @@
import { PurchaseManagement } from '@/components/accounting/PurchaseManagement';
export default function PurchasePage() {
return <PurchaseManagement />;
}

View File

@@ -0,0 +1,11 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { ReceivablesStatus } from '@/components/accounting/ReceivablesStatus';
export default function ReceivablesStatusPage() {
const searchParams = useSearchParams();
const highlightVendorId = searchParams.get('highlight') || undefined;
return <ReceivablesStatus highlightVendorId={highlightVendorId} />;
}

View File

@@ -0,0 +1,13 @@
'use client';
import { useParams, useSearchParams } from 'next/navigation';
import { SalesDetail } from '@/components/accounting/SalesManagement/SalesDetail';
export default function SalesDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const salesId = params.id as string;
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
return <SalesDetail mode={mode} salesId={salesId} />;
}

View File

@@ -0,0 +1,5 @@
import { SalesDetail } from '@/components/accounting/SalesManagement/SalesDetail';
export default function NewSalesPage() {
return <SalesDetail mode="new" />;
}

View File

@@ -0,0 +1,5 @@
import { SalesManagement } from '@/components/accounting/SalesManagement';
export default function SalesPage() {
return <SalesManagement />;
}

View File

@@ -0,0 +1,11 @@
'use client';
import { useParams } from 'next/navigation';
import { VendorLedgerDetail } from '@/components/accounting/VendorLedger/VendorLedgerDetail';
export default function VendorLedgerDetailPage() {
const params = useParams();
const vendorId = params.id as string;
return <VendorLedgerDetail vendorId={vendorId} />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { VendorLedger } from '@/components/accounting/VendorLedger';
export default function VendorLedgerPage() {
return <VendorLedger />;
}

View File

@@ -0,0 +1,13 @@
'use client';
import { useParams, useSearchParams } from 'next/navigation';
import { VendorDetail } from '@/components/accounting/VendorManagement/VendorDetail';
export default function VendorDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const vendorId = params.id as string;
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
return <VendorDetail mode={mode} vendorId={vendorId} />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { VendorDetail } from '@/components/accounting/VendorManagement/VendorDetail';
export default function NewVendorPage() {
return <VendorDetail mode="new" />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { VendorManagement } from '@/components/accounting/VendorManagement';
export default function VendorsPage() {
return <VendorManagement />;
}

View File

@@ -0,0 +1,13 @@
'use client';
import { useParams, useSearchParams } from 'next/navigation';
import { WithdrawalDetail } from '@/components/accounting/WithdrawalManagement/WithdrawalDetail';
export default function WithdrawalDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const withdrawalId = params.id as string;
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
return <WithdrawalDetail withdrawalId={withdrawalId} mode={mode} />;
}

View File

@@ -0,0 +1,5 @@
import { WithdrawalManagement } from '@/components/accounting/WithdrawalManagement';
export default function WithdrawalsPage() {
return <WithdrawalManagement />;
}

View File

@@ -0,0 +1,57 @@
'use client';
/**
* 게시글 수정 페이지
*/
import { useParams } from 'next/navigation';
import { useMemo } from 'react';
import { format } from 'date-fns';
import { BoardForm } from '@/components/board/BoardForm';
import type { Post } from '@/components/board/types';
import { MOCK_BOARDS } from '@/components/board/types';
// Mock 데이터 생성 (실제로는 API에서 가져옴)
const generateMockPost = (id: string): Post => {
const boards = MOCK_BOARDS.filter((b) => b.id !== 'all');
const board = boards[0];
return {
id,
boardId: board.id,
boardName: board.name,
title: '제목',
content: `
<p>게시글 내용입니다.</p>
<p>이것은 <strong>테스트용</strong> 콘텐츠입니다.</p>
`,
authorId: 'user1',
authorName: '홍길동',
authorDepartment: '개발팀',
authorPosition: '과장',
isPinned: false,
allowComments: true,
viewCount: 123,
attachments: [
{
id: 'file-1',
fileName: 'abc.pdf',
fileSize: 1024000,
fileUrl: '/files/abc.pdf',
mimeType: 'application/pdf',
},
],
createdAt: format(new Date(2025, 8, 9, 12, 20), "yyyy-MM-dd'T'HH:mm:ss"),
updatedAt: format(new Date(2025, 8, 9, 12, 20), "yyyy-MM-dd'T'HH:mm:ss"),
};
};
export default function BoardEditPage() {
const params = useParams();
const postId = params.id as string;
// Mock 데이터 (실제로는 API에서 가져옴)
const post = useMemo(() => generateMockPost(postId), [postId]);
return <BoardForm mode="edit" initialData={post} />;
}

View File

@@ -0,0 +1,94 @@
'use client';
/**
* 게시글 상세 페이지
*/
import { useParams } from 'next/navigation';
import { useMemo } from 'react';
import { format } from 'date-fns';
import { BoardDetail } from '@/components/board/BoardDetail';
import type { Post, Comment } from '@/components/board/types';
import { MOCK_BOARDS } from '@/components/board/types';
// 현재 로그인 사용자 ID (실제로는 auth context에서 가져옴)
const CURRENT_USER_ID = 'user1';
// Mock 데이터 생성 (실제로는 API에서 가져옴)
const generateMockPost = (id: string): Post => {
const boards = MOCK_BOARDS.filter((b) => b.id !== 'all');
const board = boards[0];
return {
id,
boardId: board.id,
boardName: board.name,
title: '제목',
content: `
<p>게시글 내용입니다.</p>
<p>이것은 <strong>테스트용</strong> 콘텐츠입니다.</p>
<p><img src="https://via.placeholder.com/600x300" alt="IMG" /></p>
<p>내용</p>
`,
authorId: 'user1',
authorName: '홍길동',
authorDepartment: '개발팀',
authorPosition: '과장',
isPinned: false,
allowComments: true,
viewCount: 123,
attachments: [
{
id: 'file-1',
fileName: 'abc.pdf',
fileSize: 1024000,
fileUrl: '/files/abc.pdf',
mimeType: 'application/pdf',
},
],
createdAt: format(new Date(2025, 8, 3, 12, 23), "yyyy-MM-dd'T'HH:mm:ss"),
updatedAt: format(new Date(2025, 8, 3, 12, 23), "yyyy-MM-dd'T'HH:mm:ss"),
};
};
const generateMockComments = (postId: string): Comment[] => [
{
id: 'comment-1',
postId,
authorId: 'user2',
authorName: '이름 직책',
authorDepartment: '부서명',
authorPosition: '',
content: '댓글 내용',
createdAt: format(new Date(2025, 8, 3, 15, 33), "yyyy-MM-dd'T'HH:mm:ss"),
updatedAt: format(new Date(2025, 8, 3, 15, 33), "yyyy-MM-dd'T'HH:mm:ss"),
},
{
id: 'comment-2',
postId,
authorId: 'user1', // 본인 댓글
authorName: '이름 직책',
authorDepartment: '부서명',
authorPosition: '',
content: '댓글 내용',
createdAt: format(new Date(2025, 8, 3, 15, 33), "yyyy-MM-dd'T'HH:mm:ss"),
updatedAt: format(new Date(2025, 8, 3, 15, 33), "yyyy-MM-dd'T'HH:mm:ss"),
},
];
export default function BoardDetailPage() {
const params = useParams();
const postId = params.id as string;
// Mock 데이터 (실제로는 API에서 가져옴)
const post = useMemo(() => generateMockPost(postId), [postId]);
const comments = useMemo(() => generateMockComments(postId), [postId]);
return (
<BoardDetail
post={post}
comments={comments}
currentUserId={CURRENT_USER_ID}
/>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { useRouter, useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { BoardForm } from '@/components/board/BoardManagement/BoardForm';
import type { Board, BoardFormData } from '@/components/board/BoardManagement/types';
// TODO: 실제 API에서 데이터 가져오기
const mockBoard: Board = {
id: '1',
target: 'all',
boardName: '공지사항',
status: 'active',
authorId: 'u1',
authorName: '홍길동',
createdAt: '2025-09-09T12:20:00Z',
updatedAt: '2025-09-09T12:20:00Z',
};
export default function BoardEditPage() {
const router = useRouter();
const params = useParams();
const [board, setBoard] = useState<Board | null>(null);
useEffect(() => {
// TODO: API 연동
// const id = params.id;
setBoard(mockBoard);
}, [params.id]);
const handleSubmit = (data: BoardFormData) => {
// TODO: API 연동
console.log('Update board:', params.id, data);
router.push(`/ko/board/board-management/${params.id}`);
};
if (!board) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}
return (
<BoardForm
mode="edit"
board={board}
onSubmit={handleSubmit}
/>
);
}

View File

@@ -0,0 +1,100 @@
'use client';
import { useRouter, useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { BoardDetail } from '@/components/board/BoardManagement/BoardDetail';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Board } from '@/components/board/BoardManagement/types';
// TODO: 실제 API에서 데이터 가져오기
const mockBoard: Board = {
id: '1',
target: 'all',
boardName: '공지사항',
status: 'active',
authorId: 'u1',
authorName: '홍길동',
createdAt: '2025-09-09T12:20:00Z',
updatedAt: '2025-09-09T12:20:00Z',
};
export default function BoardDetailPage() {
const router = useRouter();
const params = useParams();
const [board, setBoard] = useState<Board | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
useEffect(() => {
// TODO: API 연동
// const id = params.id;
setBoard(mockBoard);
}, [params.id]);
const handleEdit = () => {
router.push(`/ko/board/board-management/${params.id}/edit`);
};
const handleDelete = () => {
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
// TODO: API 연동
console.log('Delete board:', params.id);
router.push('/ko/board/board-management');
};
if (!board) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}
return (
<>
<BoardDetail
board={board}
onEdit={handleEdit}
onDelete={handleDelete}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{board.boardName}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { useRouter } from 'next/navigation';
import { BoardForm } from '@/components/board/BoardManagement/BoardForm';
import type { BoardFormData } from '@/components/board/BoardManagement/types';
export default function BoardNewPage() {
const router = useRouter();
const handleSubmit = (data: BoardFormData) => {
// TODO: API 연동
console.log('Create board:', data);
router.push('/ko/board/board-management');
};
return (
<BoardForm
mode="create"
onSubmit={handleSubmit}
/>
);
}

View File

@@ -0,0 +1,7 @@
'use client';
import { BoardManagement } from '@/components/board/BoardManagement';
export default function BoardManagementPage() {
return <BoardManagement />;
}

View File

@@ -0,0 +1,14 @@
/**
* 게시글 등록 페이지
*/
import { BoardForm } from '@/components/board/BoardForm';
export default function BoardCreatePage() {
return <BoardForm mode="create" />;
}
export const metadata = {
title: '게시글 등록',
description: '게시글을 등록하고 관리합니다.',
};

View File

@@ -0,0 +1,14 @@
/**
* 게시판 목록 페이지
*/
import { BoardList } from '@/components/board/BoardList';
export default function BoardPage() {
return <BoardList />;
}
export const metadata = {
title: '게시판',
description: '게시판의 게시글을 등록하고 관리합니다.',
};

View File

@@ -0,0 +1,5 @@
import { CompanyInfoManagement } from '@/components/settings/CompanyInfoManagement';
export default function CompanyInfoPage() {
return <CompanyInfoManagement />;
}

View File

@@ -0,0 +1,22 @@
'use client';
import { useParams } from 'next/navigation';
import { EventDetail, MOCK_EVENTS } from '@/components/customer-center/EventManagement';
export default function EventDetailPage() {
const params = useParams();
const eventId = params.id as string;
// Mock 데이터에서 이벤트 찾기
const event = MOCK_EVENTS.find((e) => e.id === eventId);
if (!event) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground"> .</p>
</div>
);
}
return <EventDetail event={event} />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { EventList } from '@/components/customer-center/EventManagement';
export default function EventsPage() {
return <EventList />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { FAQList } from '@/components/customer-center/FAQManagement';
export default function FAQPage() {
return <FAQList />;
}

View File

@@ -0,0 +1,27 @@
'use client';
/**
* 1:1 문의 수정 페이지
*/
import { useParams } from 'next/navigation';
import { InquiryForm } from '@/components/customer-center/InquiryManagement';
import { MOCK_INQUIRIES } from '@/components/customer-center/InquiryManagement/types';
export default function InquiryEditPage() {
const params = useParams();
const inquiryId = params.id as string;
// Mock: 문의 데이터 조회
const inquiry = MOCK_INQUIRIES.find((i) => i.id === inquiryId);
if (!inquiry) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<p className="text-muted-foreground"> .</p>
</div>
);
}
return <InquiryForm mode="edit" initialData={inquiry} />;
}

View File

@@ -0,0 +1,43 @@
'use client';
/**
* 1:1 문의 상세 페이지
*/
import { useParams } from 'next/navigation';
import { InquiryDetail } from '@/components/customer-center/InquiryManagement';
import { MOCK_INQUIRIES, MOCK_REPLY, MOCK_COMMENTS } from '@/components/customer-center/InquiryManagement/types';
export default function InquiryDetailPage() {
const params = useParams();
const inquiryId = params.id as string;
// Mock: 문의 데이터 조회
const inquiry = MOCK_INQUIRIES.find((i) => i.id === inquiryId);
if (!inquiry) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<p className="text-muted-foreground"> .</p>
</div>
);
}
// Mock: 답변 데이터 (답변완료 상태일 때만)
const reply = inquiry.status === 'completed' ? MOCK_REPLY : undefined;
// Mock: 댓글 데이터
const comments = MOCK_COMMENTS.filter((c) => c.inquiryId === inquiryId);
// Mock: 현재 사용자 ID
const currentUserId = 'user1';
return (
<InquiryDetail
inquiry={inquiry}
reply={reply}
comments={comments}
currentUserId={currentUserId}
/>
);
}

View File

@@ -0,0 +1,14 @@
/**
* 1:1 문의 등록 페이지
*/
import { InquiryForm } from '@/components/customer-center/InquiryManagement';
export default function InquiryCreatePage() {
return <InquiryForm mode="create" />;
}
export const metadata = {
title: '1:1 문의 등록',
description: '1:1 문의를 등록합니다.',
};

View File

@@ -0,0 +1,14 @@
/**
* 1:1 문의 목록 페이지
*/
import { InquiryList } from '@/components/customer-center/InquiryManagement';
export default function InquiriesPage() {
return <InquiryList />;
}
export const metadata = {
title: '1:1 문의',
description: '1:1 문의를 등록하고 답변을 확인합니다.',
};

View File

@@ -0,0 +1,22 @@
'use client';
import { useParams } from 'next/navigation';
import { NoticeDetail, MOCK_NOTICES } from '@/components/customer-center/NoticeManagement';
export default function NoticeDetailPage() {
const params = useParams();
const id = params.id as string;
// Mock 데이터에서 해당 공지사항 찾기
const notice = MOCK_NOTICES.find((n) => n.id === id);
if (!notice) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground"> .</p>
</div>
);
}
return <NoticeDetail notice={notice} />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { NoticeList } from '@/components/customer-center/NoticeManagement';
export default function NoticesPage() {
return <NoticeList />;
}

View File

@@ -0,0 +1,267 @@
'use client';
import { useState, useEffect } from 'react';
import { ExternalLink, Copy, Check, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react';
export interface UrlItem {
name: string;
url: string;
status?: string;
}
export interface UrlCategory {
title: string;
icon: string;
items: UrlItem[];
subCategories?: {
title: string;
items: UrlItem[];
}[];
}
interface TestUrlsClientProps {
initialData: UrlCategory[];
lastUpdated: string;
}
function UrlCard({ item, baseUrl }: { item: UrlItem; baseUrl: string }) {
const [copied, setCopied] = useState(false);
const fullUrl = `${baseUrl}${item.url}`;
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
await navigator.clipboard.writeText(fullUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleOpen = () => {
window.open(fullUrl, '_blank');
};
return (
<div
onClick={handleOpen}
className="group flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 hover:shadow-md transition-all cursor-pointer"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white truncate">
{item.name}
</span>
{item.status && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700">
{item.status}
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate mt-0.5">
{item.url}
</p>
</div>
<div className="flex items-center gap-1 ml-2">
<button
onClick={handleCopy}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="URL 복사"
>
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
<ExternalLink className="w-4 h-4 text-gray-400 group-hover:text-blue-500 transition-colors" />
</div>
</div>
);
}
function CategorySection({ category, baseUrl }: { category: UrlCategory; baseUrl: string }) {
const [expanded, setExpanded] = useState(true);
const [subExpanded, setSubExpanded] = useState<Record<string, boolean>>({});
const toggleSub = (title: string) => {
setSubExpanded((prev) => ({ ...prev, [title]: !prev[title] }));
};
const totalItems = category.items.length +
(category.subCategories?.reduce((acc, sub) => acc + sub.items.length, 0) || 0);
if (totalItems === 0) return null;
return (
<div className="mb-6">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 w-full text-left mb-3"
>
{expanded ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
<ChevronRight className="w-5 h-5 text-gray-500" />
)}
<span className="text-xl">{category.icon}</span>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{category.title}
</h2>
<span className="text-sm text-gray-500 ml-auto">
{totalItems}
</span>
</button>
{expanded && (
<div className="pl-7 space-y-4">
{category.items.length > 0 && (
<div className="grid gap-2">
{category.items.map((item) => (
<UrlCard key={item.url} item={item} baseUrl={baseUrl} />
))}
</div>
)}
{category.subCategories?.map((sub) => (
<div key={sub.title} className="mt-3">
<button
onClick={() => toggleSub(sub.title)}
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2 hover:text-gray-900 dark:hover:text-white"
>
{subExpanded[sub.title] !== false ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
{sub.title}
<span className="text-xs text-gray-400">({sub.items.length})</span>
</button>
{subExpanded[sub.title] !== false && (
<div className="grid gap-2 pl-6">
{sub.items.map((item) => (
<UrlCard key={item.url} item={item} baseUrl={baseUrl} />
))}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
export default function TestUrlsClient({ initialData, lastUpdated }: TestUrlsClientProps) {
const [baseUrl, setBaseUrl] = useState('http://localhost:3000');
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
if (typeof window !== 'undefined') {
setBaseUrl(window.location.origin);
}
}, []);
// 검색 필터링
const filteredData = initialData
.map((category) => ({
...category,
items: category.items.filter(
(item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.url.toLowerCase().includes(searchTerm.toLowerCase())
),
subCategories: category.subCategories?.map((sub) => ({
...sub,
items: sub.items.filter(
(item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.url.toLowerCase().includes(searchTerm.toLowerCase())
),
})).filter((sub) => sub.items.length > 0),
}))
.filter(
(category) =>
category.items.length > 0 || (category.subCategories && category.subCategories.length > 0)
);
const totalLinks = initialData.reduce(
(acc, cat) =>
acc +
cat.items.length +
(cat.subCategories?.reduce((subAcc, sub) => subAcc + sub.items.length, 0) || 0),
0
);
const handleRefresh = () => {
window.location.reload();
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
🔗 URL
</h1>
<button
onClick={handleRefresh}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="새로고침 (md 파일 변경사항 반영)"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<p className="text-gray-600 dark:text-gray-400">
URL ({totalLinks})
</p>
<p className="text-xs text-gray-400 mt-1">
: {lastUpdated}
</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
md
</p>
</div>
{/* Search & Base URL */}
<div className="flex gap-4 mb-6">
<input
type="text"
placeholder="페이지 또는 URL 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<div className="flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-300 dark:border-gray-600">
<span className="text-sm text-gray-500">Base:</span>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className="w-48 text-sm bg-transparent text-gray-900 dark:text-white focus:outline-none"
/>
</div>
</div>
{/* Categories */}
<div className="space-y-2">
{filteredData.map((category) => (
<CategorySection key={category.title} category={category} baseUrl={baseUrl} />
))}
</div>
{filteredData.length === 0 && (
<div className="text-center py-12 text-gray-500">
.
</div>
)}
{/* Footer */}
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500">
<p>
📁 : <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">claudedocs/[REF] all-pages-test-urls.md</code>
</p>
<p className="mt-1 text-green-600 dark:text-green-400">
md !
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,167 @@
import { promises as fs } from 'fs';
import path from 'path';
import TestUrlsClient, { UrlCategory, UrlItem } from './TestUrlsClient';
// 아이콘 매핑
const iconMap: Record<string, string> = {
'기본': '🏠',
'인사관리': '👥',
'HR': '👥',
'판매관리': '💰',
'Sales': '💰',
'기준정보관리': '📦',
'Master Data': '📦',
'생산관리': '🏭',
'Production': '🏭',
'설정': '⚙️',
'Settings': '⚙️',
'전자결재': '📝',
'Approval': '📝',
'회계관리': '💵',
'Accounting': '💵',
'게시판': '📋',
'Board': '📋',
'보고서': '📊',
'Reports': '📊',
};
function getIcon(title: string): string {
for (const [key, icon] of Object.entries(iconMap)) {
if (title.includes(key)) return icon;
}
return '📄';
}
function parseTableRow(line: string): UrlItem | null {
// | 페이지 | URL | 상태 | 형식 파싱
const parts = line.split('|').map(p => p.trim()).filter(p => p);
if (parts.length < 2) return null;
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
const url = parts[1].replace(/`/g, ''); // backtick 제거
const status = parts[2] || undefined;
// URL이 /ko로 시작하는지 확인
if (!url.startsWith('/ko')) return null;
return { name, url, status };
}
function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } {
const lines = content.split('\n');
const categories: UrlCategory[] = [];
let currentCategory: UrlCategory | null = null;
let currentSubCategory: { title: string; items: UrlItem[] } | null = null;
let lastUpdated = 'N/A';
// Last Updated 추출
const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/);
if (updateMatch) {
lastUpdated = updateMatch[1];
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// ## 카테고리 (메인 섹션)
if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) {
// 이전 카테고리 저장
if (currentCategory) {
if (currentSubCategory) {
currentCategory.subCategories = currentCategory.subCategories || [];
currentCategory.subCategories.push(currentSubCategory);
currentSubCategory = null;
}
categories.push(currentCategory);
}
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
currentCategory = {
title,
icon: getIcon(title),
items: [],
subCategories: [],
};
currentSubCategory = null;
}
// ### 서브 카테고리
else if (line.startsWith('### ') && currentCategory) {
// 이전 서브카테고리 저장
if (currentSubCategory) {
currentCategory.subCategories = currentCategory.subCategories || [];
currentCategory.subCategories.push(currentSubCategory);
}
const subTitle = line.replace('### ', '').trim();
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
if (subTitle === '메인 페이지') {
currentSubCategory = null;
} else {
currentSubCategory = {
title: subTitle,
items: [],
};
}
}
// 테이블 행 파싱
else if (line.startsWith('|') && currentCategory) {
const item = parseTableRow(line);
if (item) {
if (currentSubCategory) {
currentSubCategory.items.push(item);
} else {
currentCategory.items.push(item);
}
}
}
}
// 마지막 카테고리 저장
if (currentCategory) {
if (currentSubCategory) {
currentCategory.subCategories = currentCategory.subCategories || [];
currentCategory.subCategories.push(currentSubCategory);
}
categories.push(currentCategory);
}
// 빈 서브카테고리 제거
categories.forEach(cat => {
cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0);
});
return { categories, lastUpdated };
}
export default async function TestUrlsPage() {
// md 파일 경로
const mdFilePath = path.join(
process.cwd(),
'claudedocs',
'[REF] all-pages-test-urls.md'
);
let urlData: UrlCategory[] = [];
let lastUpdated = 'N/A';
try {
const fileContent = await fs.readFile(mdFilePath, 'utf-8');
const parsed = parseMdFile(fileContent);
urlData = parsed.categories;
lastUpdated = parsed.lastUpdated;
} catch (error) {
console.error('Failed to read md file:', error);
// 파일 읽기 실패 시 빈 데이터
urlData = [];
}
return <TestUrlsClient initialData={urlData} lastUpdated={lastUpdated} />;
}
// 캐싱 비활성화 - 항상 최신 md 파일 읽기
export const dynamic = 'force-dynamic';
export const revalidate = 0;

View File

@@ -0,0 +1,295 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { MapPin } from 'lucide-react';
import { Button } from '@/components/ui/button';
import GoogleMap from '@/components/attendance/GoogleMap';
import AttendanceComplete from '@/components/attendance/AttendanceComplete';
// ========================================
// 하드코딩 설정값 (MVP - 추후 API로 대체)
// ========================================
// TODO: 이 값들은 출퇴근관리 설정 페이지에서 관리됨
// 설정 페이지 경로: /settings/attendance-settings
// API 연동 시: GET /api/settings/attendance 에서 조회
// ────────────────────────────────────────
// - radius: 출퇴근관리 설정의 allowedRadius 값 사용
// - gpsDepartments: 로그인 사용자의 부서가 포함되어 있는지 체크
// - gpsEnabled: false면 GPS 출퇴근 기능 비활성화
// ────────────────────────────────────────
const SITE_LOCATION = {
name: '본사',
lat: 37.557358,
lng: 126.864414,
radius: 100, // meters → 출퇴근관리 설정의 allowedRadius 값으로 대체 예정
};
const TEST_USER = {
name: '홍길동',
department: '부서명',
position: '직급명',
};
// 출퇴근 상태 타입
type AttendanceStatus = 'not-checked-in' | 'checked-in' | 'checked-out';
type ViewMode = 'main' | 'check-in-complete' | 'check-out-complete';
export default function MobileAttendancePage() {
// Hydration 에러 방지: 클라이언트 마운트 상태
const [mounted, setMounted] = useState(false);
// 상태 관리
const [currentTime, setCurrentTime] = useState<string>('--:--:--');
const [currentDate, setCurrentDate] = useState<string>('');
const [distance, setDistance] = useState<number | null>(null);
const [isInRange, setIsInRange] = useState(false);
const [attendanceStatus, setAttendanceStatus] = useState<AttendanceStatus>('not-checked-in');
const [viewMode, setViewMode] = useState<ViewMode>('main');
const [checkInTime, setCheckInTime] = useState<string>('');
const [checkOutTime, setCheckOutTime] = useState<string>('');
const [userName, setUserName] = useState(TEST_USER.name);
const [userDepartment, setUserDepartment] = useState(TEST_USER.department);
const [userPosition, setUserPosition] = useState(TEST_USER.position);
// 클라이언트 마운트 확인
useEffect(() => {
setMounted(true);
}, []);
// 현재 시간 업데이트 (마운트 후에만 실행)
useEffect(() => {
if (!mounted) return;
const updateTime = () => {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
setCurrentTime(`${hours}:${minutes}:${seconds}`);
// 날짜 포맷
const year = now.getFullYear();
const month = now.getMonth() + 1;
const date = now.getDate();
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const day = dayNames[now.getDay()];
setCurrentDate(`${year}${month}${date}일 (${day})`);
};
updateTime();
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}, [mounted]);
// localStorage에서 사용자 정보 가져오기 (마운트 후에만 실행)
useEffect(() => {
if (!mounted) return;
const userDataStr = localStorage.getItem('user');
if (userDataStr) {
try {
const userData = JSON.parse(userDataStr);
if (userData.name) setUserName(userData.name);
if (userData.department) setUserDepartment(userData.department);
if (userData.position) setUserPosition(userData.position);
} catch (e) {
console.error('사용자 정보 파싱 실패:', e);
}
}
}, [mounted]);
// 거리 변경 콜백
const handleDistanceChange = useCallback((dist: number, inRange: boolean) => {
setDistance(dist);
setIsInRange(inRange);
}, []);
// 출근하기
const handleCheckIn = () => {
if (!isInRange) return;
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timeStr = `${hours}:${minutes}:${seconds}`;
setCheckInTime(timeStr);
setAttendanceStatus('checked-in');
setViewMode('check-in-complete');
// TODO: API 호출로 출근 기록 저장
console.log('[출근 기록]', {
time: timeStr,
location: SITE_LOCATION.name,
coordinates: { lat: SITE_LOCATION.lat, lng: SITE_LOCATION.lng },
});
};
// 퇴근하기
const handleCheckOut = () => {
if (!isInRange) return;
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timeStr = `${hours}:${minutes}:${seconds}`;
setCheckOutTime(timeStr);
setAttendanceStatus('checked-out');
setViewMode('check-out-complete');
// TODO: API 호출로 퇴근 기록 저장
console.log('[퇴근 기록]', {
time: timeStr,
location: SITE_LOCATION.name,
coordinates: { lat: SITE_LOCATION.lat, lng: SITE_LOCATION.lng },
});
};
// 완료 화면에서 확인 클릭
const handleConfirm = () => {
setViewMode('main');
};
// 마운트 전 로딩 UI (Hydration 에러 방지)
if (!mounted) {
return (
<div className="flex flex-col h-[calc(100vh-100px)]">
<div className="text-center py-3 border-b bg-white">
<h1 className="text-lg font-semibold"></h1>
</div>
<div className="flex-1 flex items-center justify-center bg-gray-100">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-gray-500 text-sm"> ...</p>
</div>
</div>
</div>
);
}
// 완료 화면 렌더링
if (viewMode === 'check-in-complete') {
return (
<div className="h-[calc(100vh-100px)]">
<AttendanceComplete
type="check-in"
time={checkInTime}
date={currentDate}
location={SITE_LOCATION.name}
onConfirm={handleConfirm}
/>
</div>
);
}
if (viewMode === 'check-out-complete') {
return (
<div className="h-[calc(100vh-100px)]">
<AttendanceComplete
type="check-out"
time={checkOutTime}
date={currentDate}
location={SITE_LOCATION.name}
onConfirm={handleConfirm}
/>
</div>
);
}
// 버튼 활성화 상태
const canCheckIn = isInRange && attendanceStatus === 'not-checked-in';
const canCheckOut = isInRange && attendanceStatus === 'checked-in';
return (
<div className="flex flex-col h-[calc(100vh-100px)]">
{/* 타이틀 */}
<div className="text-center py-3 border-b bg-white">
<h1 className="text-lg font-semibold"></h1>
</div>
{/* 지도 영역 */}
<div className="flex-1 relative">
<GoogleMap
siteLocation={SITE_LOCATION}
onDistanceChange={handleDistanceChange}
/>
{/* 거리 표시 오버레이 */}
{distance !== null && (
<div className="absolute top-3 left-3 bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg shadow-md">
<div className="flex items-center gap-1.5 text-sm">
<MapPin className="w-4 h-4 text-blue-500" />
<span className={isInRange ? 'text-green-600 font-medium' : 'text-gray-600'}>
{distance < 1000
? `${Math.round(distance)}m`
: `${(distance / 1000).toFixed(1)}km`}
{isInRange && ' (범위 내)'}
</span>
</div>
</div>
)}
</div>
{/* 사용자 정보 + 시간 + 버튼 */}
<div className="bg-white border-t p-4 space-y-4">
{/* 사용자 정보 */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
<span className="text-gray-600 font-medium">{userName.charAt(0)}</span>
</div>
<div>
<p className="font-semibold text-gray-900">{userName}</p>
<p className="text-sm text-gray-500">
{userDepartment} {userPosition}
</p>
</div>
</div>
{/* 현재 시간 */}
<div className="text-center">
<p className="text-3xl font-bold text-red-500" suppressHydrationWarning>{currentTime}</p>
<p className="text-xs text-gray-400 mt-0.5" suppressHydrationWarning>{currentDate}</p>
{attendanceStatus === 'checked-in' && (
<p className="text-sm text-green-600 mt-1"></p>
)}
</div>
{/* 출근/퇴근 버튼 */}
<div className="flex gap-3">
<Button
onClick={handleCheckIn}
disabled={!canCheckIn}
className={`flex-1 h-12 text-base font-medium rounded-lg transition-all ${
canCheckIn
? 'bg-blue-500 hover:bg-blue-600 text-white'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
</Button>
<Button
onClick={handleCheckOut}
disabled={!canCheckOut}
className={`flex-1 h-12 text-base font-medium rounded-lg transition-all ${
canCheckOut
? 'bg-gray-800 hover:bg-gray-900 text-white'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
</Button>
</div>
{/* 범위 밖 경고 */}
{!isInRange && distance !== null && (
<p className="text-center text-sm text-orange-500">
({SITE_LOCATION.radius}m) .
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { useRouter, useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { CardForm } from '@/components/hr/CardManagement/CardForm';
import type { Card, CardFormData } from '@/components/hr/CardManagement/types';
// TODO: 실제 API에서 데이터 가져오기
const mockCard: Card = {
id: '1',
cardCompany: 'shinhan',
cardNumber: '1234-1234-1234-1234',
cardName: '법인카드1',
expiryDate: '0327',
pinPrefix: '12',
status: 'active',
user: {
id: 'u1',
departmentId: 'd1',
departmentName: '부서명',
employeeId: 'e1',
employeeName: '홍길동',
positionId: 'p1',
positionName: '팀장',
},
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
export default function CardEditPage() {
const router = useRouter();
const params = useParams();
const [card, setCard] = useState<Card | null>(null);
useEffect(() => {
// TODO: API 연동
// const id = params.id;
setCard(mockCard);
}, [params.id]);
const handleSubmit = (data: CardFormData) => {
// TODO: API 연동
console.log('Update card:', params.id, data);
router.push(`/ko/hr/card-management/${params.id}`);
};
if (!card) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}
return (
<CardForm
mode="edit"
card={card}
onSubmit={handleSubmit}
/>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import { useRouter, useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { CardDetail } from '@/components/hr/CardManagement/CardDetail';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Card } from '@/components/hr/CardManagement/types';
// TODO: 실제 API에서 데이터 가져오기
const mockCard: Card = {
id: '1',
cardCompany: 'shinhan',
cardNumber: '1234-1234-1234-1234',
cardName: '법인카드1',
expiryDate: '0327',
pinPrefix: '12',
status: 'active',
user: {
id: 'u1',
departmentId: 'd1',
departmentName: '부서명',
employeeId: 'e1',
employeeName: '홍길동',
positionId: 'p1',
positionName: '팀장',
},
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
export default function CardDetailPage() {
const router = useRouter();
const params = useParams();
const [card, setCard] = useState<Card | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
useEffect(() => {
// TODO: API 연동
// const id = params.id;
setCard(mockCard);
}, [params.id]);
const handleEdit = () => {
router.push(`/ko/hr/card-management/${params.id}/edit`);
};
const handleDelete = () => {
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
// TODO: API 연동
console.log('Delete card:', params.id);
router.push('/ko/hr/card-management');
};
if (!card) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}
return (
<>
<CardDetail
card={card}
onEdit={handleEdit}
onDelete={handleDelete}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{card.cardName}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { useRouter } from 'next/navigation';
import { CardForm } from '@/components/hr/CardManagement/CardForm';
import type { CardFormData } from '@/components/hr/CardManagement/types';
export default function CardNewPage() {
const router = useRouter();
const handleSubmit = (data: CardFormData) => {
// TODO: API 연동
console.log('Create card:', data);
router.push('/ko/hr/card-management');
};
return (
<CardForm
mode="create"
onSubmit={handleSubmit}
/>
);
}

View File

@@ -0,0 +1,7 @@
'use client';
import { CardManagement } from '@/components/hr/CardManagement';
export default function CardManagementPage() {
return <CardManagement />;
}

View File

@@ -31,6 +31,10 @@ const mockEmployee: Employee = {
departmentPositions: [
{ id: '1', departmentId: 'd1', departmentName: '개발본부', positionId: 'p1', positionName: '팀장' }
],
clockInLocation: 'headquarters',
clockOutLocation: 'headquarters',
concurrentPosition: '',
concurrentReason: '',
userInfo: { userId: 'kimcs', role: 'manager', accountStatus: 'active' },
createdAt: '2020-03-15T00:00:00Z',
updatedAt: '2024-01-15T00:00:00Z',

View File

@@ -41,6 +41,10 @@ const mockEmployee: Employee = {
departmentPositions: [
{ id: '1', departmentId: 'd1', departmentName: '개발본부', positionId: 'p1', positionName: '팀장' }
],
clockInLocation: 'headquarters',
clockOutLocation: 'headquarters',
concurrentPosition: '',
concurrentReason: '',
userInfo: { userId: 'kimcs', role: 'manager', accountStatus: 'active' },
createdAt: '2020-03-15T00:00:00Z',
updatedAt: '2024-01-15T00:00:00Z',

View File

@@ -430,7 +430,7 @@ export default function EditItemPage() {
}
return (
<div className="py-6">
<div className="p-6">
<DynamicItemForm
mode="edit"
itemType={itemType}

View File

@@ -284,7 +284,7 @@ export default function ItemDetailPage() {
}
return (
<div className="py-6">
<div className="p-6">
<ItemDetailClient item={item} />
</div>
);

View File

@@ -86,7 +86,7 @@ export default function CreateItemPage() {
};
return (
<div className="py-6">
<div className="p-6">
{submitError && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-900">
{submitError}

View File

@@ -12,11 +12,7 @@ import ItemListClient from '@/components/items/ItemListClient';
* 품목 목록 페이지
*/
export default function ItemsPage() {
return (
<div className="p-6">
<ItemListClient />
</div>
);
return <ItemListClient />;
}
/**

View File

@@ -0,0 +1,5 @@
import { PaymentHistoryManagement } from '@/components/settings/PaymentHistoryManagement';
export default function PaymentHistoryPage() {
return <PaymentHistoryManagement />;
}

View File

@@ -190,7 +190,7 @@ export default function EditItemPage() {
if (isLoading) {
return (
<div className="py-6">
<div className="p-6">
<div className="text-center py-8"> ...</div>
</div>
);
@@ -201,7 +201,7 @@ export default function EditItemPage() {
}
return (
<div className="py-6">
<div className="p-6">
<ItemForm mode="edit" initialData={item} onSubmit={handleSubmit} />
</div>
);

View File

@@ -163,7 +163,7 @@ export default async function ItemDetailPage({
}
return (
<div className="py-6">
<div className="p-6">
<Suspense fallback={<div className="text-center py-8"> ...</div>}>
<ItemDetailClient item={item} />
</Suspense>

View File

@@ -21,7 +21,7 @@ export default function CreateItemPage() {
};
return (
<div className="py-6">
<div className="p-6">
<ItemForm mode="create" onSubmit={handleSubmit} />
</div>
);

View File

@@ -145,11 +145,9 @@ export default async function ItemsPage() {
const items = await getItems();
return (
<div className="p-6">
<Suspense fallback={<div className="text-center py-8"> ...</div>}>
<ItemListClient />
</Suspense>
</div>
<Suspense fallback={<div className="text-center py-8"> ...</div>}>
<ItemListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,5 @@
import ComprehensiveAnalysis from '@/components/reports/ComprehensiveAnalysis';
export default function ComprehensiveAnalysisPage() {
return <ComprehensiveAnalysis />;
}

View File

@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation';
export default function ReportsPage() {
// 보고서 및 분석 메인 → 종합 경영 분석으로 리다이렉트
redirect('/ko/reports/comprehensive-analysis');
}

View File

@@ -140,6 +140,12 @@ function mapStatus(apiStatus: string, isFinal: boolean): PricingStatus | 'not_re
}
}
// 품목 유형 → API item_type_code 매핑 (백엔드 pricing API용)
function mapItemTypeCode(itemType?: string): 'PRODUCT' | 'MATERIAL' {
// FG(제품)만 PRODUCT, 나머지는 모두 MATERIAL
return itemType === 'FG' ? 'PRODUCT' : 'MATERIAL';
}
// ============================================
// API 호출 함수
// ============================================

View File

@@ -0,0 +1,5 @@
import { AccountInfoClient } from '@/components/settings/AccountInfoManagement';
export default function AccountInfoPage() {
return <AccountInfoClient />;
}

View File

@@ -0,0 +1,129 @@
'use client';
import { useParams } from 'next/navigation';
import { AccountDetail } from '@/components/settings/AccountManagement/AccountDetail';
import type { Account } from '@/components/settings/AccountManagement/types';
// Mock 데이터 (API 연동 전 임시)
const mockAccounts: Account[] = [
{
id: 'account-1',
bankCode: 'shinhan',
accountNumber: '1234-1234-1234-1234',
accountName: '운영계좌 1',
accountHolder: '예금주1',
status: 'active',
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-2',
bankCode: 'kb',
accountNumber: '1234-1234-1234-1235',
accountName: '운영계좌 2',
accountHolder: '예금주2',
status: 'inactive',
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-3',
bankCode: 'woori',
accountNumber: '1234-1234-1234-1236',
accountName: '운영계좌 3',
accountHolder: '예금주3',
status: 'active',
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-4',
bankCode: 'hana',
accountNumber: '1234-1234-1234-1237',
accountName: '운영계좌 4',
accountHolder: '예금주4',
status: 'inactive',
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-5',
bankCode: 'nh',
accountNumber: '1234-1234-1234-1238',
accountName: '운영계좌 5',
accountHolder: '예금주5',
status: 'active',
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-6',
bankCode: 'ibk',
accountNumber: '1234-1234-1234-1239',
accountName: '운영계좌 6',
accountHolder: '예금주6',
status: 'inactive',
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-7',
bankCode: 'shinhan',
accountNumber: '1234-1234-1234-1240',
accountName: '운영계좌 7',
accountHolder: '예금주7',
status: 'active',
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-8',
bankCode: 'kb',
accountNumber: '1234-1234-1234-1241',
accountName: '운영계좌 8',
accountHolder: '예금주8',
status: 'inactive',
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-9',
bankCode: 'woori',
accountNumber: '1234-1234-1234-1242',
accountName: '운영계좌 9',
accountHolder: '예금주9',
status: 'active',
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-10',
bankCode: 'hana',
accountNumber: '1234-1234-1234-1243',
accountName: '운영계좌 10',
accountHolder: '예금주10',
status: 'inactive',
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
];
export default function AccountDetailPage() {
const params = useParams();
const accountId = params.id as string;
// Mock: 계좌 조회
const account = mockAccounts.find(a => a.id === accountId);
if (!account) {
return (
<div className="p-6">
<div className="text-center py-8 text-muted-foreground">
.
</div>
</div>
);
}
return <AccountDetail account={account} mode="view" />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { AccountDetail } from '@/components/settings/AccountManagement/AccountDetail';
export default function NewAccountPage() {
return <AccountDetail mode="create" />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { AccountManagement } from '@/components/settings/AccountManagement';
export default function AccountsPage() {
return <AccountManagement />;
}

View File

@@ -0,0 +1,5 @@
import { AttendanceSettingsManagement } from '@/components/settings/AttendanceSettingsManagement';
export default function AttendanceSettingsPage() {
return <AttendanceSettingsManagement />;
}

View File

@@ -0,0 +1,5 @@
import { NotificationSettingsManagement } from '@/components/settings/NotificationSettings';
export default function NotificationSettingsPage() {
return <NotificationSettingsManagement />;
}

View File

@@ -0,0 +1,23 @@
'use client';
import { useParams } from 'next/navigation';
import { PopupForm } from '@/components/settings/PopupManagement';
import { MOCK_POPUPS } from '@/components/settings/PopupManagement/types';
export default function PopupEditPage() {
const params = useParams();
const id = params.id as string;
// Mock: ID로 팝업 데이터 조회
const popup = MOCK_POPUPS.find((p) => p.id === id);
if (!popup) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<p className="text-muted-foreground"> .</p>
</div>
);
}
return <PopupForm mode="edit" initialData={popup} />;
}

View File

@@ -0,0 +1,89 @@
'use client';
import { useRouter, useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { PopupDetail } from '@/components/settings/PopupManagement';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { MOCK_POPUPS, type Popup } from '@/components/settings/PopupManagement/types';
export default function PopupDetailPage() {
const router = useRouter();
const params = useParams();
const [popup, setPopup] = useState<Popup | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
useEffect(() => {
// TODO: API 연동
const id = params.id as string;
const found = MOCK_POPUPS.find((p) => p.id === id);
setPopup(found || null);
}, [params.id]);
const handleEdit = () => {
router.push(`/ko/settings/popup-management/${params.id}/edit`);
};
const handleDelete = () => {
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
// TODO: API 연동
console.log('Delete popup:', params.id);
router.push('/ko/settings/popup-management');
};
if (!popup) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}
return (
<>
<PopupDetail
popup={popup}
onEdit={handleEdit}
onDelete={handleDelete}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{popup.title}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,5 @@
import { PopupForm } from '@/components/settings/PopupManagement';
export default function PopupCreatePage() {
return <PopupForm mode="create" />;
}

View File

@@ -0,0 +1,5 @@
import { PopupList } from '@/components/settings/PopupManagement';
export default function PopupManagementPage() {
return <PopupList />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import { SubscriptionManagement } from '@/components/settings/SubscriptionManagement';
export default function SubscriptionPage() {
return <SubscriptionManagement />;
}