From 1ba0561ee9f31130c29ea6937e373cd5d06f9363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 22 Jan 2026 23:03:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EA=B1=B0=EB=9E=98=EC=B2=98=20DevF?= =?UTF-8?q?ill=20=EC=9E=90=EB=8F=99=20=EC=B1=84=EC=9A=B0=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 거래처 샘플 데이터 생성기 추가 (clientData.ts) - DevFillContext에 'client' 페이지 타입 추가 - DevToolbar에 기준정보 섹션 (녹색 테마) 추가 - IntegratedDetailTemplate forwardRef 지원으로 외부 폼 데이터 조작 가능 - ClientDetailClientV2에서 DevFill 등록 로직 구현 --- .../clients/ClientDetailClientV2.tsx | 27 ++- src/components/dev/DevFillContext.tsx | 3 +- src/components/dev/DevToolbar.tsx | 61 ++++++- src/components/dev/generators/clientData.ts | 165 ++++++++++++++++++ .../IntegratedDetailTemplate/index.tsx | 93 +++++++--- .../IntegratedDetailTemplate/types.ts | 14 ++ 6 files changed, 339 insertions(+), 24 deletions(-) create mode 100644 src/components/dev/generators/clientData.ts diff --git a/src/components/clients/ClientDetailClientV2.tsx b/src/components/clients/ClientDetailClientV2.tsx index 312d74ae..5bc1cd8c 100644 --- a/src/components/clients/ClientDetailClientV2.tsx +++ b/src/components/clients/ClientDetailClientV2.tsx @@ -7,14 +7,16 @@ * 클라이언트 사이드 데이터 페칭 (useClientList 훅 활용) */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; -import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types'; +import type { DetailMode, IntegratedDetailTemplateRef } from '@/components/templates/IntegratedDetailTemplate/types'; import type { Client, ClientFormData } from '@/hooks/useClientList'; import { useClientList, transformClientToApiCreate, transformClientToApiUpdate } from '@/hooks/useClientList'; import { clientDetailConfig } from './clientDetailConfig'; import { toast } from 'sonner'; +import { useDevFillContext } from '@/components/dev/DevFillContext'; +import { generateClientData } from '@/components/dev/generators/clientData'; interface ClientDetailClientV2Props { clientId?: string; @@ -35,6 +37,8 @@ export function ClientDetailClientV2({ clientId, initialMode }: ClientDetailClie const router = useRouter(); const searchParams = useSearchParams(); const { fetchClient, createClient, updateClient, deleteClient } = useClientList(); + const templateRef = useRef(null); + const { isEnabled, registerFillForm, unregisterFillForm } = useDevFillContext(); // URL 쿼리에서 모드 결정 const modeFromQuery = searchParams.get('mode') as DetailMode | null; @@ -92,6 +96,24 @@ export function ClientDetailClientV2({ clientId, initialMode }: ClientDetailClie } }, [modeFromQuery, isNewMode]); + // DevFill 등록 (신규 등록 모드일 때만) + useEffect(() => { + if (!isEnabled || !isNewMode) return; + + const handleDevFill = () => { + const data = generateClientData(); + if (templateRef.current) { + templateRef.current.setFormData(data as unknown as Record); + } + }; + + registerFillForm('client', handleDevFill); + + return () => { + unregisterFillForm('client'); + }; + }, [isEnabled, isNewMode, registerFillForm, unregisterFillForm]); + // 모드 변경 핸들러 const handleModeChange = useCallback( (newMode: DetailMode) => { @@ -215,6 +237,7 @@ export function ClientDetailClientV2({ clientId, initialMode }: ClientDetailClie return ( void | Promise; diff --git a/src/components/dev/DevToolbar.tsx b/src/components/dev/DevToolbar.tsx index f2940a46..5a3d0d29 100644 --- a/src/components/dev/DevToolbar.tsx +++ b/src/components/dev/DevToolbar.tsx @@ -33,6 +33,8 @@ import { ArrowUpFromLine, // 출금 Receipt, // 매입(지출결의서) CreditCard, // 카드 + // 기준정보 아이콘 + Building2, // 거래처 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -55,6 +57,8 @@ const PAGE_PATTERNS: { pattern: RegExp; type: DevFillPageType; label: string }[] { pattern: /\/accounting\/withdrawals\/new/, type: 'withdrawal', label: '출금' }, { pattern: /\/approval\/draft\/new/, type: 'purchaseApproval', label: '매입' }, { pattern: /\/accounting\/card-transactions\/new/, type: 'cardTransaction', label: '카드' }, + // 기준정보 + { pattern: /\/client-management-sales-admin\/new/, type: 'client', label: '거래처' }, ]; // 플로우 단계 정의 @@ -74,6 +78,11 @@ const ACCOUNTING_STEPS: { type: DevFillPageType; label: string; icon: typeof Fil { type: 'cardTransaction', label: '카드', icon: CreditCard, path: '/accounting/card-transactions/new', fillEnabled: true }, ]; +// 기준정보 단계 정의 +const MASTER_DATA_STEPS: { type: DevFillPageType; label: string; icon: typeof FileText; path: string; fillEnabled: boolean }[] = [ + { type: 'client', label: '거래처', icon: Building2, path: '/sales/client-management-sales-admin/new', fillEnabled: true }, +]; + export function DevToolbar() { const pathname = usePathname(); const router = useRouter(); @@ -324,11 +333,61 @@ export function DevToolbar() { )} + {/* 기준정보 버튼 영역 */} + {isExpanded && ( +
+ 기준: + {MASTER_DATA_STEPS.map((step) => { + const Icon = step.icon; + const isActive = activePage === step.type; + const isRegistered = hasRegisteredForm(step.type); + const isCurrentLoading = isLoading === step.type; + + // 활성화된 페이지: 폼 채우기 (fillEnabled가 true인 경우만) + if (isActive && step.fillEnabled) { + return ( + + ); + } + + // 비활성화된 페이지: 해당 페이지로 이동 + return ( + + ); + })} +
+ )} + {/* 안내 메시지 */} {isExpanded && !activePage && (

- 견적/수주/작업지시/출하/입금/출금/매입 페이지에서 자동 채우기가 활성화됩니다 + 견적/수주/작업지시/출하/입금/출금/매입/거래처 페이지에서 자동 채우기가 활성화됩니다

)} diff --git a/src/components/dev/generators/clientData.ts b/src/components/dev/generators/clientData.ts new file mode 100644 index 00000000..a016119f --- /dev/null +++ b/src/components/dev/generators/clientData.ts @@ -0,0 +1,165 @@ +/** + * 거래처 샘플 데이터 생성기 + */ + +import { + randomPick, + randomInt, + randomPhone, +} from './index'; + +// ===== 상수 정의 ===== + +// 회사명 접두어 +const COMPANY_PREFIXES = [ + '(주)', '주식회사 ', '', '(유)', '유한회사 ', +]; + +// 회사명 +const COMPANY_NAMES = [ + '대한철강', '신성산업', '한국정밀', '동양기계', '서울금속', + '부산화학', '인천전자', '광주물산', '대구섬유', '울산중공업', + '경기식품', '강원목재', '충청농산', '전북수산', '제주관광', + '삼성전자', 'LG화학', '현대중공업', 'SK하이닉스', '포스코', + '한화솔루션', '롯데케미칼', 'CJ대한통운', 'GS칼텍스', 'S-Oil', +]; + +// 대표자 성 +const LAST_NAMES = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임', '한', '오', '서', '신', '권', '황', '안', '송', '류', '홍']; + +// 대표자 이름 +const FIRST_NAMES = ['영수', '민수', '철수', '영희', '민희', '수진', '지영', '성호', '현우', '준호', '서연', '지민', '하늘', '도윤', '서준', '예준', '민준', '지후', '수아', '하윤']; + +// 업태 +const BUSINESS_TYPES = [ + '제조업', '도소매업', '건설업', '서비스업', '운수업', + '정보통신업', '금융업', '부동산업', '전문서비스업', '교육서비스업', +]; + +// 종목 +const BUSINESS_ITEMS = [ + '철강', '기계', '전자부품', '화학제품', '섬유', + '식품', '자동차부품', '건설자재', '플라스틱', '금속가공', + '소프트웨어', '컨설팅', '물류', '무역', '반도체', +]; + +// 거래처 유형 +const CLIENT_TYPES = ['매입', '매출', '매입매출'] as const; + +// 주소 - 시도 +const CITIES = ['서울', '부산', '인천', '대구', '광주', '대전', '울산', '세종', '경기', '강원', '충북', '충남', '전북', '전남', '경북', '경남', '제주']; + +// 주소 - 구/시 +const DISTRICTS = ['강남구', '서초구', '송파구', '중구', '종로구', '영등포구', '마포구', '성동구', '동작구', '관악구']; + +// 주소 - 상세 +const STREET_NAMES = ['테헤란로', '강남대로', '영동대로', '올림픽로', '삼성로', '선릉로', '봉은사로', '도산대로', '언주로', '논현로']; + +// 담당자명 +const MANAGER_NAMES = ['김담당', '이과장', '박대리', '최주임', '정사원', '강매니저', '조팀장', '윤실장', '장차장', '임부장']; + +// 메모 +const MEMOS = [ + '우량 거래처', + '장기 거래 예정', + '결제 조건 협의 필요', + '월 정기 발주 거래처', + '신규 거래처 - 신용 확인 필요', + '현금 결제 선호', + '분기별 결제', + '', +]; + +// ===== 유틸리티 함수 ===== + +// 랜덤 사업자등록번호 생성 (형식: 000-00-00000) +function generateBusinessNo(): string { + const part1 = String(randomInt(100, 999)); + const part2 = String(randomInt(10, 99)); + const part3 = String(randomInt(10000, 99999)); + return `${part1}-${part2}-${part3}`; +} + +// 랜덤 이름 생성 +function generateName(): string { + return randomPick(LAST_NAMES) + randomPick(FIRST_NAMES); +} + +// 랜덤 회사명 생성 +function generateCompanyName(): string { + return randomPick(COMPANY_PREFIXES) + randomPick(COMPANY_NAMES); +} + +// 랜덤 주소 생성 +function generateAddress(): string { + const city = randomPick(CITIES); + const district = randomPick(DISTRICTS); + const street = randomPick(STREET_NAMES); + const buildingNo = randomInt(1, 500); + return `${city} ${district} ${street} ${buildingNo}`; +} + +// 랜덤 이메일 생성 +function generateEmail(companyName: string): string { + const domains = ['company.com', 'corp.co.kr', 'business.com', 'enterprise.kr', 'inc.co.kr']; + // 회사명에서 영문/숫자만 추출하거나 기본값 사용 + const cleanName = companyName.replace(/[^a-zA-Z0-9]/g, '').toLowerCase() || 'contact'; + const prefix = cleanName.slice(0, 10) || 'info'; + return `${prefix}@${randomPick(domains)}`; +} + +// 랜덤 팩스번호 생성 +function generateFax(): string { + const areaCode = randomPick(['02', '031', '032', '051', '053', '062', '042', '052']); + const middle = randomInt(100, 999); + const last = randomInt(1000, 9999); + return `${areaCode}-${middle}-${last}`; +} + +// ===== 타입 정의 ===== + +export interface ClientFormData { + businessNo: string; + clientCode: string; + name: string; + representative: string; + clientType: '매입' | '매출' | '매입매출'; + businessType: string; + businessItem: string; + address: string; + phone: string; + mobile: string; + fax: string; + email: string; + managerName: string; + managerTel: string; + systemManager: string; + memo: string; + isActive: string; // 'true' | 'false' +} + +// ===== 메인 생성 함수 ===== + +export function generateClientData(): ClientFormData { + const companyName = generateCompanyName(); + + return { + businessNo: generateBusinessNo(), + clientCode: '', // 자동 생성되므로 빈 값 + name: companyName, + representative: generateName(), + clientType: randomPick(CLIENT_TYPES), + businessType: randomPick(BUSINESS_TYPES), + businessItem: randomPick(BUSINESS_ITEMS), + address: generateAddress(), + phone: generateFax(), // 회사 전화는 지역번호 형식 + mobile: randomPhone(), + fax: generateFax(), + email: generateEmail(companyName), + managerName: randomPick(MANAGER_NAMES), + managerTel: randomPhone(), + systemManager: randomPick(MANAGER_NAMES), + memo: randomPick(MEMOS), + isActive: 'true', + }; +} \ No newline at end of file diff --git a/src/components/templates/IntegratedDetailTemplate/index.tsx b/src/components/templates/IntegratedDetailTemplate/index.tsx index d2572a72..2d75f35e 100644 --- a/src/components/templates/IntegratedDetailTemplate/index.tsx +++ b/src/components/templates/IntegratedDetailTemplate/index.tsx @@ -9,7 +9,7 @@ * - 커스텀 렌더러 지원 (renderView, renderForm, renderField) */ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; import { PageLayout } from '@/components/organisms/PageLayout'; @@ -19,31 +19,36 @@ import { FieldInput } from './FieldInput'; import { DetailSection, DetailGrid, DetailField, DetailActions, DetailSectionSkeleton } from './components'; import type { IntegratedDetailTemplateProps, + IntegratedDetailTemplateRef, DetailMode, FieldDefinition, FieldOption, ValidationRule, } from './types'; -export function IntegratedDetailTemplate>({ - config, - mode: initialMode, - initialData, - itemId, - isLoading = false, - onSubmit, - onDelete, - onCancel, - onModeChange, - onEdit: onEditProp, - renderView, - renderForm, - renderField, - headerActions, - beforeContent, - afterContent, - buttonPosition = 'bottom', -}: IntegratedDetailTemplateProps) { +// Inner component with forwardRef +function IntegratedDetailTemplateInner>( + { + config, + mode: initialMode, + initialData, + itemId, + isLoading = false, + onSubmit, + onDelete, + onCancel, + onModeChange, + onEdit: onEditProp, + renderView, + renderForm, + renderField, + headerActions, + beforeContent, + afterContent, + buttonPosition = 'bottom', + }: IntegratedDetailTemplateProps, + ref: React.ForwardedRef +) { const router = useRouter(); const params = useParams(); const locale = (params.locale as string) || 'ko'; @@ -56,6 +61,47 @@ export function IntegratedDetailTemplate>({ const [isSubmitting, setIsSubmitting] = useState(false); const [dynamicOptions, setDynamicOptions] = useState>({}); + // ===== Ref 메서드 노출 (DevFill 등에서 사용) ===== + useImperativeHandle(ref, () => ({ + setFormData: (data: Record) => { + setFormData(prev => ({ ...prev, ...data })); + }, + getFormData: () => formData, + setFieldValue: (key: string, value: unknown) => { + setFormData(prev => ({ ...prev, [key]: value })); + }, + validate: () => { + const newErrors: Record = {}; + config.fields.forEach((field) => { + const value = formData[field.key]; + if (field.required && (value === null || value === undefined || value === '')) { + newErrors[field.key] = `${field.label}은(는) 필수입니다.`; + } + }); + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }, + reset: () => { + if (initialData) { + const transformed = config.transformInitialData + ? config.transformInitialData(initialData) + : (initialData as Record); + setFormData(transformed); + } else { + const defaultData: Record = {}; + config.fields.forEach((field) => { + if (field.type === 'checkbox') { + defaultData[field.key] = false; + } else { + defaultData[field.key] = ''; + } + }); + setFormData(defaultData); + } + setErrors({}); + }, + }), [formData, config, initialData]); + // ===== 권한 계산 ===== const permissions = useMemo(() => { const p = config.permissions || {}; @@ -522,6 +568,13 @@ export function IntegratedDetailTemplate>({ } } +// forwardRef wrapper with generic support +export const IntegratedDetailTemplate = forwardRef(IntegratedDetailTemplateInner) as < + T extends Record +>( + props: IntegratedDetailTemplateProps & { ref?: React.ForwardedRef } +) => React.ReactElement; + // ===== 유효성 검사 헬퍼 ===== function validateRule( rule: ValidationRule, diff --git a/src/components/templates/IntegratedDetailTemplate/types.ts b/src/components/templates/IntegratedDetailTemplate/types.ts index 00125cbd..283cce0d 100644 --- a/src/components/templates/IntegratedDetailTemplate/types.ts +++ b/src/components/templates/IntegratedDetailTemplate/types.ts @@ -229,3 +229,17 @@ export interface ApiResponse { error?: string; message?: string; } + +// ===== Ref 타입 (외부에서 폼 데이터 조작용) ===== +export interface IntegratedDetailTemplateRef { + /** 폼 데이터 설정 (DevFill 등에서 사용) */ + setFormData: (data: Record) => void; + /** 현재 폼 데이터 가져오기 */ + getFormData: () => Record; + /** 특정 필드 값 변경 */ + setFieldValue: (key: string, value: unknown) => void; + /** 폼 유효성 검사 실행 */ + validate: () => boolean; + /** 폼 초기화 */ + reset: () => void; +}