feat(WEB): 거래처 DevFill 자동 채우기 기능 추가
- 거래처 샘플 데이터 생성기 추가 (clientData.ts) - DevFillContext에 'client' 페이지 타입 추가 - DevToolbar에 기준정보 섹션 (녹색 테마) 추가 - IntegratedDetailTemplate forwardRef 지원으로 외부 폼 데이터 조작 가능 - ClientDetailClientV2에서 DevFill 등록 로직 구현
This commit is contained in:
@@ -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<IntegratedDetailTemplateRef>(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<string, unknown>);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<IntegratedDetailTemplate
|
||||
ref={templateRef}
|
||||
config={dynamicConfig}
|
||||
mode={mode}
|
||||
initialData={initialData}
|
||||
|
||||
@@ -17,7 +17,8 @@ import React, { createContext, useContext, useState, useCallback, useEffect, Rea
|
||||
// 지원하는 페이지 타입
|
||||
export type DevFillPageType =
|
||||
| 'quote' | 'order' | 'workOrder' | 'workOrderComplete' | 'shipment' // 판매/생산 플로우
|
||||
| 'deposit' | 'withdrawal' | 'purchaseApproval' | 'cardTransaction'; // 회계 플로우
|
||||
| 'deposit' | 'withdrawal' | 'purchaseApproval' | 'cardTransaction' // 회계 플로우
|
||||
| 'client'; // 기준정보
|
||||
|
||||
// 폼 채우기 함수 타입
|
||||
type FillFormFunction = (data?: unknown) => void | Promise<void>;
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기준정보 버튼 영역 */}
|
||||
{isExpanded && (
|
||||
<div className="flex items-center gap-2 px-3 pb-3 border-t border-yellow-300 pt-3">
|
||||
<span className="text-xs text-yellow-600 font-medium mr-1">기준:</span>
|
||||
{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 (
|
||||
<Button
|
||||
key={step.type}
|
||||
size="sm"
|
||||
variant="default"
|
||||
disabled={!isRegistered || isCurrentLoading}
|
||||
className="bg-green-500 hover:bg-green-600 text-white border-green-600"
|
||||
onClick={() => handleFillForm(step.type)}
|
||||
title="폼 자동 채우기"
|
||||
>
|
||||
{isCurrentLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Icon className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{step.label} 채우기
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// 비활성화된 페이지: 해당 페이지로 이동
|
||||
return (
|
||||
<Button
|
||||
key={step.type}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={`border-green-300 text-green-700 hover:bg-green-100 hover:border-green-500 ${isActive ? 'bg-green-100 border-green-500' : ''}`}
|
||||
onClick={() => handleNavigate(step.path)}
|
||||
title={`${step.label} 페이지로 이동`}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-1" />
|
||||
{step.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
{isExpanded && !activePage && (
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-xs text-yellow-600">
|
||||
견적/수주/작업지시/출하/입금/출금/매입 페이지에서 자동 채우기가 활성화됩니다
|
||||
견적/수주/작업지시/출하/입금/출금/매입/거래처 페이지에서 자동 채우기가 활성화됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
165
src/components/dev/generators/clientData.ts
Normal file
165
src/components/dev/generators/clientData.ts
Normal file
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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<T extends Record<string, unknown>>({
|
||||
config,
|
||||
mode: initialMode,
|
||||
initialData,
|
||||
itemId,
|
||||
isLoading = false,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
onCancel,
|
||||
onModeChange,
|
||||
onEdit: onEditProp,
|
||||
renderView,
|
||||
renderForm,
|
||||
renderField,
|
||||
headerActions,
|
||||
beforeContent,
|
||||
afterContent,
|
||||
buttonPosition = 'bottom',
|
||||
}: IntegratedDetailTemplateProps<T>) {
|
||||
// Inner component with forwardRef
|
||||
function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
{
|
||||
config,
|
||||
mode: initialMode,
|
||||
initialData,
|
||||
itemId,
|
||||
isLoading = false,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
onCancel,
|
||||
onModeChange,
|
||||
onEdit: onEditProp,
|
||||
renderView,
|
||||
renderForm,
|
||||
renderField,
|
||||
headerActions,
|
||||
beforeContent,
|
||||
afterContent,
|
||||
buttonPosition = 'bottom',
|
||||
}: IntegratedDetailTemplateProps<T>,
|
||||
ref: React.ForwardedRef<IntegratedDetailTemplateRef>
|
||||
) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const locale = (params.locale as string) || 'ko';
|
||||
@@ -56,6 +61,47 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [dynamicOptions, setDynamicOptions] = useState<Record<string, FieldOption[]>>({});
|
||||
|
||||
// ===== Ref 메서드 노출 (DevFill 등에서 사용) =====
|
||||
useImperativeHandle(ref, () => ({
|
||||
setFormData: (data: Record<string, unknown>) => {
|
||||
setFormData(prev => ({ ...prev, ...data }));
|
||||
},
|
||||
getFormData: () => formData,
|
||||
setFieldValue: (key: string, value: unknown) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }));
|
||||
},
|
||||
validate: () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
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<string, unknown>);
|
||||
setFormData(transformed);
|
||||
} else {
|
||||
const defaultData: Record<string, unknown> = {};
|
||||
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<T extends Record<string, unknown>>({
|
||||
}
|
||||
}
|
||||
|
||||
// forwardRef wrapper with generic support
|
||||
export const IntegratedDetailTemplate = forwardRef(IntegratedDetailTemplateInner) as <
|
||||
T extends Record<string, unknown>
|
||||
>(
|
||||
props: IntegratedDetailTemplateProps<T> & { ref?: React.ForwardedRef<IntegratedDetailTemplateRef> }
|
||||
) => React.ReactElement;
|
||||
|
||||
// ===== 유효성 검사 헬퍼 =====
|
||||
function validateRule(
|
||||
rule: ValidationRule,
|
||||
|
||||
@@ -229,3 +229,17 @@ export interface ApiResponse<T = unknown> {
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ===== Ref 타입 (외부에서 폼 데이터 조작용) =====
|
||||
export interface IntegratedDetailTemplateRef {
|
||||
/** 폼 데이터 설정 (DevFill 등에서 사용) */
|
||||
setFormData: (data: Record<string, unknown>) => void;
|
||||
/** 현재 폼 데이터 가져오기 */
|
||||
getFormData: () => Record<string, unknown>;
|
||||
/** 특정 필드 값 변경 */
|
||||
setFieldValue: (key: string, value: unknown) => void;
|
||||
/** 폼 유효성 검사 실행 */
|
||||
validate: () => boolean;
|
||||
/** 폼 초기화 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user