refactor(WEB): 폼 템플릿 통합 및 미사용 컴포넌트 정리
- ResponsiveFormTemplate → IntegratedDetailTemplate 마이그레이션 - ClientRegistration, OrderRegistration, QuoteRegistration 완료 - QuoteRegistrationV2 미사용 import 정리 - 미사용 컴포넌트 삭제 - ListPageTemplate.tsx - ResponsiveFormTemplate.tsx - common/DataTable 폴더 전체 (SearchFilter 누락 export 에러 해결) - Config 파일 추가 - clientConfig.ts, orderConfig.ts - quoteConfig.ts에 edit 모드 config 추가 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,47 +1,41 @@
|
||||
/**
|
||||
* 거래처 등록/수정 컴포넌트
|
||||
*
|
||||
* ResponsiveFormTemplate 적용
|
||||
* IntegratedDetailTemplate 마이그레이션 (2026-01-20)
|
||||
* - 데스크톱/태블릿/모바일 통합 폼 레이아웃
|
||||
* - 섹션 기반 정보 입력
|
||||
* - 유효성 검사 및 에러 표시
|
||||
*/
|
||||
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
|
||||
import { Label } from "../ui/label";
|
||||
import {
|
||||
Building2,
|
||||
UserCircle,
|
||||
Phone,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription } from "../ui/alert";
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
name: "거래처명",
|
||||
businessNo: "사업자등록번호",
|
||||
representative: "대표자명",
|
||||
phone: "전화번호",
|
||||
email: "이메일",
|
||||
};
|
||||
import {
|
||||
ResponsiveFormTemplate,
|
||||
FormSection,
|
||||
FormFieldGrid,
|
||||
} from "../templates/ResponsiveFormTemplate";
|
||||
import { FormField } from "../molecules/FormField";
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
||||
import { Label } from '../ui/label';
|
||||
import { Building2, UserCircle, Phone, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Alert, AlertDescription } from '../ui/alert';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { clientCreateConfig, clientEditConfig } from './clientConfig';
|
||||
import { FormSection } from '../organisms/FormSection';
|
||||
import { FormFieldGrid } from '../organisms/FormFieldGrid';
|
||||
import { FormField } from '../molecules/FormField';
|
||||
import {
|
||||
ClientFormData,
|
||||
INITIAL_CLIENT_FORM,
|
||||
ClientType,
|
||||
} from "../../hooks/useClientList";
|
||||
} from '../../hooks/useClientList';
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
name: '거래처명',
|
||||
businessNo: '사업자등록번호',
|
||||
representative: '대표자명',
|
||||
phone: '전화번호',
|
||||
email: '이메일',
|
||||
};
|
||||
|
||||
interface ClientRegistrationProps {
|
||||
onBack: () => void;
|
||||
@@ -52,9 +46,9 @@ interface ClientRegistrationProps {
|
||||
|
||||
// 4자리 영문+숫자 조합 코드 생성 (중복 방지를 위해 타임스탬프 기반)
|
||||
const generateClientCode = (): string => {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const timestamp = Date.now().toString(36).toUpperCase().slice(-2); // 타임스탬프 2자리
|
||||
let random = "";
|
||||
let random = '';
|
||||
for (let i = 0; i < 2; i++) {
|
||||
random += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
@@ -80,6 +74,8 @@ export function ClientRegistration({
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const isEditMode = !!editingClient;
|
||||
|
||||
// editingClient가 변경되면 formData 업데이트
|
||||
useEffect(() => {
|
||||
if (editingClient) {
|
||||
@@ -92,40 +88,34 @@ export function ClientRegistration({
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name || formData.name.length < 2) {
|
||||
newErrors.name = "거래처명은 2자 이상 입력해주세요";
|
||||
newErrors.name = '거래처명은 2자 이상 입력해주세요';
|
||||
}
|
||||
|
||||
// 하이픈 제거 후 10자리 숫자 검증 (123-45-67890 또는 1234567890 허용)
|
||||
const businessNoDigits = formData.businessNo.replace(/-/g, "").trim();
|
||||
console.log("[ClientRegistration] businessNo 검증:", {
|
||||
원본: formData.businessNo,
|
||||
하이픈제거: businessNoDigits,
|
||||
길이: businessNoDigits.length,
|
||||
숫자만: /^\d{10}$/.test(businessNoDigits),
|
||||
});
|
||||
const businessNoDigits = formData.businessNo.replace(/-/g, '').trim();
|
||||
if (!formData.businessNo || !/^\d{10}$/.test(businessNoDigits)) {
|
||||
newErrors.businessNo = "사업자등록번호는 10자리 숫자여야 합니다";
|
||||
newErrors.businessNo = '사업자등록번호는 10자리 숫자여야 합니다';
|
||||
}
|
||||
|
||||
if (!formData.representative || formData.representative.length < 2) {
|
||||
newErrors.representative = "대표자명은 2자 이상 입력해주세요";
|
||||
newErrors.representative = '대표자명은 2자 이상 입력해주세요';
|
||||
}
|
||||
|
||||
// 전화번호 형식 검사 (선택적)
|
||||
const phonePattern = /^[0-9-]+$/;
|
||||
if (formData.phone && !phonePattern.test(formData.phone)) {
|
||||
newErrors.phone = "올바른 전화번호 형식이 아닙니다";
|
||||
newErrors.phone = '올바른 전화번호 형식이 아닙니다';
|
||||
}
|
||||
|
||||
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = "올바른 이메일 형식이 아닙니다";
|
||||
newErrors.email = '올바른 이메일 형식이 아닙니다';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validateForm()) {
|
||||
// 페이지 상단으로 스크롤
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
@@ -138,21 +128,20 @@ export function ClientRegistration({
|
||||
try {
|
||||
await onSave(formData);
|
||||
toast.success(
|
||||
editingClient ? "거래처가 수정되었습니다." : "거래처가 등록되었습니다."
|
||||
editingClient ? '거래처가 수정되었습니다.' : '거래처가 등록되었습니다.'
|
||||
);
|
||||
onBack();
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
}, [formData, editingClient, onSave, onBack]);
|
||||
|
||||
const handleFieldChange = (
|
||||
field: keyof ClientFormData,
|
||||
value: string | boolean
|
||||
) => {
|
||||
console.log("[ClientRegistration] handleFieldChange:", field, value);
|
||||
setFormData({ ...formData, [field]: value });
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
@@ -163,304 +152,314 @@ export function ClientRegistration({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveFormTemplate
|
||||
title={editingClient ? "거래처 수정" : "거래처 등록"}
|
||||
description="거래처 정보를 입력하세요"
|
||||
icon={Building2}
|
||||
onSave={handleSubmit}
|
||||
onCancel={onBack}
|
||||
saveLabel={editingClient ? "수정" : "등록"}
|
||||
isEditMode={!!editingClient}
|
||||
saveLoading={isSaving || isLoading}
|
||||
saveDisabled={isSaving || isLoading}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200 mb-6">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(errors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(errors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
// Config 선택
|
||||
const config = isEditMode ? clientEditConfig : clientCreateConfig;
|
||||
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = useCallback(
|
||||
() => (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(errors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(errors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 1. 기본 정보 */}
|
||||
<FormSection
|
||||
title="기본 정보"
|
||||
description="거래처의 기본 정보를 입력하세요"
|
||||
icon={Building2}
|
||||
>
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField
|
||||
label="사업자등록번호"
|
||||
required
|
||||
error={errors.businessNo}
|
||||
htmlFor="businessNo"
|
||||
type="custom"
|
||||
>
|
||||
{/* 1. 기본 정보 */}
|
||||
<FormSection
|
||||
title="기본 정보"
|
||||
description="거래처의 기본 정보를 입력하세요"
|
||||
icon={Building2}
|
||||
>
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField
|
||||
label="사업자등록번호"
|
||||
required
|
||||
error={errors.businessNo}
|
||||
htmlFor="businessNo"
|
||||
type="custom"
|
||||
>
|
||||
<Input
|
||||
id="businessNo"
|
||||
placeholder="10자리 숫자 (예: 123-45-67890)"
|
||||
value={formData.businessNo}
|
||||
onChange={(e) => handleFieldChange('businessNo', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="거래처 코드"
|
||||
htmlFor="clientCode"
|
||||
helpText="자동 생성됩니다"
|
||||
type="custom"
|
||||
>
|
||||
<Input
|
||||
id="clientCode"
|
||||
placeholder="자동생성"
|
||||
value={formData.clientCode || ''}
|
||||
disabled
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField
|
||||
label="거래처명"
|
||||
required
|
||||
error={errors.name}
|
||||
htmlFor="name"
|
||||
type="custom"
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="거래처명 입력"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleFieldChange('name', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="대표자명"
|
||||
required
|
||||
error={errors.representative}
|
||||
htmlFor="representative"
|
||||
type="custom"
|
||||
>
|
||||
<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" type="custom">
|
||||
<Input
|
||||
id="businessType"
|
||||
placeholder="제조업, 도소매업 등"
|
||||
value={formData.businessType}
|
||||
onChange={(e) =>
|
||||
handleFieldChange('businessType', e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="종목" htmlFor="businessItem" type="custom">
|
||||
<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" type="custom">
|
||||
<Input
|
||||
id="businessNo"
|
||||
placeholder="10자리 숫자 (예: 123-45-67890)"
|
||||
value={formData.businessNo}
|
||||
onChange={(e) => handleFieldChange("businessNo", e.target.value)}
|
||||
id="address"
|
||||
placeholder="주소 입력"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleFieldChange('address', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="거래처 코드"
|
||||
htmlFor="clientCode"
|
||||
helpText="자동 생성됩니다"
|
||||
type="custom"
|
||||
>
|
||||
<Input
|
||||
id="clientCode"
|
||||
placeholder="자동생성"
|
||||
value={formData.clientCode || ""}
|
||||
disabled
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField
|
||||
label="전화번호"
|
||||
error={errors.phone}
|
||||
htmlFor="phone"
|
||||
type="custom"
|
||||
>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="02-1234-5678"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleFieldChange('phone', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField
|
||||
label="거래처명"
|
||||
required
|
||||
error={errors.name}
|
||||
htmlFor="name"
|
||||
type="custom"
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="거래처명 입력"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleFieldChange("name", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="모바일" htmlFor="mobile" type="custom">
|
||||
<Input
|
||||
id="mobile"
|
||||
placeholder="010-1234-5678"
|
||||
value={formData.mobile}
|
||||
onChange={(e) => handleFieldChange('mobile', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="팩스" htmlFor="fax" type="custom">
|
||||
<Input
|
||||
id="fax"
|
||||
placeholder="02-1234-5678"
|
||||
value={formData.fax}
|
||||
onChange={(e) => handleFieldChange('fax', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormField
|
||||
label="대표자명"
|
||||
required
|
||||
error={errors.representative}
|
||||
htmlFor="representative"
|
||||
label="이메일"
|
||||
error={errors.email}
|
||||
htmlFor="email"
|
||||
type="custom"
|
||||
>
|
||||
<Input
|
||||
id="representative"
|
||||
placeholder="대표자명 입력"
|
||||
value={formData.representative}
|
||||
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" type="custom">
|
||||
<Input
|
||||
id="managerName"
|
||||
placeholder="담당자명 입력"
|
||||
value={formData.managerName}
|
||||
onChange={(e) => handleFieldChange('managerName', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="담당자 전화" htmlFor="managerTel" type="custom">
|
||||
<Input
|
||||
id="managerTel"
|
||||
placeholder="010-1234-5678"
|
||||
value={formData.managerTel}
|
||||
onChange={(e) => handleFieldChange('managerTel', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormField label="시스템 관리자" htmlFor="systemManager" type="custom">
|
||||
<Input
|
||||
id="systemManager"
|
||||
placeholder="시스템 관리자명"
|
||||
value={formData.systemManager}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("representative", e.target.value)
|
||||
handleFieldChange('systemManager', e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
</FormSection>
|
||||
|
||||
<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>
|
||||
{/* 4. 기타 */}
|
||||
<FormSection
|
||||
title="기타 정보"
|
||||
description="추가 정보를 입력하세요"
|
||||
icon={FileText}
|
||||
>
|
||||
<FormField label="메모" htmlFor="memo" type="custom">
|
||||
<Textarea
|
||||
id="memo"
|
||||
placeholder="메모 입력"
|
||||
value={formData.memo}
|
||||
onChange={(e) => handleFieldChange('memo', e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField label="업태" htmlFor="businessType" type="custom">
|
||||
<Input
|
||||
id="businessType"
|
||||
placeholder="제조업, 도소매업 등"
|
||||
value={formData.businessType}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("businessType", e.target.value)
|
||||
<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>
|
||||
</div>
|
||||
),
|
||||
[formData, errors, handleFieldChange]
|
||||
);
|
||||
|
||||
<FormField label="종목" htmlFor="businessItem" type="custom">
|
||||
<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" type="custom">
|
||||
<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" type="custom">
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="02-1234-5678"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleFieldChange("phone", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="모바일" htmlFor="mobile" type="custom">
|
||||
<Input
|
||||
id="mobile"
|
||||
placeholder="010-1234-5678"
|
||||
value={formData.mobile}
|
||||
onChange={(e) => handleFieldChange("mobile", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="팩스" htmlFor="fax" type="custom">
|
||||
<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" type="custom">
|
||||
<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" type="custom">
|
||||
<Input
|
||||
id="managerName"
|
||||
placeholder="담당자명 입력"
|
||||
value={formData.managerName}
|
||||
onChange={(e) => handleFieldChange("managerName", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="담당자 전화" htmlFor="managerTel" type="custom">
|
||||
<Input
|
||||
id="managerTel"
|
||||
placeholder="010-1234-5678"
|
||||
value={formData.managerTel}
|
||||
onChange={(e) => handleFieldChange("managerTel", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormField label="시스템 관리자" htmlFor="systemManager" type="custom">
|
||||
<Input
|
||||
id="systemManager"
|
||||
placeholder="시스템 관리자명"
|
||||
value={formData.systemManager}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("systemManager", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
{/*
|
||||
TODO: 기획 확정 후 활성화 (2025-12-09)
|
||||
- 발주처 설정: 계정ID, 비밀번호, 매입/매출 결제일
|
||||
- 약정 세금: 약정 여부, 금액, 시작/종료일
|
||||
- 악성채권 정보: 악성채권 여부, 금액, 발생/만료일, 진행상태
|
||||
|
||||
백엔드 API에서는 이미 지원됨 (nullable 필드)
|
||||
*/}
|
||||
|
||||
{/* 4. 기타 */}
|
||||
<FormSection
|
||||
title="기타 정보"
|
||||
description="추가 정보를 입력하세요"
|
||||
icon={FileText}
|
||||
>
|
||||
<FormField label="메모" htmlFor="memo" type="custom">
|
||||
<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>
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={config}
|
||||
mode={isEditMode ? 'edit' : 'create'}
|
||||
isLoading={isLoading}
|
||||
isSubmitting={isSaving}
|
||||
onBack={onBack}
|
||||
onCancel={onBack}
|
||||
onSubmit={handleSubmit}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
38
src/components/clients/clientConfig.ts
Normal file
38
src/components/clients/clientConfig.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { Building2 } from 'lucide-react';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
/**
|
||||
* 거래처 등록 페이지 Config
|
||||
*/
|
||||
export const clientCreateConfig: DetailConfig = {
|
||||
title: '거래처 등록',
|
||||
description: '거래처 정보를 입력하세요',
|
||||
icon: Building2,
|
||||
basePath: '/sales/client-management',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '등록',
|
||||
backLabel: '취소',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 거래처 수정 페이지 Config
|
||||
*/
|
||||
export const clientEditConfig: DetailConfig = {
|
||||
title: '거래처 수정',
|
||||
description: '거래처 정보를 수정합니다',
|
||||
icon: Building2,
|
||||
basePath: '/sales/client-management',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '수정',
|
||||
backLabel: '취소',
|
||||
},
|
||||
};
|
||||
@@ -1,548 +0,0 @@
|
||||
/**
|
||||
* DataTable Component
|
||||
*
|
||||
* 범용 데이터 테이블 컴포넌트
|
||||
* - 검색/필터링
|
||||
* - 정렬
|
||||
* - 페이지네이션
|
||||
* - 행 선택
|
||||
* - 반응형 (데스크톱: 테이블, 모바일: 카드)
|
||||
*
|
||||
* @example
|
||||
* <DataTable
|
||||
* data={items}
|
||||
* columns={columns}
|
||||
* search={{ placeholder: '검색...' }}
|
||||
* pagination={{ pageSize: 20 }}
|
||||
* selection={{ enabled: true }}
|
||||
* rowActions={[
|
||||
* { key: 'view', icon: Search, onClick: handleView },
|
||||
* { key: 'edit', icon: Edit, onClick: handleEdit },
|
||||
* ]}
|
||||
* />
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Pagination } from './Pagination';
|
||||
import { TabFilter } from './TabFilter';
|
||||
import { SearchFilter } from './SearchFilter';
|
||||
import type {
|
||||
DataTableProps,
|
||||
BaseDataItem,
|
||||
SortState,
|
||||
ColumnDef,
|
||||
} from './types';
|
||||
|
||||
export function DataTable<T extends BaseDataItem>({
|
||||
data,
|
||||
columns,
|
||||
loading = false,
|
||||
|
||||
// 검색/필터
|
||||
search,
|
||||
tabFilter,
|
||||
defaultFilterValue = 'all',
|
||||
|
||||
// 선택
|
||||
selection,
|
||||
onSelectionChange,
|
||||
|
||||
// 페이지네이션
|
||||
pagination = { pageSize: 20 },
|
||||
|
||||
// 정렬
|
||||
defaultSort,
|
||||
onSortChange,
|
||||
|
||||
// 액션
|
||||
rowActions = [],
|
||||
bulkActions = [],
|
||||
|
||||
// 스타일
|
||||
striped = false,
|
||||
hoverable = true,
|
||||
|
||||
// 빈 상태
|
||||
emptyState,
|
||||
|
||||
// 기타
|
||||
onRowClick,
|
||||
getRowKey = (row) => row.id,
|
||||
}: DataTableProps<T>) {
|
||||
// 상태
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterValue, setFilterValue] = useState(defaultFilterValue);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(pagination.pageSize);
|
||||
const [sort, setSort] = useState<SortState>(defaultSort || { column: null, direction: null });
|
||||
|
||||
// 검색/필터/정렬 적용된 데이터
|
||||
const processedData = useMemo(() => {
|
||||
let result = [...data];
|
||||
|
||||
// 탭 필터 적용
|
||||
if (tabFilter && filterValue !== 'all') {
|
||||
result = result.filter((item) => {
|
||||
const value = item[tabFilter.key];
|
||||
return value === filterValue;
|
||||
});
|
||||
}
|
||||
|
||||
// 검색 적용
|
||||
if (search && searchTerm) {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const searchFields = search.searchFields || columns.map((c) => c.key);
|
||||
|
||||
result = result.filter((item) =>
|
||||
searchFields.some((field) => {
|
||||
const value = item[field];
|
||||
if (value == null) return false;
|
||||
return String(value).toLowerCase().includes(searchLower);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬 적용
|
||||
if (sort.column && sort.direction) {
|
||||
result.sort((a, b) => {
|
||||
const aVal = a[sort.column!];
|
||||
const bVal = b[sort.column!];
|
||||
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
let comparison = 0;
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
comparison = aVal - bVal;
|
||||
} else {
|
||||
comparison = String(aVal).localeCompare(String(bVal));
|
||||
}
|
||||
|
||||
return sort.direction === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, tabFilter, filterValue, search, searchTerm, sort, columns]);
|
||||
|
||||
// 페이지네이션 적용
|
||||
const totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedData = processedData.slice(startIndex, endIndex);
|
||||
|
||||
// 전체 선택 상태
|
||||
const selectAll = useMemo(() => {
|
||||
if (paginatedData.length === 0) return false;
|
||||
return paginatedData.every((row) => selectedIds.has(getRowKey(row)));
|
||||
}, [paginatedData, selectedIds, getRowKey]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
setFilterValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleSort = useCallback((column: string) => {
|
||||
setSort((prev) => {
|
||||
let newDirection: SortState['direction'] = 'asc';
|
||||
if (prev.column === column) {
|
||||
if (prev.direction === 'asc') newDirection = 'desc';
|
||||
else if (prev.direction === 'desc') newDirection = null;
|
||||
}
|
||||
|
||||
const newSort: SortState = {
|
||||
column: newDirection ? column : null,
|
||||
direction: newDirection,
|
||||
};
|
||||
|
||||
onSortChange?.(newSort);
|
||||
return newSort;
|
||||
});
|
||||
}, [onSortChange]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const newSelected = new Set(selectedIds);
|
||||
if (selectAll) {
|
||||
paginatedData.forEach((row) => newSelected.delete(getRowKey(row)));
|
||||
} else {
|
||||
paginatedData.forEach((row) => newSelected.add(getRowKey(row)));
|
||||
}
|
||||
setSelectedIds(newSelected);
|
||||
onSelectionChange?.(newSelected);
|
||||
}, [selectAll, paginatedData, selectedIds, getRowKey, onSelectionChange]);
|
||||
|
||||
const handleSelectRow = useCallback((row: T) => {
|
||||
const key = getRowKey(row);
|
||||
const newSelected = new Set(selectedIds);
|
||||
|
||||
if (selection?.single) {
|
||||
newSelected.clear();
|
||||
if (!selectedIds.has(key)) {
|
||||
newSelected.add(key);
|
||||
}
|
||||
} else {
|
||||
if (newSelected.has(key)) {
|
||||
newSelected.delete(key);
|
||||
} else {
|
||||
newSelected.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedIds(newSelected);
|
||||
onSelectionChange?.(newSelected);
|
||||
}, [selectedIds, selection, getRowKey, onSelectionChange]);
|
||||
|
||||
const handlePageSizeChange = useCallback((size: number) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 정렬 아이콘 렌더링
|
||||
const renderSortIcon = (column: ColumnDef<T>) => {
|
||||
if (!column.sortable) return null;
|
||||
|
||||
if (sort.column !== column.key) {
|
||||
return <ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />;
|
||||
}
|
||||
if (sort.direction === 'asc') {
|
||||
return <ArrowUp className="ml-1 h-4 w-4" />;
|
||||
}
|
||||
if (sort.direction === 'desc') {
|
||||
return <ArrowDown className="ml-1 h-4 w-4" />;
|
||||
}
|
||||
return <ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />;
|
||||
};
|
||||
|
||||
// 빈 상태 렌더링
|
||||
const renderEmptyState = () => {
|
||||
const isFiltered = searchTerm || filterValue !== 'all';
|
||||
const title = isFiltered
|
||||
? emptyState?.filteredTitle || '검색 결과가 없습니다.'
|
||||
: emptyState?.title || '데이터가 없습니다.';
|
||||
const description = isFiltered
|
||||
? emptyState?.filteredDescription
|
||||
: emptyState?.description;
|
||||
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{emptyState?.icon && (
|
||||
<emptyState.icon className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
)}
|
||||
<p className="text-lg font-medium">{title}</p>
|
||||
{description && <p className="text-sm mt-1">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 선택된 항목들
|
||||
const selectedItems = useMemo(() => {
|
||||
return data.filter((item) => selectedIds.has(getRowKey(item)));
|
||||
}, [data, selectedIds, getRowKey]);
|
||||
|
||||
// 필터 옵션에 카운트 추가
|
||||
const filterOptionsWithCount = useMemo(() => {
|
||||
if (!tabFilter) return [];
|
||||
return tabFilter.options.map((opt) => ({
|
||||
...opt,
|
||||
count: opt.value === 'all'
|
||||
? data.length
|
||||
: data.filter((item) => item[tabFilter.key] === opt.value).length,
|
||||
}));
|
||||
}, [tabFilter, data]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검색/필터 */}
|
||||
{(search || tabFilter) && (
|
||||
<Card>
|
||||
<CardContent className="p-4 md:p-6">
|
||||
<SearchFilter
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchConfig={search}
|
||||
filterValue={filterValue}
|
||||
onFilterChange={tabFilter ? handleFilterChange : undefined}
|
||||
filterOptions={filterOptionsWithCount}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 벌크 액션 */}
|
||||
{bulkActions.length > 0 && selectedIds.size > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-3 flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedIds.size}개 선택됨
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{bulkActions
|
||||
.filter((action) => !action.minSelected || selectedIds.size >= action.minSelected)
|
||||
.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant={action.variant || 'outline'}
|
||||
size="sm"
|
||||
onClick={() => action.onClick(selectedItems)}
|
||||
>
|
||||
{action.icon && <action.icon className="w-4 h-4 mr-1" />}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 메인 테이블 카드 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm md:text-base">
|
||||
{tabFilter
|
||||
? `${filterOptionsWithCount.find((o) => o.value === filterValue)?.label || ''} 목록`
|
||||
: '전체 목록'}{' '}
|
||||
({processedData.length}개)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 md:p-6">
|
||||
{/* 탭 필터 */}
|
||||
{tabFilter && (
|
||||
<div className="mb-6">
|
||||
<TabFilter
|
||||
value={filterValue}
|
||||
onChange={handleFilterChange}
|
||||
options={filterOptionsWithCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{loading ? (
|
||||
<ContentLoadingSpinner text="데이터를 불러오는 중..." />
|
||||
) : paginatedData.length === 0 ? (
|
||||
renderEmptyState()
|
||||
) : (
|
||||
<>
|
||||
{/* 모바일 카드 뷰 */}
|
||||
<div className="lg:hidden space-y-3">
|
||||
{paginatedData.map((row, index) => (
|
||||
<div
|
||||
key={getRowKey(row)}
|
||||
className={cn(
|
||||
'border rounded-lg p-4 space-y-3 bg-card transition-colors',
|
||||
hoverable && 'hover:bg-muted/50',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{selection?.enabled && (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(getRowKey(row))}
|
||||
onCheckedChange={() => handleSelectRow(row)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
{columns
|
||||
.filter((col) => col.renderMobile !== null)
|
||||
.map((col) => {
|
||||
const value = row[col.key];
|
||||
const rendered = col.renderMobile
|
||||
? col.renderMobile(value, row, index)
|
||||
: col.render
|
||||
? col.render(value, row, index)
|
||||
: value;
|
||||
if (rendered === null || rendered === undefined) return null;
|
||||
return (
|
||||
<div key={col.key}>
|
||||
{rendered as React.ReactNode}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{rowActions.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-1 pt-2 border-t">
|
||||
{rowActions
|
||||
.filter((action) => !action.visible || action.visible(row))
|
||||
.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant={action.variant || 'ghost'}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick(row);
|
||||
}}
|
||||
title={action.tooltip}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
{action.icon && <action.icon className="h-4 w-4" />}
|
||||
{action.label && (
|
||||
<span className="ml-1 text-xs">{action.label}</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데스크톱 테이블 */}
|
||||
<div className="hidden lg:block rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{selection?.enabled && (
|
||||
<TableHead className="w-[50px]">
|
||||
{!selection.single && (
|
||||
<Checkbox
|
||||
checked={selectAll}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
)}
|
||||
{columns
|
||||
.filter((col) => !col.hideOnMobile || !col.hideOnTablet)
|
||||
.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn(
|
||||
col.width && `w-[${col.width}]`,
|
||||
col.minWidth && `min-w-[${col.minWidth}]`,
|
||||
col.hideOnMobile && 'hidden md:table-cell',
|
||||
col.hideOnTablet && 'hidden lg:table-cell',
|
||||
col.sortable && 'cursor-pointer select-none',
|
||||
col.align === 'center' && 'text-center',
|
||||
col.align === 'right' && 'text-right'
|
||||
)}
|
||||
onClick={() => col.sortable && handleSort(col.key)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex items-center',
|
||||
col.align === 'center' && 'justify-center',
|
||||
col.align === 'right' && 'justify-end'
|
||||
)}>
|
||||
{col.header}
|
||||
{renderSortIcon(col)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
{rowActions.length > 0 && (
|
||||
<TableHead className="text-right min-w-[100px]">작업</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedData.map((row, index) => (
|
||||
<TableRow
|
||||
key={getRowKey(row)}
|
||||
className={cn(
|
||||
hoverable && 'hover:bg-muted/50',
|
||||
striped && index % 2 === 1 && 'bg-muted/25',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{selection?.enabled && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(getRowKey(row))}
|
||||
onCheckedChange={() => handleSelectRow(row)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{columns
|
||||
.filter((col) => !col.hideOnMobile || !col.hideOnTablet)
|
||||
.map((col) => {
|
||||
const value = row[col.key];
|
||||
return (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(
|
||||
col.hideOnMobile && 'hidden md:table-cell',
|
||||
col.hideOnTablet && 'hidden lg:table-cell',
|
||||
col.align === 'center' && 'text-center',
|
||||
col.align === 'right' && 'text-right'
|
||||
)}
|
||||
>
|
||||
{col.render ? col.render(value, row, index) : String(value ?? '-')}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
{rowActions.length > 0 && (
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{rowActions
|
||||
.filter((action) => !action.visible || action.visible(row))
|
||||
.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant={action.variant || 'ghost'}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick(row);
|
||||
}}
|
||||
title={action.tooltip || action.label}
|
||||
>
|
||||
{action.icon && <action.icon className="w-4 h-4" />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
onPageChange={setCurrentPage}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={pagination.showPageSizeSelector ? handlePageSizeChange : undefined}
|
||||
pageSizeOptions={pagination.pageSizeOptions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataTable;
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Pagination Component
|
||||
*
|
||||
* 테이블 페이지네이션 공통 컴포넌트
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { PaginationProps } from './types';
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
startIndex,
|
||||
endIndex,
|
||||
onPageChange,
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions,
|
||||
}: PaginationProps) {
|
||||
if (totalItems === 0) return null;
|
||||
|
||||
// 표시할 페이지 번호 계산
|
||||
const getVisiblePages = () => {
|
||||
const pages: (number | 'ellipsis')[] = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible + 2) {
|
||||
// 전체 페이지가 적으면 모두 표시
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// 항상 첫 페이지
|
||||
pages.push(1);
|
||||
|
||||
// 현재 페이지 주변
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
if (start > 2) {
|
||||
pages.push('ellipsis');
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (end < totalPages - 1) {
|
||||
pages.push('ellipsis');
|
||||
}
|
||||
|
||||
// 항상 마지막 페이지
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
전체 {totalItems}개 중 {startIndex + 1}-{Math.min(endIndex, totalItems)}개 표시
|
||||
</span>
|
||||
{pageSizeOptions && onPageSizeChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">페이지당</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[70px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">이전</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{getVisiblePages().map((page, index) => {
|
||||
if (page === 'ellipsis') {
|
||||
return (
|
||||
<span key={`ellipsis-${index}`} className="px-2 text-muted-foreground">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page)}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<span className="hidden sm:inline">다음</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Pagination;
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* SearchFilter Component
|
||||
*
|
||||
* 검색 입력과 드롭다운 필터를 제공하는 공통 컴포넌트
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SearchFilterProps } from './types';
|
||||
|
||||
export function SearchFilter({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchConfig,
|
||||
filterValue,
|
||||
onFilterChange,
|
||||
filterOptions,
|
||||
}: SearchFilterProps) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={searchConfig?.placeholder || '검색...'}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
{filterOptions && filterOptions.length > 0 && onFilterChange && (
|
||||
<Select value={filterValue} onValueChange={onFilterChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="필터" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{option.count !== undefined && ` (${option.count})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchFilter;
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* StatCards Component
|
||||
*
|
||||
* 통계 카드 그리드 컴포넌트
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { StatCardsProps } from './types';
|
||||
|
||||
export function StatCards({ stats, onStatClick }: StatCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className={onStatClick && stat.filterValue ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}
|
||||
onClick={() => {
|
||||
if (onStatClick && stat.filterValue) {
|
||||
onStatClick(stat.filterValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className="text-3xl md:text-4xl font-bold mt-2">{stat.value}</p>
|
||||
</div>
|
||||
{stat.icon && (
|
||||
<stat.icon
|
||||
className={`w-10 h-10 md:w-12 md:h-12 opacity-15 ${stat.iconColor || ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatCards;
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* TabFilter Component
|
||||
*
|
||||
* 탭 형태의 필터 컴포넌트
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { TabFilterProps } from './types';
|
||||
|
||||
export function TabFilter({ value, onChange, options }: TabFilterProps) {
|
||||
return (
|
||||
<Tabs value={value} onValueChange={onChange} className="w-full">
|
||||
<div className="overflow-x-auto -mx-2 px-2">
|
||||
<TabsList className="inline-flex w-auto min-w-full md:grid md:w-full md:max-w-2xl" style={{
|
||||
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`
|
||||
}}>
|
||||
{options.map((option) => (
|
||||
<TabsTrigger key={option.value} value={option.value} className="whitespace-nowrap">
|
||||
{option.label}
|
||||
{option.count !== undefined && ` (${option.count})`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default TabFilter;
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* DataTable 공통 컴포넌트 모듈
|
||||
*
|
||||
* 모든 목록 페이지에서 재사용 가능한 테이블 컴포넌트
|
||||
*/
|
||||
|
||||
import { DataTable } from './DataTable';
|
||||
|
||||
export { DataTable } from './DataTable';
|
||||
export { SearchFilter } from './SearchFilter';
|
||||
export { Pagination } from './Pagination';
|
||||
export { TabFilter } from './TabFilter';
|
||||
export { StatCards } from './StatCards';
|
||||
export * from './types';
|
||||
|
||||
export default DataTable;
|
||||
@@ -1,248 +0,0 @@
|
||||
/**
|
||||
* DataTable 공통 컴포넌트 타입 정의
|
||||
*
|
||||
* 모든 목록 페이지에서 재사용 가능한 테이블 컴포넌트
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
// ===== 기본 타입 =====
|
||||
|
||||
/** 데이터 항목의 기본 인터페이스 */
|
||||
export interface BaseDataItem {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 컬럼 정렬 방향 */
|
||||
export type SortDirection = 'asc' | 'desc' | null;
|
||||
|
||||
/** 컬럼 정렬 상태 */
|
||||
export interface SortState {
|
||||
column: string | null;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
// ===== 컬럼 정의 =====
|
||||
|
||||
/** 컬럼 정의 인터페이스 */
|
||||
export interface ColumnDef<T extends BaseDataItem> {
|
||||
/** 컬럼 키 (데이터 필드명) */
|
||||
key: string;
|
||||
/** 컬럼 헤더 텍스트 */
|
||||
header: string;
|
||||
/** 컬럼 너비 (CSS 값) */
|
||||
width?: string;
|
||||
/** 최소 너비 */
|
||||
minWidth?: string;
|
||||
/** 정렬 가능 여부 */
|
||||
sortable?: boolean;
|
||||
/** 모바일에서 숨김 여부 */
|
||||
hideOnMobile?: boolean;
|
||||
/** 태블릿에서 숨김 여부 */
|
||||
hideOnTablet?: boolean;
|
||||
/** 텍스트 정렬 */
|
||||
align?: 'left' | 'center' | 'right';
|
||||
/** 셀 렌더링 함수 */
|
||||
render?: (value: unknown, row: T, index: number) => ReactNode;
|
||||
/** 모바일 카드에서의 렌더링 (null이면 표시 안함) */
|
||||
renderMobile?: (value: unknown, row: T, index: number) => ReactNode | null;
|
||||
}
|
||||
|
||||
// ===== 검색/필터 =====
|
||||
|
||||
/** 필터 옵션 */
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** 탭 필터 설정 */
|
||||
export interface TabFilter {
|
||||
key: string;
|
||||
options: FilterOption[];
|
||||
}
|
||||
|
||||
/** 검색 설정 */
|
||||
export interface SearchConfig {
|
||||
placeholder?: string;
|
||||
/** 검색 대상 필드 키 배열 */
|
||||
searchFields?: string[];
|
||||
}
|
||||
|
||||
// ===== 선택 =====
|
||||
|
||||
/** 선택 설정 */
|
||||
export interface SelectionConfig {
|
||||
/** 선택 기능 활성화 */
|
||||
enabled: boolean;
|
||||
/** 단일 선택 모드 */
|
||||
single?: boolean;
|
||||
}
|
||||
|
||||
// ===== 페이지네이션 =====
|
||||
|
||||
/** 페이지네이션 설정 */
|
||||
export interface PaginationConfig {
|
||||
/** 페이지당 항목 수 */
|
||||
pageSize: number;
|
||||
/** 페이지 사이즈 옵션 */
|
||||
pageSizeOptions?: number[];
|
||||
/** 페이지 사이즈 변경 가능 여부 */
|
||||
showPageSizeSelector?: boolean;
|
||||
}
|
||||
|
||||
// ===== 액션 버튼 =====
|
||||
|
||||
/** 행 액션 정의 */
|
||||
export interface RowAction<T extends BaseDataItem> {
|
||||
key: string;
|
||||
icon?: LucideIcon;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
variant?: 'default' | 'ghost' | 'outline' | 'destructive';
|
||||
/** 표시 조건 */
|
||||
visible?: (row: T) => boolean;
|
||||
/** 클릭 핸들러 */
|
||||
onClick: (row: T) => void;
|
||||
}
|
||||
|
||||
/** 벌크 액션 정의 (선택된 항목에 대한 액션) */
|
||||
export interface BulkAction<T extends BaseDataItem> {
|
||||
key: string;
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
variant?: 'default' | 'outline' | 'destructive';
|
||||
/** 활성화 조건 (선택된 항목 수) */
|
||||
minSelected?: number;
|
||||
/** 클릭 핸들러 */
|
||||
onClick: (selectedItems: T[]) => void;
|
||||
}
|
||||
|
||||
// ===== 통계 카드 =====
|
||||
|
||||
/** 통계 카드 항목 */
|
||||
export interface StatItem {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon?: LucideIcon;
|
||||
iconColor?: string;
|
||||
/** 클릭 시 해당 필터로 이동 */
|
||||
filterValue?: string;
|
||||
}
|
||||
|
||||
// ===== 빈 상태 =====
|
||||
|
||||
/** 빈 상태 설정 */
|
||||
export interface EmptyStateConfig {
|
||||
/** 빈 상태 아이콘 */
|
||||
icon?: LucideIcon;
|
||||
/** 빈 상태 제목 */
|
||||
title?: string;
|
||||
/** 빈 상태 설명 */
|
||||
description?: string;
|
||||
/** 필터 적용 시 빈 상태 제목 */
|
||||
filteredTitle?: string;
|
||||
/** 필터 적용 시 빈 상태 설명 */
|
||||
filteredDescription?: string;
|
||||
}
|
||||
|
||||
// ===== 메인 Props =====
|
||||
|
||||
/** DataTable 메인 Props */
|
||||
export interface DataTableProps<T extends BaseDataItem> {
|
||||
/** 데이터 배열 */
|
||||
data: T[];
|
||||
/** 컬럼 정의 */
|
||||
columns: ColumnDef<T>[];
|
||||
/** 로딩 상태 */
|
||||
loading?: boolean;
|
||||
|
||||
// === 검색/필터 ===
|
||||
/** 검색 설정 */
|
||||
search?: SearchConfig;
|
||||
/** 탭 필터 설정 */
|
||||
tabFilter?: TabFilter;
|
||||
/** 기본 필터 값 */
|
||||
defaultFilterValue?: string;
|
||||
|
||||
// === 선택 ===
|
||||
/** 선택 설정 */
|
||||
selection?: SelectionConfig;
|
||||
/** 선택 변경 핸들러 */
|
||||
onSelectionChange?: (selectedIds: Set<string>) => void;
|
||||
|
||||
// === 페이지네이션 ===
|
||||
/** 페이지네이션 설정 */
|
||||
pagination?: PaginationConfig;
|
||||
|
||||
// === 정렬 ===
|
||||
/** 기본 정렬 */
|
||||
defaultSort?: SortState;
|
||||
/** 정렬 변경 핸들러 (서버 사이드 정렬용) */
|
||||
onSortChange?: (sort: SortState) => void;
|
||||
|
||||
// === 액션 ===
|
||||
/** 행 액션 */
|
||||
rowActions?: RowAction<T>[];
|
||||
/** 벌크 액션 */
|
||||
bulkActions?: BulkAction<T>[];
|
||||
|
||||
// === 스타일 ===
|
||||
/** 테이블 최소 높이 */
|
||||
minHeight?: string;
|
||||
/** 스트라이프 스타일 */
|
||||
striped?: boolean;
|
||||
/** 호버 스타일 */
|
||||
hoverable?: boolean;
|
||||
|
||||
// === 빈 상태 ===
|
||||
/** 빈 상태 설정 */
|
||||
emptyState?: EmptyStateConfig;
|
||||
|
||||
// === 기타 ===
|
||||
/** 행 클릭 핸들러 */
|
||||
onRowClick?: (row: T) => void;
|
||||
/** 행 키 추출 함수 (기본: row.id) */
|
||||
getRowKey?: (row: T) => string;
|
||||
}
|
||||
|
||||
// ===== 서브 컴포넌트 Props =====
|
||||
|
||||
/** SearchFilter Props */
|
||||
export interface SearchFilterProps {
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchConfig?: SearchConfig;
|
||||
filterValue?: string;
|
||||
onFilterChange?: (value: string) => void;
|
||||
filterOptions?: FilterOption[];
|
||||
}
|
||||
|
||||
/** Pagination Props */
|
||||
export interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
onPageChange: (page: number) => void;
|
||||
pageSize?: number;
|
||||
onPageSizeChange?: (size: number) => void;
|
||||
pageSizeOptions?: number[];
|
||||
}
|
||||
|
||||
/** TabFilter Props */
|
||||
export interface TabFilterProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: FilterOption[];
|
||||
}
|
||||
|
||||
/** StatCards Props */
|
||||
export interface StatCardsProps {
|
||||
stats: StatItem[];
|
||||
onStatClick?: (filterValue: string) => void;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
/**
|
||||
* 수주 등록 컴포넌트
|
||||
*
|
||||
* IntegratedDetailTemplate 마이그레이션 (2026-01-20)
|
||||
* - 견적 불러오기 섹션
|
||||
* - 기본 정보 섹션
|
||||
* - 수주/배송 정보 섹션
|
||||
@@ -19,7 +20,6 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
@@ -44,12 +44,15 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Info,
|
||||
MapPin,
|
||||
Truck,
|
||||
Package,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ResponsiveFormTemplate,
|
||||
FormSection,
|
||||
} from "@/components/templates/ResponsiveFormTemplate";
|
||||
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
|
||||
import { orderCreateConfig, orderEditConfig } from "./orderConfig";
|
||||
import { FormSection } from "@/components/organisms/FormSection";
|
||||
import { QuotationSelectDialog } from "./QuotationSelectDialog";
|
||||
import { type QuotationForSelect, type QuotationItem } from "./actions";
|
||||
import { ItemAddDialog, OrderItem } from "./ItemAddDialog";
|
||||
@@ -177,6 +180,9 @@ export function OrderRegistration({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
|
||||
// Config 선택
|
||||
const config = isEditMode ? orderEditConfig : orderCreateConfig;
|
||||
|
||||
// 거래처 목록 조회
|
||||
const { clients, fetchClients, isLoading: isClientsLoading } = useClientList();
|
||||
|
||||
@@ -329,7 +335,7 @@ export function OrderRegistration({
|
||||
}, []);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
const handleSave = useCallback(async () => {
|
||||
// 유효성 검사
|
||||
const errors = validateForm();
|
||||
setFieldErrors(errors);
|
||||
@@ -346,21 +352,12 @@ export function OrderRegistration({
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
}, [form, validateForm, onSave]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveFormTemplate
|
||||
title={isEditMode ? "수주 수정" : "수주 등록"}
|
||||
description="견적을 수주로 전환하거나 새 수주를 등록합니다"
|
||||
icon={FileText}
|
||||
onSave={handleSave}
|
||||
onCancel={onBack}
|
||||
saveLabel="저장"
|
||||
cancelLabel="취소"
|
||||
saveLoading={isSaving}
|
||||
saveDisabled={isSaving}
|
||||
>
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = useCallback(
|
||||
() => (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{/* Validation 에러 Alert */}
|
||||
{Object.keys(fieldErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
@@ -391,13 +388,12 @@ export function OrderRegistration({
|
||||
)}
|
||||
|
||||
{/* 견적 불러오기 섹션 */}
|
||||
<FormSection title="견적 불러오기" icon={Search}>
|
||||
<FormSection
|
||||
title="견적 불러오기"
|
||||
description="확정된 견적을 선택하면 정보가 자동으로 채워집니다"
|
||||
icon={Search}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Info className="h-4 w-4" />
|
||||
확정된 견적을 선택하면 정보가 자동으로 채워집니다
|
||||
</div>
|
||||
|
||||
{form.selectedQuotation ? (
|
||||
<div className="p-4 border rounded-lg bg-muted/30">
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -443,7 +439,11 @@ export function OrderRegistration({
|
||||
</FormSection>
|
||||
|
||||
{/* 기본 정보 섹션 */}
|
||||
<FormSection title="기본 정보" icon={FileText}>
|
||||
<FormSection
|
||||
title="기본 정보"
|
||||
description="발주처 및 현장 정보를 입력하세요"
|
||||
icon={FileText}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
@@ -524,7 +524,11 @@ export function OrderRegistration({
|
||||
</FormSection>
|
||||
|
||||
{/* 수주/배송 정보 섹션 */}
|
||||
<FormSection title="수주/배송 정보">
|
||||
<FormSection
|
||||
title="수주/배송 정보"
|
||||
description="출고 및 배송 정보를 입력하세요"
|
||||
icon={Truck}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 출고예정일 */}
|
||||
<div className="space-y-2">
|
||||
@@ -699,7 +703,11 @@ export function OrderRegistration({
|
||||
</FormSection>
|
||||
|
||||
{/* 수신처 주소 섹션 */}
|
||||
<FormSection title="수신처 주소">
|
||||
<FormSection
|
||||
title="수신처 주소"
|
||||
description="배송지 주소를 입력하세요"
|
||||
icon={MapPin}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -732,7 +740,11 @@ export function OrderRegistration({
|
||||
</FormSection>
|
||||
|
||||
{/* 비고 섹션 */}
|
||||
<FormSection title="비고">
|
||||
<FormSection
|
||||
title="비고"
|
||||
description="특이사항을 입력하세요"
|
||||
icon={MessageSquare}
|
||||
>
|
||||
<Textarea
|
||||
placeholder="특이사항을 입력하세요"
|
||||
value={form.remarks}
|
||||
@@ -744,7 +756,11 @@ export function OrderRegistration({
|
||||
</FormSection>
|
||||
|
||||
{/* 품목 내역 섹션 */}
|
||||
<FormSection title="품목 내역">
|
||||
<FormSection
|
||||
title="품목 내역"
|
||||
description="수주 품목을 관리하세요"
|
||||
icon={Package}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 품목 에러 메시지 */}
|
||||
{fieldErrors.items && (
|
||||
@@ -863,7 +879,33 @@ export function OrderRegistration({
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</ResponsiveFormTemplate>
|
||||
</div>
|
||||
),
|
||||
[
|
||||
form,
|
||||
fieldErrors,
|
||||
clients,
|
||||
isClientsLoading,
|
||||
openPostcode,
|
||||
clearFieldError,
|
||||
handleClearQuotation,
|
||||
handleQuantityChange,
|
||||
handleRemoveItem,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={config}
|
||||
mode={isEditMode ? "edit" : "create"}
|
||||
isLoading={false}
|
||||
isSubmitting={isSaving}
|
||||
onBack={onBack}
|
||||
onCancel={onBack}
|
||||
onSubmit={handleSave}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
|
||||
{/* 견적 선택 팝업 */}
|
||||
<QuotationSelectDialog
|
||||
@@ -881,4 +923,4 @@ export function OrderRegistration({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
38
src/components/orders/orderConfig.ts
Normal file
38
src/components/orders/orderConfig.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { FileText } from 'lucide-react';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
/**
|
||||
* 수주 등록 페이지 Config
|
||||
*/
|
||||
export const orderCreateConfig: DetailConfig = {
|
||||
title: '수주 등록',
|
||||
description: '견적을 수주로 전환하거나 새 수주를 등록합니다',
|
||||
icon: FileText,
|
||||
basePath: '/sales/order-management',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
backLabel: '취소',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 수주 수정 페이지 Config
|
||||
*/
|
||||
export const orderEditConfig: DetailConfig = {
|
||||
title: '수주 수정',
|
||||
description: '수주 정보를 수정합니다',
|
||||
icon: FileText,
|
||||
basePath: '/sales/order-management',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
backLabel: '취소',
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,12 +25,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import {
|
||||
ResponsiveFormTemplate,
|
||||
FormSection,
|
||||
FormFieldGrid,
|
||||
} from "../templates/ResponsiveFormTemplate";
|
||||
import { FormField } from "../molecules/FormField";
|
||||
// FormField는 실제로 사용되지 않으므로 제거
|
||||
|
||||
import { LocationListPanel } from "./LocationListPanel";
|
||||
import { LocationDetailPanel } from "./LocationDetailPanel";
|
||||
|
||||
@@ -43,3 +43,54 @@ export const quoteCreateConfig: DetailConfig = {
|
||||
backLabel: '목록',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 견적 수정 페이지 Config
|
||||
*/
|
||||
export const quoteEditConfig: DetailConfig = {
|
||||
title: '견적',
|
||||
description: '견적 정보를 수정합니다',
|
||||
icon: FileText,
|
||||
basePath: '/sales/quote-management',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: false, // QuoteFooterBar에서 처리
|
||||
backLabel: '목록',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 견적 등록 페이지 Config (구버전 QuoteRegistration용)
|
||||
* - 저장/취소 버튼이 IntegratedDetailTemplate에서 처리됨
|
||||
*/
|
||||
export const quoteRegistrationCreateConfig: DetailConfig = {
|
||||
title: '견적 등록',
|
||||
description: '새 견적을 등록합니다',
|
||||
icon: FileText,
|
||||
basePath: '/sales/quote-management',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
backLabel: '취소',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 견적 수정 페이지 Config (구버전 QuoteRegistration용)
|
||||
*/
|
||||
export const quoteRegistrationEditConfig: DetailConfig = {
|
||||
title: '견적 수정',
|
||||
description: '견적 정보를 수정합니다',
|
||||
icon: FileText,
|
||||
basePath: '/sales/quote-management',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
backLabel: '취소',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, ComponentType } from "react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { SearchFilter } from "@/components/organisms/SearchFilter";
|
||||
import { StatCards } from "@/components/organisms/StatCards";
|
||||
import { DataTable, Column } from "@/components/organisms/DataTable";
|
||||
import { EmptyState } from "@/components/organisms/EmptyState";
|
||||
import { MobileCard } from "@/components/organisms/MobileCard";
|
||||
|
||||
interface ListPageTemplateProps<T extends object> {
|
||||
// Header
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
actions?: ReactNode;
|
||||
|
||||
// Stats
|
||||
stats?: Array<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
iconColor?: string;
|
||||
trend?: { value: string; isPositive: boolean };
|
||||
}>;
|
||||
|
||||
// Search & Filter
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
filterButton?: boolean;
|
||||
onFilterClick?: () => void;
|
||||
extraActions?: ReactNode;
|
||||
|
||||
// Data
|
||||
data: T[];
|
||||
keyField: keyof T;
|
||||
loading?: boolean;
|
||||
|
||||
// Table
|
||||
columns: Column<T>[];
|
||||
onRowClick?: (row: T) => void;
|
||||
|
||||
// Mobile
|
||||
renderMobileCard: (item: T) => {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: ReactNode;
|
||||
badge?: { label: string; variant?: "default" | "secondary" | "destructive" | "outline" };
|
||||
fields: Array<{ label: string; value: string; badge?: boolean; badgeVariant?: string }>;
|
||||
actions?: Array<{ label: string; onClick: () => void; icon?: LucideIcon | ComponentType<any>; variant?: "default" | "outline" | "destructive" }>;
|
||||
};
|
||||
|
||||
// Empty State
|
||||
emptyIcon?: LucideIcon;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
emptyAction?: { label: string; onClick: () => void };
|
||||
|
||||
// Pagination
|
||||
pagination?: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function ListPageTemplate<T extends object>({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
actions,
|
||||
stats,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
filterButton,
|
||||
onFilterClick,
|
||||
extraActions,
|
||||
data,
|
||||
keyField,
|
||||
loading,
|
||||
columns,
|
||||
onRowClick,
|
||||
renderMobileCard,
|
||||
emptyIcon,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
pagination
|
||||
}: ListPageTemplateProps<T>) {
|
||||
const isEmpty = !loading && data.length === 0;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={description}
|
||||
icon={icon}
|
||||
actions={actions}
|
||||
/>
|
||||
|
||||
{stats && <StatCards stats={stats} />}
|
||||
|
||||
<SearchFilter
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
filterButton={filterButton}
|
||||
onFilterClick={onFilterClick}
|
||||
extraActions={extraActions}
|
||||
/>
|
||||
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
icon={emptyIcon || icon}
|
||||
title={emptyTitle || `${title} 데이터가 없습니다`}
|
||||
description={emptyDescription}
|
||||
action={emptyAction}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
keyField={keyField}
|
||||
onRowClick={onRowClick}
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
/>
|
||||
|
||||
<div className="md:hidden space-y-3">
|
||||
{data.map((item) => {
|
||||
const cardProps = renderMobileCard(item);
|
||||
return <MobileCard key={String(item[keyField])} {...cardProps} />;
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* 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={isEditMode ? `${title} 수정` : title}
|
||||
description={description}
|
||||
icon={icon}
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export { ListPageTemplate } from "./ListPageTemplate";
|
||||
export { IntegratedListTemplateV2 } from "./IntegratedListTemplateV2";
|
||||
export type {
|
||||
TabOption,
|
||||
|
||||
Reference in New Issue
Block a user