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:
byeongcheolryu
2025-12-04 20:52:42 +09:00
parent 42f80e2b16
commit 751e65f59b
52 changed files with 8869 additions and 1088 deletions

View File

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

View File

@@ -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} 품목 정보`,
};
}

View File

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

View File

@@ -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>
&apos;{client.name}&apos; ?
<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>
</>
);
}

View File

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

View File

@@ -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 />
? .

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -182,12 +182,10 @@ export function LoginPage() {
<p className="text-xs text-muted-foreground">{t('login')}</p>
</div>
</button>
{/* 2025-12-04: MVP에서 회원가입 버튼 제거 (운영 페이지로 이동 예정) */}
<div className="flex items-center gap-3">
<ThemeSelect native={false} />
<LanguageSelect native={false} />
<Button variant="ghost" onClick={() => router.push("/signup")} className="rounded-xl">
{t('signUp')}
</Button>
</div>
</div>
</div>
@@ -291,37 +289,7 @@ export function LoginPage() {
</Button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-card text-muted-foreground">{tCommon('or')}</span>
</div>
</div>
<div className="space-y-3">
<Button
variant="outline"
onClick={() => router.push("/signup")}
className="w-full rounded-xl"
>
{t('createAccount')}
</Button>
</div>
</div>
{/* Signup Link */}
<div className="text-center mt-6">
<p className="text-sm text-muted-foreground">
{t('noAccount')}{" "}
<button
onClick={() => router.push("/signup")}
className="text-primary font-medium hover:underline"
>
{t('signUp')}
</button>
</p>
{/* 2025-12-04: MVP에서 회원가입 섹션 제거 (운영 페이지로 이동 예정) */}
</div>
</div>
</div>

View File

@@ -0,0 +1,247 @@
/**
* 거래처 상세 보기 컴포넌트
*
* 스크린샷 기준 4개 섹션:
* 1. 기본 정보
* 2. 연락처 정보
* 3. 결제 정보
* 4. 악성채권 정보 (있는 경우 빨간 테두리)
*/
"use client";
import { useRouter } from "next/navigation";
import { Button } from "../ui/button";
import { Badge } from "../ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import {
Building2,
Phone,
CreditCard,
AlertTriangle,
ArrowLeft,
Pencil,
Trash2,
MapPin,
Mail,
} from "lucide-react";
import { Client } from "../../hooks/useClientList";
interface ClientDetailProps {
client: Client;
onBack: () => void;
onEdit: () => void;
onDelete: () => void;
}
// 상세 항목 표시 컴포넌트
function DetailItem({
label,
value,
icon,
valueClassName,
}: {
label: string;
value: React.ReactNode;
icon?: React.ReactNode;
valueClassName?: string;
}) {
return (
<div>
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<div className={`flex items-center gap-2 ${valueClassName || ""}`}>
{icon}
<span className="font-medium">{value || "-"}</span>
</div>
</div>
);
}
export function ClientDetail({
client,
onBack,
onEdit,
onDelete,
}: ClientDetailProps) {
const router = useRouter();
// 금액 포맷
const formatCurrency = (amount: string) => {
if (!amount) return "-";
const num = Number(amount);
return `${num.toLocaleString()}`;
};
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-center gap-3">
<Building2 className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold">{client.name}</h1>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={onEdit}>
<Pencil className="h-4 w-4 mr-2" />
</Button>
<Button variant="destructive" onClick={onDelete}>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
{/* 1. 기본 정보 */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" />
<CardTitle> </CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<DetailItem label="CODE" value={client.code} />
<DetailItem label="사업자번호" value={client.businessNo} />
<DetailItem
label="거래처 유형"
value={
<Badge
variant={
client.clientType === "매출"
? "default"
: client.clientType === "매입"
? "secondary"
: "outline"
}
>
{client.clientType}
</Badge>
}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<DetailItem label="거래처명" value={client.name} />
<DetailItem label="대표자" value={client.representative} />
<DetailItem
label="상태"
value={
<Badge
variant={client.status === "활성" ? "default" : "secondary"}
className={
client.status === "활성"
? "bg-green-100 text-green-800"
: ""
}
>
{client.status}
</Badge>
}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<DetailItem
label="주소"
value={client.address}
icon={<MapPin className="h-4 w-4 text-muted-foreground" />}
/>
<DetailItem label="업태" value={client.businessType} />
<DetailItem label="종목" value={client.businessItem} />
</div>
{client.memo && (
<DetailItem label="비고" value={client.memo} />
)}
</CardContent>
</Card>
{/* 2. 연락처 정보 */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Phone className="h-5 w-5 text-primary" />
<CardTitle> </CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<DetailItem
label="전화"
value={client.phone}
icon={<Phone className="h-4 w-4 text-muted-foreground" />}
/>
<DetailItem
label="휴대전화"
value={client.mobile}
icon={<Phone className="h-4 w-4 text-muted-foreground" />}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<DetailItem label="팩스" value={client.fax} />
<DetailItem
label="이메일"
value={client.email}
icon={<Mail className="h-4 w-4 text-muted-foreground" />}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<DetailItem label="담당자명" value={client.managerName} />
<DetailItem label="담당자 연락처" value={client.managerTel} />
</div>
</CardContent>
</Card>
{/* 3. 결제 정보 */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<CreditCard className="h-5 w-5 text-primary" />
<CardTitle> </CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<DetailItem label="매입 결제일" value={client.purchasePaymentDay} />
<DetailItem label="매출 결제일" value={client.salesPaymentDay} />
</div>
</CardContent>
</Card>
{/* 4. 악성채권 정보 (있는 경우에만 표시) */}
{client.badDebt && (
<Card className="border-red-300 bg-red-50/30">
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
<CardTitle className="text-red-700"> </CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<DetailItem
label="악성채권 금액"
value={formatCurrency(client.badDebtAmount)}
valueClassName="text-red-600 font-bold"
/>
<DetailItem label="수령일" value={client.badDebtReceiveDate} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<DetailItem label="종료일" value={client.badDebtEndDate} />
<DetailItem label="진행 상태" value={client.badDebtProgress} />
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,607 @@
/**
* 거래처 등록/수정 컴포넌트
*
* ResponsiveFormTemplate 적용
* - 데스크톱/태블릿/모바일 통합 폼 레이아웃
* - 섹션 기반 정보 입력
* - 유효성 검사 및 에러 표시
*/
"use client";
import { useState, useEffect } from "react";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Checkbox } from "../ui/checkbox";
import { Label } from "../ui/label";
import {
Building2,
UserCircle,
Phone,
CreditCard,
FileText,
AlertTriangle,
Calculator,
} from "lucide-react";
import { toast } from "sonner";
import {
ResponsiveFormTemplate,
FormSection,
FormFieldGrid,
} from "../templates/ResponsiveFormTemplate";
import { FormField } from "../molecules/FormField";
import {
ClientFormData,
INITIAL_CLIENT_FORM,
ClientType,
BadDebtProgress,
} from "../../hooks/useClientList";
interface ClientRegistrationProps {
onBack: () => void;
onSave: (client: ClientFormData) => Promise<void>;
editingClient?: ClientFormData | null;
isLoading?: boolean;
}
export function ClientRegistration({
onBack,
onSave,
editingClient,
isLoading = false,
}: ClientRegistrationProps) {
const [formData, setFormData] = useState<ClientFormData>(
editingClient || INITIAL_CLIENT_FORM
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
// editingClient가 변경되면 formData 업데이트
useEffect(() => {
if (editingClient) {
setFormData(editingClient);
}
}, [editingClient]);
// 유효성 검사
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name || formData.name.length < 2) {
newErrors.name = "거래처명은 2자 이상 입력해주세요";
}
if (!formData.businessNo || !/^\d{10}$/.test(formData.businessNo)) {
newErrors.businessNo = "사업자등록번호는 10자리 숫자여야 합니다";
}
if (!formData.representative || formData.representative.length < 2) {
newErrors.representative = "대표자명은 2자 이상 입력해주세요";
}
// 전화번호 형식 검사 (선택적)
const phonePattern = /^[0-9-]+$/;
if (formData.phone && !phonePattern.test(formData.phone)) {
newErrors.phone = "올바른 전화번호 형식이 아닙니다";
}
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "올바른 이메일 형식이 아닙니다";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) {
toast.error("입력 내용을 확인해주세요.");
return;
}
setIsSaving(true);
try {
await onSave(formData);
toast.success(
editingClient ? "거래처가 수정되었습니다." : "거래처가 등록되었습니다."
);
onBack();
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
};
const handleFieldChange = (
field: keyof ClientFormData,
value: string | boolean
) => {
setFormData({ ...formData, [field]: value });
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
return (
<ResponsiveFormTemplate
title={editingClient ? "거래처 수정" : "거래처 등록"}
description="거래처 정보를 입력하세요"
icon={Building2}
onSave={handleSubmit}
onCancel={onBack}
saveLabel={editingClient ? "수정" : "등록"}
isEditMode={!!editingClient}
saveLoading={isSaving || isLoading}
saveDisabled={isSaving || isLoading}
maxWidth="2xl"
>
{/* 1. 기본 정보 */}
<FormSection
title="기본 정보"
description="거래처의 기본 정보를 입력하세요"
icon={Building2}
>
<FormFieldGrid columns={2}>
<FormField
label="사업자등록번호"
required
error={errors.businessNo}
htmlFor="businessNo"
>
<Input
id="businessNo"
placeholder="10자리 숫자"
value={formData.businessNo}
onChange={(e) => handleFieldChange("businessNo", e.target.value)}
/>
</FormField>
<FormField
label="거래처 코드"
htmlFor="clientCode"
helpText="자동 생성됩니다"
>
<Input
id="clientCode"
placeholder="자동생성"
value={formData.clientCode || ""}
disabled
/>
</FormField>
</FormFieldGrid>
<FormFieldGrid columns={2}>
<FormField
label="거래처명"
required
error={errors.name}
htmlFor="name"
>
<Input
id="name"
placeholder="거래처명 입력"
value={formData.name}
onChange={(e) => handleFieldChange("name", e.target.value)}
/>
</FormField>
<FormField
label="대표자명"
required
error={errors.representative}
htmlFor="representative"
>
<Input
id="representative"
placeholder="대표자명 입력"
value={formData.representative}
onChange={(e) =>
handleFieldChange("representative", e.target.value)
}
/>
</FormField>
</FormFieldGrid>
<FormField label="거래처 유형" required type="custom">
<RadioGroup
value={formData.clientType}
onValueChange={(value) =>
handleFieldChange("clientType", value as ClientType)
}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="매입" id="type-purchase" />
<Label htmlFor="type-purchase"></Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="매출" id="type-sales" />
<Label htmlFor="type-sales"></Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="매입매출" id="type-both" />
<Label htmlFor="type-both"></Label>
</div>
</RadioGroup>
</FormField>
<FormFieldGrid columns={2}>
<FormField label="업태" htmlFor="businessType">
<Input
id="businessType"
placeholder="제조업, 도소매업 등"
value={formData.businessType}
onChange={(e) =>
handleFieldChange("businessType", e.target.value)
}
/>
</FormField>
<FormField label="종목" htmlFor="businessItem">
<Input
id="businessItem"
placeholder="철강, 건설 등"
value={formData.businessItem}
onChange={(e) =>
handleFieldChange("businessItem", e.target.value)
}
/>
</FormField>
</FormFieldGrid>
</FormSection>
{/* 2. 연락처 정보 */}
<FormSection
title="연락처 정보"
description="거래처의 연락처 정보를 입력하세요"
icon={Phone}
>
<FormField label="주소" htmlFor="address">
<Input
id="address"
placeholder="주소 입력"
value={formData.address}
onChange={(e) => handleFieldChange("address", e.target.value)}
/>
</FormField>
<FormFieldGrid columns={3}>
<FormField label="전화번호" error={errors.phone} htmlFor="phone">
<Input
id="phone"
placeholder="02-1234-5678"
value={formData.phone}
onChange={(e) => handleFieldChange("phone", e.target.value)}
/>
</FormField>
<FormField label="모바일" htmlFor="mobile">
<Input
id="mobile"
placeholder="010-1234-5678"
value={formData.mobile}
onChange={(e) => handleFieldChange("mobile", e.target.value)}
/>
</FormField>
<FormField label="팩스" htmlFor="fax">
<Input
id="fax"
placeholder="02-1234-5678"
value={formData.fax}
onChange={(e) => handleFieldChange("fax", e.target.value)}
/>
</FormField>
</FormFieldGrid>
<FormField label="이메일" error={errors.email} htmlFor="email">
<Input
id="email"
type="email"
placeholder="example@company.com"
value={formData.email}
onChange={(e) => handleFieldChange("email", e.target.value)}
/>
</FormField>
</FormSection>
{/* 3. 담당자 정보 */}
<FormSection
title="담당자 정보"
description="거래처 담당자 정보를 입력하세요"
icon={UserCircle}
>
<FormFieldGrid columns={2}>
<FormField label="담당자명" htmlFor="managerName">
<Input
id="managerName"
placeholder="담당자명 입력"
value={formData.managerName}
onChange={(e) => handleFieldChange("managerName", e.target.value)}
/>
</FormField>
<FormField label="담당자 전화" htmlFor="managerTel">
<Input
id="managerTel"
placeholder="010-1234-5678"
value={formData.managerTel}
onChange={(e) => handleFieldChange("managerTel", e.target.value)}
/>
</FormField>
</FormFieldGrid>
<FormField label="시스템 관리자" htmlFor="systemManager">
<Input
id="systemManager"
placeholder="시스템 관리자명"
value={formData.systemManager}
onChange={(e) =>
handleFieldChange("systemManager", e.target.value)
}
/>
</FormField>
</FormSection>
{/* 4. 발주처 설정 */}
<FormSection
title="발주처 설정"
description="발주처로 사용할 경우 계정 정보를 입력하세요"
icon={CreditCard}
>
<FormFieldGrid columns={2}>
<FormField label="계정 ID" htmlFor="accountId">
<Input
id="accountId"
placeholder="계정 ID"
value={formData.accountId}
onChange={(e) => handleFieldChange("accountId", e.target.value)}
/>
</FormField>
<FormField label="비밀번호" htmlFor="accountPassword">
<Input
id="accountPassword"
type="password"
placeholder="비밀번호"
value={formData.accountPassword}
onChange={(e) =>
handleFieldChange("accountPassword", e.target.value)
}
/>
</FormField>
</FormFieldGrid>
<FormFieldGrid columns={2}>
<FormField label="매입 결제일" htmlFor="purchasePaymentDay">
<Select
value={formData.purchasePaymentDay}
onValueChange={(value) =>
handleFieldChange("purchasePaymentDay", value)
}
>
<SelectTrigger id="purchasePaymentDay">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="말일"></SelectItem>
<SelectItem value="익월 10일"> 10</SelectItem>
<SelectItem value="익월 15일"> 15</SelectItem>
<SelectItem value="익월 20일"> 20</SelectItem>
<SelectItem value="익월 25일"> 25</SelectItem>
<SelectItem value="익월 말일"> </SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="매출 결제일" htmlFor="salesPaymentDay">
<Select
value={formData.salesPaymentDay}
onValueChange={(value) =>
handleFieldChange("salesPaymentDay", value)
}
>
<SelectTrigger id="salesPaymentDay">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="말일"></SelectItem>
<SelectItem value="익월 10일"> 10</SelectItem>
<SelectItem value="익월 15일"> 15</SelectItem>
<SelectItem value="익월 20일"> 20</SelectItem>
<SelectItem value="익월 25일"> 25</SelectItem>
<SelectItem value="익월 말일"> </SelectItem>
</SelectContent>
</Select>
</FormField>
</FormFieldGrid>
</FormSection>
{/* 5. 약정 세금 */}
<FormSection
title="약정 세금"
description="세금 약정이 있는 경우 입력하세요"
icon={Calculator}
>
<FormField label="약정 여부" type="custom">
<div className="flex items-center space-x-2">
<Checkbox
id="taxAgreement"
checked={formData.taxAgreement}
onCheckedChange={(checked) =>
handleFieldChange("taxAgreement", checked as boolean)
}
/>
<Label htmlFor="taxAgreement"> </Label>
</div>
</FormField>
{formData.taxAgreement && (
<>
<FormField label="약정 금액" htmlFor="taxAmount">
<Input
id="taxAmount"
type="number"
placeholder="약정 금액"
value={formData.taxAmount}
onChange={(e) => handleFieldChange("taxAmount", e.target.value)}
/>
</FormField>
<FormFieldGrid columns={2}>
<FormField label="약정 시작일" htmlFor="taxStartDate">
<Input
id="taxStartDate"
type="date"
value={formData.taxStartDate}
onChange={(e) =>
handleFieldChange("taxStartDate", e.target.value)
}
/>
</FormField>
<FormField label="약정 종료일" htmlFor="taxEndDate">
<Input
id="taxEndDate"
type="date"
value={formData.taxEndDate}
onChange={(e) =>
handleFieldChange("taxEndDate", e.target.value)
}
/>
</FormField>
</FormFieldGrid>
</>
)}
</FormSection>
{/* 6. 악성채권 */}
<FormSection
title="악성채권 정보"
description="악성채권이 있는 경우 입력하세요"
icon={AlertTriangle}
>
<FormField label="악성채권 여부" type="custom">
<div className="flex items-center space-x-2">
<Checkbox
id="badDebt"
checked={formData.badDebt}
onCheckedChange={(checked) =>
handleFieldChange("badDebt", checked as boolean)
}
/>
<Label htmlFor="badDebt"> </Label>
</div>
</FormField>
{formData.badDebt && (
<>
<FormField label="악성채권 금액" htmlFor="badDebtAmount">
<Input
id="badDebtAmount"
type="number"
placeholder="채권 금액"
value={formData.badDebtAmount}
onChange={(e) =>
handleFieldChange("badDebtAmount", e.target.value)
}
/>
</FormField>
<FormFieldGrid columns={2}>
<FormField label="채권 발생일" htmlFor="badDebtReceiveDate">
<Input
id="badDebtReceiveDate"
type="date"
value={formData.badDebtReceiveDate}
onChange={(e) =>
handleFieldChange("badDebtReceiveDate", e.target.value)
}
/>
</FormField>
<FormField label="채권 만료일" htmlFor="badDebtEndDate">
<Input
id="badDebtEndDate"
type="date"
value={formData.badDebtEndDate}
onChange={(e) =>
handleFieldChange("badDebtEndDate", e.target.value)
}
/>
</FormField>
</FormFieldGrid>
<FormField label="진행 상태" htmlFor="badDebtProgress">
<Select
value={formData.badDebtProgress}
onValueChange={(value) =>
handleFieldChange("badDebtProgress", value as BadDebtProgress)
}
>
<SelectTrigger id="badDebtProgress">
<SelectValue placeholder="진행 상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="협의중"></SelectItem>
<SelectItem value="소송중"></SelectItem>
<SelectItem value="회수완료"></SelectItem>
<SelectItem value="대손처리"></SelectItem>
</SelectContent>
</Select>
</FormField>
</>
)}
</FormSection>
{/* 7. 기타 */}
<FormSection
title="기타 정보"
description="추가 정보를 입력하세요"
icon={FileText}
>
<FormField label="메모" htmlFor="memo">
<Textarea
id="memo"
placeholder="메모 입력"
value={formData.memo}
onChange={(e) => handleFieldChange("memo", e.target.value)}
rows={4}
/>
</FormField>
<FormField label="상태" type="custom">
<RadioGroup
value={formData.isActive ? "활성" : "비활성"}
onValueChange={(value) =>
handleFieldChange("isActive", value === "활성")
}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="활성" id="status-active" />
<Label htmlFor="status-active"></Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="비활성" id="status-inactive" />
<Label htmlFor="status-inactive"></Label>
</div>
</RadioGroup>
</FormField>
</FormSection>
</ResponsiveFormTemplate>
);
}

View File

@@ -9,7 +9,7 @@
'use client';
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import type {
DynamicFormData,
DynamicFormErrors,
@@ -25,6 +25,21 @@ export function useDynamicFormState(
const [errors, setErrors] = useState<DynamicFormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// 2025-12-04: Edit 모드에서 initialData가 비동기로 로드될 때 formData 동기화
// useState의 초기값은 첫 렌더 시에만 사용되므로,
// initialData가 나중에 변경되면 formData를 업데이트해야 함
const isInitialDataLoaded = useRef(false);
useEffect(() => {
// initialData가 있고, 아직 로드되지 않았을 때만 동기화
// (사용자가 수정 중인 데이터를 덮어쓰지 않도록)
if (initialData && Object.keys(initialData).length > 0 && !isInitialDataLoaded.current) {
console.log('[useDynamicFormState] initialData 동기화:', initialData);
setFormData(initialData);
isInitialDataLoaded.current = true;
}
}, [initialData]);
// 필드 값 설정
const setFieldValue = useCallback((fieldKey: string, value: DynamicFieldValue) => {
setFormData((prev) => ({
@@ -149,17 +164,21 @@ export function useDynamicFormState(
);
// 폼 제출
// 2025-12-04: 실패 시에만 버튼 다시 활성화 (로그인 방식)
// 성공 시에는 페이지 이동하므로 버튼 비활성화 상태 유지
const handleSubmit = useCallback(
async (onSubmit: (data: DynamicFormData) => Promise<void>) => {
setIsSubmitting(true);
try {
await onSubmit(formData);
// 성공 시: setIsSubmitting(false)를 호출하지 않음
// 페이지 이동하므로 버튼 비활성화 상태 유지 → 중복 클릭 방지
} catch (err) {
console.error('폼 제출 실패:', err);
throw err;
} finally {
// 실패 시에만 버튼 다시 활성화 → 재시도 가능
setIsSubmitting(false);
throw err;
}
},
[formData]

View File

@@ -32,6 +32,7 @@ import {
generateAssemblyItemNameSimple,
generateAssemblySpecification,
generateBendingItemCodeSimple,
generatePurchasedItemCode,
} from './utils/itemCodeGenerator';
import type { DynamicItemFormProps, DynamicFormData, DynamicSection, DynamicFieldValue, BOMLine, BOMSearchState } from './types';
import type { ItemType, BendingDetail } from '@/types/item';
@@ -255,6 +256,10 @@ export default function DynamicItemForm({
const [bendingDetails, setBendingDetails] = useState<BendingDetail[]>([]);
const [widthSum, setWidthSum] = useState<string>('');
// FG(제품) 전용 파일 업로드 상태 관리
const [specificationFile, setSpecificationFile] = useState<File | null>(null);
const [certificationFile, setCertificationFile] = useState<File | null>(null);
// 조건부 표시 관리
const { shouldShowSection, shouldShowField } = useConditionalDisplay(structure, formData);
@@ -274,7 +279,7 @@ export default function DynamicItemForm({
.map((item: { code?: string; item_code?: string }) => item.code || item.item_code || '')
.filter((code: string) => code);
setExistingItemCodes(codes);
console.log('[DynamicItemForm] PT 기존 품목코드 로드:', codes.length, '개');
// console.log('[DynamicItemForm] PT 기존 품목코드 로드:', codes.length, '개');
}
} catch (err) {
console.error('[DynamicItemForm] PT 품목코드 조회 실패:', err);
@@ -287,7 +292,7 @@ export default function DynamicItemForm({
}
}, [selectedItemType]);
// 품목 유형 변경 시 폼 초기화
// 품목 유형 변경 시 폼 초기화 (create 모드)
useEffect(() => {
if (selectedItemType && mode === 'create' && structure) {
// 기본값 설정
@@ -322,6 +327,82 @@ export default function DynamicItemForm({
}
}, [selectedItemType, structure, mode, resetForm]);
// Edit 모드: structure 로드 후 initialData를 field_key 형식으로 변환
// 2025-12-04: initialData 키(item_name)와 structure의 field_key(98_item_name)가 다른 문제 해결
const [isEditDataMapped, setIsEditDataMapped] = useState(false);
useEffect(() => {
if (mode !== 'edit' || !structure || !initialData || isEditDataMapped) return;
// console.log('[DynamicItemForm] Edit mode: mapping initialData to field_key format');
// initialData의 간단한 키를 structure의 field_key로 매핑
// 예: { item_name: '테스트' } → { '98_item_name': '테스트' }
const mappedData: DynamicFormData = {};
// field_key에서 실제 필드명 추출하는 함수
// 예: '98_item_name' → 'item_name', '110_품목명' → '품목명'
const extractFieldName = (fieldKey: string): string => {
const underscoreIndex = fieldKey.indexOf('_');
if (underscoreIndex > 0) {
return fieldKey.substring(underscoreIndex + 1);
}
return fieldKey;
};
// structure에서 모든 필드의 field_key 수집
const fieldKeyMap: Record<string, string> = {}; // 간단한 키 → field_key 매핑
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const field = f.field;
const fieldKey = field.field_key || `field_${field.id}`;
const simpleName = extractFieldName(fieldKey);
fieldKeyMap[simpleName] = fieldKey;
// field_name도 매핑에 추가 (한글 필드명 지원)
if (field.field_name) {
fieldKeyMap[field.field_name] = fieldKey;
}
});
});
structure.directFields.forEach((f) => {
const field = f.field;
const fieldKey = field.field_key || `field_${field.id}`;
const simpleName = extractFieldName(fieldKey);
fieldKeyMap[simpleName] = fieldKey;
if (field.field_name) {
fieldKeyMap[field.field_name] = fieldKey;
}
});
// console.log('[DynamicItemForm] fieldKeyMap:', fieldKeyMap);
// initialData를 field_key 형식으로 변환
Object.entries(initialData).forEach(([key, value]) => {
// 이미 field_key 형식인 경우 그대로 사용
if (key.includes('_') && /^\d+_/.test(key)) {
mappedData[key] = value;
}
// 간단한 키인 경우 field_key로 변환
else if (fieldKeyMap[key]) {
mappedData[fieldKeyMap[key]] = value;
}
// 매핑 없는 경우 그대로 유지
else {
mappedData[key] = value;
}
});
// console.log('[DynamicItemForm] Mapped initialData:', mappedData);
// 변환된 데이터로 폼 리셋
resetForm(mappedData);
setIsEditDataMapped(true);
}, [mode, structure, initialData, isEditDataMapped, resetForm]);
// 모든 필드 목록 (밸리데이션용) - 숨겨진 섹션/필드 제외
const allFields = useMemo<ItemFieldResponse[]>(() => {
if (!structure) return [];
@@ -440,10 +521,10 @@ export default function DynamicItemForm({
return allSpecificationKeys[0] || '';
}, [structure, allSpecificationKeys, shouldShowSection, shouldShowField]);
// 부품 유형 필드 탐지 (PT 품목에서 절곡/조립 부품 판별용)
const { partTypeFieldKey, selectedPartType, isBendingPart, isAssemblyPart } = useMemo(() => {
// 부품 유형 필드 탐지 (PT 품목에서 절곡/조립/구매 부품 판별용)
const { partTypeFieldKey, selectedPartType, isBendingPart, isAssemblyPart, isPurchasedPart } = useMemo(() => {
if (!structure || selectedItemType !== 'PT') {
return { partTypeFieldKey: '', selectedPartType: '', isBendingPart: false, isAssemblyPart: false };
return { partTypeFieldKey: '', selectedPartType: '', isBendingPart: false, isAssemblyPart: false, isPurchasedPart: false };
}
let foundPartTypeKey = '';
@@ -477,19 +558,17 @@ export default function DynamicItemForm({
const isBending = currentPartType.includes('절곡') || currentPartType.toUpperCase() === 'BENDING';
// "조립 부품", "ASSEMBLY", "조립부품" 등 다양한 형태 지원
const isAssembly = currentPartType.includes('조립') || currentPartType.toUpperCase() === 'ASSEMBLY';
// "구매 부품", "PURCHASED", "구매부품" 등 다양한 형태 지원
const isPurchased = currentPartType.includes('구매') || currentPartType.toUpperCase() === 'PURCHASED';
console.log('[DynamicItemForm] 부품 유형 감지:', {
partTypeFieldKey: foundPartTypeKey,
currentPartType,
isBending,
isAssembly,
});
// console.log('[DynamicItemForm] 부품 유형 감지:', { partTypeFieldKey: foundPartTypeKey, currentPartType, isBending, isAssembly, isPurchased });
return {
partTypeFieldKey: foundPartTypeKey,
selectedPartType: currentPartType,
isBendingPart: isBending,
isAssemblyPart: isAssembly,
isPurchasedPart: isPurchased,
};
}, [structure, selectedItemType, formData]);
@@ -508,7 +587,7 @@ export default function DynamicItemForm({
// 이전 값이 있고, 현재 값과 다른 경우에만 초기화
if (prevPartType && prevPartType !== currentPartType) {
console.log('[DynamicItemForm] 부품 유형 변경 감지:', prevPartType, '→', currentPartType);
// console.log('[DynamicItemForm] 부품 유형 변경 감지:', prevPartType, '→', currentPartType);
// setTimeout으로 다음 틱에서 초기화 실행
// → 부품 유형 Select 값 변경이 먼저 완료된 후 초기화
@@ -555,7 +634,7 @@ export default function DynamicItemForm({
// 중복 제거 후 초기화
const uniqueFields = [...new Set(fieldsToReset)];
console.log('[DynamicItemForm] 초기화할 필드:', uniqueFields);
// console.log('[DynamicItemForm] 초기화할 필드:', uniqueFields);
uniqueFields.forEach((fieldKey) => {
setFieldValue(fieldKey, '');
@@ -612,12 +691,12 @@ export default function DynamicItemForm({
// bending_parts는 무조건 우선 (덮어쓰기)
if (isBendingItemNameField) {
console.log('[checkField] 절곡부품 품목명 필드 발견!', { fieldKey, fieldName });
// console.log('[checkField] 절곡부품 품목명 필드 발견!', { fieldKey, fieldName });
bendingItemNameKey = fieldKey;
}
// 일반 품목명은 아직 없을 때만
else if (isGeneralItemNameField && !bendingItemNameKey) {
console.log('[checkField] 일반 품목명 필드 발견!', { fieldKey, fieldName });
// console.log('[checkField] 일반 품목명 필드 발견!', { fieldKey, fieldName });
bendingItemNameKey = fieldKey;
}
@@ -686,19 +765,7 @@ export default function DynamicItemForm({
const autoCode = generateBendingItemCodeSimple(itemNameValue, categoryValue, shapeLengthValue);
console.log('[DynamicItemForm] 절곡부품 필드 탐지:', {
bendingItemNameKey,
itemNameKey,
effectiveItemNameKey,
materialKey,
categoryKeysWithIds,
activeCategoryKey,
widthSumKey,
shapeLengthKey,
formDataKeys: Object.keys(formData),
values: { itemNameValue, categoryValue, shapeLengthValue },
autoCode,
});
// console.log('[DynamicItemForm] 절곡부품 필드 탐지:', { bendingItemNameKey, materialKey, activeCategoryKey, autoCode });
return {
bendingFieldKeys: {
@@ -726,13 +793,13 @@ export default function DynamicItemForm({
// 품목명이 변경되었고, 이전 값이 있었을 때만 종류 필드 초기화
if (prevItemNameValue && prevItemNameValue !== currentItemNameValue) {
console.log('[DynamicItemForm] 품목명 변경 감지:', prevItemNameValue, '→', currentItemNameValue);
// console.log('[DynamicItemForm] 품목명 변경 감지:', prevItemNameValue, '→', currentItemNameValue);
// 모든 종류 필드 값 초기화
allCategoryKeysWithIds.forEach(({ key }) => {
const currentVal = (formData[key] as string) || '';
if (currentVal) {
console.log('[DynamicItemForm] 종류 필드 초기화:', key);
// console.log('[DynamicItemForm] 종류 필드 초기화:', key);
setFieldValue(key, '');
}
});
@@ -763,12 +830,7 @@ export default function DynamicItemForm({
fieldKey.includes('부품구성');
if (isCheckbox && isBomRelated) {
console.log('[DynamicItemForm] BOM 체크박스 필드 발견:', {
fieldKey,
fieldName,
fieldType,
resultKey: field.field_key || `field_${field.id}`,
});
// console.log('[DynamicItemForm] BOM 체크박스 필드 발견:', { fieldKey, fieldName });
return field.field_key || `field_${field.id}`;
}
}
@@ -789,17 +851,12 @@ export default function DynamicItemForm({
fieldKey.includes('부품구성');
if (isCheckbox && isBomRelated) {
console.log('[DynamicItemForm] BOM 체크박스 필드 발견 (직접필드):', {
fieldKey,
fieldName,
fieldType,
resultKey: field.field_key || `field_${field.id}`,
});
// console.log('[DynamicItemForm] BOM 체크박스 필드 발견 (직접필드):', { fieldKey, fieldName });
return field.field_key || `field_${field.id}`;
}
}
console.log('[DynamicItemForm] BOM 체크박스 필드를 찾지 못함');
// console.log('[DynamicItemForm] BOM 체크박스 필드를 찾지 못함');
return '';
}, [structure]);
@@ -878,15 +935,7 @@ export default function DynamicItemForm({
// 규격: 가로x세로x길이(네자리)
const autoSpec = generateAssemblySpecification(sideSpecWidth, sideSpecHeight, assemblyLength);
console.log('[DynamicItemForm] 조립 부품 필드 탐지:', {
isAssembly,
sideSpecWidthKey,
sideSpecHeightKey,
assemblyLengthKey,
values: { sideSpecWidth, sideSpecHeight, assemblyLength },
autoItemName,
autoSpec,
});
// console.log('[DynamicItemForm] 조립 부품 필드 탐지:', { isAssembly, autoItemName, autoSpec });
return {
hasAssemblyFields: isAssembly,
@@ -900,6 +949,97 @@ export default function DynamicItemForm({
};
}, [structure, selectedItemType, formData, itemNameKey]);
// 구매 부품(전동개폐기) 필드 탐지 - 품목명, 용량, 전원
// 2025-12-04: 구매 부품 품목코드 자동생성 추가
const { purchasedFieldKeys, autoPurchasedItemCode } = useMemo(() => {
if (!structure || selectedItemType !== 'PT' || !isPurchasedPart) {
return {
purchasedFieldKeys: {
itemName: '', // 품목명 (전동개폐기 등)
capacity: '', // 용량 (150, 300, etc.)
power: '', // 전원 (220V, 380V)
},
autoPurchasedItemCode: '',
};
}
let purchasedItemNameKey = '';
let capacityKey = '';
let powerKey = '';
const checkField = (fieldKey: string, field: ItemFieldResponse) => {
const fieldName = field.field_name || '';
const lowerKey = fieldKey.toLowerCase();
// 구매 부품 품목명 필드 탐지 - PurchasedItemName 우선 탐지
const isPurchasedItemNameField = lowerKey.includes('purchaseditemname');
const isItemNameField =
isPurchasedItemNameField ||
lowerKey.includes('item_name') ||
lowerKey.includes('품목명') ||
fieldName.includes('품목명') ||
fieldName === '품목명';
// PurchasedItemName을 우선적으로 사용 (더 정확한 매칭)
if (isPurchasedItemNameField) {
purchasedItemNameKey = fieldKey; // 덮어쓰기 (우선순위 높음)
} else if (isItemNameField && !purchasedItemNameKey) {
purchasedItemNameKey = fieldKey;
}
// 용량 필드 탐지
const isCapacityField =
lowerKey.includes('capacity') ||
lowerKey.includes('용량') ||
fieldName.includes('용량') ||
fieldName === '용량';
if (isCapacityField && !capacityKey) {
capacityKey = fieldKey;
}
// 전원 필드 탐지
const isPowerField =
lowerKey.includes('power') ||
lowerKey.includes('전원') ||
fieldName.includes('전원') ||
fieldName === '전원';
if (isPowerField && !powerKey) {
powerKey = fieldKey;
}
};
// 모든 필드 검사
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
});
structure.directFields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
// 품목코드 자동생성: 품목명 + 용량 + 전원
const itemNameValue = purchasedItemNameKey ? (formData[purchasedItemNameKey] as string) || '' : '';
const capacityValue = capacityKey ? (formData[capacityKey] as string) || '' : '';
const powerValue = powerKey ? (formData[powerKey] as string) || '' : '';
const autoCode = generatePurchasedItemCode(itemNameValue, capacityValue, powerValue);
// console.log('[DynamicItemForm] 구매 부품 필드 탐지:', { purchasedItemNameKey, autoCode });
return {
purchasedFieldKeys: {
itemName: purchasedItemNameKey,
capacity: capacityKey,
power: powerKey,
},
autoPurchasedItemCode: autoCode,
};
}, [structure, selectedItemType, isPurchasedPart, formData]);
// 품목코드 자동생성 값
// PT(부품): 영문약어-순번 (예: GR-001, MOTOR-002)
// 기타 품목: 품목명-규격 (기존 방식)
@@ -949,6 +1089,7 @@ export default function DynamicItemForm({
// 2025-12-03: 한글 field_key 지원 추가
const fieldKeyToBackendKey: Record<string, string> = {
'item_name': 'name',
'productName': 'name', // FG(제품) 품목명 필드
'품목명': 'name', // 한글 field_key 지원
'specification': 'spec',
'standard': 'spec', // 규격 대체 필드명
@@ -972,11 +1113,16 @@ export default function DynamicItemForm({
};
// formData를 백엔드 필드명으로 변환
// console.log('[DynamicItemForm] formData before conversion:', formData);
const convertedData: Record<string, any> = {};
Object.entries(formData).forEach(([key, value]) => {
// "{id}_{fieldKey}" 형식에서 fieldKey 추출
const underscoreIndex = key.indexOf('_');
if (underscoreIndex > 0) {
// "{id}_{fieldKey}" 형식 체크: 숫자로 시작하고 _가 있는 경우
// 예: "98_item_name" → true, "item_name" → false
const isFieldKeyFormat = /^\d+_/.test(key);
if (isFieldKeyFormat) {
// "{id}_{fieldKey}" 형식에서 fieldKey 추출
const underscoreIndex = key.indexOf('_');
const fieldKey = key.substring(underscoreIndex + 1);
const backendKey = fieldKeyToBackendKey[fieldKey] || fieldKey;
@@ -990,10 +1136,19 @@ export default function DynamicItemForm({
convertedData[backendKey] = value;
}
} else {
// 변환 불필요한 필드는 그대로
convertedData[key] = value;
// field_key 형식이 아닌 경우, 매핑 테이블에서 변환 시도
const backendKey = fieldKeyToBackendKey[key] || key;
if (backendKey === 'is_active') {
const isActive = value === true || value === 'true' || value === '1' ||
value === 1 || value === '활성' || value === 'active';
convertedData[backendKey] = isActive;
} else {
convertedData[backendKey] = value;
}
}
});
// console.log('[DynamicItemForm] convertedData after conversion:', convertedData);
// 품목명 값 추출 (품목코드와 품목명 모두 필요)
// 2025-12-04: 절곡 부품은 별도 품목명 필드(bendingFieldKeys.itemName) 사용
@@ -1004,9 +1159,10 @@ export default function DynamicItemForm({
? (formData[effectiveItemNameKeyForSubmit] as string) || ''
: '';
// 조립/절곡 부품 자동생성 값 결정
// 조립/절곡/구매 부품 자동생성 값 결정
// 조립 부품: 품목명 = "품목명 가로x세로", 규격 = "가로x세로x길이"
// 절곡 부품: 품목명 = bendingFieldKeys.itemName에서 선택한 값, 규격 = 없음 (품목코드로 대체)
// 구매 부품: 품목명 = purchasedFieldKeys.itemName에서 선택한 값
let finalName: string;
let finalSpec: string | undefined;
@@ -1018,27 +1174,29 @@ export default function DynamicItemForm({
// 절곡 부품: bendingFieldKeys.itemName의 값 사용
finalName = itemNameValue || convertedData.name || '';
finalSpec = convertedData.spec;
} else if (isPurchasedPart) {
// 구매 부품: purchasedFieldKeys.itemName의 값 사용
const purchasedItemNameValue = purchasedFieldKeys.itemName
? (formData[purchasedFieldKeys.itemName] as string) || ''
: '';
finalName = purchasedItemNameValue || convertedData.name || '';
finalSpec = convertedData.spec;
} else {
// 기타: 기존 로직
finalName = convertedData.name || itemNameValue;
finalSpec = convertedData.spec;
}
console.log('[DynamicItemForm] 품목명/규격 결정:', {
isAssemblyPart,
autoAssemblyItemName,
autoAssemblySpec,
convertedDataName: convertedData.name,
convertedDataSpec: convertedData.spec,
finalName,
finalSpec,
});
// console.log('[DynamicItemForm] 품목명/규격 결정:', { finalName, finalSpec });
// 품목코드 결정
// 2025-12-04: 절곡 부품은 autoBendingItemCode 사용
// 2025-12-04: 구매 부품은 autoPurchasedItemCode 사용
let finalCode: string;
if (isBendingPart && autoBendingItemCode) {
finalCode = autoBendingItemCode;
} else if (isPurchasedPart && autoPurchasedItemCode) {
finalCode = autoPurchasedItemCode;
} else if (hasAutoItemCode && autoGeneratedItemCode) {
finalCode = autoGeneratedItemCode;
} else {
@@ -1078,16 +1236,17 @@ export default function DynamicItemForm({
part_type: 'ASSEMBLY',
bending_diagram: bendingDiagram || null, // 조립품도 동일한 전개도 필드 사용
} : {}),
// 구매품 데이터 (PT - 구매 부품 전용)
...(selectedItemType === 'PT' && isPurchasedPart ? {
part_type: 'PURCHASED',
} : {}),
// FG(제품)은 단위 필드가 없으므로 기본값 'EA' 설정
...(selectedItemType === 'FG' && !convertedData.unit ? {
unit: 'EA',
} : {}),
};
// is_active 디버깅 로그
console.log('[DynamicItemForm] is_active 디버깅:', {
formDataKeys: Object.keys(formData).filter(k => k.includes('active') || k.includes('상태') || k.includes('status')),
convertedIsActive: convertedData.is_active,
submitDataIsActive: submitData.is_active,
formDataValues: Object.entries(formData).filter(([k]) => k.includes('active') || k.includes('상태') || k.includes('status')),
});
console.log('[DynamicItemForm] 제출 데이터:', submitData);
// console.log('[DynamicItemForm] 제출 데이터:', submitData);
await handleSubmit(async () => {
await onSubmit(submitData);
@@ -1211,10 +1370,17 @@ export default function DynamicItemForm({
const isSpecField = fieldKey === activeSpecificationKey;
const isStatusField = fieldKey === statusFieldKey;
// 품목명 필드인지 체크 (FG 품목코드 자동생성 위치)
const isItemNameField = fieldKey === itemNameKey;
// 비고 필드인지 체크 (절곡부품 품목코드 자동생성 위치)
const fieldName = field.field_name || '';
const isNoteField = fieldKey.includes('note') || fieldKey.includes('비고') ||
fieldName.includes('비고') || fieldName === '비고';
// 인정 유효기간 종료일 필드인지 체크 (FG 시방서/인정서 파일 업로드 위치)
const isCertEndDateField = fieldKey.includes('certification_end') ||
fieldKey.includes('인정_유효기간_종료') ||
fieldName.includes('인정 유효기간 종료') ||
fieldName.includes('유효기간 종료');
// 절곡부품 박스 스타일링 (재질, 폭합계, 모양&길이)
const isBendingBoxField = isBendingPart && (
@@ -1283,6 +1449,87 @@ export default function DynamicItemForm({
</p>
</div>
)}
{/* 비고 필드 다음에 구매부품(전동개폐기) 품목코드 자동생성 */}
{isNoteField && isPurchasedPart && (
<div className="mt-4">
<Label htmlFor="purchased_item_code_auto"> ()</Label>
<Input
id="purchased_item_code_auto"
value={autoPurchasedItemCode || ''}
placeholder="품목명, 용량, 전원을 선택하면 자동으로 생성됩니다"
disabled
className="bg-muted text-muted-foreground"
/>
<p className="text-xs text-muted-foreground mt-1">
* &apos;++&apos; (: 전동개폐기150KG380V)
</p>
</div>
)}
{/* FG(제품) 전용: 품목명 필드 다음에 품목코드 자동생성 */}
{isItemNameField && selectedItemType === 'FG' && (
<div className="mt-4">
<Label htmlFor="fg_item_code_auto"> ()</Label>
<Input
id="fg_item_code_auto"
value={(formData[itemNameKey] as string) || ''}
placeholder="품목명이 입력되면 자동으로 동일하게 생성됩니다"
disabled
className="bg-muted text-muted-foreground"
/>
<p className="text-xs text-muted-foreground mt-1">
* (FG)
</p>
</div>
)}
{/* FG(제품) 전용: 인정 유효기간 종료일 다음에 시방서/인정서 파일 업로드 */}
{isCertEndDateField && selectedItemType === 'FG' && (
<div className="mt-4 space-y-4">
{/* 시방서 파일 업로드 */}
<div>
<Label htmlFor="specification_file"> (PDF)</Label>
<div className="mt-1.5">
<Input
id="specification_file"
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setSpecificationFile(file);
}}
disabled={isSubmitting}
className="cursor-pointer"
/>
{specificationFile && (
<p className="text-xs text-muted-foreground mt-1">
: {specificationFile.name}
</p>
)}
</div>
</div>
{/* 인정서 파일 업로드 */}
<div>
<Label htmlFor="certification_file"> (PDF)</Label>
<div className="mt-1.5">
<Input
id="certification_file"
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setCertificationFile(file);
}}
disabled={isSubmitting}
className="cursor-pointer"
/>
{certificationFile && (
<p className="text-xs text-muted-foreground mt-1">
: {certificationFile.name}
</p>
)}
</div>
</div>
</div>
)}
</div>
);
})}
@@ -1402,12 +1649,7 @@ export default function DynamicItemForm({
const isBomRequired = bomValue === true || bomValue === 'true' || bomValue === '1' || bomValue === 1;
// 디버깅 로그
console.log('[DynamicItemForm] BOM 체크 디버깅:', {
bomRequiredFieldKey,
bomValue,
isBomRequired,
formDataKeys: Object.keys(formData),
});
// console.log('[DynamicItemForm] BOM 체크 디버깅:', { bomRequiredFieldKey, bomValue, isBomRequired });
if (!isBomRequired) return null;

View File

@@ -355,6 +355,41 @@ export function generateAssemblySpecification(
return `${sideSpecWidth}x${sideSpecHeight}x${assemblyLength}`;
}
// ============================================
// 구매 부품 (전동개폐기) 품목코드 자동생성
// 2025-12-04 추가
// ============================================
/**
* 전동개폐기 품목코드 생성 (품목명 + 용량 + 전원)
* @param itemName 품목명 (예: "전동개폐기")
* @param capacity 용량 (예: "150", "300")
* @param power 전원 (예: "220V", "380V")
* @returns 품목코드 (예: "전동개폐기150KG380V")
*/
export function generatePurchasedItemCode(
itemName: string,
capacity?: string,
power?: string
): string {
if (!itemName) return '';
// 품목명에서 괄호 앞부분만 추출 (예: "전동개폐기 (E)" → "전동개폐기")
const cleanItemName = itemName.replace(/\s*\([^)]*\)\s*$/, '').trim();
if (!capacity || !power) {
return cleanItemName;
}
// 용량에서 'KG' 제외하고 숫자만 추출 (이미 "100KG" 형태로 들어올 수 있음)
const cleanCapacity = capacity.replace(/KG$/i, '');
// 전원에서 'V' 제외하고 숫자만 추출 후 다시 V 붙이기 (일관성 유지)
const cleanPower = power.replace(/V$/i, '') + 'V';
return `${cleanItemName}${cleanCapacity}KG${cleanPower}`;
}
// ============================================
// 하드코딩 내역 목록 (문서화용)
// ============================================

View File

@@ -90,7 +90,7 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
</Button>
<Button
type="button"
onClick={() => router.push(`/items/${encodeURIComponent(item.itemCode)}/edit`)}
onClick={() => router.push(`/items/${encodeURIComponent(item.itemCode)}/edit?type=${item.itemType}&id=${item.id}`)}
>
<Edit className="w-4 h-4 mr-2" />

View File

@@ -109,20 +109,16 @@ export default function ProductForm({
)}
</div>
<div className="md:col-span-2">
<div>
<Label> ()</Label>
<Input
value={(() => {
const pName = productName || '';
const iName = getValues('itemName') || '';
return pName && iName ? `${pName}-${iName}` : '';
})()}
value={getValues('itemName') || ''}
disabled
className="bg-muted text-muted-foreground"
placeholder="상품명과 품목명 입력면 자동으로 생성됩니다"
placeholder="품목명 입력면 자동으로 동일하게 생성됩니다"
/>
<p className="text-xs text-muted-foreground mt-1">
* '상품명-품목명'
*
</p>
</div>
@@ -159,6 +155,15 @@ export default function ProductForm({
*
</p>
</div>
<div>
<Label></Label>
<Input
placeholder="비고 사항을 입력하세요"
value={remarks}
onChange={(e) => setRemarks(e.target.value)}
/>
</div>
</>
);
}
@@ -199,7 +204,7 @@ export function ProductCertificationSection({
</div>
{/* 인정번호, 유효기간, 파일 업로드, 비고 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="certificationNumber"></Label>
<Input
@@ -232,20 +237,28 @@ export function ProductCertificationSection({
{/* 시방서 파일 */}
<div className="space-y-2">
<Label> (PDF, DOCX, HWP, JPG, PNG / 20MB)</Label>
<div className="flex gap-2">
<Input
type="file"
accept=".pdf,.docx,.hwp,.jpg,.jpeg,.png"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setSpecificationFile(file);
}
}}
className="flex-1"
disabled={isSubmitting}
/>
<Label> (PDF)</Label>
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<span className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium border rounded-md bg-background hover:bg-accent hover:text-accent-foreground">
</span>
<input
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setSpecificationFile(file);
}
}}
className="hidden"
disabled={isSubmitting}
/>
</label>
<span className="text-sm text-muted-foreground">
{specificationFile ? specificationFile.name : '선택된 파일 없음'}
</span>
{specificationFile && (
<Button
type="button"
@@ -253,34 +266,38 @@ export function ProductCertificationSection({
size="sm"
onClick={() => setSpecificationFile(null)}
disabled={isSubmitting}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{specificationFile && (
<p className="text-xs text-muted-foreground mt-1">
: {specificationFile.name}
</p>
)}
</div>
{/* 인정서 파일 */}
<div className="space-y-2">
<Label> (PDF, DOCX, HWP, JPG, PNG / 20MB)</Label>
<div className="flex gap-2">
<Input
type="file"
accept=".pdf,.docx,.hwp,.jpg,.jpeg,.png"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setCertificationFile(file);
}
}}
className="flex-1"
disabled={isSubmitting}
/>
<Label> (PDF)</Label>
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<span className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium border rounded-md bg-background hover:bg-accent hover:text-accent-foreground">
</span>
<input
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setCertificationFile(file);
}
}}
className="hidden"
disabled={isSubmitting}
/>
</label>
<span className="text-sm text-muted-foreground">
{certificationFile ? certificationFile.name : '선택된 파일 없음'}
</span>
{certificationFile && (
<Button
type="button"
@@ -288,20 +305,16 @@ export function ProductCertificationSection({
size="sm"
onClick={() => setCertificationFile(null)}
disabled={isSubmitting}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{certificationFile && (
<p className="text-xs text-muted-foreground mt-1">
: {certificationFile.name}
</p>
)}
</div>
{/* 비고 */}
<div className="md:col-span-2">
<div>
<Label></Label>
<Textarea
value={remarks}

View File

@@ -3,6 +3,7 @@
* - 전동개폐기, 모터, 체인 등
*/
import { useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
@@ -16,6 +17,7 @@ import {
import type { UseFormRegister, UseFormSetValue, FieldErrors } from 'react-hook-form';
import type { CreateItemFormData } from '@/lib/utils/validation';
import { PART_TYPE_CATEGORIES } from '../../constants';
import { generatePurchasedItemCode } from '@/components/items/DynamicItemForm/utils/itemCodeGenerator';
export interface PurchasedPartFormProps {
selectedCategory1: string;
@@ -60,6 +62,18 @@ export default function PurchasedPartForm({
setValue,
errors,
}: PurchasedPartFormProps) {
// 전동개폐기 품목코드 자동생성 (품목명 + 용량 + 전원)
const generatedItemCode = useMemo(() => {
if (selectedCategory1 === 'electric_opener') {
const category = PART_TYPE_CATEGORIES.PURCHASED?.categories.find(
c => c.value === selectedCategory1
);
const itemName = category?.label || '';
return generatePurchasedItemCode(itemName, electricOpenerCapacity, electricOpenerPower);
}
return '';
}, [selectedCategory1, electricOpenerCapacity, electricOpenerPower]);
return (
<>
{/* 품목명 선택 */}
@@ -258,19 +272,21 @@ export default function PurchasedPartForm({
/>
</div>
{/* 품목코드 자동생성 */}
<div className="md:col-span-2">
<Label> ()</Label>
<Input
value=""
disabled
className="bg-muted text-muted-foreground"
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
/>
<p className="text-xs text-muted-foreground mt-1">
* '품목명-규격'
</p>
</div>
{/* 품목코드 자동생성 - 전동개폐기만 표시 */}
{selectedCategory1 === 'electric_opener' && (
<div className="md:col-span-2">
<Label> ()</Label>
<Input
value={generatedItemCode}
disabled
className="bg-muted text-muted-foreground"
placeholder="용량과 전원을 선택하면 자동으로 생성됩니다"
/>
<p className="text-xs text-muted-foreground mt-1">
* '품목명+용량+전원' (: 전동개폐기150KG380V)
</p>
</div>
)}
{/* 품목 상태 */}
<div className="md:col-span-2">

View File

@@ -90,7 +90,10 @@ export default function ItemListClient() {
// 삭제 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<{ id: string; code: string } | null>(null);
const [itemToDelete, setItemToDelete] = useState<{ id: string; code: string; itemType: string } | null>(null);
// Materials 타입 (SM, RM, CS는 Material 테이블 사용)
const MATERIAL_TYPES = ['SM', 'RM', 'CS'];
// API에서 품목 목록 및 테이블 컬럼 조회 (서버 사이드 검색/필터링)
const {
@@ -148,17 +151,19 @@ export default function ItemListClient() {
});
};
const handleView = (itemCode: string) => {
router.push(`/items/${encodeURIComponent(itemCode)}`);
const handleView = (itemCode: string, itemType: string, itemId: string) => {
// itemType을 query param으로 전달 (Materials 조회를 위해)
router.push(`/items/${encodeURIComponent(itemCode)}?type=${itemType}&id=${itemId}`);
};
const handleEdit = (itemCode: string) => {
router.push(`/items/${encodeURIComponent(itemCode)}/edit`);
const handleEdit = (itemCode: string, itemType: string, itemId: string) => {
// itemType을 query param으로 전달 (Materials 조회를 위해)
router.push(`/items/${encodeURIComponent(itemCode)}/edit?type=${itemType}&id=${itemId}`);
};
// 삭제 확인 다이얼로그 열기
const openDeleteDialog = (itemId: string, itemCode: string) => {
setItemToDelete({ id: itemId, code: itemCode });
const openDeleteDialog = (itemId: string, itemCode: string, itemType: string) => {
setItemToDelete({ id: itemId, code: itemCode, itemType });
setDeleteDialogOpen(true);
};
@@ -168,7 +173,17 @@ export default function ItemListClient() {
try {
console.log('[Delete] 삭제 요청:', itemToDelete);
const response = await fetch(`/api/proxy/items/${itemToDelete.id}`, {
// Materials (SM, RM, CS)는 /products/materials 엔드포인트 사용
// Products (FG, PT)는 /items 엔드포인트 사용
const isMaterial = MATERIAL_TYPES.includes(itemToDelete.itemType);
const deleteUrl = isMaterial
? `/api/proxy/products/materials/${itemToDelete.id}`
: `/api/proxy/items/${itemToDelete.id}`;
console.log('[Delete] URL:', deleteUrl, '(isMaterial:', isMaterial, ')');
const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
@@ -222,7 +237,15 @@ export default function ItemListClient() {
for (const id of itemIds) {
try {
const response = await fetch(`/api/proxy/items/${id}`, {
// 해당 품목의 itemType 찾기
const item = items.find((i) => i.id === id);
const isMaterial = item ? MATERIAL_TYPES.includes(item.itemType) : false;
// Materials는 /products/materials 엔드포인트, Products는 /items 엔드포인트
const deleteUrl = isMaterial
? `/api/proxy/products/materials/${id}`
: `/api/proxy/items/${id}`;
const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
@@ -329,7 +352,7 @@ export default function ItemListClient() {
<Button
variant="ghost"
size="sm"
onClick={() => handleView(item.itemCode)}
onClick={(e) => { e.stopPropagation(); handleView(item.itemCode, item.itemType, item.id); }}
title="상세 보기"
>
<Search className="w-4 h-4" />
@@ -337,7 +360,7 @@ export default function ItemListClient() {
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(item.itemCode)}
onClick={(e) => { e.stopPropagation(); handleEdit(item.itemCode, item.itemType, item.id); }}
title="수정"
>
<Edit className="w-4 h-4" />
@@ -345,7 +368,7 @@ export default function ItemListClient() {
<Button
variant="ghost"
size="sm"
onClick={() => openDeleteDialog(item.id, item.itemCode)}
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode, item.itemType); }}
title="삭제"
>
<Trash2 className="w-4 h-4 text-red-500" />
@@ -388,7 +411,7 @@ export default function ItemListClient() {
}
isSelected={isSelected}
onToggleSelection={onToggle}
onCardClick={() => handleView(item.itemCode)}
onCardClick={() => handleView(item.itemCode, item.itemType, item.id)}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
{item.specification && (
@@ -400,34 +423,37 @@ export default function ItemListClient() {
</div>
}
actions={
<div className="flex items-center justify-end gap-1 pt-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleView(item.itemCode); }}
className="h-8 px-3"
>
<Search className="h-4 w-4 mr-1" />
<span className="text-xs"></span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleEdit(item.itemCode); }}
className="h-8 px-3"
>
<Edit className="h-4 w-4 mr-1" />
<span className="text-xs"></span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode); }}
className="h-8 px-2"
>
<Trash2 className="h-4 w-4 text-red-600" />
</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(); handleView(item.itemCode, item.itemType, item.id); }}
>
<Search 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(item.itemCode, item.itemType, item.id); }}
>
<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(); openDeleteDialog(item.id, item.itemCode, item.itemType); }}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);

View File

@@ -426,9 +426,25 @@ export function FieldDialog({
<DialogFooter className="shrink-0 bg-white z-10 px-6 py-4 border-t">
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={async () => {
console.log('[FieldDialog] 🔵 저장 버튼 클릭!', {
fieldInputMode,
editingFieldId,
selectedMasterFieldId,
newFieldName,
newFieldKey,
isNameEmpty,
isKeyEmpty,
isKeyInvalid,
});
setIsSubmitted(true);
// 2025-11-28: field_key validation 추가
if ((fieldInputMode === 'custom' || editingFieldId) && (isNameEmpty || isKeyEmpty || isKeyInvalid)) return;
const shouldValidate = fieldInputMode === 'custom' || editingFieldId;
console.log('[FieldDialog] 🔵 shouldValidate:', shouldValidate);
if (shouldValidate && (isNameEmpty || isKeyEmpty || isKeyInvalid)) {
console.log('[FieldDialog] ❌ 유효성 검사 실패로 return');
return;
}
console.log('[FieldDialog] ✅ handleAddField 호출');
await handleAddField();
setIsSubmitted(false);
}}></Button>

View File

@@ -117,8 +117,8 @@ export function useFieldManagement(): UseFieldManagementReturn {
const masterField = itemMasterFields.find(f => f.id === Number(selectedMasterFieldId));
if (masterField) {
setNewFieldName(masterField.field_name);
// 2025-11-28: field_key 사용 (없으면 빈 문자열로 사용자가 입력하도록)
setNewFieldKey('');
// 2025-12-04: master 모드에서 field_key를 field_{id} 형태로 설정 (백엔드 검증 통과용)
setNewFieldKey(`field_${selectedMasterFieldId}`);
setNewFieldInputType(masterField.field_type || 'textbox');
// properties에서 required 확인, 또는 validation_rules에서 확인
const isRequired = (masterField.properties as any)?.required || false;
@@ -139,7 +139,22 @@ export function useFieldManagement(): UseFieldManagementReturn {
// 필드 추가 (2025-11-27: async/await 추가 - 다른 탭 실시간 동기화)
const handleAddField = async (selectedPage: ItemPage | undefined) => {
console.log('[useFieldManagement] 🟢 handleAddField 시작!', {
selectedPage: selectedPage?.id,
selectedSectionForField,
newFieldName,
newFieldKey,
fieldInputMode,
selectedMasterFieldId,
});
if (!selectedPage || !selectedSectionForField || !newFieldName.trim() || !newFieldKey.trim()) {
console.log('[useFieldManagement] ❌ 필수값 누락으로 return', {
selectedPage: !!selectedPage,
selectedSectionForField,
newFieldName: newFieldName.trim(),
newFieldKey: newFieldKey.trim(),
});
toast.error('모든 필수 항목을 입력해주세요');
return;
}

View File

@@ -60,11 +60,11 @@ export default function Sidebar({
<div
ref={menuContainerRef}
className={`sidebar-scroll flex-1 overflow-y-auto transition-all duration-300 ${
sidebarCollapsed ? 'px-3 py-4' : 'px-4 py-3 md:px-6 md:py-4'
sidebarCollapsed ? 'px-2 py-3' : 'px-3 py-4 md:px-4 md:py-4'
}`}
>
<div className={`transition-all duration-300 ${
sidebarCollapsed ? 'space-y-2 mt-4' : 'space-y-3 mt-3'
sidebarCollapsed ? 'space-y-1.5 mt-4' : 'space-y-1.5 mt-3'
}`}>
{menuItems.map((item) => {
const IconComponent = item.icon;
@@ -82,7 +82,7 @@ export default function Sidebar({
<button
onClick={() => handleMenuClick(item.id, item.path, !!hasChildren)}
className={`w-full flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
sidebarCollapsed ? 'p-4 justify-center' : 'space-x-3 p-4 md:p-5'
sidebarCollapsed ? 'p-3 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
} ${
isActive
? "text-white clean-shadow scale-[0.98]"
@@ -91,8 +91,8 @@ export default function Sidebar({
style={isActive ? { backgroundColor: '#3B82F6' } : {}}
title={sidebarCollapsed ? item.label : undefined}
>
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon ${
sidebarCollapsed ? 'w-8 h-8' : 'w-9 h-9'
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon aspect-square ${
sidebarCollapsed ? 'w-7' : 'w-8'
} ${
isActive
? "bg-white/20"
@@ -123,7 +123,7 @@ export default function Sidebar({
{/* 서브메뉴 */}
{hasChildren && isExpanded && !sidebarCollapsed && (
<div className="mt-2 ml-4 space-y-1 border-l-2 border-primary/20 pl-4">
<div className="mt-1.5 ml-3 space-y-1.5 border-l-2 border-primary/20 pl-3">
{item.children?.map((subItem) => {
const SubIcon = subItem.icon;
const isSubActive = activeMenu === subItem.id;
@@ -134,7 +134,7 @@ export default function Sidebar({
>
<button
onClick={() => handleMenuClick(subItem.id, subItem.path, false)}
className={`w-full flex items-center rounded-lg transition-all duration-200 p-3 space-x-3 group ${
className={`w-full flex items-center rounded-lg transition-all duration-200 p-2.5 space-x-2.5 group ${
isSubActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"

View File

@@ -0,0 +1,185 @@
/**
* FormField - 통합 폼 필드 컴포넌트
*/
import { ReactNode } from "react";
import { Label } from "../ui/label";
import { Input } from "../ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
import { Textarea } from "../ui/textarea";
import { AlertCircle } from "lucide-react";
export type FormFieldType = 'text' | 'number' | 'date' | 'select' | 'textarea' | 'custom' | 'password';
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
export interface FormFieldProps {
label: string;
required?: boolean;
type?: FormFieldType;
value?: string | number;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
error?: string;
helpText?: string;
options?: SelectOption[];
selectPlaceholder?: string;
children?: ReactNode;
className?: string;
inputClassName?: string;
rows?: number;
min?: number;
max?: number;
step?: number;
htmlFor?: string;
}
export function FormField({
label,
required = false,
type = 'text',
value,
onChange,
placeholder,
disabled = false,
error,
helpText,
options = [],
selectPlaceholder = "선택하세요",
children,
className = "",
inputClassName = "",
rows = 3,
min,
max,
step,
htmlFor,
}: FormFieldProps) {
const renderInput = () => {
switch (type) {
case 'select':
return (
<Select
value={value as string}
onValueChange={onChange}
disabled={disabled}
>
<SelectTrigger className={`${error ? 'border-red-500' : ''} ${inputClassName}`}>
<SelectValue placeholder={selectPlaceholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
case 'textarea':
return (
<Textarea
id={htmlFor}
value={value as string}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
disabled={disabled}
rows={rows}
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
/>
);
case 'custom':
return children;
case 'number':
return (
<Input
id={htmlFor}
type="number"
value={value}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
disabled={disabled}
min={min}
max={max}
step={step}
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
/>
);
case 'date':
return (
<Input
id={htmlFor}
type="date"
value={value as string}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
/>
);
case 'password':
return (
<Input
id={htmlFor}
type="password"
value={value as string}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
/>
);
case 'text':
default:
return (
<Input
id={htmlFor}
type="text"
value={value as string}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
/>
);
}
};
return (
<div className={className}>
<Label htmlFor={htmlFor}>
{label} {required && <span className="text-red-500">*</span>}
</Label>
<div className="mt-1">
{renderInput()}
</div>
{error && (
<div className="flex items-center gap-1 mt-1 text-sm text-red-500">
<AlertCircle className="h-3 w-3" />
<span>{error}</span>
</div>
)}
{helpText && !error && (
<p className="text-xs text-muted-foreground mt-1">{helpText}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
/**
* FormActions - 폼 하단 액션 버튼 그룹
*/
import { ReactNode } from "react";
import { Button } from "../ui/button";
import { Save, X } from "lucide-react";
export interface FormActionsProps {
onSave?: () => void;
onCancel?: () => void;
saveLabel?: string;
cancelLabel?: string;
saveDisabled?: boolean;
cancelDisabled?: boolean;
saveLoading?: boolean;
children?: ReactNode;
className?: string;
align?: 'left' | 'center' | 'right';
}
export function FormActions({
onSave,
onCancel,
saveLabel = "저장",
cancelLabel = "취소",
saveDisabled = false,
cancelDisabled = false,
saveLoading = false,
children,
className = "",
align = 'right',
}: FormActionsProps) {
const alignClasses = {
left: "justify-start",
center: "justify-center",
right: "justify-end",
};
return (
<div className={`flex flex-col md:flex-row gap-3 ${alignClasses[align]} ${className}`}>
{children ? (
children
) : (
<>
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={cancelDisabled}
className="w-full md:w-auto"
>
<X className="h-4 w-4 mr-2" />
{cancelLabel}
</Button>
)}
{onSave && (
<Button
type="button"
onClick={onSave}
disabled={saveDisabled || saveLoading}
className="w-full md:w-auto"
>
<Save className="h-4 w-4 mr-2" />
{saveLoading ? "저장 중..." : saveLabel}
</Button>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
/**
* FormFieldGrid - 반응형 폼 필드 그리드
*
* 모바일: 1컬럼
* 태블릿: 2컬럼
* 데스크톱: 3컬럼 (또는 사용자 지정)
*/
import { ReactNode } from "react";
export interface FormFieldGridProps {
children: ReactNode;
columns?: 1 | 2 | 3 | 4;
className?: string;
}
export function FormFieldGrid({
children,
columns = 3,
className = "",
}: FormFieldGridProps) {
const gridClasses = {
1: "grid-cols-1",
2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
};
return (
<div className={`grid ${gridClasses[columns]} gap-4 ${className}`}>
{children}
</div>
);
}

View File

@@ -0,0 +1,62 @@
/**
* FormSection - 폼 섹션 카드 컴포넌트
*
* 등록 페이지의 각 섹션을 카드로 감싸는 컴포넌트
* 제목, 설명, 아이콘을 포함할 수 있습니다.
*/
import { ReactNode } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { LucideIcon } from "lucide-react";
export interface FormSectionProps {
title?: string;
description?: string;
icon?: LucideIcon;
children: ReactNode;
className?: string;
headerAction?: ReactNode;
variant?: 'default' | 'highlighted';
}
export function FormSection({
title,
description,
icon: Icon,
children,
className = "",
headerAction,
variant = 'default',
}: FormSectionProps) {
const variantClasses = {
default: "",
highlighted: "border-blue-200 bg-blue-50/30",
};
return (
<Card className={`${variantClasses[variant]} ${className}`}>
{(title || description) && (
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{Icon && <Icon className="h-5 w-5 text-primary" />}
<div>
{title && <CardTitle>{title}</CardTitle>}
{description && (
<CardDescription className="mt-1">{description}</CardDescription>
)}
</div>
</div>
{headerAction && (
<div>{headerAction}</div>
)}
</div>
</CardHeader>
)}
<CardContent className={title || description ? "" : "pt-6"}>
{children}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,425 @@
/**
* 발주서 (Purchase Order Document)
*
* - 로트번호 및 결재란
* - 신청업체 정보
* - 신청내용
* - 부자재 목록
*/
import { QuoteFormData } from "./QuoteRegistration";
interface PurchaseOrderDocumentProps {
quote: QuoteFormData;
}
export function PurchaseOrderDocument({ quote }: PurchaseOrderDocumentProps) {
const formatDate = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`;
};
// 발주번호 생성 (견적번호 기반)
const purchaseOrderNumber = quote.id?.replace('Q', 'KQ#-SC-') + '-01' || 'KQ#-SC-XXXXXX-01';
// BOM에서 부자재 목록 추출 (샘플 데이터)
const materialItems = quote.items?.map((item, index) => ({
no: index + 1,
name: item.productName || '아연도각파이프',
spec: `${item.openWidth}×${item.openHeight}`,
length: Number(item.openHeight) || 3000,
quantity: item.quantity || 1,
note: ''
})) || [
{ no: 1, name: '아연도각파이프', spec: '100-50-2T', length: 3000, quantity: 6, note: '' },
{ no: 2, name: '아연도각파이프', spec: '100-100-2T', length: 3000, quantity: 6, note: '' },
{ no: 3, name: '아연도앵글', spec: '50-50-4T', length: 2500, quantity: 10, note: '' },
{ no: 4, name: '외주 발주 코팅 비비그레스', spec: '', length: 0, quantity: 1, note: '' },
];
return (
<>
<style>{`
@media print {
@page {
size: A4 portrait;
margin: 10mm;
}
body {
background: white !important;
}
#purchase-order-content {
background: white !important;
padding: 0 !important;
}
}
/* 발주서 공문서 스타일 */
.purchase-order {
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
background: white;
color: #000;
line-height: 1.4;
}
.po-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #000;
}
.po-title {
flex: 1;
text-align: center;
}
.po-title h1 {
font-size: 36px;
font-weight: 700;
letter-spacing: 8px;
margin: 0;
}
.po-approval-section {
border: 2px solid #000;
background: white;
}
.po-lot-number-row {
display: grid;
grid-template-columns: 100px 1fr;
border-bottom: 2px solid #000;
}
.po-lot-label {
background: #e8e8e8;
border-right: 2px solid #000;
padding: 8px;
text-align: center;
font-weight: 700;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.po-lot-value {
background: white;
padding: 8px;
text-align: center;
font-weight: 700;
font-size: 14px;
color: #000;
display: flex;
align-items: center;
justify-content: center;
}
.po-approval-box {
width: 100%;
border: none;
display: grid;
grid-template-columns: 60px 1fr;
grid-template-rows: auto auto auto;
}
.po-approval-merged-vertical-cell {
border-right: 1px solid #000;
padding: 4px;
text-align: center;
font-weight: 600;
font-size: 11px;
background: white;
display: flex;
align-items: center;
justify-content: center;
grid-row: 1 / 4;
}
.po-approval-header {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
border-bottom: 1px solid #000;
}
.po-approval-header-cell {
border-right: 1px solid #000;
padding: 8px;
text-align: center;
font-weight: 600;
font-size: 12px;
background: white;
}
.po-approval-header-cell:last-child {
border-right: none;
}
.po-approval-content-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
border-bottom: 1px solid #000;
}
.po-approval-name-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}
.po-approval-signature-cell {
border-right: 1px solid #000;
padding: 8px;
text-align: center;
font-size: 11px;
height: 50px;
background: white;
}
.po-approval-signature-cell:last-child {
border-right: none;
}
.po-approval-name-cell {
border-right: 1px solid #000;
padding: 6px;
text-align: center;
font-weight: 600;
font-size: 11px;
background: white;
}
.po-approval-name-cell:last-child {
border-right: none;
}
.po-section-table {
width: 100%;
border-collapse: collapse;
border: 2px solid #000;
margin-bottom: 15px;
}
.po-section-header {
background: #e8e8e8;
border: 1px solid #666;
padding: 8px;
text-align: center;
font-weight: 700;
font-size: 13px;
width: 120px;
}
.po-section-content {
border: 1px solid #999;
padding: 8px;
font-size: 12px;
}
.po-materials-table {
width: 100%;
border-collapse: collapse;
border: 2px solid #000;
margin-bottom: 20px;
}
.po-materials-table th {
background: #e8e8e8;
border: 1px solid #666;
padding: 8px;
text-align: center;
font-weight: 700;
font-size: 12px;
}
.po-materials-table td {
border: 1px solid #999;
padding: 8px;
font-size: 12px;
}
.po-notes {
margin-top: 20px;
padding: 15px;
background: #f9f9f9;
border: 1px solid #ddd;
font-size: 11px;
line-height: 1.6;
}
`}</style>
{/* 발주서 내용 */}
<div id="purchase-order-content" className="purchase-order p-12 print:p-8">
{/* 헤더: 제목 + 결재란 */}
<div className="po-header">
{/* 제목 */}
<div className="po-title">
<h1> </h1>
</div>
{/* 로트번호 + 결재란 */}
<div className="po-approval-section">
{/* 로트번호 */}
<div className="po-lot-number-row">
<div className="po-lot-label">
</div>
<div className="po-lot-value">
{purchaseOrderNumber}
</div>
</div>
{/* 결재란 */}
<div className="po-approval-box">
<div className="po-approval-merged-vertical-cell"><br/></div>
{/* 결재란 헤더 */}
<div className="po-approval-header">
<div className="po-approval-header-cell"></div>
<div className="po-approval-header-cell"></div>
<div className="po-approval-header-cell"></div>
</div>
{/* 결재+서명란 */}
<div className="po-approval-content-row">
<div className="po-approval-signature-cell"></div>
<div className="po-approval-signature-cell"></div>
<div className="po-approval-signature-cell"></div>
</div>
{/* 이름란 */}
<div className="po-approval-name-row">
<div className="po-approval-name-cell">/</div>
<div className="po-approval-name-cell"></div>
<div className="po-approval-name-cell"></div>
</div>
</div>
</div>
</div>
{/* 신청업체 */}
<table className="po-section-table">
<tbody>
<tr>
<th className="po-section-header" rowSpan={3}> </th>
<th className="po-section-header" style={{ width: '100px' }}></th>
<td className="po-section-content">{quote.clientName || '-'}</td>
<th className="po-section-header" style={{ width: '100px' }}></th>
<td className="po-section-content">{formatDate(quote.registrationDate || '')}</td>
</tr>
<tr>
<th className="po-section-header"></th>
<td className="po-section-content">{quote.manager || '-'}</td>
<th className="po-section-header"></th>
<td className="po-section-content">{quote.contact || '-'}</td>
</tr>
<tr>
<th className="po-section-header">F A X</th>
<td className="po-section-content">-</td>
<th className="po-section-header">()</th>
<td className="po-section-content">{quote.items?.reduce((sum, item) => sum + (item.quantity || 0), 0) || 0}</td>
</tr>
</tbody>
</table>
{/* 신청내용 */}
<table className="po-section-table">
<tbody>
<tr>
<th className="po-section-header" rowSpan={5}> </th>
<th className="po-section-header" style={{ width: '100px' }}></th>
<td className="po-section-content" colSpan={3}>{quote.siteName || '-'}</td>
</tr>
<tr>
<th className="po-section-header"></th>
<td className="po-section-content" colSpan={3}>{formatDate(quote.dueDate || '')}</td>
</tr>
<tr>
<th className="po-section-header"></th>
<td className="po-section-content">{formatDate(quote.registrationDate || '')}</td>
<th className="po-section-header" style={{ width: '100px' }}></th>
<td className="po-section-content"></td>
</tr>
<tr>
<th className="po-section-header"></th>
<td className="po-section-content" colSpan={3}> 16-180</td>
</tr>
<tr>
<th className="po-section-header"></th>
<td className="po-section-content">{quote.manager || '-'}</td>
<th className="po-section-header" style={{ width: '100px' }}></th>
<td className="po-section-content">{quote.contact || '-'}</td>
</tr>
</tbody>
</table>
{/* 발주개소 정보 */}
<table className="po-section-table">
<tbody>
<tr>
<th className="po-section-header" style={{ width: '120px' }}></th>
<td className="po-section-content" style={{ width: '200px' }}>-</td>
<th className="po-section-header" style={{ width: '120px' }}></th>
<td className="po-section-content">{quote.items?.reduce((sum, item) => sum + (item.quantity || 0), 0) || 0}</td>
</tr>
</tbody>
</table>
{/* 유의사항 */}
<div style={{ marginBottom: '10px', fontSize: '11px', lineHeight: '1.6' }}>
<p>1. .</p>
<p>2. .</p>
</div>
{/* 부자재 테이블 */}
<div style={{ fontWeight: '700', marginBottom: '10px', fontSize: '14px' }}> </div>
<table className="po-materials-table">
<thead>
<tr>
<th style={{ width: '50px' }}></th>
<th></th>
<th style={{ width: '200px' }}></th>
<th style={{ width: '100px' }}>(mm)</th>
<th style={{ width: '80px' }}></th>
<th style={{ width: '150px' }}></th>
</tr>
</thead>
<tbody>
{materialItems.map((item) => (
<tr key={item.no}>
<td style={{ textAlign: 'center' }}>{item.no}</td>
<td>{item.name}</td>
<td>{item.spec}</td>
<td style={{ textAlign: 'right' }}>{item.length > 0 ? item.length.toLocaleString() : ''}</td>
<td style={{ textAlign: 'center', fontWeight: '600' }}>{item.quantity}</td>
<td>{item.note}</td>
</tr>
))}
</tbody>
</table>
{/* 특이사항 */}
<div style={{ marginBottom: '20px', padding: '10px', border: '1px solid #ddd', background: '#fafafa' }}>
<strong style={{ fontSize: '12px' }}></strong>
<div style={{ fontSize: '11px', marginTop: '5px', lineHeight: '1.6' }}>
{quote.remarks || '스크린 셔터 부품구성표 기반 자동 견적'}
</div>
</div>
{/* 하단 안내사항 */}
<div className="po-notes">
<p style={{ fontWeight: '600', marginBottom: '5px' }}> </p>
<p> .</p>
<p> , .</p>
<p> .</p>
<p style={{ marginTop: '10px', textAlign: 'center', fontWeight: '600' }}>
: {quote.writer || '담당자'} | {quote.contact || '031-983-5130'}
</p>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,508 @@
/**
* 견적 산출내역서 / 견적서 컴포넌트
* - documentType="견적서": 간단한 견적서
* - documentType="견적산출내역서": 상세 산출내역서 + 소요자재 내역
*/
import { QuoteFormData } from "./QuoteRegistration";
interface QuoteCalculationReportProps {
quote: QuoteFormData;
documentType?: "견적산출내역서" | "견적서";
showDetailedBreakdown?: boolean;
showMaterialList?: boolean;
}
export function QuoteCalculationReport({
quote,
documentType = "견적산출내역서",
showDetailedBreakdown = true,
showMaterialList = true
}: QuoteCalculationReportProps) {
const formatAmount = (amount: number | null | undefined) => {
if (amount == null) return '0';
return Number(amount).toLocaleString('ko-KR');
};
const formatDate = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`;
};
// 총 금액 계산
const totalAmount = quote.items?.reduce((sum, item) => {
return sum + (item.inspectionFee || 0) * (item.quantity || 1);
}, 0) || 0;
// 소요자재 내역 생성 (샘플 데이터)
const materialItems = quote.items?.map((item, index) => ({
no: index + 1,
name: item.productName || '가이드레일',
spec: `${item.openWidth || 0}×${item.openHeight || 0}mm`,
quantity: item.quantity || 1,
unit: 'SET'
})) || [];
return (
<>
<style>{`
@media print {
@page {
size: A4 portrait;
margin: 15mm;
}
body {
background: white !important;
}
.print\\:hidden {
display: none !important;
}
#quote-report-content {
background: white !important;
padding: 0 !important;
}
table {
page-break-inside: avoid;
}
.page-break-after {
page-break-after: always;
}
}
/* 공문서 스타일 */
.official-doc {
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
background: white;
color: #000;
line-height: 1.5;
}
.doc-header {
text-align: center;
border-bottom: 3px double #000;
padding-bottom: 20px;
margin-bottom: 30px;
}
.doc-title {
font-size: 26px;
font-weight: 700;
letter-spacing: 2px;
margin-bottom: 12px;
}
.doc-number {
font-size: 14px;
color: #333;
}
.info-box {
border: 2px solid #000;
margin-bottom: 20px;
}
.info-box-header {
background: #f0f0f0;
border-bottom: 2px solid #000;
padding: 8px 12px;
font-weight: 700;
text-align: center;
font-size: 14px;
}
.info-box-content {
padding: 0;
}
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table th {
background: #f8f8f8;
border: 1px solid #999;
padding: 8px 10px;
text-align: center;
font-weight: 600;
font-size: 13px;
width: 100px;
}
.info-table td {
border: 1px solid #999;
padding: 8px 10px;
font-size: 13px;
}
.amount-box {
border: 3px double #000;
padding: 20px;
text-align: center;
margin: 30px 0;
background: #fafafa;
}
.amount-label {
font-size: 16px;
font-weight: 600;
margin-bottom: 10px;
}
.amount-value {
font-size: 32px;
font-weight: 700;
color: #000;
letter-spacing: 1px;
}
.amount-note {
font-size: 13px;
color: #666;
margin-top: 8px;
}
.section-title {
background: #000;
color: white;
padding: 10px 15px;
font-weight: 700;
font-size: 15px;
margin: 30px 0 15px 0;
text-align: center;
letter-spacing: 1px;
}
.detail-table {
width: 100%;
border-collapse: collapse;
border: 2px solid #000;
}
.detail-table thead th {
background: #e8e8e8;
border: 1px solid #666;
padding: 10px 6px;
text-align: center;
font-weight: 700;
font-size: 12px;
}
.detail-table tbody td {
border: 1px solid #999;
padding: 8px 6px;
font-size: 12px;
}
.detail-table tbody tr:hover {
background: #f9f9f9;
}
.detail-table tfoot td {
background: #f0f0f0;
border: 1px solid #666;
padding: 10px;
font-weight: 700;
font-size: 13px;
}
.material-table {
width: 100%;
border-collapse: collapse;
border: 2px solid #000;
margin-top: 15px;
}
.material-table th {
background: #e8e8e8;
border: 1px solid #666;
padding: 8px;
text-align: center;
font-weight: 600;
font-size: 12px;
}
.material-table td {
border: 1px solid #999;
padding: 8px;
font-size: 12px;
}
.stamp-area {
border: 2px solid #000;
width: 80px;
height: 80px;
display: inline-block;
position: relative;
margin-left: 20px;
}
.stamp-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 10px;
color: #999;
text-align: center;
line-height: 1.3;
}
.footer-note {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #ccc;
font-size: 11px;
color: #666;
line-height: 1.6;
}
.signature-section {
margin-top: 30px;
text-align: right;
}
`}</style>
{/* 문서 컴포넌트 */}
<div className="official-doc">
{/* 문서 헤더 */}
<div className="doc-header">
<div className="doc-title">
{documentType === "견적서" ? "견 적 서" : "견 적 산 출 내 역 서"}
</div>
<div className="doc-number">
: {quote.id || '-'} | : {formatDate(quote.registrationDate || '')}
</div>
</div>
{/* 수요자 정보 */}
<div className="info-box">
<div className="info-box-header"> </div>
<div className="info-box-content">
<table className="info-table">
<tbody>
<tr>
<th></th>
<td colSpan={3}>{quote.clientName || '-'}</td>
</tr>
<tr>
<th></th>
<td>{quote.siteName || '-'}</td>
<th></th>
<td>{quote.manager || '-'}</td>
</tr>
<tr>
<th></th>
<td>{quote.items?.[0]?.productName || '-'}</td>
<th></th>
<td>{quote.contact || '-'}</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 공급자 정보 */}
<div className="info-box">
<div className="info-box-header"> </div>
<div className="info-box-content">
<table className="info-table">
<tbody>
<tr>
<th></th>
<td>()</td>
<th></th>
<td>139-87-00353</td>
</tr>
<tr>
<th></th>
<td> </td>
<th></th>
<td></td>
</tr>
<tr>
<th></th>
<td colSpan={3}>, , </td>
</tr>
<tr>
<th></th>
<td colSpan={3}> 45-22</td>
</tr>
<tr>
<th></th>
<td>031-983-5130</td>
<th></th>
<td>02-6911-6315</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 총 견적금액 */}
<div className="amount-box">
<div className="amount-label"> </div>
<div className="amount-value"> {formatAmount(totalAmount)}</div>
<div className="amount-note"> </div>
</div>
{/* 세부 산출내역서 */}
{showDetailedBreakdown && quote.items && quote.items.length > 0 && (
<div className="page-break-after">
<div className="section-title"> </div>
<table className="detail-table">
<thead>
<tr>
<th style={{ width: '40px' }}>No.</th>
<th style={{ width: '200px' }}></th>
<th style={{ width: '150px' }}></th>
<th style={{ width: '70px' }}></th>
<th style={{ width: '50px' }}></th>
<th style={{ width: '110px' }}></th>
<th style={{ width: '130px' }}></th>
</tr>
</thead>
<tbody>
{quote.items.map((item, index) => (
<tr key={item.id || `item-${index}`}>
<td style={{ textAlign: 'center' }}>{index + 1}</td>
<td>{item.productName}</td>
<td style={{ fontSize: '11px' }}>{`${item.openWidth}×${item.openHeight}mm`}</td>
<td style={{ textAlign: 'right' }}>{item.quantity}</td>
<td style={{ textAlign: 'center' }}>SET</td>
<td style={{ textAlign: 'right' }}>{formatAmount(item.inspectionFee)}</td>
<td style={{ textAlign: 'right', fontWeight: '600' }}>{formatAmount((item.inspectionFee || 0) * (item.quantity || 1))}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={6} style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontWeight: '700' }}> </td>
<td style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontSize: '15px', fontWeight: '700' }}>{formatAmount(totalAmount)}</td>
</tr>
</tfoot>
</table>
</div>
)}
{/* 소요자재 내역 */}
{showMaterialList && documentType !== "견적서" && (
<div>
<div className="section-title"> </div>
{/* 제품 정보 */}
<div className="info-box" style={{ marginTop: '15px' }}>
<div className="info-box-content">
<table className="info-table">
<tbody>
<tr>
<th></th>
<td>{quote.items?.[0]?.productCategory === 'steel' ? '철재' : '스크린'}</td>
<th></th>
<td>{quote.items?.[0]?.code || '-'}</td>
</tr>
<tr>
<th></th>
<td>W {quote.items?.[0]?.openWidth || '-'} × H {quote.items?.[0]?.openHeight || '-'} (mm)</td>
<th></th>
<td>W {Number(quote.items?.[0]?.openWidth || 0) + 100} × H {Number(quote.items?.[0]?.openHeight || 0) + 100} (mm)</td>
</tr>
<tr>
<th></th>
<td>{quote.items?.[0]?.quantity || 1} SET</td>
<th></th>
<td>2438 × 550 (mm)</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 자재 목록 테이블 */}
<table className="material-table">
<thead>
<tr>
<th style={{ width: '40px' }}>No.</th>
<th></th>
<th style={{ width: '250px' }}></th>
<th style={{ width: '80px' }}></th>
<th style={{ width: '60px' }}></th>
</tr>
</thead>
<tbody>
{materialItems.map((item, index) => (
<tr key={index}>
<td style={{ textAlign: 'center' }}>{index + 1}</td>
<td>{item.name}</td>
<td>{item.spec}</td>
<td style={{ textAlign: 'center', fontWeight: '600' }}>{item.quantity}</td>
<td style={{ textAlign: 'center' }}>{item.unit}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 비고사항 */}
{quote.remarks && (
<div style={{ marginTop: '30px' }}>
<div className="section-title"> </div>
<div style={{
border: '2px solid #000',
padding: '15px',
minHeight: '100px',
whiteSpace: 'pre-wrap',
fontSize: '13px',
lineHeight: '1.8',
marginTop: '15px'
}}>
{quote.remarks}
</div>
</div>
)}
{/* 서명란 */}
<div className="signature-section">
<div style={{ display: 'inline-block', textAlign: 'left' }}>
<div style={{ marginBottom: '15px', fontSize: '14px' }}>
.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<div>
<div style={{ fontSize: '13px', marginBottom: '5px' }}>{formatDate(quote.registrationDate || '')}</div>
<div style={{ fontSize: '15px', fontWeight: '600' }}>
: () ()
</div>
</div>
<div className="stamp-area">
<div className="stamp-text">
(<br/>)
</div>
</div>
</div>
</div>
</div>
{/* 하단 안내사항 */}
<div className="footer-note">
<p style={{ fontWeight: '600', marginBottom: '8px' }}> </p>
<p>1. {formatDate(quote.registrationDate || '')} , .</p>
<p>2. 30, .</p>
<p>3. .</p>
<p>4. .</p>
<p style={{ marginTop: '12px', textAlign: 'center', fontWeight: '600' }}>
: {quote.manager || '담당자'} | {quote.contact || '031-983-5130'}
</p>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,461 @@
/**
* 견적서 (Quote Document)
*
* - 수요자/공급자 정보
* - 총 견적금액
* - 제품구성 정보
* - 품목 내역 테이블
* - 비용 산출
* - 비고사항
* - 서명란
*/
import { QuoteFormData } from "./QuoteRegistration";
interface QuoteDocumentProps {
quote: QuoteFormData;
}
export function QuoteDocument({ quote }: QuoteDocumentProps) {
const formatAmount = (amount: number | undefined) => {
if (amount === undefined || amount === null) return '0';
return amount.toLocaleString('ko-KR');
};
const formatDate = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`;
};
// 품목 내역 생성
const quoteItems = quote.items?.map((item, index) => ({
no: index + 1,
itemName: item.productName || '스크린셔터',
spec: `${item.openWidth}×${item.openHeight}`,
quantity: item.quantity || 1,
unit: '개소',
unitPrice: item.unitPrice || 0,
totalPrice: item.totalAmount || 0,
})) || [];
// 합계 계산
const subtotal = quoteItems.reduce((sum, item) => sum + item.totalPrice, 0);
const vat = Math.round(subtotal * 0.1);
const totalAmount = subtotal + vat;
return (
<>
<style>{`
@media print {
@page {
size: A4 portrait;
margin: 15mm;
}
body {
background: white !important;
}
.print\\:hidden {
display: none !important;
}
#quote-document-content {
background: white !important;
padding: 0 !important;
}
table {
page-break-inside: avoid;
}
}
/* 공문서 스타일 */
.official-doc {
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
background: white;
color: #000;
line-height: 1.5;
}
.doc-header {
text-align: center;
border-bottom: 3px double #000;
padding-bottom: 20px;
margin-bottom: 30px;
}
.doc-title {
font-size: 26px;
font-weight: 700;
letter-spacing: 2px;
margin-bottom: 12px;
}
.doc-number {
font-size: 14px;
color: #333;
}
.info-box {
border: 2px solid #000;
margin-bottom: 20px;
}
.info-box-header {
background: #f0f0f0;
border-bottom: 2px solid #000;
padding: 8px 12px;
font-weight: 700;
text-align: center;
font-size: 14px;
}
.info-box-content {
padding: 0;
}
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table th {
background: #f8f8f8;
border: 1px solid #999;
padding: 8px 10px;
text-align: center;
font-weight: 600;
font-size: 13px;
width: 100px;
}
.info-table td {
border: 1px solid #999;
padding: 8px 10px;
font-size: 13px;
}
.amount-box {
border: 3px double #000;
padding: 20px;
text-align: center;
margin: 30px 0;
background: #fafafa;
}
.amount-label {
font-size: 16px;
font-weight: 600;
margin-bottom: 10px;
}
.amount-value {
font-size: 32px;
font-weight: 700;
color: #000;
letter-spacing: 1px;
}
.amount-note {
font-size: 13px;
color: #666;
margin-top: 8px;
}
.section-title {
background: #000;
color: white;
padding: 10px 15px;
font-weight: 700;
font-size: 15px;
margin: 30px 0 15px 0;
text-align: center;
letter-spacing: 1px;
}
.detail-table {
width: 100%;
border-collapse: collapse;
border: 2px solid #000;
}
.detail-table thead th {
background: #e8e8e8;
border: 1px solid #666;
padding: 10px 6px;
text-align: center;
font-weight: 700;
font-size: 12px;
}
.detail-table tbody td {
border: 1px solid #999;
padding: 8px 6px;
font-size: 12px;
}
.detail-table tbody tr:hover {
background: #f9f9f9;
}
.detail-table tfoot td {
background: #f0f0f0;
border: 1px solid #666;
padding: 10px;
font-weight: 700;
font-size: 13px;
}
.stamp-area {
border: 2px solid #000;
width: 80px;
height: 80px;
display: inline-block;
position: relative;
margin-left: 20px;
}
.stamp-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 10px;
color: #999;
text-align: center;
line-height: 1.3;
}
.footer-note {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #ccc;
font-size: 11px;
color: #666;
line-height: 1.6;
}
.signature-section {
margin-top: 30px;
text-align: right;
}
`}</style>
{/* 견적서 내용 */}
<div id="quote-document-content" className="official-doc p-12 print:p-8">
{/* 문서 헤더 */}
<div className="doc-header">
<div className="doc-title"> </div>
<div className="doc-number">
: {quote.id || 'Q-XXXXXX'} | : {formatDate(quote.registrationDate || '')}
</div>
</div>
{/* 수요자 정보 */}
<div className="info-box">
<div className="info-box-header"> </div>
<div className="info-box-content">
<table className="info-table">
<tbody>
<tr>
<th></th>
<td colSpan={3}>{quote.clientName || '-'}</td>
</tr>
<tr>
<th></th>
<td>{quote.siteName || '-'}</td>
<th></th>
<td>{quote.manager || '-'}</td>
</tr>
<tr>
<th></th>
<td>{formatDate(quote.registrationDate || '')}</td>
<th></th>
<td>{quote.contact || '-'}</td>
</tr>
<tr>
<th></th>
<td colSpan={3}>{formatDate(quote.dueDate || '')}</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 공급자 정보 */}
<div className="info-box">
<div className="info-box-header"> </div>
<div className="info-box-content">
<table className="info-table">
<tbody>
<tr>
<th></th>
<td></td>
<th></th>
<td>139-87-00333</td>
</tr>
<tr>
<th></th>
<td> </td>
<th></th>
<td></td>
</tr>
<tr>
<th></th>
<td colSpan={3}>, , </td>
</tr>
<tr>
<th></th>
<td colSpan={3}> 45-22</td>
</tr>
<tr>
<th></th>
<td>031-983-5130</td>
<th></th>
<td>02-6911-6315</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 총 견적금액 */}
<div className="amount-box">
<div className="amount-label"> </div>
<div className="amount-value"> {formatAmount(totalAmount)}</div>
<div className="amount-note"> </div>
</div>
{/* 제품구성 정보 */}
{quote.items && quote.items.length > 0 && (
<>
<div className="section-title"> </div>
<div className="info-box" style={{ marginTop: '15px' }}>
<div className="info-box-content">
<table className="info-table">
<tbody>
<tr>
<th></th>
<td>{quote.items[0]?.productName || '스크린셔터'}</td>
<th> </th>
<td>{quote.items.reduce((sum, item) => sum + (item.quantity || 0), 0)}</td>
</tr>
<tr>
<th></th>
<td>{quote.items[0]?.openWidth}×{quote.items[0]?.openHeight}</td>
<th></th>
<td>{quote.items[0]?.installType || '-'}</td>
</tr>
</tbody>
</table>
</div>
</div>
</>
)}
{/* 품목 내역 */}
{quoteItems.length > 0 && (
<>
<div className="section-title"> </div>
<table className="detail-table">
<thead>
<tr>
<th style={{ width: '40px' }}>No.</th>
<th style={{ width: '200px' }}></th>
<th style={{ width: '150px' }}></th>
<th style={{ width: '70px' }}></th>
<th style={{ width: '50px' }}></th>
<th style={{ width: '110px' }}></th>
<th style={{ width: '130px' }}></th>
</tr>
</thead>
<tbody>
{quoteItems.map((item) => (
<tr key={item.no}>
<td style={{ textAlign: 'center' }}>{item.no}</td>
<td>{item.itemName}</td>
<td style={{ textAlign: 'center' }}>{item.spec || '-'}</td>
<td style={{ textAlign: 'right' }}>{item.quantity}</td>
<td style={{ textAlign: 'center' }}>{item.unit}</td>
<td style={{ textAlign: 'right' }}>{formatAmount(item.unitPrice)}</td>
<td style={{ textAlign: 'right', fontWeight: '600' }}>{formatAmount(item.totalPrice)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={6} style={{ textAlign: 'right', padding: '12px' }}> </td>
<td style={{ textAlign: 'right', padding: '12px' }}>{formatAmount(subtotal)}</td>
</tr>
<tr>
<td colSpan={6} style={{ textAlign: 'right', padding: '12px' }}> (10%)</td>
<td style={{ textAlign: 'right', padding: '12px' }}>{formatAmount(vat)}</td>
</tr>
<tr>
<td colSpan={6} style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0' }}> </td>
<td style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontSize: '15px', fontWeight: '700' }}>
{formatAmount(totalAmount)}
</td>
</tr>
</tfoot>
</table>
</>
)}
{/* 비고사항 */}
{quote.remarks && (
<>
<div className="section-title"> </div>
<div style={{
border: '2px solid #000',
padding: '15px',
minHeight: '100px',
whiteSpace: 'pre-wrap',
fontSize: '13px',
lineHeight: '1.8',
marginTop: '15px'
}}>
{quote.remarks}
</div>
</>
)}
{/* 서명란 */}
<div className="signature-section">
<div style={{ display: 'inline-block', textAlign: 'left' }}>
<div style={{ marginBottom: '15px', fontSize: '14px' }}>
.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<div>
<div style={{ fontSize: '13px', marginBottom: '5px' }}>{formatDate(quote.registrationDate || '')}</div>
<div style={{ fontSize: '15px', fontWeight: '600' }}>
공급자: 동호기업 ()
</div>
</div>
<div className="stamp-area">
<div className="stamp-text">
(<br/>)
</div>
</div>
</div>
</div>
</div>
{/* 하단 안내사항 */}
<div className="footer-note">
<p style={{ fontWeight: '600', marginBottom: '8px' }}> </p>
<p>1. {formatDate(quote.registrationDate || '')} , .</p>
<p>2. {formatDate(quote.dueDate || '')}, .</p>
<p>3. .</p>
<p>4. .</p>
<p style={{ marginTop: '12px', textAlign: 'center', fontWeight: '600' }}>
: {quote.writer || '담당자'} | {quote.contact || '031-983-5130'}
</p>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,801 @@
/**
* 견적 등록/수정 컴포넌트
*
* ResponsiveFormTemplate 적용
* - 기본 정보 섹션
* - 자동 견적 산출 섹션 (동적 항목 추가/삭제)
*/
"use client";
import { useState, useEffect } from "react";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Button } from "../ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Badge } from "../ui/badge";
import {
FileText,
Calculator,
Plus,
Copy,
Trash2,
Sparkles,
} from "lucide-react";
import { toast } from "sonner";
import {
ResponsiveFormTemplate,
FormSection,
FormFieldGrid,
} from "../templates/ResponsiveFormTemplate";
import { FormField } from "../molecules/FormField";
// 견적 항목 타입
export interface QuoteItem {
id: string;
floor: string; // 층수
code: string; // 부호
productCategory: string; // 제품 카테고리 (PC)
productName: string; // 제품명
openWidth: string; // 오픈사이즈 W0
openHeight: string; // 오픈사이즈 H0
guideRailType: string; // 가이드레일 설치 유형 (GT)
motorPower: string; // 모터 전원 (MP)
controller: string; // 연동제어기 (CT)
quantity: number; // 수량 (QTY)
wingSize: string; // 마구리 날개치수 (WS)
inspectionFee: number; // 검사비 (INSP)
}
// 견적 폼 데이터 타입
export interface QuoteFormData {
id?: string;
registrationDate: string;
writer: string;
clientId: string;
clientName: string;
siteName: string; // 현장명 (직접 입력)
manager: string;
contact: string;
dueDate: string;
remarks: string;
items: QuoteItem[];
}
// 초기 견적 항목
const createNewItem = (): QuoteItem => ({
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
floor: "",
code: "",
productCategory: "",
productName: "",
openWidth: "",
openHeight: "",
guideRailType: "",
motorPower: "",
controller: "",
quantity: 1,
wingSize: "50",
inspectionFee: 50000,
});
// 초기 폼 데이터
export const INITIAL_QUOTE_FORM: QuoteFormData = {
registrationDate: new Date().toISOString().split("T")[0],
writer: "드미트리", // TODO: 로그인 사용자 정보로 대체
clientId: "",
clientName: "",
siteName: "", // 현장명 (직접 입력)
manager: "",
contact: "",
dueDate: "",
remarks: "",
items: [createNewItem()],
};
// 샘플 발주처 데이터 (TODO: API에서 가져오기)
const SAMPLE_CLIENTS = [
{ id: "client-1", name: "인천건설 - 최담당" },
{ id: "client-2", name: "ABC건설" },
{ id: "client-3", name: "XYZ산업" },
];
// 제품 카테고리 옵션
const PRODUCT_CATEGORIES = [
{ value: "screen", label: "스크린" },
{ value: "steel", label: "철재" },
{ value: "aluminum", label: "알루미늄" },
{ value: "etc", label: "기타" },
];
// 제품명 옵션 (카테고리별)
const PRODUCTS: Record<string, { value: string; label: string }[]> = {
screen: [
{ value: "SCR-001", label: "스크린 A형" },
{ value: "SCR-002", label: "스크린 B형" },
{ value: "SCR-003", label: "스크린 C형" },
],
steel: [
{ value: "STL-001", label: "철재 도어 A" },
{ value: "STL-002", label: "철재 도어 B" },
],
aluminum: [
{ value: "ALU-001", label: "알루미늄 프레임" },
],
etc: [
{ value: "ETC-001", label: "기타 제품" },
],
};
// 가이드레일 설치 유형
const GUIDE_RAIL_TYPES = [
{ value: "wall", label: "벽부착형" },
{ value: "ceiling", label: "천장매립형" },
{ value: "floor", label: "바닥매립형" },
];
// 모터 전원
const MOTOR_POWERS = [
{ value: "single", label: "단상 220V" },
{ value: "three", label: "삼상 380V" },
];
// 연동제어기
const CONTROLLERS = [
{ value: "basic", label: "기본 제어기" },
{ value: "smart", label: "스마트 제어기" },
{ value: "premium", label: "프리미엄 제어기" },
];
interface QuoteRegistrationProps {
onBack: () => void;
onSave: (quote: QuoteFormData) => Promise<void>;
editingQuote?: QuoteFormData | null;
isLoading?: boolean;
}
export function QuoteRegistration({
onBack,
onSave,
editingQuote,
isLoading = false,
}: QuoteRegistrationProps) {
const [formData, setFormData] = useState<QuoteFormData>(
editingQuote || INITIAL_QUOTE_FORM
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
const [activeItemIndex, setActiveItemIndex] = useState(0);
// editingQuote가 변경되면 formData 업데이트
useEffect(() => {
if (editingQuote) {
setFormData(editingQuote);
}
}, [editingQuote]);
// 유효성 검사
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.clientId) {
newErrors.clientId = "발주처를 선택해주세요";
}
// 견적 항목 검사
formData.items.forEach((item, index) => {
if (!item.productCategory) {
newErrors[`item-${index}-productCategory`] = "제품 카테고리를 선택해주세요";
}
if (!item.productName) {
newErrors[`item-${index}-productName`] = "제품명을 선택해주세요";
}
if (!item.openWidth) {
newErrors[`item-${index}-openWidth`] = "오픈사이즈(W)를 입력해주세요";
}
if (!item.openHeight) {
newErrors[`item-${index}-openHeight`] = "오픈사이즈(H)를 입력해주세요";
}
if (!item.guideRailType) {
newErrors[`item-${index}-guideRailType`] = "설치 유형을 선택해주세요";
}
if (!item.motorPower) {
newErrors[`item-${index}-motorPower`] = "모터 전원을 선택해주세요";
}
if (!item.controller) {
newErrors[`item-${index}-controller`] = "제어기를 선택해주세요";
}
if (item.quantity < 1) {
newErrors[`item-${index}-quantity`] = "수량은 1 이상이어야 합니다";
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) {
toast.error("입력 내용을 확인해주세요.");
return;
}
setIsSaving(true);
try {
await onSave(formData);
toast.success(
editingQuote ? "견적이 수정되었습니다." : "견적이 등록되었습니다."
);
onBack();
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
};
const handleFieldChange = (
field: keyof QuoteFormData,
value: string | QuoteItem[]
) => {
setFormData({ ...formData, [field]: value });
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
// 발주처 선택
const handleClientChange = (clientId: string) => {
const client = SAMPLE_CLIENTS.find((c) => c.id === clientId);
setFormData({
...formData,
clientId,
clientName: client?.name || "",
});
};
// 견적 항목 변경
const handleItemChange = (
index: number,
field: keyof QuoteItem,
value: string | number
) => {
const newItems = [...formData.items];
newItems[index] = { ...newItems[index], [field]: value };
// 제품 카테고리 변경 시 제품명 초기화
if (field === "productCategory") {
newItems[index].productName = "";
}
setFormData({ ...formData, items: newItems });
// 에러 클리어
const errorKey = `item-${index}-${field}`;
if (errors[errorKey]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[errorKey];
return newErrors;
});
}
};
// 견적 항목 추가
const handleAddItem = () => {
const newItems = [...formData.items, createNewItem()];
setFormData({ ...formData, items: newItems });
setActiveItemIndex(newItems.length - 1);
};
// 견적 항목 복사
const handleCopyItem = (index: number) => {
const itemToCopy = formData.items[index];
const newItem: QuoteItem = {
...itemToCopy,
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
};
const newItems = [...formData.items, newItem];
setFormData({ ...formData, items: newItems });
setActiveItemIndex(newItems.length - 1);
toast.success("견적 항목이 복사되었습니다.");
};
// 견적 항목 삭제
const handleDeleteItem = (index: number) => {
if (formData.items.length === 1) {
toast.error("최소 1개의 견적 항목이 필요합니다.");
return;
}
const newItems = formData.items.filter((_, i) => i !== index);
setFormData({ ...formData, items: newItems });
if (activeItemIndex >= newItems.length) {
setActiveItemIndex(newItems.length - 1);
}
toast.success("견적 항목이 삭제되었습니다.");
};
// 자동 견적 산출
const handleAutoCalculate = () => {
toast.info(`자동 견적 산출 (${formData.items.length}개 항목) - API 연동 필요`);
};
// 샘플 데이터 생성
const handleGenerateSample = () => {
toast.info("완벽한 샘플 생성 - API 연동 필요");
};
return (
<ResponsiveFormTemplate
title={editingQuote ? "견적 수정" : "견적 등록"}
description=""
icon={FileText}
onSave={handleSubmit}
onCancel={onBack}
saveLabel="저장"
cancelLabel="취소"
isEditMode={!!editingQuote}
saveLoading={isSaving || isLoading}
saveDisabled={isSaving || isLoading}
maxWidth="2xl"
>
{/* 1. 기본 정보 */}
<FormSection
title="기본 정보"
description=""
icon={FileText}
>
<FormFieldGrid columns={3}>
<FormField label="등록일" htmlFor="registrationDate">
<Input
id="registrationDate"
type="date"
value={formData.registrationDate}
disabled
className="bg-gray-50"
/>
</FormField>
<FormField label="작성자" htmlFor="writer">
<Input
id="writer"
value={formData.writer}
disabled
className="bg-gray-50"
/>
</FormField>
<FormField
label="발주처 선택"
required
error={errors.clientId}
htmlFor="clientId"
>
<Select
value={formData.clientId}
onValueChange={handleClientChange}
>
<SelectTrigger id="clientId">
<SelectValue placeholder="발주처를 선택하세요" />
</SelectTrigger>
<SelectContent>
{SAMPLE_CLIENTS.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</FormFieldGrid>
<FormFieldGrid columns={3}>
<FormField label="현장명" htmlFor="siteName">
<Input
id="siteName"
placeholder="현장명을 입력하세요"
value={formData.siteName}
onChange={(e) => handleFieldChange("siteName", e.target.value)}
/>
</FormField>
<FormField label="발주 담당자" htmlFor="manager">
<Input
id="manager"
placeholder="담당자명을 입력하세요"
value={formData.manager}
onChange={(e) => handleFieldChange("manager", e.target.value)}
/>
</FormField>
<FormField label="연락처" htmlFor="contact">
<Input
id="contact"
placeholder="010-1234-5678"
value={formData.contact}
onChange={(e) => handleFieldChange("contact", e.target.value)}
/>
</FormField>
</FormFieldGrid>
<FormFieldGrid columns={3}>
<FormField label="납기일" htmlFor="dueDate">
<Input
id="dueDate"
type="date"
value={formData.dueDate}
onChange={(e) => handleFieldChange("dueDate", e.target.value)}
/>
</FormField>
<div className="col-span-2" />
</FormFieldGrid>
<FormField label="비고" htmlFor="remarks">
<Textarea
id="remarks"
placeholder="특이사항을 입력하세요"
value={formData.remarks}
onChange={(e) => handleFieldChange("remarks", e.target.value)}
rows={3}
/>
</FormField>
</FormSection>
{/* 2. 자동 견적 산출 */}
<FormSection
title="자동 견적 산출"
description="입력값을 기반으로 견적을 자동으로 산출합니다"
icon={Calculator}
>
{/* 견적 탭 */}
<Card className="border-gray-200">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-wrap">
{formData.items.map((item, index) => (
<Button
key={item.id}
variant={activeItemIndex === index ? "default" : "outline"}
size="sm"
onClick={() => setActiveItemIndex(index)}
className="min-w-[70px]"
>
{index + 1}
</Button>
))}
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleCopyItem(activeItemIndex)}
title="복사"
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteItem(activeItemIndex)}
title="삭제"
className="text-red-500 hover:text-red-600"
disabled={formData.items.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{formData.items[activeItemIndex] && (
<>
<FormFieldGrid columns={3}>
<FormField label="층수" htmlFor={`floor-${activeItemIndex}`}>
<Input
id={`floor-${activeItemIndex}`}
placeholder="예: 1층, B1, 지하1층"
value={formData.items[activeItemIndex].floor}
onChange={(e) =>
handleItemChange(activeItemIndex, "floor", e.target.value)
}
/>
</FormField>
<FormField label="부호" htmlFor={`code-${activeItemIndex}`}>
<Input
id={`code-${activeItemIndex}`}
placeholder="예: A, B, C"
value={formData.items[activeItemIndex].code}
onChange={(e) =>
handleItemChange(activeItemIndex, "code", e.target.value)
}
/>
</FormField>
<FormField
label="제품 카테고리 (PC)"
required
error={errors[`item-${activeItemIndex}-productCategory`]}
htmlFor={`productCategory-${activeItemIndex}`}
>
<Select
value={formData.items[activeItemIndex].productCategory}
onValueChange={(value) =>
handleItemChange(activeItemIndex, "productCategory", value)
}
>
<SelectTrigger id={`productCategory-${activeItemIndex}`}>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{PRODUCT_CATEGORIES.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</FormFieldGrid>
<FormFieldGrid columns={3}>
<FormField
label="제품명"
required
error={errors[`item-${activeItemIndex}-productName`]}
htmlFor={`productName-${activeItemIndex}`}
>
<Select
value={formData.items[activeItemIndex].productName}
onValueChange={(value) =>
handleItemChange(activeItemIndex, "productName", value)
}
disabled={!formData.items[activeItemIndex].productCategory}
>
<SelectTrigger id={`productName-${activeItemIndex}`}>
<SelectValue placeholder="제품을 선택하세요" />
</SelectTrigger>
<SelectContent>
{(PRODUCTS[formData.items[activeItemIndex].productCategory] || []).map((product) => (
<SelectItem key={product.value} value={product.value}>
{product.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label="오픈사이즈 (W0)"
required
error={errors[`item-${activeItemIndex}-openWidth`]}
htmlFor={`openWidth-${activeItemIndex}`}
>
<Input
id={`openWidth-${activeItemIndex}`}
placeholder="예: 2000"
value={formData.items[activeItemIndex].openWidth}
onChange={(e) =>
handleItemChange(activeItemIndex, "openWidth", e.target.value)
}
/>
</FormField>
<FormField
label="오픈사이즈 (H0)"
required
error={errors[`item-${activeItemIndex}-openHeight`]}
htmlFor={`openHeight-${activeItemIndex}`}
>
<Input
id={`openHeight-${activeItemIndex}`}
placeholder="예: 2500"
value={formData.items[activeItemIndex].openHeight}
onChange={(e) =>
handleItemChange(activeItemIndex, "openHeight", e.target.value)
}
/>
</FormField>
</FormFieldGrid>
<FormFieldGrid columns={3}>
<FormField
label="가이드레일 설치 유형 (GT)"
required
error={errors[`item-${activeItemIndex}-guideRailType`]}
htmlFor={`guideRailType-${activeItemIndex}`}
>
<Select
value={formData.items[activeItemIndex].guideRailType}
onValueChange={(value) =>
handleItemChange(activeItemIndex, "guideRailType", value)
}
>
<SelectTrigger id={`guideRailType-${activeItemIndex}`}>
<SelectValue placeholder="설치 유형 선택" />
</SelectTrigger>
<SelectContent>
{GUIDE_RAIL_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label="모터 전원 (MP)"
required
error={errors[`item-${activeItemIndex}-motorPower`]}
htmlFor={`motorPower-${activeItemIndex}`}
>
<Select
value={formData.items[activeItemIndex].motorPower}
onValueChange={(value) =>
handleItemChange(activeItemIndex, "motorPower", value)
}
>
<SelectTrigger id={`motorPower-${activeItemIndex}`}>
<SelectValue placeholder="전원 선택" />
</SelectTrigger>
<SelectContent>
{MOTOR_POWERS.map((power) => (
<SelectItem key={power.value} value={power.value}>
{power.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label="연동제어기 (CT)"
required
error={errors[`item-${activeItemIndex}-controller`]}
htmlFor={`controller-${activeItemIndex}`}
>
<Select
value={formData.items[activeItemIndex].controller}
onValueChange={(value) =>
handleItemChange(activeItemIndex, "controller", value)
}
>
<SelectTrigger id={`controller-${activeItemIndex}`}>
<SelectValue placeholder="제어기 선택" />
</SelectTrigger>
<SelectContent>
{CONTROLLERS.map((ctrl) => (
<SelectItem key={ctrl.value} value={ctrl.value}>
{ctrl.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</FormFieldGrid>
<FormFieldGrid columns={3}>
<FormField
label="수량 (QTY)"
required
error={errors[`item-${activeItemIndex}-quantity`]}
htmlFor={`quantity-${activeItemIndex}`}
>
<Input
id={`quantity-${activeItemIndex}`}
type="number"
min="1"
value={formData.items[activeItemIndex].quantity}
onChange={(e) =>
handleItemChange(activeItemIndex, "quantity", parseInt(e.target.value) || 1)
}
/>
</FormField>
<FormField
label="마구리 날개치수 (WS)"
htmlFor={`wingSize-${activeItemIndex}`}
>
<Input
id={`wingSize-${activeItemIndex}`}
placeholder="예: 50"
value={formData.items[activeItemIndex].wingSize}
onChange={(e) =>
handleItemChange(activeItemIndex, "wingSize", e.target.value)
}
/>
</FormField>
<FormField
label="검사비 (INSP)"
htmlFor={`inspectionFee-${activeItemIndex}`}
>
<Input
id={`inspectionFee-${activeItemIndex}`}
type="number"
placeholder="예: 50000"
value={formData.items[activeItemIndex].inspectionFee}
onChange={(e) =>
handleItemChange(activeItemIndex, "inspectionFee", parseInt(e.target.value) || 0)
}
/>
</FormField>
</FormFieldGrid>
</>
)}
</CardContent>
</Card>
{/* 견적 추가 버튼 */}
<Button
variant="outline"
className="w-full"
onClick={handleAddItem}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
{/* 자동 견적 산출 버튼 */}
<Button
variant="default"
className="w-full bg-blue-600 hover:bg-blue-700"
onClick={handleAutoCalculate}
>
({formData.items.length} )
</Button>
</FormSection>
{/* 3. 샘플 데이터 (개발용) */}
<Card className="border-blue-200 bg-blue-50/50">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-sm font-medium">
()
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
.
14( 5, 5, 4), 40, 25, 20
, BOM (2~3 )
.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleGenerateSample}
className="bg-white"
>
<Sparkles className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent className="pt-2">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary"> 14</Badge>
<Badge variant="secondary"> 40</Badge>
<Badge variant="secondary"> 25</Badge>
<Badge variant="secondary"> 20</Badge>
<Badge variant="outline">BOM 2~3 </Badge>
<Badge variant="outline"> </Badge>
<Badge variant="outline"> </Badge>
</div>
</CardContent>
</Card>
</ResponsiveFormTemplate>
);
}

View File

@@ -51,7 +51,7 @@ export interface PaginationConfig {
export interface StatCard {
label: string;
value: number;
value: string | number;
icon: LucideIcon;
iconColor: string;
}
@@ -108,7 +108,7 @@ export interface IntegratedListTemplateV2Props<T = any> {
allData?: T[]; // 모바일 인피니티 스크롤용 전체 필터된 데이터
mobileDisplayCount?: number; // 모바일에서 표시할 개수
onLoadMore?: () => void; // 더 불러오기 콜백
infinityScrollSentinelRef?: RefObject<HTMLDivElement>; // 인피니티 스크롤용 sentinel ref
infinityScrollSentinelRef?: RefObject<HTMLDivElement | null>; // 인피니티 스크롤용 sentinel ref
// 체크박스 선택
selectedItems: Set<string>;

View File

@@ -0,0 +1,96 @@
/**
* ResponsiveFormTemplate - 통합 등록 페이지 템플릿
*/
"use client";
import { ReactNode } from "react";
import { LucideIcon } from "lucide-react";
import { PageLayout } from "../organisms/PageLayout";
import { PageHeader } from "../organisms/PageHeader";
import { FormActions } from "../organisms/FormActions";
// Re-export form components for convenience
export { FormSection } from "../organisms/FormSection";
export type { FormSectionProps } from "../organisms/FormSection";
export { FormField } from "../molecules/FormField";
export type { FormFieldProps, FormFieldType, SelectOption } from "../molecules/FormField";
export { FormFieldGrid } from "../organisms/FormFieldGrid";
export type { FormFieldGridProps } from "../organisms/FormFieldGrid";
export { FormActions } from "../organisms/FormActions";
export type { FormActionsProps } from "../organisms/FormActions";
export interface ResponsiveFormTemplateProps {
title: string;
description?: string;
icon?: LucideIcon;
headerActions?: ReactNode;
isEditMode?: boolean;
children: ReactNode;
onSave?: () => void;
onCancel?: () => void;
saveLabel?: string;
cancelLabel?: string;
saveDisabled?: boolean;
saveLoading?: boolean;
showActions?: boolean;
customActions?: ReactNode;
className?: string;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
versionInfo?: ReactNode;
}
export function ResponsiveFormTemplate({
title,
description,
icon,
headerActions,
isEditMode,
children,
onSave,
onCancel,
saveLabel = "저장",
cancelLabel = "취소",
saveDisabled = false,
saveLoading = false,
showActions = true,
customActions,
className = "",
maxWidth = 'full',
versionInfo,
}: ResponsiveFormTemplateProps) {
return (
<PageLayout maxWidth={maxWidth} versionInfo={versionInfo}>
{/* 헤더 */}
<PageHeader
title={title}
description={description}
icon={icon}
rightActions={headerActions}
isEditMode={isEditMode}
/>
{/* 메인 컨텐츠 */}
<div className={`space-y-6 ${className}`}>
{children}
</div>
{/* 하단 액션 버튼 */}
{showActions && (
<div className="sticky bottom-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t pt-4 pb-4 -mx-3 md:-mx-6 px-3 md:px-6 mt-6">
{customActions || (
<FormActions
onSave={onSave}
onCancel={onCancel}
saveLabel={saveLabel}
cancelLabel={cancelLabel}
saveDisabled={saveDisabled}
saveLoading={saveLoading}
/>
)}
</div>
)}
</PageLayout>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react";
import { cn } from "./utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,366 @@
/**
* 거래처 그룹(ClientGroup) API 훅
*
* 백엔드 API: /api/v1/client-groups
* - GET /client-groups - 목록 조회
* - GET /client-groups/{id} - 단건 조회
* - POST /client-groups - 생성
* - PUT /client-groups/{id} - 수정
* - DELETE /client-groups/{id} - 삭제
* - PATCH /client-groups/{id}/toggle - 활성/비활성 토글
*/
import { useState, useCallback } from 'react';
// 백엔드 API 응답 타입
export interface ClientGroupApiResponse {
id: number;
tenant_id: number;
group_code: string;
group_name: string;
price_rate: string | number; // decimal(10,4)
is_active: boolean | number; // 0 or 1
created_at: string;
updated_at: string;
created_by: number | null;
updated_by: number | null;
}
// 프론트엔드 타입
export interface ClientGroup {
id: string;
code: string;
name: string;
priceRate: number;
status: '활성' | '비활성';
createdAt: string;
updatedAt: string;
}
// 폼 데이터 타입
export interface ClientGroupFormData {
groupCode: string;
groupName: string;
priceRate: number;
isActive?: boolean;
}
// 페이지네이션 정보
export interface PaginationInfo {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
from: number;
to: number;
}
// 훅 반환 타입
export interface UseClientGroupListReturn {
groups: ClientGroup[];
pagination: PaginationInfo | null;
isLoading: boolean;
error: string | null;
fetchGroups: (params?: FetchGroupsParams) => Promise<void>;
fetchGroup: (id: string) => Promise<ClientGroup | null>;
createGroup: (data: ClientGroupFormData) => Promise<ClientGroup>;
updateGroup: (id: string, data: ClientGroupFormData) => Promise<ClientGroup>;
deleteGroup: (id: string) => Promise<void>;
toggleGroupStatus: (id: string) => Promise<void>;
}
// API 요청 파라미터
interface FetchGroupsParams {
page?: number;
size?: number;
q?: string;
onlyActive?: boolean;
}
// API 응답 → 프론트엔드 타입 변환
function transformGroupFromApi(apiGroup: ClientGroupApiResponse): ClientGroup {
// is_active가 boolean 또는 number(0/1)일 수 있음
const isActive = apiGroup.is_active === true || apiGroup.is_active === 1;
return {
id: String(apiGroup.id),
code: apiGroup.group_code || '',
name: apiGroup.group_name || '',
priceRate: Number(apiGroup.price_rate) || 0,
status: isActive ? '활성' : '비활성',
createdAt: apiGroup.created_at || '',
updatedAt: apiGroup.updated_at || '',
};
}
// 프론트엔드 타입 → API 요청 변환 (생성용)
function transformGroupToApiCreate(data: ClientGroupFormData): Record<string, unknown> {
return {
group_code: data.groupCode,
group_name: data.groupName,
price_rate: data.priceRate,
is_active: data.isActive !== false, // 기본값 true
};
}
// 프론트엔드 타입 → API 요청 변환 (수정용)
function transformGroupToApiUpdate(data: ClientGroupFormData): Record<string, unknown> {
return {
group_code: data.groupCode,
group_name: data.groupName,
price_rate: data.priceRate,
is_active: data.isActive,
};
}
/**
* 거래처 그룹 관리 훅
*/
export function useClientGroupList(): UseClientGroupListReturn {
const [groups, setGroups] = useState<ClientGroup[]>([]);
const [pagination, setPagination] = useState<PaginationInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* 거래처 그룹 목록 조회
*/
const fetchGroups = useCallback(async (params?: FetchGroupsParams) => {
setIsLoading(true);
setError(null);
try {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.size) searchParams.set('size', String(params.size));
if (params?.q) searchParams.set('q', params.q);
if (params?.onlyActive !== undefined) {
searchParams.set('only_active', String(params.onlyActive));
}
const queryString = searchParams.toString();
const url = `/api/proxy/client-groups${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(`거래처 그룹 목록 조회 실패: ${response.status}`);
}
const result = await response.json();
// Laravel paginate 응답 구조: { success, data: { current_page, data: [...], last_page, ... } }
if (result.success && result.data) {
const paginatedData = result.data;
const items: ClientGroupApiResponse[] = paginatedData.data || [];
const transformedGroups = items.map(transformGroupFromApi);
setGroups(transformedGroups);
setPagination({
currentPage: paginatedData.current_page || 1,
lastPage: paginatedData.last_page || 1,
perPage: paginatedData.per_page || 20,
total: paginatedData.total || 0,
from: paginatedData.from || 0,
to: paginatedData.to || 0,
});
} else if (Array.isArray(result)) {
// 단순 배열 응답 (페이지네이션 없음)
const transformedGroups = result.map(transformGroupFromApi);
setGroups(transformedGroups);
setPagination(null);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '목록 조회 중 오류가 발생했습니다';
setError(errorMessage);
console.error('fetchGroups error:', err);
} finally {
setIsLoading(false);
}
}, []);
/**
* 거래처 그룹 단건 조회
*/
const fetchGroup = useCallback(async (id: string): Promise<ClientGroup | null> => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/proxy/client-groups/${id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(`거래처 그룹 조회 실패: ${response.status}`);
}
const result = await response.json();
const data = result.data || result;
return transformGroupFromApi(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '조회 중 오류가 발생했습니다';
setError(errorMessage);
console.error('fetchGroup error:', err);
return null;
} finally {
setIsLoading(false);
}
}, []);
/**
* 거래처 그룹 생성
*/
const createGroup = useCallback(async (data: ClientGroupFormData): Promise<ClientGroup> => {
setIsLoading(true);
setError(null);
try {
const apiData = transformGroupToApiCreate(data);
const response = await fetch('/api/proxy/client-groups', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(apiData),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `거래처 그룹 생성 실패: ${response.status}`);
}
const result = await response.json();
const resultData = result.data || result;
return transformGroupFromApi(resultData);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '생성 중 오류가 발생했습니다';
setError(errorMessage);
throw err;
} finally {
setIsLoading(false);
}
}, []);
/**
* 거래처 그룹 수정
*/
const updateGroup = useCallback(async (id: string, data: ClientGroupFormData): Promise<ClientGroup> => {
setIsLoading(true);
setError(null);
try {
const apiData = transformGroupToApiUpdate(data);
const response = await fetch(`/api/proxy/client-groups/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(apiData),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `거래처 그룹 수정 실패: ${response.status}`);
}
const result = await response.json();
const resultData = result.data || result;
return transformGroupFromApi(resultData);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '수정 중 오류가 발생했습니다';
setError(errorMessage);
throw err;
} finally {
setIsLoading(false);
}
}, []);
/**
* 거래처 그룹 삭제
*/
const deleteGroup = useCallback(async (id: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/proxy/client-groups/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `거래처 그룹 삭제 실패: ${response.status}`);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '삭제 중 오류가 발생했습니다';
setError(errorMessage);
throw err;
} finally {
setIsLoading(false);
}
}, []);
/**
* 거래처 그룹 활성/비활성 토글
*/
const toggleGroupStatus = useCallback(async (id: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/proxy/client-groups/${id}/toggle`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `상태 변경 실패: ${response.status}`);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '상태 변경 중 오류가 발생했습니다';
setError(errorMessage);
throw err;
} finally {
setIsLoading(false);
}
}, []);
return {
groups,
pagination,
isLoading,
error,
fetchGroups,
fetchGroup,
createGroup,
updateGroup,
deleteGroup,
toggleGroupStatus,
};
}

530
src/hooks/useClientList.ts Normal file
View File

@@ -0,0 +1,530 @@
"use client";
import { useState, useCallback } from "react";
// ============================================
// 타입 정의
// ============================================
// 거래처 유형
export type ClientType = "매입" | "매출" | "매입매출";
// 악성채권 진행상태
export type BadDebtProgress = "협의중" | "소송중" | "회수완료" | "대손처리" | "";
// 백엔드 API 응답 타입 (확장)
export interface ClientApiResponse {
id: number;
tenant_id: number;
client_group_id: number | null;
client_code: string;
name: string;
contact_person: string | null;
phone: string | null;
email: string | null;
address: string | null;
business_no: string | null;
business_type: string | null;
business_item: string | null;
is_active: "Y" | "N";
created_at: string;
updated_at: string;
// 2차 추가 필드 (백엔드 완료 시 활성화)
client_type?: ClientType;
mobile?: string | null;
fax?: string | null;
manager_name?: string | null;
manager_tel?: string | null;
system_manager?: string | null;
account_id?: string | null;
account_password?: string | null;
purchase_payment_day?: string | null;
sales_payment_day?: string | null;
tax_agreement?: boolean;
tax_amount?: number | null;
tax_start_date?: string | null;
tax_end_date?: string | null;
bad_debt?: boolean;
bad_debt_amount?: number | null;
bad_debt_receive_date?: string | null;
bad_debt_end_date?: string | null;
bad_debt_progress?: BadDebtProgress;
memo?: string | null;
}
// 프론트엔드 타입 (확장)
export interface Client {
id: string;
code: string;
name: string;
businessNo: string;
representative: string; // contact_person
phone: string;
address: string;
email: string;
businessType: string;
businessItem: string;
registeredDate: string;
status: "활성" | "비활성";
groupId: string | null;
groupName?: string;
// 2차 추가 필드
clientType: ClientType;
mobile: string;
fax: string;
managerName: string;
managerTel: string;
systemManager: string;
accountId: string;
accountPassword: string;
purchasePaymentDay: string;
salesPaymentDay: string;
taxAgreement: boolean;
taxAmount: string;
taxStartDate: string;
taxEndDate: string;
badDebt: boolean;
badDebtAmount: string;
badDebtReceiveDate: string;
badDebtEndDate: string;
badDebtProgress: BadDebtProgress;
memo: string;
}
// 페이지네이션 정보
export interface PaginationInfo {
currentPage: number;
lastPage: number;
total: number;
perPage: number;
}
// 검색 파라미터
export interface ClientSearchParams {
page?: number;
size?: number;
q?: string;
onlyActive?: boolean;
}
// 생성/수정 요청 타입 (확장)
export interface ClientFormData {
clientCode?: string;
name: string;
businessNo: string;
representative: string;
phone: string;
address: string;
email: string;
businessType: string;
businessItem: string;
groupId?: string | null;
isActive: boolean;
// 2차 추가 필드
clientType: ClientType;
mobile: string;
fax: string;
managerName: string;
managerTel: string;
systemManager: string;
accountId: string;
accountPassword: string;
purchasePaymentDay: string;
salesPaymentDay: string;
taxAgreement: boolean;
taxAmount: string;
taxStartDate: string;
taxEndDate: string;
badDebt: boolean;
badDebtAmount: string;
badDebtReceiveDate: string;
badDebtEndDate: string;
badDebtProgress: BadDebtProgress;
memo: string;
}
// 폼 초기값
export const INITIAL_CLIENT_FORM: ClientFormData = {
name: "",
businessNo: "",
representative: "",
phone: "",
address: "",
email: "",
businessType: "",
businessItem: "",
groupId: null,
isActive: true,
clientType: "매입",
mobile: "",
fax: "",
managerName: "",
managerTel: "",
systemManager: "",
accountId: "",
accountPassword: "",
purchasePaymentDay: "말일",
salesPaymentDay: "말일",
taxAgreement: false,
taxAmount: "",
taxStartDate: "",
taxEndDate: "",
badDebt: false,
badDebtAmount: "",
badDebtReceiveDate: "",
badDebtEndDate: "",
badDebtProgress: "",
memo: "",
};
// ============================================
// 데이터 변환 유틸리티
// ============================================
// API 응답 → 프론트엔드 타입 변환
export function transformClientFromApi(api: ClientApiResponse): Client {
return {
id: String(api.id),
code: api.client_code,
name: api.name,
representative: api.contact_person || "",
phone: api.phone || "",
email: api.email || "",
address: api.address || "",
businessNo: api.business_no || "",
businessType: api.business_type || "",
businessItem: api.business_item || "",
registeredDate: api.created_at ? api.created_at.split(" ")[0] : "",
status: api.is_active === "Y" ? "활성" : "비활성",
groupId: api.client_group_id ? String(api.client_group_id) : null,
// 2차 추가 필드
clientType: api.client_type || "매입",
mobile: api.mobile || "",
fax: api.fax || "",
managerName: api.manager_name || "",
managerTel: api.manager_tel || "",
systemManager: api.system_manager || "",
accountId: api.account_id || "",
accountPassword: "", // 비밀번호는 조회 시 비움
purchasePaymentDay: api.purchase_payment_day || "말일",
salesPaymentDay: api.sales_payment_day || "말일",
taxAgreement: api.tax_agreement || false,
taxAmount: api.tax_amount ? String(api.tax_amount) : "",
taxStartDate: api.tax_start_date || "",
taxEndDate: api.tax_end_date || "",
badDebt: api.bad_debt || false,
badDebtAmount: api.bad_debt_amount ? String(api.bad_debt_amount) : "",
badDebtReceiveDate: api.bad_debt_receive_date || "",
badDebtEndDate: api.bad_debt_end_date || "",
badDebtProgress: api.bad_debt_progress || "",
memo: api.memo || "",
};
}
// 프론트엔드 → API 요청 변환 (생성)
export function transformClientToApiCreate(form: ClientFormData): Record<string, unknown> {
return {
client_code: form.clientCode,
name: form.name,
contact_person: form.representative || null,
phone: form.phone || null,
email: form.email || null,
address: form.address || null,
business_no: form.businessNo || null,
business_type: form.businessType || null,
business_item: form.businessItem || null,
client_group_id: form.groupId ? Number(form.groupId) : null,
is_active: form.isActive ? "Y" : "N",
// 2차 추가 필드
client_type: form.clientType,
mobile: form.mobile || null,
fax: form.fax || null,
manager_name: form.managerName || null,
manager_tel: form.managerTel || null,
system_manager: form.systemManager || null,
account_id: form.accountId || null,
account_password: form.accountPassword || null,
purchase_payment_day: form.purchasePaymentDay || null,
sales_payment_day: form.salesPaymentDay || null,
tax_agreement: form.taxAgreement,
tax_amount: form.taxAmount ? Number(form.taxAmount) : null,
tax_start_date: form.taxStartDate || null,
tax_end_date: form.taxEndDate || null,
bad_debt: form.badDebt,
bad_debt_amount: form.badDebtAmount ? Number(form.badDebtAmount) : null,
bad_debt_receive_date: form.badDebtReceiveDate || null,
bad_debt_end_date: form.badDebtEndDate || null,
bad_debt_progress: form.badDebtProgress || null,
memo: form.memo || null,
};
}
// 프론트엔드 → API 요청 변환 (수정)
export function transformClientToApiUpdate(form: ClientFormData): Record<string, unknown> {
const data: Record<string, unknown> = {
name: form.name,
contact_person: form.representative || null,
phone: form.phone || null,
email: form.email || null,
address: form.address || null,
business_no: form.businessNo || null,
business_type: form.businessType || null,
business_item: form.businessItem || null,
client_group_id: form.groupId ? Number(form.groupId) : null,
is_active: form.isActive ? "Y" : "N",
// 2차 추가 필드
client_type: form.clientType,
mobile: form.mobile || null,
fax: form.fax || null,
manager_name: form.managerName || null,
manager_tel: form.managerTel || null,
system_manager: form.systemManager || null,
account_id: form.accountId || null,
purchase_payment_day: form.purchasePaymentDay || null,
sales_payment_day: form.salesPaymentDay || null,
tax_agreement: form.taxAgreement,
tax_amount: form.taxAmount ? Number(form.taxAmount) : null,
tax_start_date: form.taxStartDate || null,
tax_end_date: form.taxEndDate || null,
bad_debt: form.badDebt,
bad_debt_amount: form.badDebtAmount ? Number(form.badDebtAmount) : null,
bad_debt_receive_date: form.badDebtReceiveDate || null,
bad_debt_end_date: form.badDebtEndDate || null,
bad_debt_progress: form.badDebtProgress || null,
memo: form.memo || null,
};
// 비밀번호는 입력한 경우에만 전송
if (form.accountPassword) {
data.account_password = form.accountPassword;
}
return data;
}
// Client → ClientFormData 변환 (수정 시 폼 초기화용)
export function clientToFormData(client: Client): ClientFormData {
return {
clientCode: client.code,
name: client.name,
businessNo: client.businessNo,
representative: client.representative,
phone: client.phone,
address: client.address,
email: client.email,
businessType: client.businessType,
businessItem: client.businessItem,
groupId: client.groupId,
isActive: client.status === "활성",
clientType: client.clientType,
mobile: client.mobile,
fax: client.fax,
managerName: client.managerName,
managerTel: client.managerTel,
systemManager: client.systemManager,
accountId: client.accountId,
accountPassword: "", // 비밀번호는 비움
purchasePaymentDay: client.purchasePaymentDay,
salesPaymentDay: client.salesPaymentDay,
taxAgreement: client.taxAgreement,
taxAmount: client.taxAmount,
taxStartDate: client.taxStartDate,
taxEndDate: client.taxEndDate,
badDebt: client.badDebt,
badDebtAmount: client.badDebtAmount,
badDebtReceiveDate: client.badDebtReceiveDate,
badDebtEndDate: client.badDebtEndDate,
badDebtProgress: client.badDebtProgress,
memo: client.memo,
};
}
// ============================================
// useClientList 훅
// ============================================
export function useClientList() {
const [clients, setClients] = useState<Client[]>([]);
const [pagination, setPagination] = useState<PaginationInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 목록 조회
const fetchClients = useCallback(async (params: ClientSearchParams = {}) => {
setIsLoading(true);
setError(null);
try {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page));
if (params.size) searchParams.set("size", String(params.size));
if (params.q) searchParams.set("q", params.q);
if (params.onlyActive !== undefined) {
searchParams.set("only_active", params.onlyActive ? "1" : "0");
}
const response = await fetch(`/api/proxy/clients?${searchParams.toString()}`);
if (!response.ok) {
throw new Error(`API 오류: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
const apiClients: ClientApiResponse[] = result.data.data || [];
const transformedClients = apiClients.map(transformClientFromApi);
setClients(transformedClients);
setPagination({
currentPage: result.data.current_page || 1,
lastPage: result.data.last_page || 1,
total: result.data.total || 0,
perPage: result.data.per_page || 20,
});
} else {
throw new Error(result.message || "데이터 조회 실패");
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류";
setError(errorMessage);
setClients([]);
setPagination(null);
} finally {
setIsLoading(false);
}
}, []);
// 단건 조회
const fetchClient = useCallback(async (id: string): Promise<Client | null> => {
try {
const response = await fetch(`/api/proxy/clients/${id}`);
if (!response.ok) {
throw new Error(`API 오류: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
return transformClientFromApi(result.data);
}
return null;
} catch (err) {
console.error("거래처 조회 실패:", err);
return null;
}
}, []);
// 생성
const createClient = useCallback(async (formData: ClientFormData): Promise<Client | null> => {
try {
const response = await fetch("/api/proxy/clients", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(transformClientToApiCreate(formData)),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `API 오류: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
return transformClientFromApi(result.data);
}
return null;
} catch (err) {
console.error("거래처 생성 실패:", err);
throw err;
}
}, []);
// 수정
const updateClient = useCallback(async (id: string, formData: ClientFormData): Promise<Client | null> => {
try {
const response = await fetch(`/api/proxy/clients/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(transformClientToApiUpdate(formData)),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `API 오류: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
return transformClientFromApi(result.data);
}
return null;
} catch (err) {
console.error("거래처 수정 실패:", err);
throw err;
}
}, []);
// 삭제
const deleteClient = useCallback(async (id: string): Promise<boolean> => {
try {
const response = await fetch(`/api/proxy/clients/${id}`, {
method: "DELETE",
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `API 오류: ${response.status}`);
}
return true;
} catch (err) {
console.error("거래처 삭제 실패:", err);
throw err;
}
}, []);
// 활성/비활성 토글
const toggleClientStatus = useCallback(async (id: string): Promise<Client | null> => {
try {
const response = await fetch(`/api/proxy/clients/${id}/toggle`, {
method: "PATCH",
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `API 오류: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
return transformClientFromApi(result.data);
}
return null;
} catch (err) {
console.error("거래처 상태 변경 실패:", err);
throw err;
}
}, []);
return {
// 상태
clients,
pagination,
isLoading,
error,
// 액션
fetchClients,
fetchClient,
createClient,
updateClient,
deleteClient,
toggleClientStatus,
// 유틸리티
setClients,
};
}

View File

@@ -311,7 +311,7 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
{/* 데스크톱 사이드바 (모바일에서 숨김) */}
<div
className={`sticky top-[106px] self-start h-[calc(100vh-118px)] mt-3 border-none bg-transparent hidden md:block transition-all duration-300 flex-shrink-0 ${
sidebarCollapsed ? 'w-24' : 'w-80'
sidebarCollapsed ? 'w-24' : 'w-64'
}`}
>
<Sidebar

View File

@@ -41,9 +41,9 @@ export const AUTH_CONFIG = {
],
// 게스트 전용 라우트 (로그인 후 접근 불가)
// 2025-12-04: MVP에서 /signup 제거 (운영 페이지로 이동 예정)
guestOnlyRoutes: [
'/login',
'/signup',
'/forgot-password',
],

View File

@@ -239,6 +239,13 @@ export function middleware(request: NextRequest) {
// 4⃣ 인증 체크
const { isAuthenticated, authMode } = checkAuthentication(request);
// 4.5️⃣ MVP: /signup 접근 차단 → /login 리다이렉트 (2025-12-04)
// 회원가입 기능은 운영 페이지로 이동 예정
if (pathnameWithoutLocale === '/signup' || pathnameWithoutLocale.startsWith('/signup/')) {
console.log(`[Signup Blocked] Redirecting to /login from ${pathname}`);
return NextResponse.redirect(new URL('/login', request.url));
}
// 5⃣ 게스트 전용 라우트 (로그인/회원가입)
if (isGuestOnlyRoute(pathnameWithoutLocale)) {
// 이미 로그인한 경우 대시보드로