feat(WEB): 전체 페이지 ?mode= URL 네비게이션 패턴 적용
- 등록(?mode=new), 상세(?mode=view), 수정(?mode=edit) URL 패턴 일괄 적용
- 중복 패턴 제거: /edit?mode=edit → ?mode=edit (16개 파일)
- 제목 일관성: {기능} 등록/상세/수정 패턴 적용
- 검수 체크리스트 문서 추가 (79개 페이지)
- UniversalListPage, IntegratedDetailTemplate 공통 컴포넌트 개선
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,15 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 악성채권 추심관리 목록 페이지
|
||||
* 악성채권 추심관리 목록/등록 페이지
|
||||
*
|
||||
* 경로: /accounting/bad-debt-collection
|
||||
* API:
|
||||
* - GET /api/v1/bad-debts - 악성채권 목록
|
||||
* - GET /api/v1/bad-debts/summary - 통계 정보
|
||||
* 경로: /accounting/bad-debt-collection?mode=new
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BadDebtCollection } from '@/components/accounting/BadDebtCollection';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { BadDebtCollection, BadDebtDetailClientV2 } from '@/components/accounting/BadDebtCollection';
|
||||
import { getBadDebts, getBadDebtSummary } from '@/components/accounting/BadDebtCollection/actions';
|
||||
import type { BadDebtSummary } from '@/components/accounting/BadDebtCollection/types';
|
||||
|
||||
@@ -23,21 +22,32 @@ const DEFAULT_SUMMARY: BadDebtSummary = {
|
||||
};
|
||||
|
||||
export default function BadDebtCollectionPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getBadDebts>>>([]);
|
||||
const [summary, setSummary] = useState<BadDebtSummary>(DEFAULT_SUMMARY);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getBadDebts({ size: 100 }),
|
||||
getBadDebtSummary(),
|
||||
])
|
||||
.then(([badDebts, summaryResult]) => {
|
||||
setData(badDebts);
|
||||
setSummary(summaryResult);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
if (mode !== 'new') {
|
||||
Promise.all([
|
||||
getBadDebts({ size: 100 }),
|
||||
getBadDebtSummary(),
|
||||
])
|
||||
.then(([badDebts, summaryResult]) => {
|
||||
setData(badDebts);
|
||||
setSummary(summaryResult);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
if (mode === 'new') {
|
||||
return <BadDebtDetailClientV2 />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -53,4 +63,4 @@ export default function BadDebtCollectionPage() {
|
||||
initialSummary={summary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { BillManagementClient } from '@/components/accounting/BillManagement/BillManagementClient';
|
||||
import { BillDetail } from '@/components/accounting/BillManagement/BillDetail';
|
||||
import { getBills } from '@/components/accounting/BillManagement/actions';
|
||||
import type { BillRecord } from '@/components/accounting/BillManagement/types';
|
||||
|
||||
@@ -15,6 +16,7 @@ const DEFAULT_PAGINATION = {
|
||||
|
||||
export default function BillsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
const vendorId = searchParams.get('vendorId') || undefined;
|
||||
const billType = searchParams.get('type') || 'received';
|
||||
const page = searchParams.get('page') ? parseInt(searchParams.get('page')!) : 1;
|
||||
@@ -24,15 +26,23 @@ export default function BillsPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getBills({ billType, page, perPage: 20 })
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [billType, page]);
|
||||
if (mode !== 'new') {
|
||||
getBills({ billType, page, perPage: 20 })
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [mode, billType, page]);
|
||||
|
||||
if (mode === 'new') {
|
||||
return <BillDetail billId="new" mode="new" />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -50,4 +60,4 @@ export default function BillsPage() {
|
||||
initialBillType={billType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { CardTransactionInquiry } from '@/components/accounting/CardTransactionInquiry';
|
||||
import CardTransactionDetailClient from '@/components/accounting/CardTransactionInquiry/CardTransactionDetailClient';
|
||||
|
||||
export default function CardTransactionsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <CardTransactionDetailClient initialMode="create" />;
|
||||
}
|
||||
|
||||
return <CardTransactionInquiry />;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { VendorManagement } from '@/components/accounting/VendorManagement';
|
||||
import { VendorDetail } from '@/components/accounting/VendorManagement/VendorDetail';
|
||||
import { getClients } from '@/components/accounting/VendorManagement/actions';
|
||||
|
||||
export default function VendorsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getClients>>['data']>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getClients({ size: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
if (mode !== 'new') {
|
||||
getClients({ size: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
if (mode === 'new') {
|
||||
return <VendorDetail mode="new" />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -32,4 +45,4 @@ export default function VendorsPage() {
|
||||
initialTotal={total}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { DraftBox } from '@/components/approval/DraftBox';
|
||||
import { DocumentCreate } from '@/components/approval/DocumentCreate';
|
||||
|
||||
export default function DraftBoxPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <DocumentCreate />;
|
||||
}
|
||||
|
||||
return <DraftBox />;
|
||||
}
|
||||
|
||||
435
src/app/[locale]/(protected)/board/[boardCode]/page.tsx
Normal file
435
src/app/[locale]/(protected)/board/[boardCode]/page.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 특정 게시판의 게시글 목록 페이지
|
||||
* URL: /board/[boardCode]
|
||||
*
|
||||
* Note: /boards/[boardCode]/page.tsx와 동일한 기능
|
||||
* 라우팅 일관성을 위해 singular 경로에도 페이지 추가
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter, useParams, useSearchParams } from 'next/navigation';
|
||||
import { MessageSquare, Plus } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type TableColumn,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { getDynamicBoardPosts } from '@/components/board/DynamicBoard/actions';
|
||||
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
||||
import { BoardDetail } from '@/components/board/BoardDetail';
|
||||
import type { PostApiData } from '@/components/customer-center/shared/types';
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
// 정렬 옵션
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
];
|
||||
|
||||
// 게시글 상태 옵션
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'published', label: '게시됨' },
|
||||
{ value: 'draft', label: '임시저장' },
|
||||
];
|
||||
|
||||
interface BoardPost {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
status: string;
|
||||
views: number;
|
||||
isNotice: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// API 데이터 → 프론트엔드 타입 변환
|
||||
function transformApiToPost(apiData: PostApiData): BoardPost {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
title: apiData.title,
|
||||
content: apiData.content,
|
||||
authorId: String(apiData.user_id),
|
||||
authorName: apiData.author?.name || '회원',
|
||||
status: apiData.status,
|
||||
views: apiData.views,
|
||||
isNotice: apiData.is_notice,
|
||||
createdAt: apiData.created_at,
|
||||
updatedAt: apiData.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export default function BoardCodePage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const boardCode = params.boardCode as string;
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
// mode=new: 게시글 작성 폼 표시
|
||||
if (mode === 'new') {
|
||||
return <BoardDetail boardCode={boardCode} mode="create" />;
|
||||
}
|
||||
|
||||
// 게시판 정보
|
||||
const [boardName, setBoardName] = useState<string>('게시판');
|
||||
const [boardDescription, setBoardDescription] = useState<string>('');
|
||||
|
||||
// 게시글 목록
|
||||
const [posts, setPosts] = useState<BoardPost[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 필터 및 검색
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortOption, setSortOption] = useState<string>('latest');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
// 게시판 정보 로드
|
||||
useEffect(() => {
|
||||
async function fetchBoardInfo() {
|
||||
const result = await getBoardByCode(boardCode);
|
||||
if (result.success && result.data) {
|
||||
setBoardName(result.data.boardName);
|
||||
setBoardDescription(result.data.description || '');
|
||||
}
|
||||
}
|
||||
fetchBoardInfo();
|
||||
}, [boardCode]);
|
||||
|
||||
// 게시글 목록 로드
|
||||
useEffect(() => {
|
||||
async function fetchPosts() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await getDynamicBoardPosts(boardCode, { per_page: 100 });
|
||||
|
||||
if (result.success && result.data) {
|
||||
const transformed = result.data.data.map(transformApiToPost);
|
||||
setPosts(transformed);
|
||||
} else {
|
||||
setError(result.error || '게시글 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
fetchPosts();
|
||||
}, [boardCode]);
|
||||
|
||||
// 필터링 및 정렬
|
||||
const filteredData = useMemo(() => {
|
||||
let result = [...posts];
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all') {
|
||||
result = result.filter((item) => item.status === statusFilter);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (startDate && endDate) {
|
||||
result = result.filter((item) => {
|
||||
const itemDate = format(new Date(item.createdAt), 'yyyy-MM-dd');
|
||||
return itemDate >= startDate && itemDate <= endDate;
|
||||
});
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const searchLower = searchValue.toLowerCase();
|
||||
result = result.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(searchLower) ||
|
||||
item.authorName.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (sortOption === 'latest') {
|
||||
result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
} else {
|
||||
result.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [posts, statusFilter, startDate, endDate, searchValue, sortOption]);
|
||||
|
||||
// 페이지네이션
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredData.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
}, [filteredData, currentPage]);
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE);
|
||||
|
||||
// 핸들러
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(item: BoardPost) => {
|
||||
router.push(`/ko/board/${boardCode}/${item.id}?mode=view`);
|
||||
},
|
||||
[router, boardCode]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push(`/ko/board/${boardCode}?mode=new`);
|
||||
}, [router, boardCode]);
|
||||
|
||||
// 상태 Badge
|
||||
const getStatusBadge = (status: string) => {
|
||||
if (status === 'published') {
|
||||
return <Badge variant="secondary" className="bg-green-100 text-green-700">게시됨</Badge>;
|
||||
}
|
||||
if (status === 'draft') {
|
||||
return <Badge variant="secondary" className="bg-yellow-100 text-yellow-700">임시저장</Badge>;
|
||||
}
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
};
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||
{ key: 'title', label: '제목', className: 'min-w-[200px]' },
|
||||
{ key: 'author', label: '작성자', className: 'w-[120px]' },
|
||||
{ key: 'views', label: '조회수', className: 'w-[80px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' },
|
||||
];
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(
|
||||
item: BoardPost,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
|
||||
) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.isNotice && (
|
||||
<Badge variant="destructive" className="text-xs">공지</Badge>
|
||||
)}
|
||||
{item.title}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.authorName}</TableCell>
|
||||
<TableCell className="text-center">{item.views}</TableCell>
|
||||
<TableCell className="text-center">{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{format(new Date(item.createdAt), 'yyyy-MM-dd')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(
|
||||
item: BoardPost,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void }
|
||||
) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">#{globalIndex}</span>
|
||||
{item.isNotice && (
|
||||
<Badge variant="destructive" className="text-xs">공지</Badge>
|
||||
)}
|
||||
</div>
|
||||
{getStatusBadge(item.status)}
|
||||
</div>
|
||||
<h3 className="font-medium">{item.title}</h3>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{item.authorName}</span>
|
||||
<span>{format(new Date(item.createdAt), 'yyyy-MM-dd')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// UniversalListPage 설정
|
||||
const boardConfig: UniversalListConfig<BoardPost> = {
|
||||
title: boardName,
|
||||
description: boardDescription || `${boardName} 게시판입니다.`,
|
||||
icon: MessageSquare,
|
||||
basePath: `/board/${boardCode}`,
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: filteredData,
|
||||
totalCount: filteredData.length,
|
||||
}),
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
headerActions: () => (
|
||||
<>
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
<Button className="ml-auto" onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
글쓰기
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {filteredData.length}건
|
||||
</span>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={setStatusFilter}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sortOption} onValueChange={setSortOption}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
searchPlaceholder: '제목, 작성자로 검색...',
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
clientSideFiltering: true,
|
||||
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
};
|
||||
|
||||
return (
|
||||
<UniversalListPage<BoardPost>
|
||||
config={boardConfig}
|
||||
initialData={filteredData}
|
||||
initialTotalCount={filteredData.length}
|
||||
externalSelection={{
|
||||
selectedItems,
|
||||
setSelectedItems,
|
||||
toggleSelection: handleToggleSelection,
|
||||
toggleSelectAll: handleToggleSelectAll,
|
||||
}}
|
||||
externalSearch={{
|
||||
searchTerm: searchValue,
|
||||
setSearchTerm: setSearchValue,
|
||||
}}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { BoardManagement } from '@/components/board/BoardManagement';
|
||||
import { BoardDetailClientV2 } from '@/components/board/BoardManagement/BoardDetailClientV2';
|
||||
|
||||
export default function BoardManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <BoardDetailClientV2 boardId="new" />;
|
||||
}
|
||||
|
||||
return <BoardManagement />;
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ export default function DynamicBoardDetailPage() {
|
||||
|
||||
// 수정 페이지로 이동
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/boards/${boardCode}/${postId}/edit`);
|
||||
router.push(`/ko/boards/${boardCode}/${postId}?mode=edit`);
|
||||
}, [router, boardCode, postId]);
|
||||
|
||||
// 목록으로 이동
|
||||
|
||||
@@ -205,7 +205,7 @@ export default function DynamicBoardListPage() {
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push(`/ko/boards/${boardCode}/create`);
|
||||
router.push(`/ko/boards/${boardCode}?mode=new`);
|
||||
}, [router, boardCode]);
|
||||
|
||||
// 상태 Badge
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { ItemManagementClient } from '@/components/business/construction/item-management';
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ItemManagementClient, ItemDetailClient } from '@/components/business/construction/item-management';
|
||||
|
||||
export default function ItemManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <ItemDetailClient isNewMode />;
|
||||
}
|
||||
|
||||
return <ItemManagementClient />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { LaborManagementClient } from '@/components/business/construction/labor-management';
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { LaborManagementClient, LaborDetailClientV2 } from '@/components/business/construction/labor-management';
|
||||
|
||||
export default function LaborManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <LaborDetailClientV2 initialMode="create" />;
|
||||
}
|
||||
|
||||
return <LaborManagementClient />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import PricingListClient from '@/components/business/construction/pricing-management/PricingListClient';
|
||||
import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management';
|
||||
|
||||
export default function PricingPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <PricingDetailClientV2 initialMode="create" />;
|
||||
}
|
||||
|
||||
return <PricingListClient />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { OrderManagementListClient } from '@/components/business/construction/order-management';
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { OrderManagementListClient, OrderDetailForm } from '@/components/business/construction/order-management';
|
||||
|
||||
export default function OrderManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <OrderDetailForm mode="create" />;
|
||||
}
|
||||
|
||||
return <OrderManagementListClient />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import StructureReviewListClient from '@/components/business/construction/structure-review/StructureReviewListClient';
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { StructureReviewListClient, StructureReviewDetailForm } from '@/components/business/construction/structure-review';
|
||||
|
||||
export default function StructureReviewListPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <StructureReviewDetailForm mode="new" />;
|
||||
}
|
||||
|
||||
return <StructureReviewListClient />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { PartnerListClient } from '@/components/business/construction/partners';
|
||||
import PartnerForm from '@/components/business/construction/partners/PartnerForm';
|
||||
|
||||
export default function PartnersPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <PartnerForm mode="new" />;
|
||||
}
|
||||
|
||||
return <PartnerListClient />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { SiteBriefingListClient } from '@/components/business/construction/site-briefings';
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { SiteBriefingListClient, SiteBriefingForm } from '@/components/business/construction/site-briefings';
|
||||
|
||||
export default function SiteBriefingsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <SiteBriefingForm mode="new" />;
|
||||
}
|
||||
|
||||
return <SiteBriefingListClient />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import ProjectDetailClient from '@/components/business/construction/management/ProjectDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
locale: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function ProjectExecutionManagementPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
|
||||
return <ProjectDetailClient projectId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import ProjectDetailClient from '@/components/business/construction/management/ProjectDetailClient';
|
||||
|
||||
export default function ProjectExecutionManagementPage() {
|
||||
// projectId 없이 호출 → 전체 데이터 칸반보드
|
||||
return <ProjectDetailClient />;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import IssueManagementListClient from '@/components/business/construction/issue-management/IssueManagementListClient';
|
||||
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
|
||||
import {
|
||||
getIssueList,
|
||||
getIssueStats,
|
||||
@@ -12,33 +14,44 @@ import type {
|
||||
} from '@/components/business/construction/issue-management/types';
|
||||
|
||||
export default function IssueManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
const [data, setData] = useState<Issue[]>([]);
|
||||
const [stats, setStats] = useState<IssueStats | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getIssueList({ size: 1000 }),
|
||||
getIssueStats(),
|
||||
]);
|
||||
if (mode !== 'new') {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getIssueList({ size: 1000 }),
|
||||
getIssueStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setData(listResult.data.items);
|
||||
if (listResult.success && listResult.data) {
|
||||
setData(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load issue management data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load issue management data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
loadData();
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
if (mode === 'new') {
|
||||
return <IssueDetailForm mode="create" />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 1:1 문의 목록 페이지
|
||||
* URL: /customer-center/qna
|
||||
* - ?mode=new: 문의 등록
|
||||
*/
|
||||
|
||||
import { InquiryList } from '@/components/customer-center/InquiryManagement';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { InquiryList, InquiryDetailClientV2 } from '@/components/customer-center/InquiryManagement';
|
||||
|
||||
export default function InquiriesPage() {
|
||||
return <InquiryList />;
|
||||
}
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
export const metadata = {
|
||||
title: '1:1 문의',
|
||||
description: '1:1 문의를 등록하고 답변을 확인합니다.',
|
||||
};
|
||||
// mode=new: 문의 등록
|
||||
if (mode === 'new') {
|
||||
return <InquiryDetailClientV2 />;
|
||||
}
|
||||
|
||||
return <InquiryList />;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
CreditAnalysisModal,
|
||||
MOCK_CREDIT_DATA,
|
||||
} from '@/components/accounting/VendorManagement/CreditAnalysisModal';
|
||||
|
||||
export default function CreditAnalysisTestPage() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleApprove = () => {
|
||||
toast.success('거래가 승인되었습니다.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기업 신용분석 모달 테스트</CardTitle>
|
||||
<CardDescription>
|
||||
신규 거래처 등록 시 표시되는 신용분석 모달을 테스트합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">목업 데이터 정보</h3>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>사업자번호: {MOCK_CREDIT_DATA.businessNumber}</li>
|
||||
<li>법인명: {MOCK_CREDIT_DATA.companyName}</li>
|
||||
<li>신용등급: Level {MOCK_CREDIT_DATA.creditLevel} ({MOCK_CREDIT_DATA.creditStatus})</li>
|
||||
<li>거래 승인: {MOCK_CREDIT_DATA.approval.safety}</li>
|
||||
<li>외상 가능: {MOCK_CREDIT_DATA.approval.creditAvailable ? '가능' : '불가'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
신용분석 모달 열기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreditAnalysisModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
data={MOCK_CREDIT_DATA}
|
||||
onApprove={handleApprove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
src/app/[locale]/(protected)/hr/documents/page.tsx
Normal file
272
src/app/[locale]/(protected)/hr/documents/page.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useState, useMemo, Suspense } from 'react';
|
||||
import { FileText, ArrowLeft, Calendar, Clock, MapPin, FileCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FormSectionSkeleton } from '@/components/ui/skeleton';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// 문서 유형 라벨
|
||||
const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||
businessTripRequest: '출장신청',
|
||||
vacationRequest: '휴가신청',
|
||||
fieldWorkRequest: '외근신청',
|
||||
overtimeRequest: '연장근무신청',
|
||||
};
|
||||
|
||||
// 문서 유형 아이콘
|
||||
const DOCUMENT_TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
businessTripRequest: MapPin,
|
||||
vacationRequest: Calendar,
|
||||
fieldWorkRequest: MapPin,
|
||||
overtimeRequest: Clock,
|
||||
};
|
||||
|
||||
// 문서 유형 설명
|
||||
const DOCUMENT_TYPE_DESCRIPTIONS: Record<string, string> = {
|
||||
businessTripRequest: '업무상 출장이 필요한 경우 작성합니다',
|
||||
vacationRequest: '연차, 병가 등 휴가 신청 시 작성합니다',
|
||||
fieldWorkRequest: '사업장 외 근무가 필요한 경우 작성합니다',
|
||||
overtimeRequest: '정규 근무시간 외 연장근무 시 작성합니다',
|
||||
};
|
||||
|
||||
function DocumentNewContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const documentType = searchParams.get('type') || 'businessTripRequest';
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
startDate: format(new Date(), 'yyyy-MM-dd'),
|
||||
endDate: format(new Date(), 'yyyy-MM-dd'),
|
||||
startTime: '09:00',
|
||||
endTime: '18:00',
|
||||
destination: '',
|
||||
purpose: '',
|
||||
content: '',
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 문서 유형 정보
|
||||
const typeInfo = useMemo(() => ({
|
||||
label: DOCUMENT_TYPE_LABELS[documentType] || '문서 등록',
|
||||
description: DOCUMENT_TYPE_DESCRIPTIONS[documentType] || '문서를 작성합니다',
|
||||
Icon: DOCUMENT_TYPE_ICONS[documentType] || FileText,
|
||||
}), [documentType]);
|
||||
|
||||
// 입력 핸들러
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 제출 핸들러
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// TODO: 백엔드 API 구현 필요
|
||||
toast.success(`${typeInfo.label}이 등록되었습니다`);
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error('Document creation error:', error);
|
||||
toast.error('문서 등록에 실패했습니다');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 취소 핸들러
|
||||
const handleCancel = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 max-w-2xl">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
근태관리로 돌아가기
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<typeInfo.Icon className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{typeInfo.label}</h1>
|
||||
<p className="text-muted-foreground">{typeInfo.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileCheck className="w-5 h-5" />
|
||||
신청 정보
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
필수 정보를 입력해주세요
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 제목 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">제목</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder={`${typeInfo.label} 제목을 입력하세요`}
|
||||
value={formData.title}
|
||||
onChange={(e) => handleChange('title', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 날짜 (출장/휴가/외근) */}
|
||||
{['businessTripRequest', 'vacationRequest', 'fieldWorkRequest'].includes(documentType) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startDate">시작일</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
type="date"
|
||||
value={formData.startDate}
|
||||
onChange={(e) => handleChange('startDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endDate">종료일</Label>
|
||||
<Input
|
||||
id="endDate"
|
||||
type="date"
|
||||
value={formData.endDate}
|
||||
onChange={(e) => handleChange('endDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 시간 (연장근무) */}
|
||||
{documentType === 'overtimeRequest' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startTime">시작 시간</Label>
|
||||
<Input
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={formData.startTime}
|
||||
onChange={(e) => handleChange('startTime', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endTime">종료 시간</Label>
|
||||
<Input
|
||||
id="endTime"
|
||||
type="time"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => handleChange('endTime', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 목적지/장소 (출장/외근) */}
|
||||
{['businessTripRequest', 'fieldWorkRequest'].includes(documentType) && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="destination">
|
||||
{documentType === 'businessTripRequest' ? '출장지' : '외근 장소'}
|
||||
</Label>
|
||||
<Input
|
||||
id="destination"
|
||||
placeholder="장소를 입력하세요"
|
||||
value={formData.destination}
|
||||
onChange={(e) => handleChange('destination', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 목적/사유 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="purpose">
|
||||
{documentType === 'vacationRequest' ? '휴가 사유' : '목적'}
|
||||
</Label>
|
||||
<Input
|
||||
id="purpose"
|
||||
placeholder={documentType === 'vacationRequest' ? '휴가 사유를 입력하세요' : '목적을 입력하세요'}
|
||||
value={formData.purpose}
|
||||
onChange={(e) => handleChange('purpose', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상세 내용 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">상세 내용</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
placeholder="상세 내용을 입력하세요"
|
||||
rows={4}
|
||||
value={formData.content}
|
||||
onChange={(e) => handleChange('content', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? '등록 중...' : '등록'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentListContent() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<div className="text-center py-12">
|
||||
<FileText className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">HR 문서 관리</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
근태관리에서 사유 등록 시 문서를 작성할 수 있습니다.
|
||||
</p>
|
||||
<Button onClick={() => router.push('/hr/attendance')}>
|
||||
근태관리로 이동
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return (
|
||||
<Suspense fallback={<FormSectionSkeleton rows={6} />}>
|
||||
<DocumentNewContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return <DocumentListContent />;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export default function EmployeeDetailPage() {
|
||||
try {
|
||||
const result = await updateEmployee(id, data);
|
||||
if (result.success) {
|
||||
router.push(`/ko/hr/employee-management/${id}`);
|
||||
router.push(`/ko/hr/employee-management/${id}?mode=view`);
|
||||
} else {
|
||||
console.error('[EmployeeDetailPage] Update failed:', result.error);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,68 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 사원관리 페이지 (Employee Management)
|
||||
*
|
||||
* 사원 정보를 관리하는 시스템
|
||||
* - 사원 목록 조회/검색/필터
|
||||
* - 사원 등록/수정/삭제
|
||||
* - CSV 일괄 등록
|
||||
* - 사용자 초대
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { useSearchParams, useRouter, useParams } from 'next/navigation';
|
||||
import { EmployeeManagement } from '@/components/hr/EmployeeManagement';
|
||||
import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm';
|
||||
import { createEmployee } from '@/components/hr/EmployeeManagement/actions';
|
||||
import { ListPageSkeleton } from '@/components/ui/skeleton';
|
||||
import type { Metadata } from 'next';
|
||||
import { toast } from 'sonner';
|
||||
import type { EmployeeFormData } from '@/components/hr/EmployeeManagement/types';
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: '사원관리',
|
||||
description: '사원 정보를 관리합니다',
|
||||
};
|
||||
function formatErrorMessage(result: {
|
||||
error?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
status?: number;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (result.status) parts.push(`[${result.status}]`);
|
||||
if (result.error) parts.push(result.error);
|
||||
if (result.errors) {
|
||||
const errorMessages = Object.entries(result.errors)
|
||||
.map(([field, messages]) => `${field}: ${messages[0]}`)
|
||||
.join(', ');
|
||||
if (errorMessages) parts.push(`(${errorMessages})`);
|
||||
}
|
||||
return parts.join(' ') || '알 수 없는 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
function EmployeeManagementContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const mode = searchParams.get('mode');
|
||||
const locale = params.locale as string || 'ko';
|
||||
|
||||
if (mode === 'new') {
|
||||
const handleSave = async (data: EmployeeFormData) => {
|
||||
try {
|
||||
const result = await createEmployee(data);
|
||||
if (result.success) {
|
||||
toast.success('사원이 등록되었습니다.');
|
||||
router.push(`/${locale}/hr/employee-management`);
|
||||
} else {
|
||||
const errorMessage = formatErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
return <EmployeeForm mode="create" onSave={handleSave} />;
|
||||
}
|
||||
|
||||
return <EmployeeManagement />;
|
||||
}
|
||||
|
||||
export default function EmployeeManagementPage() {
|
||||
return (
|
||||
<Suspense fallback={<ListPageSkeleton showHeader={false} showStats={true} statsCount={4} />}>
|
||||
<EmployeeManagement />
|
||||
<EmployeeManagementContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 공정 목록 페이지
|
||||
* 공정 목록/등록 페이지
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import ProcessListClient from '@/components/process-management/ProcessListClient';
|
||||
import { ProcessDetailClientV2 } from '@/components/process-management';
|
||||
import { ListPageSkeleton } from '@/components/ui/skeleton';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '공정 목록',
|
||||
description: '공정 관리 - 생산 공정 목록 조회 및 관리',
|
||||
};
|
||||
function ProcessManagementContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <ProcessDetailClientV2 processId="new" />;
|
||||
}
|
||||
|
||||
return <ProcessListClient />;
|
||||
}
|
||||
|
||||
export default function ProcessManagementPage() {
|
||||
return (
|
||||
<Suspense fallback={<ListPageSkeleton showHeader={false} />}>
|
||||
<ProcessListClient />
|
||||
<ProcessManagementContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하관리 - 목록 페이지
|
||||
* 출하관리 - 목록/등록 페이지
|
||||
* URL: /outbound/shipments
|
||||
* URL: /outbound/shipments?mode=new
|
||||
*/
|
||||
|
||||
import { ShipmentList } from '@/components/outbound/ShipmentManagement';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ShipmentList, ShipmentCreate } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
export default function ShipmentsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <ShipmentCreate />;
|
||||
}
|
||||
|
||||
return <ShipmentList />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,104 @@
|
||||
* 품목기준관리 API 연동
|
||||
* - 품목 목록: API에서 조회
|
||||
* - 테이블 컬럼: custom-tabs API에서 동적 구성
|
||||
* - ?mode=new: 등록 화면으로 전환
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import ItemListClient from '@/components/items/ItemListClient';
|
||||
import DynamicItemForm from '@/components/items/DynamicItemForm';
|
||||
import type { DynamicFormData } from '@/components/items/DynamicItemForm/types';
|
||||
import { DuplicateCodeError } from '@/lib/api/error-handler';
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* 품목 목록 페이지
|
||||
* 품목 목록 페이지 (mode=new 시 등록 화면)
|
||||
*/
|
||||
export default function ItemsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
// mode=new 일 때 등록 화면 렌더링
|
||||
if (mode === 'new') {
|
||||
return <CreateItemContent />;
|
||||
}
|
||||
|
||||
return <ItemListClient />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
* 품목 등록 컴포넌트 (mode=new용)
|
||||
*/
|
||||
export const metadata = {
|
||||
title: '품목 관리',
|
||||
description: '품목 목록 조회 및 관리',
|
||||
};
|
||||
function CreateItemContent() {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (data: DynamicFormData) => {
|
||||
setSubmitError(null);
|
||||
|
||||
// 필드명 변환: spec → specification (백엔드 API 규격)
|
||||
const submitData = { ...data };
|
||||
if (submitData.spec !== undefined) {
|
||||
submitData.specification = submitData.spec;
|
||||
delete submitData.spec;
|
||||
}
|
||||
|
||||
// item_type 설정
|
||||
const itemType = submitData.product_type as string;
|
||||
submitData.item_type = itemType;
|
||||
|
||||
// 이미지 데이터 제거 (파일 업로드는 별도 API 사용)
|
||||
if (submitData.bending_diagram && typeof submitData.bending_diagram === 'string' && (submitData.bending_diagram as string).startsWith('data:')) {
|
||||
delete submitData.bending_diagram;
|
||||
}
|
||||
if (submitData.specification_file && typeof submitData.specification_file === 'string' && (submitData.specification_file as string).startsWith('data:')) {
|
||||
delete submitData.specification_file;
|
||||
}
|
||||
if (submitData.certification_file && typeof submitData.certification_file === 'string' && (submitData.certification_file as string).startsWith('data:')) {
|
||||
delete submitData.certification_file;
|
||||
}
|
||||
|
||||
// API 호출: POST /api/proxy/items
|
||||
const response = await fetch('/api/proxy/items', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(submitData),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
if (response.status === 400 && result.duplicate_id) {
|
||||
throw new DuplicateCodeError(
|
||||
result.message || '해당 품목코드가 이미 존재합니다.',
|
||||
result.duplicate_id,
|
||||
result.duplicate_code
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage = result.message || '품목 등록에 실패했습니다.';
|
||||
setSubmitError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return { id: result.data.id, ...result.data };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{submitError && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-900">
|
||||
⚠️ {submitError}
|
||||
</div>
|
||||
)}
|
||||
<DynamicItemForm
|
||||
mode="create"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업지시 목록 페이지
|
||||
* URL: /production/work-orders
|
||||
* - ?mode=new: 작업지시 등록
|
||||
*/
|
||||
|
||||
import { WorkOrderList } from '@/components/production/WorkOrders';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { WorkOrderList, WorkOrderCreate } from '@/components/production/WorkOrders';
|
||||
|
||||
export default function WorkOrdersPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
// mode=new: 작업지시 등록
|
||||
if (mode === 'new') {
|
||||
return <WorkOrderCreate />;
|
||||
}
|
||||
|
||||
return <WorkOrderList />;
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 검사 목록 페이지
|
||||
* 검사 목록/등록 페이지
|
||||
* URL: /quality/inspections
|
||||
* URL: /quality/inspections?mode=new
|
||||
*/
|
||||
|
||||
import { InspectionList } from '@/components/quality/InspectionManagement';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { InspectionList, InspectionCreate } from '@/components/quality/InspectionManagement';
|
||||
|
||||
export default function InspectionsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <InspectionCreate />;
|
||||
}
|
||||
|
||||
return <InspectionList />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ClientDetailClientV2 } from '@/components/clients/ClientDetailClientV2';
|
||||
import { useClientList, Client } from "@/hooks/useClientList";
|
||||
import {
|
||||
Building2,
|
||||
@@ -52,9 +53,11 @@ import { isNextRedirectError } from "@/lib/utils/redirect-error";
|
||||
|
||||
export default function CustomerAccountManagementPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// API 훅 사용
|
||||
// API 훅 사용 (훅은 조건부 return 전에 항상 호출되어야 함 - React 훅 규칙)
|
||||
const {
|
||||
clients,
|
||||
pagination,
|
||||
@@ -261,15 +264,15 @@ export default function CustomerAccountManagementPage() {
|
||||
|
||||
// 핸들러 - 페이지 기반 네비게이션
|
||||
const handleAddNew = () => {
|
||||
router.push("/sales/client-management-sales-admin/new");
|
||||
router.push("/sales/client-management-sales-admin?mode=new");
|
||||
};
|
||||
|
||||
const handleEdit = (customer: Client) => {
|
||||
router.push(`/sales/client-management-sales-admin/${customer.id}/edit`);
|
||||
router.push(`/sales/client-management-sales-admin/${customer.id}?mode=edit`);
|
||||
};
|
||||
|
||||
const handleView = (customer: Client) => {
|
||||
router.push(`/sales/client-management-sales-admin/${customer.id}`);
|
||||
router.push(`/sales/client-management-sales-admin/${customer.id}?mode=view`);
|
||||
};
|
||||
|
||||
const handleDelete = (customerId: string) => {
|
||||
@@ -354,6 +357,11 @@ export default function CustomerAccountManagementPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// mode=new 처리 (모든 훅 호출 후에 조건부 return - React 훅 규칙 준수)
|
||||
if (mode === 'new') {
|
||||
return <ClientDetailClientV2 />;
|
||||
}
|
||||
|
||||
// 상태 뱃지
|
||||
const getStatusBadge = (status: "활성" | "비활성") => {
|
||||
if (status === "활성") {
|
||||
|
||||
@@ -99,12 +99,15 @@ function getOrderStatusBadge(status: OrderStatus) {
|
||||
order_confirmed: { label: "수주확정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
production_ordered: { label: "생산지시완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_production: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
produced: { label: "생산완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
rework: { label: "재작업중", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
work_completed: { label: "작업완료", className: "bg-blue-600 text-white border-blue-600" },
|
||||
shipping: { label: "출하중", className: "bg-purple-100 text-purple-700 border-purple-200" },
|
||||
shipped: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
completed: { label: "완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const config = statusConfig[status];
|
||||
const config = statusConfig[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
return (
|
||||
<BadgeSm className={config.className}>
|
||||
{config.label}
|
||||
@@ -535,7 +538,7 @@ export default function OrderEditPage() {
|
||||
// Edit mode config override
|
||||
const editConfig = {
|
||||
...orderSalesConfig,
|
||||
title: '수주 수정',
|
||||
title: '수주',
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
* - 기본 정보, 수주/배송 정보, 비고
|
||||
* - 제품 내역 테이블
|
||||
* - 상태별 버튼 차이
|
||||
* - ?mode=edit: 수정 모드 (V2 패턴)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -79,12 +80,15 @@ function getOrderStatusBadge(status: OrderStatus) {
|
||||
order_confirmed: { label: "수주확정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
production_ordered: { label: "생산지시완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_production: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
produced: { label: "생산완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
rework: { label: "재작업중", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
work_completed: { label: "작업완료", className: "bg-blue-600 text-white border-blue-600" },
|
||||
shipping: { label: "출하중", className: "bg-purple-100 text-purple-700 border-purple-200" },
|
||||
shipped: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
completed: { label: "완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const config = statusConfig[status];
|
||||
const config = statusConfig[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
return (
|
||||
<BadgeSm className={config.className}>
|
||||
{config.label}
|
||||
@@ -105,7 +109,12 @@ function InfoItem({ label, value }: { label: string; value?: string }) {
|
||||
export default function OrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const orderId = params.id as string;
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
// V2 패턴: mode 파라미터로 모드 결정
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -159,7 +168,8 @@ export default function OrderDetailPage() {
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}/edit`);
|
||||
// V2 패턴: ?mode=edit로 이동
|
||||
router.push(`/sales/order-management-sales/${orderId}?mode=edit`);
|
||||
};
|
||||
|
||||
const handleProductionOrder = () => {
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { OrderRegistration, OrderFormData, createOrder } from "@/components/orders";
|
||||
import {
|
||||
FileText,
|
||||
Edit,
|
||||
@@ -79,7 +80,49 @@ function getOrderStatusBadge(status: OrderStatus) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 등록 컴포넌트 (mode=new용)
|
||||
*/
|
||||
function CreateOrderContent() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
};
|
||||
|
||||
const handleSave = async (formData: OrderFormData) => {
|
||||
try {
|
||||
const result = await createOrder(formData);
|
||||
if (result.success) {
|
||||
toast.success("수주가 등록되었습니다.");
|
||||
router.push("/sales/order-management-sales");
|
||||
} else {
|
||||
toast.error(result.error || "수주 등록에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("수주 등록 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
return <OrderRegistration onBack={handleBack} onSave={handleSave} />;
|
||||
}
|
||||
|
||||
export default function OrderManagementSalesPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
// mode=new 처리: 별도 컴포넌트로 분리하여 Hooks 규칙 준수
|
||||
if (mode === 'new') {
|
||||
return <CreateOrderContent />;
|
||||
}
|
||||
|
||||
return <OrderListContent />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 목록 컴포넌트 (기본 화면)
|
||||
*/
|
||||
function OrderListContent() {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -265,11 +308,11 @@ export default function OrderManagementSalesPage() {
|
||||
|
||||
// 핸들러
|
||||
const handleView = (order: Order) => {
|
||||
router.push(`/sales/order-management-sales/${order.id}`);
|
||||
router.push(`/sales/order-management-sales/${order.id}?mode=view`);
|
||||
};
|
||||
|
||||
const handleEdit = (order: Order) => {
|
||||
router.push(`/sales/order-management-sales/${order.id}/edit`);
|
||||
router.push(`/sales/order-management-sales/${order.id}?mode=edit`);
|
||||
};
|
||||
|
||||
// 개별 취소 기능은 상세 페이지에서 처리
|
||||
@@ -690,7 +733,7 @@ export default function OrderManagementSalesPage() {
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
수주완료
|
||||
</Button>
|
||||
<Button onClick={() => router.push("/sales/order-management-sales/new")}>
|
||||
<Button onClick={() => router.push("/sales/order-management-sales?mode=new")}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
수주 등록
|
||||
</Button>
|
||||
|
||||
@@ -13,10 +13,14 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { PricingListClient } from '@/components/pricing';
|
||||
import { getPricingListData, type PricingListItem } from '@/components/pricing/actions';
|
||||
|
||||
export default function PricingManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const mode = searchParams.get('mode');
|
||||
const [data, setData] = useState<PricingListItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -28,6 +32,27 @@ export default function PricingManagementPage() {
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
// mode=new: 단가 등록은 품목 선택이 필요하므로 안내 표시
|
||||
if (mode === 'new') {
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">품목을 선택해주세요</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
단가 등록은 품목 선택이 필요합니다.<br />
|
||||
단가 목록에서 미등록 품목을 선택한 후 등록해주세요.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/sales/pricing-management')}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
단가 목록으로 이동
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
/**
|
||||
* 견적관리 페이지 (Client Component)
|
||||
*
|
||||
* 초기 데이터를 useEffect에서 fetch하여 Client Component에 전달
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { QuoteManagementClient } from '@/components/quotes/QuoteManagementClient';
|
||||
import { getQuotes } from '@/components/quotes/actions';
|
||||
import { QuoteRegistration, QuoteFormData } from '@/components/quotes/QuoteRegistration';
|
||||
import { getQuotes, createQuote, transformFormDataToApi } from '@/components/quotes';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
@@ -18,18 +19,56 @@ const DEFAULT_PAGINATION = {
|
||||
};
|
||||
|
||||
export default function QuoteManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getQuotes>>['data']>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getQuotes({ perPage: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
if (mode !== 'new') {
|
||||
getQuotes({ perPage: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
if (mode === 'new') {
|
||||
const handleBack = () => {
|
||||
router.push('/sales/quote-management');
|
||||
};
|
||||
|
||||
const handleSave = async (formData: QuoteFormData): Promise<{ success: boolean; error?: string }> => {
|
||||
if (isSaving) return { success: false, error: '저장 중입니다.' };
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const apiData = transformFormDataToApi(formData);
|
||||
const result = await createQuote(apiData as any);
|
||||
|
||||
if (result.success && result.data) {
|
||||
router.push(`/sales/quote-management/${result.data.id}`);
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: result.error || '견적 등록에 실패했습니다.' };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '견적 등록에 실패했습니다.' };
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <QuoteRegistration onBack={handleBack} onSave={handleSave} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -45,4 +84,4 @@ export default function QuoteManagementPage() {
|
||||
initialPagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AccountManagement } from '@/components/settings/AccountManagement';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { accountConfig } from '@/components/settings/AccountManagement/accountConfig';
|
||||
import { createBankAccount } from '@/components/settings/AccountManagement/actions';
|
||||
import type { AccountFormData } from '@/components/settings/AccountManagement/types';
|
||||
|
||||
export default function AccountsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
const result = await createBankAccount(data as AccountFormData);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={accountConfig}
|
||||
mode="create"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AccountManagement />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { PermissionDetailClient } from '@/components/settings/PermissionManagement/PermissionDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
@@ -9,5 +10,8 @@ interface PageProps {
|
||||
|
||||
export default function PermissionDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
return <PermissionDetailClient permissionId={id} />;
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') as 'view' | 'edit' | null;
|
||||
|
||||
return <PermissionDetailClient permissionId={id} mode={mode || 'view'} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { PermissionManagement } from '@/components/settings/PermissionManagement';
|
||||
import { PermissionDetailClient } from '@/components/settings/PermissionManagement/PermissionDetailClient';
|
||||
|
||||
export default function PermissionsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <PermissionDetailClient permissionId="new" isNew />;
|
||||
}
|
||||
|
||||
return <PermissionManagement />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { PopupList } from '@/components/settings/PopupManagement';
|
||||
import { PopupDetailClientV2 } from '@/components/settings/PopupManagement/PopupDetailClientV2';
|
||||
import { getPopups } from '@/components/settings/PopupManagement/actions';
|
||||
import type { Popup } from '@/components/settings/PopupManagement/types';
|
||||
|
||||
export default function PopupManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
const [data, setData] = useState<Popup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getPopups({ size: 100 })
|
||||
.then(result => {
|
||||
setData(result);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
if (mode !== 'new') {
|
||||
getPopups({ size: 100 })
|
||||
.then(result => {
|
||||
setData(result);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
if (mode === 'new') {
|
||||
return <PopupDetailClientV2 popupId="new" />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -26,4 +39,4 @@ export default function PopupManagementPage() {
|
||||
}
|
||||
|
||||
return <PopupList initialData={data} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 193 192">
|
||||
<rect width="193" height="192" rx="24" fill="#3B82F6"/>
|
||||
<image x="0" y="0" width="193" height="192" xlink:href="data:image/png;base64,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"/>
|
||||
<defs>
|
||||
<clipPath id="rounded">
|
||||
<rect width="193" height="192" rx="40" ry="40"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#rounded)">
|
||||
<rect width="193" height="192" fill="#3B82F6"/>
|
||||
<image x="0" y="0" width="193" height="192" xlink:href="data:image/png;base64,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"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.5 KiB |
Reference in New Issue
Block a user