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:
유병철
2026-01-25 12:27:43 +09:00
parent 72f1accbe4
commit f6551c7e8b
162 changed files with 2907 additions and 480 deletions

View File

@@ -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}
/>
);
}
}

View File

@@ -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}
/>
);
}
}

View File

@@ -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 />;
}

View File

@@ -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}
/>
);
}
}

View File

@@ -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 />;
}

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

View File

@@ -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 />;
}

View File

@@ -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]);
// 목록으로 이동

View File

@@ -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

View File

@@ -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 />;
}

View File

@@ -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 />;
}

View File

@@ -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 />;
}
}

View File

@@ -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 />;
}
}

View File

@@ -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 />;
}
}

View File

@@ -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 />;
}
}

View File

@@ -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 />;
}
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,8 @@
'use client';
import ProjectDetailClient from '@/components/business/construction/management/ProjectDetailClient';
export default function ProjectExecutionManagementPage() {
// projectId 없이 호출 → 전체 데이터 칸반보드
return <ProjectDetailClient />;
}

View File

@@ -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 (

View File

@@ -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 />;
}

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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 />;
}
}

View File

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

View File

@@ -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 />;
}

View File

@@ -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 />;
}
}

View File

@@ -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 === "활성") {

View File

@@ -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 (

View File

@@ -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 = () => {

View File

@@ -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>

View File

@@ -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]">

View File

@@ -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}
/>
);
}
}

View File

@@ -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 />;
}
}

View File

@@ -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'} />;
}

View File

@@ -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 />;
}
}

View File

@@ -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} />;
}
}

View File

@@ -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