fix: 품목관리 수정 기능 버그 수정 및 Sales 페이지 추가
## 품목관리 수정 버그 수정 - FG(제품) 수정 시 품목명 반영 안되는 문제 해결 - productName → name 필드 매핑 추가 - FG 품목코드 = 품목명 동기화 로직 추가 - Materials(SM, RM, CS) 수정페이지 진입 오류 해결 - UNIQUE 제약조건 위반 오류 해결 ## Sales 페이지 - 거래처관리 (client-management-sales-admin) 페이지 구현 - 견적관리 (quote-management) 페이지 구현 - 관련 컴포넌트 및 훅 추가 ## 기타 - 회원가입 페이지 차단 처리 - 디버깅용 콘솔 로그 추가 (PUT 요청/응답 확인용) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,208 +1,347 @@
|
||||
/**
|
||||
* 품목 수정 페이지
|
||||
*
|
||||
* API 연동:
|
||||
* - GET /api/proxy/items/code/{itemCode}?include_bom=true (품목 조회)
|
||||
* - PUT /api/proxy/items/{id} (품목 수정)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import ItemForm from '@/components/items/ItemForm';
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import DynamicItemForm from '@/components/items/DynamicItemForm';
|
||||
import type { DynamicFormData } from '@/components/items/DynamicItemForm/types';
|
||||
import type { ItemType } from '@/types/item';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
// Mock 데이터 (API 연동 전 임시)
|
||||
const mockItems: ItemMaster[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemCode: 'KD-FG-001',
|
||||
itemName: '스크린 제품 A',
|
||||
itemType: 'FG',
|
||||
unit: 'EA',
|
||||
specification: '2000x2000',
|
||||
isActive: true,
|
||||
category1: '본체부품',
|
||||
category2: '가이드시스템',
|
||||
salesPrice: 150000,
|
||||
purchasePrice: 100000,
|
||||
marginRate: 33.3,
|
||||
processingCost: 20000,
|
||||
laborCost: 15000,
|
||||
installCost: 10000,
|
||||
productCategory: 'SCREEN',
|
||||
lotAbbreviation: 'KD',
|
||||
note: '스크린 제품 샘플입니다.',
|
||||
safetyStock: 10,
|
||||
leadTime: 7,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
updatedAt: '2025-01-12T00:00:00Z',
|
||||
bom: [
|
||||
{
|
||||
id: 'bom-1',
|
||||
childItemCode: 'KD-PT-001',
|
||||
childItemName: '가이드레일(벽면형)',
|
||||
quantity: 2,
|
||||
unit: 'EA',
|
||||
unitPrice: 35000,
|
||||
quantityFormula: 'H / 1000',
|
||||
},
|
||||
{
|
||||
id: 'bom-2',
|
||||
childItemCode: 'KD-PT-002',
|
||||
childItemName: '절곡품 샘플',
|
||||
quantity: 4,
|
||||
unit: 'EA',
|
||||
unitPrice: 30000,
|
||||
isBending: true,
|
||||
},
|
||||
{
|
||||
id: 'bom-3',
|
||||
childItemCode: 'KD-SM-001',
|
||||
childItemName: '볼트 M6x20',
|
||||
quantity: 20,
|
||||
unit: 'EA',
|
||||
unitPrice: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
itemCode: 'KD-PT-001',
|
||||
itemName: '가이드레일(벽면형)',
|
||||
itemType: 'PT',
|
||||
unit: 'EA',
|
||||
specification: '2438mm',
|
||||
isActive: true,
|
||||
category1: '본체부품',
|
||||
category2: '가이드시스템',
|
||||
category3: '가이드레일',
|
||||
salesPrice: 50000,
|
||||
purchasePrice: 35000,
|
||||
marginRate: 30,
|
||||
partType: 'ASSEMBLY',
|
||||
partUsage: 'GUIDE_RAIL',
|
||||
installationType: '벽면형',
|
||||
assemblyType: 'M',
|
||||
assemblyLength: '2438',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
itemCode: 'KD-PT-002',
|
||||
itemName: '절곡품 샘플',
|
||||
itemType: 'PT',
|
||||
unit: 'EA',
|
||||
specification: 'EGI 1.55T',
|
||||
isActive: true,
|
||||
partType: 'BENDING',
|
||||
material: 'EGI 1.55T',
|
||||
length: '2000',
|
||||
salesPrice: 30000,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
itemCode: 'KD-RM-001',
|
||||
itemName: 'SPHC-SD',
|
||||
itemType: 'RM',
|
||||
unit: 'KG',
|
||||
specification: '1.6T x 1219 x 2438',
|
||||
isActive: true,
|
||||
category1: '철강재',
|
||||
purchasePrice: 1500,
|
||||
material: 'SPHC-SD',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
itemCode: 'KD-SM-001',
|
||||
itemName: '볼트 M6x20',
|
||||
itemType: 'SM',
|
||||
unit: 'EA',
|
||||
specification: 'M6x20',
|
||||
isActive: true,
|
||||
category1: '구조재/부속품',
|
||||
category2: '볼트/너트',
|
||||
purchasePrice: 50,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
];
|
||||
// Materials 타입 (SM, RM, CS는 Material 테이블 사용)
|
||||
const MATERIAL_TYPES = ['SM', 'RM', 'CS'];
|
||||
|
||||
/**
|
||||
* API 응답 타입 (백엔드 Product 모델 기준)
|
||||
*
|
||||
* 백엔드 필드명: code, name, product_type (item_code, item_name, item_type 아님!)
|
||||
*/
|
||||
interface ItemApiResponse {
|
||||
id: number;
|
||||
// 백엔드 Product 모델 필드
|
||||
code: string;
|
||||
name: string;
|
||||
product_type: string;
|
||||
// 기존 필드도 fallback으로 유지
|
||||
item_code?: string;
|
||||
item_name?: string;
|
||||
item_type?: string;
|
||||
unit?: string;
|
||||
specification?: string;
|
||||
is_active?: boolean;
|
||||
description?: string;
|
||||
note?: string;
|
||||
part_type?: string;
|
||||
part_usage?: string;
|
||||
material?: string;
|
||||
length?: string;
|
||||
thickness?: string;
|
||||
installation_type?: string;
|
||||
assembly_type?: string;
|
||||
assembly_length?: string;
|
||||
side_spec_width?: string;
|
||||
side_spec_height?: string;
|
||||
product_category?: string;
|
||||
lot_abbreviation?: string;
|
||||
certification_number?: string;
|
||||
certification_start_date?: string;
|
||||
certification_end_date?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답을 DynamicFormData로 변환
|
||||
*
|
||||
* API snake_case 필드를 폼 field_key로 매핑
|
||||
* (품목기준관리 API의 field_key가 snake_case 형식)
|
||||
*/
|
||||
function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
|
||||
const formData: DynamicFormData = {};
|
||||
|
||||
// 백엔드 Product 모델 필드: code, name, product_type
|
||||
// 프론트엔드 폼 필드: item_name, item_code 등 (snake_case)
|
||||
|
||||
// 기본 필드 (백엔드 name → 폼 item_name)
|
||||
const itemName = data.name || data.item_name;
|
||||
if (itemName) formData['item_name'] = itemName;
|
||||
if (data.unit) formData['unit'] = data.unit;
|
||||
if (data.specification) formData['specification'] = data.specification;
|
||||
if (data.description) formData['description'] = data.description;
|
||||
if (data.note) formData['note'] = data.note;
|
||||
formData['is_active'] = data.is_active ?? true;
|
||||
|
||||
// 부품 관련 필드 (PT)
|
||||
if (data.part_type) formData['part_type'] = data.part_type;
|
||||
if (data.part_usage) formData['part_usage'] = data.part_usage;
|
||||
if (data.material) formData['material'] = data.material;
|
||||
if (data.length) formData['length'] = data.length;
|
||||
if (data.thickness) formData['thickness'] = data.thickness;
|
||||
|
||||
// 조립 부품 관련
|
||||
if (data.installation_type) formData['installation_type'] = data.installation_type;
|
||||
if (data.assembly_type) formData['assembly_type'] = data.assembly_type;
|
||||
if (data.assembly_length) formData['assembly_length'] = data.assembly_length;
|
||||
if (data.side_spec_width) formData['side_spec_width'] = data.side_spec_width;
|
||||
if (data.side_spec_height) formData['side_spec_height'] = data.side_spec_height;
|
||||
|
||||
// 제품 관련 필드 (FG)
|
||||
if (data.product_category) formData['product_category'] = data.product_category;
|
||||
if (data.lot_abbreviation) formData['lot_abbreviation'] = data.lot_abbreviation;
|
||||
|
||||
// 인정 정보
|
||||
if (data.certification_number) formData['certification_number'] = data.certification_number;
|
||||
if (data.certification_start_date) formData['certification_start_date'] = data.certification_start_date;
|
||||
if (data.certification_end_date) formData['certification_end_date'] = data.certification_end_date;
|
||||
|
||||
// 기타 동적 필드들 (API에서 받은 모든 필드를 포함)
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
// 이미 처리한 특수 필드들 제외 (백엔드 필드명 + 기존 필드명)
|
||||
const excludeKeys = [
|
||||
'id', 'code', 'name', 'product_type', // 백엔드 Product 모델 필드
|
||||
'item_code', 'item_name', 'item_type', // 기존 호환 필드
|
||||
'created_at', 'updated_at', 'deleted_at', 'bom',
|
||||
'tenant_id', 'category_id', 'category', 'component_lines',
|
||||
];
|
||||
if (!excludeKeys.includes(key) && value !== null && value !== undefined) {
|
||||
// 아직 설정 안된 필드만 추가
|
||||
if (!(key in formData)) {
|
||||
formData[key] = value as DynamicFormData[string];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
export default function EditItemPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [item, setItem] = useState<ItemMaster | null>(null);
|
||||
const searchParams = useSearchParams();
|
||||
const [itemId, setItemId] = useState<number | null>(null);
|
||||
const [itemType, setItemType] = useState<ItemType | null>(null);
|
||||
const [initialData, setInitialData] = useState<DynamicFormData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// URL에서 type과 id 쿼리 파라미터 읽기
|
||||
const urlItemType = searchParams.get('type') || 'FG';
|
||||
const urlItemId = searchParams.get('id');
|
||||
|
||||
// 품목 데이터 로드
|
||||
useEffect(() => {
|
||||
// TODO: API 연동 시 fetchItemByCode() 호출
|
||||
const fetchItem = async () => {
|
||||
setIsLoading(true);
|
||||
if (!params.id || typeof params.id !== 'string') {
|
||||
setError('잘못된 품목 ID입니다.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// params.id 타입 체크
|
||||
if (!params.id || typeof params.id !== 'string') {
|
||||
alert('잘못된 품목 ID입니다.');
|
||||
router.push('/items');
|
||||
setIsLoading(true);
|
||||
const itemCode = decodeURIComponent(params.id);
|
||||
// console.log('[EditItem] Fetching item:', { itemCode, urlItemType, urlItemId });
|
||||
|
||||
let response: Response;
|
||||
|
||||
// Materials (SM, RM, CS)는 다른 API 엔드포인트 사용
|
||||
if (MATERIAL_TYPES.includes(urlItemType) && urlItemId) {
|
||||
// GET /api/proxy/items/{id}?item_type=MATERIAL
|
||||
// console.log('[EditItem] Using Material API');
|
||||
response = await fetch(`/api/proxy/items/${urlItemId}?item_type=MATERIAL`);
|
||||
} else {
|
||||
// Products (FG, PT): GET /api/proxy/items/code/{itemCode}?include_bom=true
|
||||
// console.log('[EditItem] Using Product API');
|
||||
response = await fetch(`/api/proxy/items/code/${encodeURIComponent(itemCode)}?include_bom=true`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setError('품목을 찾을 수 없습니다.');
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
setError(errorData?.message || `오류 발생 (${response.status})`);
|
||||
}
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mock: 데이터 조회
|
||||
const itemCode = decodeURIComponent(params.id);
|
||||
const foundItem = mockItems.find((item) => item.itemCode === itemCode);
|
||||
const result = await response.json();
|
||||
// console.log('[EditItem] API Response:', result);
|
||||
|
||||
if (foundItem) {
|
||||
setItem(foundItem);
|
||||
if (result.success && result.data) {
|
||||
const apiData = result.data as ItemApiResponse;
|
||||
|
||||
// ID, 품목 유형 저장
|
||||
// Product: product_type, Material: material_type 또는 type_code
|
||||
setItemId(apiData.id);
|
||||
const resolvedItemType = apiData.product_type || (apiData as Record<string, unknown>).material_type || (apiData as Record<string, unknown>).type_code || apiData.item_type;
|
||||
// console.log('[EditItem] Resolved itemType:', resolvedItemType);
|
||||
setItemType(resolvedItemType as ItemType);
|
||||
|
||||
// 폼 데이터로 변환
|
||||
const formData = mapApiResponseToFormData(apiData);
|
||||
// console.log('[EditItem] Mapped form data:', formData);
|
||||
setInitialData(formData);
|
||||
} else {
|
||||
alert('품목을 찾을 수 없습니다.');
|
||||
router.push('/items');
|
||||
setError(result.message || '품목 정보를 불러올 수 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
alert('품목 조회에 실패했습니다.');
|
||||
router.push('/items');
|
||||
} catch (err) {
|
||||
console.error('[EditItem] Error:', err);
|
||||
setError('품목 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchItem();
|
||||
}, [params.id, router]);
|
||||
}, [params.id, urlItemType, urlItemId]);
|
||||
|
||||
const handleSubmit = async (data: CreateItemFormData) => {
|
||||
// TODO: API 연동 시 updateItem() 호출
|
||||
console.log('품목 수정 데이터:', data);
|
||||
/**
|
||||
* 품목 수정 제출 핸들러
|
||||
*
|
||||
* API 엔드포인트:
|
||||
* - Products (FG, PT): PUT /api/proxy/items/{id}
|
||||
* - Materials (SM, RM, CS): PATCH /api/proxy/products/materials/{id}
|
||||
*
|
||||
* 주의: 리다이렉트는 DynamicItemForm에서 처리하므로 여기서는 API 호출만 수행
|
||||
*/
|
||||
const handleSubmit = async (data: DynamicFormData) => {
|
||||
if (!itemId) {
|
||||
throw new Error('품목 ID가 없습니다.');
|
||||
}
|
||||
|
||||
// Mock: 성공 메시지
|
||||
alert(`품목 "${data.itemName}" (${data.itemCode})이(가) 수정되었습니다.`);
|
||||
// console.log('[EditItem] Submitting update:', { itemId, itemType, data });
|
||||
|
||||
// API 연동 예시:
|
||||
// const updatedItem = await updateItem(item.itemCode, data);
|
||||
// router.push(`/items/${updatedItem.itemCode}`);
|
||||
// Materials (SM, RM, CS)는 /products/materials 엔드포인트 + PATCH 메서드 사용
|
||||
// Products (FG, PT)는 /items 엔드포인트 + PUT 메서드 사용
|
||||
const isMaterial = itemType ? MATERIAL_TYPES.includes(itemType) : false;
|
||||
const updateUrl = isMaterial
|
||||
? `/api/proxy/products/materials/${itemId}`
|
||||
: `/api/proxy/items/${itemId}`;
|
||||
const method = isMaterial ? 'PATCH' : 'PUT';
|
||||
|
||||
// console.log('[EditItem] Update URL:', updateUrl, '(method:', method, ', isMaterial:', isMaterial, ')');
|
||||
|
||||
// 수정 시 code/material_code는 변경하지 않음 (UNIQUE 제약조건 위반 방지)
|
||||
// DynamicItemForm에서 자동생성되는 code를 제외해야 함
|
||||
let submitData = { ...data };
|
||||
|
||||
// FG(제품)의 경우: 품목코드 = 품목명이므로, name 변경 시 code도 함께 변경
|
||||
// 다른 타입: code 제외 (UNIQUE 제약조건)
|
||||
if (itemType === 'FG') {
|
||||
// FG는 품목명이 품목코드가 되므로 name 값으로 code 설정
|
||||
submitData.code = submitData.name;
|
||||
} else {
|
||||
delete submitData.code;
|
||||
}
|
||||
|
||||
// 공통: spec → specification 필드명 변환 (백엔드 API 규격)
|
||||
if (submitData.spec !== undefined) {
|
||||
submitData.specification = submitData.spec;
|
||||
delete submitData.spec;
|
||||
}
|
||||
|
||||
if (isMaterial) {
|
||||
// Materials의 경우 추가 필드명 변환
|
||||
// DynamicItemForm에서 오는 데이터: name, product_type 등
|
||||
// Material API가 기대하는 데이터: name, material_type 등
|
||||
submitData = {
|
||||
...submitData,
|
||||
// Material API 필드명 매핑
|
||||
material_type: submitData.product_type || itemType,
|
||||
// 불필요한 필드 제거
|
||||
product_type: undefined,
|
||||
material_code: undefined, // 수정 시 코드 변경 불가
|
||||
};
|
||||
// console.log('[EditItem] Material submitData:', submitData);
|
||||
} else {
|
||||
// Products (FG, PT)의 경우
|
||||
// console.log('[EditItem] Product submitData:', submitData);
|
||||
}
|
||||
|
||||
// API 호출
|
||||
console.log('========== [EditItem] PUT 요청 데이터 ==========');
|
||||
console.log('URL:', updateUrl);
|
||||
console.log('Method:', method);
|
||||
console.log('전송 데이터:', JSON.stringify(submitData, null, 2));
|
||||
console.log('================================================');
|
||||
|
||||
const response = await fetch(updateUrl, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(submitData),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('========== [EditItem] PUT 응답 ==========');
|
||||
console.log('Response:', JSON.stringify(result, null, 2));
|
||||
console.log('==========================================');
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.message || '품목 수정에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 성공 메시지만 표시 (리다이렉트는 DynamicItemForm에서 처리)
|
||||
// alert 제거 - DynamicItemForm에서 router.push('/items')로 이동
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="text-center py-8">로딩 중...</div>
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">품목 정보 로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button
|
||||
onClick={() => router.push('/items')}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
품목 목록으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 없음
|
||||
if (!itemType || !initialData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<p className="text-muted-foreground">품목 정보를 불러올 수 없습니다.</p>
|
||||
<button
|
||||
onClick={() => router.push('/items')}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
품목 목록으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<ItemForm mode="edit" initialData={item} onSubmit={handleSubmit} />
|
||||
<DynamicItemForm
|
||||
mode="edit"
|
||||
itemType={itemType}
|
||||
initialData={initialData}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +1,186 @@
|
||||
/**
|
||||
* 품목 상세 조회 페이지
|
||||
*
|
||||
* API 연동: GET /api/proxy/items/code/{itemCode}?include_bom=true
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { notFound } from 'next/navigation';
|
||||
import ItemDetailClient from '@/components/items/ItemDetailClient';
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
// Mock 데이터 (API 연동 전 임시)
|
||||
const mockItems: ItemMaster[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemCode: 'KD-FG-001',
|
||||
itemName: '스크린 제품 A',
|
||||
itemType: 'FG',
|
||||
unit: 'EA',
|
||||
specification: '2000x2000',
|
||||
isActive: true,
|
||||
category1: '본체부품',
|
||||
category2: '가이드시스템',
|
||||
salesPrice: 150000,
|
||||
purchasePrice: 100000,
|
||||
marginRate: 33.3,
|
||||
processingCost: 20000,
|
||||
laborCost: 15000,
|
||||
installCost: 10000,
|
||||
productCategory: 'SCREEN',
|
||||
lotAbbreviation: 'KD',
|
||||
note: '스크린 제품 샘플입니다.',
|
||||
safetyStock: 10,
|
||||
leadTime: 7,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
updatedAt: '2025-01-12T00:00:00Z',
|
||||
bom: [
|
||||
{
|
||||
id: 'bom-1',
|
||||
childItemCode: 'KD-PT-001',
|
||||
childItemName: '가이드레일(벽면형)',
|
||||
quantity: 2,
|
||||
unit: 'EA',
|
||||
unitPrice: 35000,
|
||||
quantityFormula: 'H / 1000',
|
||||
},
|
||||
{
|
||||
id: 'bom-2',
|
||||
childItemCode: 'KD-PT-002',
|
||||
childItemName: '절곡품 샘플',
|
||||
quantity: 4,
|
||||
unit: 'EA',
|
||||
unitPrice: 30000,
|
||||
isBending: true,
|
||||
},
|
||||
{
|
||||
id: 'bom-3',
|
||||
childItemCode: 'KD-SM-001',
|
||||
childItemName: '볼트 M6x20',
|
||||
quantity: 20,
|
||||
unit: 'EA',
|
||||
unitPrice: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
itemCode: 'KD-PT-001',
|
||||
itemName: '가이드레일(벽면형)',
|
||||
itemType: 'PT',
|
||||
unit: 'EA',
|
||||
specification: '2438mm',
|
||||
isActive: true,
|
||||
category1: '본체부품',
|
||||
category2: '가이드시스템',
|
||||
category3: '가이드레일',
|
||||
salesPrice: 50000,
|
||||
purchasePrice: 35000,
|
||||
marginRate: 30,
|
||||
partType: 'ASSEMBLY',
|
||||
partUsage: 'GUIDE_RAIL',
|
||||
installationType: '벽면형',
|
||||
assemblyType: 'M',
|
||||
assemblyLength: '2438',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
itemCode: 'KD-PT-002',
|
||||
itemName: '절곡품 샘플',
|
||||
itemType: 'PT',
|
||||
unit: 'EA',
|
||||
specification: 'EGI 1.55T',
|
||||
isActive: true,
|
||||
partType: 'BENDING',
|
||||
material: 'EGI 1.55T',
|
||||
length: '2000',
|
||||
salesPrice: 30000,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
itemCode: 'KD-RM-001',
|
||||
itemName: 'SPHC-SD',
|
||||
itemType: 'RM',
|
||||
unit: 'KG',
|
||||
specification: '1.6T x 1219 x 2438',
|
||||
isActive: true,
|
||||
category1: '철강재',
|
||||
purchasePrice: 1500,
|
||||
material: 'SPHC-SD',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
itemCode: 'KD-SM-001',
|
||||
itemName: '볼트 M6x20',
|
||||
itemType: 'SM',
|
||||
unit: 'EA',
|
||||
specification: 'M6x20',
|
||||
isActive: true,
|
||||
category1: '구조재/부속품',
|
||||
category2: '볼트/너트',
|
||||
purchasePrice: 50,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
];
|
||||
// Materials 타입 (SM, RM, CS는 Material 테이블 사용)
|
||||
const MATERIAL_TYPES = ['SM', 'RM', 'CS'];
|
||||
|
||||
/**
|
||||
* 품목 조회 함수
|
||||
* TODO: API 연동 시 fetchItemByCode()로 교체
|
||||
* API 응답을 ItemMaster 타입으로 변환
|
||||
*/
|
||||
async function getItemByCode(itemCode: string): Promise<ItemMaster | null> {
|
||||
// API 연동 전 mock 데이터 반환
|
||||
// const item = await fetchItemByCode(itemCode);
|
||||
const item = mockItems.find(
|
||||
(item) => item.itemCode === decodeURIComponent(itemCode)
|
||||
);
|
||||
return item || null;
|
||||
function mapApiResponseToItemMaster(data: Record<string, unknown>): ItemMaster {
|
||||
return {
|
||||
id: String(data.id || ''),
|
||||
// 백엔드 필드 매핑:
|
||||
// - Product: code, name, product_type
|
||||
// - Material: material_code, name, material_type (또는 type_code)
|
||||
itemCode: String(data.code || data.material_code || data.item_code || data.itemCode || ''),
|
||||
itemName: String(data.name || data.item_name || data.itemName || ''),
|
||||
itemType: String(data.product_type || data.material_type || data.type_code || data.item_type || data.itemType || 'FG'),
|
||||
unit: String(data.unit || 'EA'),
|
||||
specification: data.specification ? String(data.specification) : undefined,
|
||||
isActive: Boolean(data.is_active ?? data.isActive ?? true),
|
||||
category1: data.category1 ? String(data.category1) : undefined,
|
||||
category2: data.category2 ? String(data.category2) : undefined,
|
||||
category3: data.category3 ? String(data.category3) : undefined,
|
||||
salesPrice: data.sales_price ? Number(data.sales_price) : undefined,
|
||||
purchasePrice: data.purchase_price ? Number(data.purchase_price) : undefined,
|
||||
marginRate: data.margin_rate ? Number(data.margin_rate) : undefined,
|
||||
processingCost: data.processing_cost ? Number(data.processing_cost) : undefined,
|
||||
laborCost: data.labor_cost ? Number(data.labor_cost) : undefined,
|
||||
installCost: data.install_cost ? Number(data.install_cost) : undefined,
|
||||
productCategory: data.product_category ? String(data.product_category) : undefined,
|
||||
lotAbbreviation: data.lot_abbreviation ? String(data.lot_abbreviation) : undefined,
|
||||
note: data.note ? String(data.note) : undefined,
|
||||
description: data.description ? String(data.description) : undefined,
|
||||
safetyStock: data.safety_stock ? Number(data.safety_stock) : undefined,
|
||||
leadTime: data.lead_time ? Number(data.lead_time) : undefined,
|
||||
currentRevision: data.current_revision ? Number(data.current_revision) : 0,
|
||||
isFinal: Boolean(data.is_final ?? false),
|
||||
createdAt: String(data.created_at || data.createdAt || ''),
|
||||
updatedAt: data.updated_at ? String(data.updated_at) : undefined,
|
||||
// 부품 관련
|
||||
partType: data.part_type ? String(data.part_type) : undefined,
|
||||
partUsage: data.part_usage ? String(data.part_usage) : undefined,
|
||||
installationType: data.installation_type ? String(data.installation_type) : undefined,
|
||||
assemblyType: data.assembly_type ? String(data.assembly_type) : undefined,
|
||||
assemblyLength: data.assembly_length ? String(data.assembly_length) : undefined,
|
||||
material: data.material ? String(data.material) : undefined,
|
||||
sideSpecWidth: data.side_spec_width ? String(data.side_spec_width) : undefined,
|
||||
sideSpecHeight: data.side_spec_height ? String(data.side_spec_height) : undefined,
|
||||
guideRailModelType: data.guide_rail_model_type ? String(data.guide_rail_model_type) : undefined,
|
||||
guideRailModel: data.guide_rail_model ? String(data.guide_rail_model) : undefined,
|
||||
length: data.length ? String(data.length) : undefined,
|
||||
// BOM (있으면)
|
||||
bom: Array.isArray(data.bom) ? data.bom.map((bomItem: Record<string, unknown>) => ({
|
||||
id: String(bomItem.id || ''),
|
||||
childItemCode: String(bomItem.child_item_code || bomItem.childItemCode || ''),
|
||||
childItemName: String(bomItem.child_item_name || bomItem.childItemName || ''),
|
||||
quantity: Number(bomItem.quantity || 1),
|
||||
unit: String(bomItem.unit || 'EA'),
|
||||
unitPrice: bomItem.unit_price ? Number(bomItem.unit_price) : undefined,
|
||||
quantityFormula: bomItem.quantity_formula ? String(bomItem.quantity_formula) : undefined,
|
||||
isBending: Boolean(bomItem.is_bending ?? false),
|
||||
})) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 상세 페이지
|
||||
*/
|
||||
export default async function ItemDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const item = await getItemByCode(id);
|
||||
export default function ItemDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [item, setItem] = useState<ItemMaster | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// URL에서 type과 id 쿼리 파라미터 읽기
|
||||
const itemType = searchParams.get('type') || 'FG';
|
||||
const itemId = searchParams.get('id');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItem = async () => {
|
||||
if (!params.id || typeof params.id !== 'string') {
|
||||
setError('잘못된 품목 ID입니다.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const itemCode = decodeURIComponent(params.id);
|
||||
console.log('[ItemDetail] Fetching item:', { itemCode, itemType, itemId });
|
||||
|
||||
let response: Response;
|
||||
|
||||
// Materials (SM, RM, CS)는 다른 API 엔드포인트 사용
|
||||
if (MATERIAL_TYPES.includes(itemType) && itemId) {
|
||||
// GET /api/proxy/items/{id}?item_type=MATERIAL
|
||||
console.log('[ItemDetail] Using Material API');
|
||||
response = await fetch(`/api/proxy/items/${itemId}?item_type=MATERIAL`);
|
||||
} else {
|
||||
// Products (FG, PT): GET /api/proxy/items/code/{itemCode}?include_bom=true
|
||||
console.log('[ItemDetail] Using Product API');
|
||||
response = await fetch(`/api/proxy/items/code/${encodeURIComponent(itemCode)}?include_bom=true`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setError('품목을 찾을 수 없습니다.');
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
setError(errorData?.message || `오류 발생 (${response.status})`);
|
||||
}
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[ItemDetail] API Response:', result);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const mappedItem = mapApiResponseToItemMaster(result.data);
|
||||
setItem(mappedItem);
|
||||
} else {
|
||||
setError(result.message || '품목 정보를 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ItemDetail] Error:', err);
|
||||
setError('품목 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchItem();
|
||||
}, [params.id, itemType, itemId]);
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">품목 정보 로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button
|
||||
onClick={() => router.push('/items')}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
품목 목록으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 품목 없음
|
||||
if (!item) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
|
||||
<ItemDetailClient item={item} />
|
||||
</Suspense>
|
||||
<ItemDetailClient item={item} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const item = await getItemByCode(id);
|
||||
|
||||
if (!item) {
|
||||
return {
|
||||
title: '품목을 찾을 수 없습니다',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${item.itemName} - 품목 상세`,
|
||||
description: `${item.itemCode} 품목 정보`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 거래처 수정 페이지
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { ClientRegistration } from "@/components/clients/ClientRegistration";
|
||||
import {
|
||||
useClientList,
|
||||
ClientFormData,
|
||||
clientToFormData,
|
||||
} from "@/hooks/useClientList";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function ClientEditPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const { fetchClient, updateClient, isLoading: hookLoading } = useClientList();
|
||||
const [editingClient, setEditingClient] = useState<ClientFormData | null>(
|
||||
null
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadClient = async () => {
|
||||
if (!id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchClient(id);
|
||||
if (data) {
|
||||
setEditingClient(clientToFormData(data));
|
||||
} else {
|
||||
toast.error("거래처를 찾을 수 없습니다.");
|
||||
router.push("/sales/client-management-sales-admin");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("데이터 로드 중 오류가 발생했습니다.");
|
||||
router.push("/sales/client-management-sales-admin");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadClient();
|
||||
}, [id, fetchClient, router]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push(`/sales/client-management-sales-admin/${id}`);
|
||||
};
|
||||
|
||||
const handleSave = async (formData: ClientFormData) => {
|
||||
await updateClient(id, formData);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!editingClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
editingClient={editingClient}
|
||||
isLoading={hookLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 거래처 상세 페이지
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { ClientDetail } from "@/components/clients/ClientDetail";
|
||||
import { useClientList, Client } from "@/hooks/useClientList";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function ClientDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const { fetchClient, deleteClient } = useClientList();
|
||||
const [client, setClient] = useState<Client | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadClient = async () => {
|
||||
if (!id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchClient(id);
|
||||
if (data) {
|
||||
setClient(data);
|
||||
} else {
|
||||
toast.error("거래처를 찾을 수 없습니다.");
|
||||
router.push("/sales/client-management-sales-admin");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("데이터 로드 중 오류가 발생했습니다.");
|
||||
router.push("/sales/client-management-sales-admin");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadClient();
|
||||
}, [id, fetchClient, router]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/client-management-sales-admin");
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/client-management-sales-admin/${id}/edit`);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteClient(id);
|
||||
toast.success("거래처가 삭제되었습니다.");
|
||||
router.push("/sales/client-management-sales-admin");
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClientDetail
|
||||
client={client}
|
||||
onBack={handleBack}
|
||||
onEdit={handleEdit}
|
||||
onDelete={() => setShowDeleteDialog(true)}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>거래처 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{client.name}' 거래처를 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 거래처 등록 페이지
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ClientRegistration } from "@/components/clients/ClientRegistration";
|
||||
import { useClientList, ClientFormData } from "@/hooks/useClientList";
|
||||
|
||||
export default function ClientNewPage() {
|
||||
const router = useRouter();
|
||||
const { createClient, isLoading } = useClientList();
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/client-management-sales-admin");
|
||||
};
|
||||
|
||||
const handleSave = async (formData: ClientFormData) => {
|
||||
await createClient(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<ClientRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,15 @@
|
||||
* - 체크박스 포함 DataTable (Desktop)
|
||||
* - 체크박스 포함 모바일 카드 (Mobile)
|
||||
* - 페이지네이션 + 모바일 인피니티 스크롤
|
||||
*
|
||||
* API 연동: 2025-12-04
|
||||
* - useClientList 훅으로 백엔드 API 연동
|
||||
* - 페이지 기반 CRUD (등록/수정/상세 → 별도 페이지로 이동)
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useClientList, Client } from "@/hooks/useClientList";
|
||||
import {
|
||||
Building2,
|
||||
Plus,
|
||||
@@ -20,25 +26,16 @@ import {
|
||||
Users,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TabOption,
|
||||
TableColumn,
|
||||
} from "@/components/templates/IntegratedListTemplateV2";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
TableRow,
|
||||
TableCell,
|
||||
@@ -56,117 +53,24 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
// 거래처 타입
|
||||
interface CustomerAccount {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
businessNo: string;
|
||||
representative: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
email: string;
|
||||
businessType: string;
|
||||
businessItem: string;
|
||||
registeredDate: string;
|
||||
status: "활성" | "비활성";
|
||||
}
|
||||
|
||||
// 샘플 거래처 데이터
|
||||
const SAMPLE_CUSTOMERS: CustomerAccount[] = [
|
||||
{
|
||||
id: "1",
|
||||
code: "C-001",
|
||||
name: "ABC건설",
|
||||
businessNo: "123-45-67890",
|
||||
representative: "홍길동",
|
||||
phone: "02-1234-5678",
|
||||
address: "서울시 강남구 테헤란로 123",
|
||||
email: "abc@company.com",
|
||||
businessType: "건설업",
|
||||
businessItem: "건축공사",
|
||||
registeredDate: "2024-01-15",
|
||||
status: "활성",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
code: "C-002",
|
||||
name: "삼성전자",
|
||||
businessNo: "234-56-78901",
|
||||
representative: "김대표",
|
||||
phone: "02-2345-6789",
|
||||
address: "서울시 서초구 서초대로 456",
|
||||
email: "samsung@company.com",
|
||||
businessType: "제조업",
|
||||
businessItem: "전자제품",
|
||||
registeredDate: "2024-02-20",
|
||||
status: "활성",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
code: "C-003",
|
||||
name: "LG전자",
|
||||
businessNo: "345-67-89012",
|
||||
representative: "이사장",
|
||||
phone: "02-3456-7890",
|
||||
address: "서울시 영등포구 여의대로 789",
|
||||
email: "lg@company.com",
|
||||
businessType: "제조업",
|
||||
businessItem: "가전제품",
|
||||
registeredDate: "2024-03-10",
|
||||
status: "활성",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
code: "C-004",
|
||||
name: "현대건설",
|
||||
businessNo: "456-78-90123",
|
||||
representative: "박부장",
|
||||
phone: "02-4567-8901",
|
||||
address: "서울시 종로구 종로 101",
|
||||
email: "hyundai@company.com",
|
||||
businessType: "건설업",
|
||||
businessItem: "토목공사",
|
||||
registeredDate: "2024-04-05",
|
||||
status: "비활성",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
code: "C-005",
|
||||
name: "SK하이닉스",
|
||||
businessNo: "567-89-01234",
|
||||
representative: "최이사",
|
||||
phone: "031-5678-9012",
|
||||
address: "경기도 이천시 부발읍",
|
||||
email: "skhynix@company.com",
|
||||
businessType: "제조업",
|
||||
businessItem: "반도체",
|
||||
registeredDate: "2024-05-12",
|
||||
status: "활성",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CustomerAccountManagementPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// API 훅 사용
|
||||
const {
|
||||
clients,
|
||||
pagination,
|
||||
isLoading,
|
||||
fetchClients,
|
||||
deleteClient: deleteClientApi,
|
||||
} = useClientList();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterType, setFilterType] = useState("all");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCustomer, setEditingCustomer] = useState<CustomerAccount | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
businessNo: "",
|
||||
representative: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
email: "",
|
||||
businessType: "",
|
||||
businessItem: "",
|
||||
});
|
||||
|
||||
// 삭제 확인 다이얼로그 state
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
@@ -178,46 +82,52 @@ export default function CustomerAccountManagementPage() {
|
||||
const [mobileDisplayCount, setMobileDisplayCount] = useState(20);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 로컬 데이터 state
|
||||
const [customers, setCustomers] = useState<CustomerAccount[]>(SAMPLE_CUSTOMERS);
|
||||
|
||||
// 필터링
|
||||
const filteredCustomers = customers
|
||||
.filter((customer) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
customer.name.toLowerCase().includes(searchLower) ||
|
||||
customer.code.toLowerCase().includes(searchLower) ||
|
||||
customer.representative.toLowerCase().includes(searchLower) ||
|
||||
customer.phone.includes(searchTerm) ||
|
||||
customer.businessNo.includes(searchTerm);
|
||||
|
||||
let matchesFilter = true;
|
||||
if (filterType === "active") {
|
||||
matchesFilter = customer.status === "활성";
|
||||
} else if (filterType === "inactive") {
|
||||
matchesFilter = customer.status === "비활성";
|
||||
}
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return (
|
||||
new Date(b.registeredDate).getTime() -
|
||||
new Date(a.registeredDate).getTime()
|
||||
);
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
fetchClients({
|
||||
page: currentPage,
|
||||
size: itemsPerPage,
|
||||
q: searchTerm || undefined,
|
||||
onlyActive: filterType === "active" ? true : filterType === "inactive" ? false : undefined,
|
||||
});
|
||||
}, [currentPage, filterType, fetchClients]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredCustomers.length / itemsPerPage);
|
||||
const paginatedCustomers = filteredCustomers.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
// 검색어 변경 시 디바운스 처리
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
useEffect(() => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchClients({
|
||||
page: 1,
|
||||
size: itemsPerPage,
|
||||
q: searchTerm || undefined,
|
||||
onlyActive: filterType === "active" ? true : filterType === "inactive" ? false : undefined,
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchTerm]);
|
||||
|
||||
// 클라이언트 사이드 필터링 (탭용)
|
||||
const filteredClients = clients.filter((client) => {
|
||||
if (filterType === "active") return client.status === "활성";
|
||||
if (filterType === "inactive") return client.status === "비활성";
|
||||
return true;
|
||||
});
|
||||
|
||||
// 페이지네이션 (API에서 처리하므로 clients 직접 사용)
|
||||
const totalPages = pagination ? pagination.lastPage : 1;
|
||||
const paginatedClients = filteredClients;
|
||||
|
||||
// 모바일용 인피니티 스크롤 데이터
|
||||
const mobileCustomers = filteredCustomers.slice(0, mobileDisplayCount);
|
||||
const mobileClients = filteredClients.slice(0, mobileDisplayCount);
|
||||
|
||||
// Intersection Observer를 이용한 인피니티 스크롤
|
||||
useEffect(() => {
|
||||
@@ -228,10 +138,10 @@ export default function CustomerAccountManagementPage() {
|
||||
(entries) => {
|
||||
if (
|
||||
entries[0].isIntersecting &&
|
||||
mobileDisplayCount < filteredCustomers.length
|
||||
mobileDisplayCount < filteredClients.length
|
||||
) {
|
||||
setMobileDisplayCount((prev) =>
|
||||
Math.min(prev + 20, filteredCustomers.length)
|
||||
Math.min(prev + 20, filteredClients.length)
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -248,17 +158,17 @@ export default function CustomerAccountManagementPage() {
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [mobileDisplayCount, filteredCustomers.length]);
|
||||
}, [mobileDisplayCount, filteredClients.length]);
|
||||
|
||||
// 탭이나 검색어 변경 시 모바일 표시 개수 초기화
|
||||
useEffect(() => {
|
||||
setMobileDisplayCount(20);
|
||||
}, [searchTerm, filterType]);
|
||||
|
||||
// 통계
|
||||
const totalCustomers = customers.length;
|
||||
const activeCustomers = customers.filter((c) => c.status === "활성").length;
|
||||
const inactiveCustomers = customers.filter((c) => c.status === "비활성").length;
|
||||
// 통계 (API에서 가져온 전체 데이터 기반)
|
||||
const totalCustomers = pagination?.total || clients.length;
|
||||
const activeCustomers = clients.filter((c) => c.status === "활성").length;
|
||||
const inactiveCustomers = clients.filter((c) => c.status === "비활성").length;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
@@ -281,62 +191,27 @@ export default function CustomerAccountManagementPage() {
|
||||
},
|
||||
];
|
||||
|
||||
// 핸들러
|
||||
// 데이터 새로고침 함수
|
||||
const refreshData = useCallback(() => {
|
||||
fetchClients({
|
||||
page: currentPage,
|
||||
size: itemsPerPage,
|
||||
q: searchTerm || undefined,
|
||||
onlyActive: filterType === "active" ? true : filterType === "inactive" ? false : undefined,
|
||||
});
|
||||
}, [currentPage, itemsPerPage, searchTerm, filterType, fetchClients]);
|
||||
|
||||
// 핸들러 - 페이지 기반 네비게이션
|
||||
const handleAddNew = () => {
|
||||
setEditingCustomer(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
businessNo: "",
|
||||
representative: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
email: "",
|
||||
businessType: "",
|
||||
businessItem: "",
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
router.push("/sales/client-management-sales-admin/new");
|
||||
};
|
||||
|
||||
const handleEdit = (customer: CustomerAccount) => {
|
||||
setEditingCustomer(customer);
|
||||
setFormData({
|
||||
name: customer.name,
|
||||
businessNo: customer.businessNo,
|
||||
representative: customer.representative,
|
||||
phone: customer.phone,
|
||||
address: customer.address,
|
||||
email: customer.email,
|
||||
businessType: customer.businessType,
|
||||
businessItem: customer.businessItem,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
const handleEdit = (customer: Client) => {
|
||||
router.push(`/sales/client-management-sales-admin/${customer.id}/edit`);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (editingCustomer) {
|
||||
setCustomers(
|
||||
customers.map((c) =>
|
||||
c.id === editingCustomer.id ? { ...c, ...formData } : c
|
||||
)
|
||||
);
|
||||
toast.success("거래처 정보가 수정되었습니다");
|
||||
} else {
|
||||
const newCode = `C-${String(customers.length + 1).padStart(3, "0")}`;
|
||||
const newCustomer: CustomerAccount = {
|
||||
id: String(customers.length + 1),
|
||||
code: newCode,
|
||||
...formData,
|
||||
registeredDate: new Date().toISOString().split("T")[0],
|
||||
status: "활성",
|
||||
};
|
||||
setCustomers([...customers, newCustomer]);
|
||||
toast.success("새 거래처가 등록되었습니다");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleView = (customer: CustomerAccount) => {
|
||||
toast.info(`상세보기: ${customer.name}`);
|
||||
const handleView = (customer: Client) => {
|
||||
router.push(`/sales/client-management-sales-admin/${customer.id}`);
|
||||
};
|
||||
|
||||
const handleDelete = (customerId: string) => {
|
||||
@@ -344,13 +219,19 @@ export default function CustomerAccountManagementPage() {
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
const handleConfirmDelete = async () => {
|
||||
if (deleteTargetId) {
|
||||
const customer = customers.find((c) => c.id === deleteTargetId);
|
||||
setCustomers(customers.filter((c) => c.id !== deleteTargetId));
|
||||
toast.success(`거래처가 삭제되었습니다${customer ? `: ${customer.name}` : ""}`);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
try {
|
||||
const customer = clients.find((c) => c.id === deleteTargetId);
|
||||
await deleteClientApi(deleteTargetId);
|
||||
toast.success(`거래처가 삭제되었습니다${customer ? `: ${customer.name}` : ""}`);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
refreshData();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "삭제 중 오류가 발생했습니다";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -367,12 +248,12 @@ export default function CustomerAccountManagementPage() {
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (
|
||||
selectedItems.size === paginatedCustomers.length &&
|
||||
paginatedCustomers.length > 0
|
||||
selectedItems.size === paginatedClients.length &&
|
||||
paginatedClients.length > 0
|
||||
) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedCustomers.map((c) => c.id)));
|
||||
setSelectedItems(new Set(paginatedClients.map((c) => c.id)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -385,11 +266,19 @@ export default function CustomerAccountManagementPage() {
|
||||
setIsBulkDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmBulkDelete = () => {
|
||||
setCustomers(customers.filter((c) => !selectedItems.has(c.id)));
|
||||
toast.success(`${selectedItems.size}개의 거래처가 삭제되었습니다`);
|
||||
setSelectedItems(new Set());
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
const handleConfirmBulkDelete = async () => {
|
||||
try {
|
||||
// 선택된 항목들을 순차적으로 삭제
|
||||
const deletePromises = Array.from(selectedItems).map((id) => deleteClientApi(id));
|
||||
await Promise.all(deletePromises);
|
||||
toast.success(`${selectedItems.size}개의 거래처가 삭제되었습니다`);
|
||||
setSelectedItems(new Set());
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
refreshData();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "삭제 중 오류가 발생했습니다";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태 뱃지
|
||||
@@ -413,7 +302,7 @@ export default function CustomerAccountManagementPage() {
|
||||
{
|
||||
value: "all",
|
||||
label: "전체",
|
||||
count: customers.length,
|
||||
count: totalCustomers,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
@@ -446,7 +335,7 @@ export default function CustomerAccountManagementPage() {
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (
|
||||
customer: CustomerAccount,
|
||||
customer: Client,
|
||||
index: number,
|
||||
globalIndex: number
|
||||
) => {
|
||||
@@ -504,7 +393,7 @@ export default function CustomerAccountManagementPage() {
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
customer: CustomerAccount,
|
||||
customer: Client,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
@@ -544,32 +433,34 @@ export default function CustomerAccountManagementPage() {
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(customer);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(customer.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(customer);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(customer.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -598,10 +489,10 @@ export default function CustomerAccountManagementPage() {
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
tableColumns={tableColumns}
|
||||
tableTitle={`${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredCustomers.length}개)`}
|
||||
data={paginatedCustomers}
|
||||
totalCount={filteredCustomers.length}
|
||||
allData={mobileCustomers}
|
||||
tableTitle={`${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredClients.length}개)`}
|
||||
data={paginatedClients}
|
||||
totalCount={filteredClients.length}
|
||||
allData={mobileClients}
|
||||
mobileDisplayCount={mobileDisplayCount}
|
||||
infinityScrollSentinelRef={sentinelRef}
|
||||
selectedItems={selectedItems}
|
||||
@@ -611,124 +502,16 @@ export default function CustomerAccountManagementPage() {
|
||||
getItemId={(customer) => customer.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredCustomers.length,
|
||||
totalItems: pagination?.total || filteredClients.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCustomer ? "거래처 수정" : "거래처 등록"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>거래처 정보를 입력하세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4 px-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">거래처명 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder="ABC건설"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="businessNo">사업자번호 *</Label>
|
||||
<Input
|
||||
id="businessNo"
|
||||
value={formData.businessNo}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, businessNo: e.target.value })
|
||||
}
|
||||
placeholder="123-45-67890"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="representative">대표자 *</Label>
|
||||
<Input
|
||||
id="representative"
|
||||
value={formData.representative}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, representative: e.target.value })
|
||||
}
|
||||
placeholder="홍길동"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">전화번호 *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, phone: e.target.value })
|
||||
}
|
||||
placeholder="02-1234-5678"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="address">주소 *</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, address: e.target.value })
|
||||
}
|
||||
placeholder="서울시 강남구 테헤란로 123"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
placeholder="abc@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="businessType">업태</Label>
|
||||
<Input
|
||||
id="businessType"
|
||||
value={formData.businessType}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, businessType: e.target.value })
|
||||
}
|
||||
placeholder="건설업"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="businessItem">업종</Label>
|
||||
<Input
|
||||
id="businessItem"
|
||||
value={formData.businessItem}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, businessItem: e.target.value })
|
||||
}
|
||||
placeholder="건축공사"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>저장</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
@@ -736,7 +519,7 @@ export default function CustomerAccountManagementPage() {
|
||||
<AlertDialogTitle>거래처 삭제 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTargetId
|
||||
? `거래처: ${customers.find((c) => c.id === deleteTargetId)?.name || deleteTargetId}`
|
||||
? `거래처: ${clients.find((c) => c.id === deleteTargetId)?.name || deleteTargetId}`
|
||||
: ""}
|
||||
<br />
|
||||
이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 견적 수정 페이지
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { QuoteRegistration, QuoteFormData, INITIAL_QUOTE_FORM } from "@/components/quotes/QuoteRegistration";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 샘플 견적 데이터 (TODO: API에서 가져오기)
|
||||
const SAMPLE_QUOTE: QuoteFormData = {
|
||||
id: "Q2024-001",
|
||||
registrationDate: "2025-10-29",
|
||||
writer: "드미트리",
|
||||
clientId: "client-1",
|
||||
clientName: "인천건설 - 최담당",
|
||||
siteName: "인천 송도 현장", // 직접 입력
|
||||
manager: "김영업",
|
||||
contact: "010-1234-5678",
|
||||
dueDate: "2025-11-30",
|
||||
remarks: "스크린 셔터 부품구성표 기반 자동 견적",
|
||||
items: [
|
||||
{
|
||||
id: "item-1",
|
||||
floor: "1층",
|
||||
code: "A",
|
||||
productCategory: "screen",
|
||||
productName: "SCR-001",
|
||||
openWidth: "2000",
|
||||
openHeight: "2500",
|
||||
guideRailType: "wall",
|
||||
motorPower: "single",
|
||||
controller: "basic",
|
||||
quantity: 1,
|
||||
wingSize: "50",
|
||||
inspectionFee: 50000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function QuoteEditPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const quoteId = params.id as string;
|
||||
|
||||
const [quote, setQuote] = useState<QuoteFormData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: API에서 견적 데이터 가져오기
|
||||
const fetchQuote = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 임시: 샘플 데이터 사용
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
setQuote({ ...SAMPLE_QUOTE, id: quoteId });
|
||||
} catch (error) {
|
||||
toast.error("견적 정보를 불러오는데 실패했습니다.");
|
||||
router.push("/sales/quote-management");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchQuote();
|
||||
}, [quoteId, router]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
|
||||
const handleSave = async (formData: QuoteFormData) => {
|
||||
// TODO: API 연동
|
||||
console.log("견적 수정 데이터:", formData);
|
||||
|
||||
// 임시: 성공 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
toast.success("견적이 수정되었습니다. (API 연동 필요)");
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-2 text-sm text-gray-500">견적 정보를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QuoteRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
editingQuote={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
/**
|
||||
* 견적 상세 페이지
|
||||
* - 기본 정보 표시
|
||||
* - 자동 견적 산출 정보
|
||||
* - 견적서 / 산출내역서 / 발주서 모달
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { QuoteFormData, INITIAL_QUOTE_FORM } from "@/components/quotes/QuoteRegistration";
|
||||
import { QuoteDocument } from "@/components/quotes/QuoteDocument";
|
||||
import { QuoteCalculationReport } from "@/components/quotes/QuoteCalculationReport";
|
||||
import { PurchaseOrderDocument } from "@/components/quotes/PurchaseOrderDocument";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
FileText,
|
||||
Edit,
|
||||
List,
|
||||
Printer,
|
||||
FileOutput,
|
||||
Download,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
X,
|
||||
FileCheck,
|
||||
ShoppingCart,
|
||||
} from "lucide-react";
|
||||
|
||||
// 샘플 견적 데이터 (TODO: API에서 가져오기)
|
||||
const SAMPLE_QUOTE: QuoteFormData = {
|
||||
id: "Q2024-001",
|
||||
registrationDate: "2025-10-29",
|
||||
writer: "드미트리",
|
||||
clientId: "client-1",
|
||||
clientName: "인천건설",
|
||||
siteName: "송도 오피스텔 A동",
|
||||
manager: "김영업",
|
||||
contact: "010-1234-5678",
|
||||
dueDate: "2025-11-30",
|
||||
remarks: "스크린 셔터 부품구성표 기반 자동 견적",
|
||||
items: [
|
||||
{
|
||||
id: "item-1",
|
||||
floor: "1층",
|
||||
code: "A",
|
||||
productCategory: "screen",
|
||||
productName: "스크린 셔터 (표준형)",
|
||||
openWidth: "2000",
|
||||
openHeight: "2500",
|
||||
guideRailType: "wall",
|
||||
motorPower: "single",
|
||||
controller: "basic",
|
||||
quantity: 1,
|
||||
wingSize: "50",
|
||||
inspectionFee: 337000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function QuoteDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const quoteId = params.id as string;
|
||||
|
||||
const [quote, setQuote] = useState<QuoteFormData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [isQuoteDocumentOpen, setIsQuoteDocumentOpen] = useState(false);
|
||||
const [isCalculationReportOpen, setIsCalculationReportOpen] = useState(false);
|
||||
const [isPurchaseOrderOpen, setIsPurchaseOrderOpen] = useState(false);
|
||||
|
||||
// 산출내역서 표시 옵션
|
||||
const [showDetailedBreakdown, setShowDetailedBreakdown] = useState(true);
|
||||
const [showMaterialList, setShowMaterialList] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: API에서 견적 데이터 가져오기
|
||||
const fetchQuote = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 임시: 샘플 데이터 사용
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
setQuote({ ...SAMPLE_QUOTE, id: quoteId });
|
||||
} catch (error) {
|
||||
toast.error("견적 정보를 불러오는데 실패했습니다.");
|
||||
router.push("/sales/quote-management");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchQuote();
|
||||
}, [quoteId, router]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/quote-management/${quoteId}/edit`);
|
||||
};
|
||||
|
||||
const handleFinalize = () => {
|
||||
toast.success("견적이 최종 확정되었습니다. (API 연동 필요)");
|
||||
};
|
||||
|
||||
const handleConvertToOrder = () => {
|
||||
toast.info("수주 등록 화면으로 이동합니다. (API 연동 필요)");
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return "-";
|
||||
return dateStr;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number | undefined) => {
|
||||
if (!amount) return "0";
|
||||
return amount.toLocaleString("ko-KR");
|
||||
};
|
||||
|
||||
// 총 금액 계산
|
||||
const totalAmount =
|
||||
quote?.items?.reduce((sum, item) => {
|
||||
return sum + (item.inspectionFee || 0) * (item.quantity || 1);
|
||||
}, 0) || 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
견적 정보를 불러오는 중...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!quote) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p className="text-gray-500">견적 정보를 찾을 수 없습니다.</p>
|
||||
<Button onClick={handleBack} className="mt-4">
|
||||
목록으로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" />
|
||||
견적 상세
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">견적번호: {quote.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* 문서 버튼들 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsQuoteDocumentOpen(true)}
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
견적서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCalculationReportOpen(true)}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
산출내역서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsPurchaseOrderOpen(true)}
|
||||
>
|
||||
<FileOutput className="w-4 h-4 mr-2" />
|
||||
발주서
|
||||
</Button>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="w-4 h-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFinalize}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<FileCheck className="w-4 h-4 mr-2" />
|
||||
최종확정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>견적번호</Label>
|
||||
<Input
|
||||
value={quote.id || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>작성자</Label>
|
||||
<Input
|
||||
value={quote.writer || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>발주처</Label>
|
||||
<Input
|
||||
value={quote.clientName || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>담당자</Label>
|
||||
<Input
|
||||
value={quote.manager || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>연락처</Label>
|
||||
<Input
|
||||
value={quote.contact || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>현장명</Label>
|
||||
<Input
|
||||
value={quote.siteName || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>등록일</Label>
|
||||
<Input
|
||||
value={formatDate(quote.registrationDate || "")}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>납기일</Label>
|
||||
<Input
|
||||
value={formatDate(quote.dueDate || "")}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quote.remarks && (
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={quote.remarks}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자동 견적 산출 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>자동 견적 산출 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{quote.items && quote.items.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{quote.items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border rounded-lg p-4 bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant="outline">항목 {index + 1}</Badge>
|
||||
<Badge variant="secondary">{item.floor}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">제품명</span>
|
||||
<p className="font-medium">{item.productName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">오픈사이즈</span>
|
||||
<p className="font-medium">
|
||||
{item.openWidth} × {item.openHeight} mm
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">수량</span>
|
||||
<p className="font-medium">{item.quantity} SET</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">금액</span>
|
||||
<p className="font-medium text-blue-600">
|
||||
₩{formatAmount((item.inspectionFee || 0) * (item.quantity || 1))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<div className="flex justify-between items-center text-lg font-bold">
|
||||
<span>총 견적금액</span>
|
||||
<span className="text-blue-600">
|
||||
₩{formatAmount(totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
산출 항목이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 견적서 다이얼로그 */}
|
||||
<Dialog open={isQuoteDocumentOpen} onOpenChange={setIsQuoteDocumentOpen}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] flex flex-col p-0">
|
||||
<DialogHeader className="flex-shrink-0 px-6 py-4 border-b">
|
||||
<DialogTitle>견적서</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex-shrink-0 px-6 py-3 border-b bg-muted/30 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => toast.info("이메일 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
이메일
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => toast.info("팩스 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<FileOutput className="w-4 h-4 mr-2" />
|
||||
팩스
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-yellow-500 hover:bg-yellow-600"
|
||||
onClick={() => toast.info("카카오톡 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
카카오톡
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsQuoteDocumentOpen(false)}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 */}
|
||||
<div className="flex-1 overflow-y-auto bg-gray-100 p-4">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
<QuoteDocument quote={quote} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 산출내역서 다이얼로그 */}
|
||||
<Dialog
|
||||
open={isCalculationReportOpen}
|
||||
onOpenChange={setIsCalculationReportOpen}
|
||||
>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="flex-shrink-0 px-6 py-4 border-b">
|
||||
<DialogTitle>산출내역서</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex-shrink-0 px-6 py-3 border-b bg-muted/30 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div className="flex gap-4 flex-wrap items-center">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => toast.info("이메일 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
이메일
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => toast.info("팩스 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<FileOutput className="w-4 h-4 mr-2" />
|
||||
팩스
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-yellow-500 hover:bg-yellow-600"
|
||||
onClick={() => toast.info("카카오톡 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
카카오톡
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 표시 옵션 체크박스 */}
|
||||
<div className="flex gap-4 pl-4 border-l">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDetailedBreakdown}
|
||||
onChange={(e) => setShowDetailedBreakdown(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">산출내역서</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMaterialList}
|
||||
onChange={(e) => setShowMaterialList(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">소요자재 내역</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsCalculationReportOpen(false)}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 */}
|
||||
<div className="flex-1 overflow-y-auto bg-gray-100 p-4">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
<QuoteCalculationReport
|
||||
quote={quote}
|
||||
documentType="견적산출내역서"
|
||||
showDetailedBreakdown={showDetailedBreakdown}
|
||||
showMaterialList={showMaterialList}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 발주서 다이얼로그 */}
|
||||
<Dialog open={isPurchaseOrderOpen} onOpenChange={setIsPurchaseOrderOpen}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="flex-shrink-0 px-6 py-4 border-b">
|
||||
<DialogTitle>발주서</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex-shrink-0 px-6 py-3 border-b bg-muted/30 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => toast.info("이메일 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
이메일
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => toast.info("팩스 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<FileOutput className="w-4 h-4 mr-2" />
|
||||
팩스
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-yellow-500 hover:bg-yellow-600"
|
||||
onClick={() => toast.info("카카오톡 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
카카오톡
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsPurchaseOrderOpen(false)}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 */}
|
||||
<div className="flex-1 overflow-y-auto bg-gray-100 p-4">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
<PurchaseOrderDocument quote={quote} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 견적 등록 페이지
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { QuoteRegistration, QuoteFormData } from "@/components/quotes/QuoteRegistration";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function QuoteNewPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
|
||||
const handleSave = async (formData: QuoteFormData) => {
|
||||
// TODO: API 연동
|
||||
console.log("견적 등록 데이터:", formData);
|
||||
|
||||
// 임시: 성공 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
toast.success("견적이 등록되었습니다. (API 연동 필요)");
|
||||
};
|
||||
|
||||
return (
|
||||
<QuoteRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
FileText,
|
||||
Edit,
|
||||
@@ -167,6 +168,7 @@ const SAMPLE_QUOTES: Quote[] = [
|
||||
];
|
||||
|
||||
export default function QuoteManagementPage() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterType, setFilterType] = useState("all");
|
||||
const [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false);
|
||||
@@ -337,11 +339,11 @@ export default function QuoteManagementPage() {
|
||||
|
||||
// 핸들러
|
||||
const handleView = (quote: Quote) => {
|
||||
toast.info(`상세보기: ${quote.quoteNumber}`);
|
||||
router.push(`/sales/quote-management/${quote.id}`);
|
||||
};
|
||||
|
||||
const handleEdit = (quote: Quote) => {
|
||||
toast.info(`수정: ${quote.quoteNumber}`);
|
||||
router.push(`/sales/quote-management/${quote.id}/edit`);
|
||||
};
|
||||
|
||||
const handleDelete = (quoteId: string) => {
|
||||
@@ -600,48 +602,50 @@ export default function QuoteManagementPage() {
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{quote.currentRevision > 0 && (
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{quote.currentRevision > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewHistory(quote);
|
||||
}}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
이력
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewHistory(quote);
|
||||
handleEdit(quote);
|
||||
}}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
이력
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(quote);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{!quote.isFinal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(quote.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!quote.isFinal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(quote.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -655,7 +659,7 @@ export default function QuoteManagementPage() {
|
||||
description="견적서 작성 및 관리"
|
||||
icon={FileText}
|
||||
headerActions={
|
||||
<Button onClick={() => toast.info("견적 등록 기능 준비중")}>
|
||||
<Button onClick={() => router.push("/sales/quote-management/new")}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
견적 등록
|
||||
</Button>
|
||||
|
||||
@@ -161,11 +161,11 @@ async function proxyRequest(
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
// 3. 요청 바디 읽기 (POST, PUT, DELETE)
|
||||
// 3. 요청 바디 읽기 (POST, PUT, DELETE, PATCH)
|
||||
let body: string | undefined;
|
||||
const contentType = request.headers.get('content-type') || 'application/json';
|
||||
|
||||
if (['POST', 'PUT', 'DELETE'].includes(method)) {
|
||||
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
||||
if (contentType.includes('application/json')) {
|
||||
body = await request.text();
|
||||
console.log('🔵 [PROXY] Request:', method, url.toString());
|
||||
@@ -293,3 +293,16 @@ export async function DELETE(
|
||||
const resolvedParams = await params;
|
||||
return proxyRequest(request, resolvedParams, 'DELETE');
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH 요청 프록시
|
||||
* Next.js 15: params는 Promise이므로 await 필요
|
||||
* 용도: toggle 엔드포인트 (/clients/{id}/toggle, /client-groups/{id}/toggle)
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const resolvedParams = await params;
|
||||
return proxyRequest(request, resolvedParams, 'PATCH');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user