feat: 견적서 목업 데이터 → API 연동 전환

- EstimateDetailTableSection: 하드코딩된 셀렉트 옵션 → API 데이터 연동
  - 재료/도장/모터/제어기/시공비: getCommonCodeOptions() 사용
  - 공과 품목: getExpenseItemOptions() 사용
- EstimateListClient: 거래처/견적자 필터 API 연동
  - MOCK_PARTNERS → getClientOptions()
  - MOCK_ESTIMATORS → getUserOptions()
- actions.ts: 공통코드/거래처/사용자/공과품목 API 함수 추가
- constants.ts: MOCK_MATERIALS 제거
- EstimateDetailForm: MOCK_MATERIALS import 제거
This commit is contained in:
2026-01-16 19:35:03 +09:00
committed by hskwon
parent 29b4ba8b5b
commit 2465d739fe
5 changed files with 317 additions and 51 deletions

View File

@@ -20,7 +20,7 @@ import type {
import { getEmptyEstimateDetailFormData, estimateDetailToFormData } from './types';
import { ElectronicApprovalModal } from './modals/ElectronicApprovalModal';
import { EstimateDocumentModal } from './modals/EstimateDocumentModal';
import { MOCK_MATERIALS } from './utils';
// MOCK_MATERIALS 제거됨 - API 데이터 사용
import {
EstimateInfoSection,
EstimateSummarySection,
@@ -95,6 +95,11 @@ export default function EstimateDetailForm({
// ===== 저장 핸들러 (IntegratedDetailTemplate용) =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
try {
// 🔍 디버깅: 저장 전 formData 확인 (브라우저 콘솔)
console.log('🔍 [handleConfirmSave] formData.detailItems:', formData.detailItems?.length, '개');
console.log('🔍 [handleConfirmSave] formData.priceAdjustmentData:', formData.priceAdjustmentData);
console.log('🔍 [handleConfirmSave] formData 전체:', formData);
// 현재 사용자 이름을 견적자로 설정하여 저장
const result = await updateEstimate(estimateId, {
...formData,
@@ -295,7 +300,7 @@ export default function EstimateDetailForm({
id: String(Date.now() + Math.random() + i),
no: currentLength + i + 1,
name: '',
material: MOCK_MATERIALS[0]?.value || '',
material: 'screen', // 기본값: 스크린 (API 옵션 첫번째 값)
width: 0,
height: 0,
quantity: 1,

View File

@@ -31,7 +31,8 @@ import {
STATUS_STYLES,
STATUS_LABELS,
} from './types';
import { getEstimateList, getEstimateStats, deleteEstimate, deleteEstimates } from './actions';
import { getEstimateList, getEstimateStats, deleteEstimate, deleteEstimates, getClientOptions, getUserOptions } from './actions';
import type { ClientOption, UserOption } from './actions';
// 테이블 컬럼 정의
const tableColumns = [
@@ -48,19 +49,6 @@ const tableColumns = [
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
// 거래처/견적자 옵션 (다중선택용)
const MOCK_PARTNERS = [
{ value: '1', label: '회사명' },
{ value: '2', label: '야사 대림아파트' },
{ value: '3', label: '여의 현장아파트' },
];
const MOCK_ESTIMATORS = [
{ value: 'hong', label: '홍길동' },
{ value: 'kim', label: '김철수' },
{ value: 'lee', label: '이영희' },
];
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
@@ -82,6 +70,9 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
const [endDate, setEndDate] = useState('');
// Stats 데이터
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
// 필터 옵션 데이터
const [partnerOptions, setPartnerOptions] = useState<ClientOption[]>([]);
const [estimatorOptions, setEstimatorOptions] = useState<UserOption[]>([]);
// Stats 로드
useEffect(() => {
@@ -94,6 +85,22 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
}
}, [initialStats]);
// 거래처/견적자 옵션 로드
useEffect(() => {
// 거래처 옵션 로드
getClientOptions().then((result) => {
if (result.success && result.data) {
setPartnerOptions(result.data);
}
});
// 견적자(사용자) 옵션 로드
getUserOptions().then((result) => {
if (result.success && result.data) {
setEstimatorOptions(result.data);
}
});
}, []);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(item: Estimate) => {
@@ -172,13 +179,13 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
key: 'partner',
label: '거래처',
type: 'multi',
options: MOCK_PARTNERS,
options: partnerOptions,
},
{
key: 'estimator',
label: '견적자',
type: 'multi',
options: MOCK_ESTIMATORS,
options: estimatorOptions,
},
{
key: 'status',
@@ -382,7 +389,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
/>
),
}),
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
[startDate, endDate, activeStatTab, stats, partnerOptions, estimatorOptions, handleRowClick, handleEdit]
);
return <UniversalListPage config={config} initialData={initialData} />;

View File

@@ -940,4 +940,202 @@ export async function getExpenseItemOptions(): Promise<{
console.error('공과 품목 목록 조회 오류:', error);
return { success: false, error: '공과 품목 목록을 불러오는데 실패했습니다.' };
}
}
// ==================== 공통코드/거래처/사용자 API ====================
/**
* 공통코드 옵션 타입
*/
export interface CommonCodeOption {
value: string;
label: string;
code: string;
price?: number;
attributes?: Record<string, unknown>;
}
/**
* 특정 그룹의 공통코드 목록 조회
* GET /api/v1/settings/common/{group}
*/
export async function getCommonCodes(group: string): Promise<{
success: boolean;
data?: CommonCodeOption[];
error?: string;
}> {
try {
const response = await apiClient.get<{
success: boolean;
message: string;
data: Array<{
id: number;
code: string;
name: string;
attributes: string | null;
sort_order: number;
}>;
}>(`/settings/common/${group}`);
const items = Array.isArray(response.data) ? response.data : [];
const options: CommonCodeOption[] = items.map((item) => {
const attrs = item.attributes ? JSON.parse(item.attributes) : {};
return {
value: attrs.price !== undefined ? String(attrs.price) : item.code,
label: item.name,
code: item.code,
price: attrs.price,
attributes: attrs,
};
});
return { success: true, data: options };
} catch (error) {
console.error(`공통코드(${group}) 목록 조회 오류:`, error);
return { success: false, error: `공통코드(${group}) 목록을 불러오는데 실패했습니다.` };
}
}
/**
* 거래처 옵션 타입
*/
export interface ClientOption {
value: string;
label: string;
}
/**
* 거래처 목록 조회 (셀렉트 옵션용)
* GET /api/v1/clients
*/
export async function getClientOptions(): Promise<{
success: boolean;
data?: ClientOption[];
error?: string;
}> {
try {
const response = await apiClient.get<{
success: boolean;
message: string;
data: {
data: Array<{
id: number;
name: string;
code?: string;
}>;
};
}>('/clients', {
params: {
active: '1',
size: '100',
},
});
const paginatedData = response.data;
const items = Array.isArray(paginatedData.data) ? paginatedData.data : [];
const options: ClientOption[] = items.map((item) => ({
value: String(item.id),
label: item.name,
}));
return { success: true, data: options };
} catch (error) {
console.error('거래처 목록 조회 오류:', error);
return { success: false, error: '거래처 목록을 불러오는데 실패했습니다.' };
}
}
/**
* 사용자 옵션 타입
*/
export interface UserOption {
value: string;
label: string;
}
/**
* 사용자 목록 조회 (견적자 셀렉트 옵션용)
* GET /api/v1/users
*/
export async function getUserOptions(): Promise<{
success: boolean;
data?: UserOption[];
error?: string;
}> {
try {
const response = await apiClient.get<{
success: boolean;
message: string;
data: {
data: Array<{
id: number;
name: string;
email?: string;
}>;
};
}>('/users', {
params: {
active: '1',
size: '100',
},
});
const paginatedData = response.data;
const items = Array.isArray(paginatedData.data) ? paginatedData.data : [];
const options: UserOption[] = items.map((item) => ({
value: String(item.id),
label: item.name,
}));
return { success: true, data: options };
} catch (error) {
console.error('사용자 목록 조회 오류:', error);
return { success: false, error: '사용자 목록을 불러오는데 실패했습니다.' };
}
}
/**
* 견적서 옵션 데이터 일괄 조회
* 제품, 도장, 모터, 제어기, 시공비 등 모든 셀렉트 옵션
*/
export interface EstimateOptionsData {
materials: CommonCodeOption[];
paintings: CommonCodeOption[];
motors: CommonCodeOption[];
controllers: CommonCodeOption[];
widthConstructions: CommonCodeOption[];
heightConstructions: CommonCodeOption[];
}
export async function getEstimateOptions(): Promise<{
success: boolean;
data?: EstimateOptionsData;
error?: string;
}> {
try {
const [materials, paintings, motors, controllers, widthConstructions, heightConstructions] =
await Promise.all([
getCommonCodes('material_type'),
getCommonCodes('painting_type'),
getCommonCodes('motor_type'),
getCommonCodes('controller_type'),
getCommonCodes('width_construction_cost'),
getCommonCodes('height_construction_cost'),
]);
return {
success: true,
data: {
materials: materials.data || [],
paintings: paintings.data || [],
motors: motors.data || [],
controllers: controllers.data || [],
widthConstructions: widthConstructions.data || [],
heightConstructions: heightConstructions.data || [],
},
};
} catch (error) {
console.error('견적서 옵션 일괄 조회 오류:', error);
return { success: false, error: '견적서 옵션을 불러오는데 실패했습니다.' };
}
}

View File

@@ -27,8 +27,9 @@ import {
TableRow,
} from '@/components/ui/table';
import type { EstimateDetailItem } from '../types';
import { formatAmount, MOCK_MATERIALS } from '../utils';
import { formatAmount } from '../utils';
import { calculateItemValuesWithApplied, calculateTotalsWithApplied } from '../hooks/useEstimateCalculations';
import type { CommonCodeOption } from '../actions';
// 계산식 정보
const FORMULA_INFO: Record<string, string> = {
@@ -85,10 +86,21 @@ export interface AppliedPrices {
controller: number;
}
// 옵션 데이터 타입
export interface EstimateDetailOptions {
materials: CommonCodeOption[];
paintings: CommonCodeOption[];
motors: CommonCodeOption[];
controllers: CommonCodeOption[];
widthConstructions: CommonCodeOption[];
heightConstructions: CommonCodeOption[];
}
interface EstimateDetailTableSectionProps {
detailItems: EstimateDetailItem[];
appliedPrices: AppliedPrices | null;
isViewMode: boolean;
options?: EstimateDetailOptions;
onAddItems: (count: number) => void;
onRemoveItem: (id: string) => void;
onRemoveSelected: () => void;
@@ -99,10 +111,45 @@ interface EstimateDetailTableSectionProps {
onReset: () => void;
}
// API 데이터 로드 전 기본 옵션 (폴백용)
const DEFAULT_OPTIONS: EstimateDetailOptions = {
materials: [
{ value: 'screen', label: '스크린', code: 'screen' },
{ value: 'slat', label: '슬랫', code: 'slat' },
{ value: 'bending', label: '벤딩', code: 'bending' },
{ value: 'jointbar', label: '조인트바', code: 'jointbar' },
],
paintings: [
{ value: '0', label: '직접입력', code: '0', price: 0 },
{ value: '50000', label: '도장A', code: 'painting_a', price: 50000 },
{ value: '80000', label: '도장B', code: 'painting_b', price: 80000 },
],
motors: [
{ value: '300000', label: '모터 300,000', code: 'motor_300k', price: 300000 },
{ value: '500000', label: '모터 500,000', code: 'motor_500k', price: 500000 },
],
controllers: [
{ value: '150000', label: '제어기 150,000', code: 'ctrl_150k', price: 150000 },
{ value: '250000', label: '제어기 250,000', code: 'ctrl_250k', price: 250000 },
],
widthConstructions: [
{ value: '300000', label: '3.01~4.0M', code: 'w_3_4m', price: 300000 },
{ value: '400000', label: '4.01~5.0M', code: 'w_4_5m', price: 400000 },
{ value: '500000', label: '5.01~6.0M', code: 'w_5_6m', price: 500000 },
{ value: '600000', label: '6.01~7.0M', code: 'w_6_7m', price: 600000 },
],
heightConstructions: [
{ value: '5000', label: '3.51~4.5M', code: 'h_3_4m', price: 5000 },
{ value: '8000', label: '4.51~5.5M', code: 'h_4_5m', price: 8000 },
{ value: '10000', label: '5.51~6.5M', code: 'h_5_6m', price: 10000 },
],
};
export function EstimateDetailTableSection({
detailItems,
appliedPrices,
isViewMode,
options,
onAddItems,
onRemoveItem,
onRemoveSelected,
@@ -112,6 +159,8 @@ export function EstimateDetailTableSection({
onApplyAdjustedPrice,
onReset,
}: EstimateDetailTableSectionProps) {
// API 옵션이 없으면 기본 옵션 사용
const opts = options || DEFAULT_OPTIONS;
const selectedCount = detailItems.filter((item) => (item as unknown as { selected?: boolean }).selected).length;
const allSelected = detailItems.length > 0 && detailItems.every((item) => (item as unknown as { selected?: boolean }).selected);
const totals = calculateTotalsWithApplied(detailItems, appliedPrices);
@@ -279,7 +328,7 @@ export function EstimateDetailTableSection({
{/* 01: 명칭 */}
<TableCell>
<Input
value={item.name}
value={item.name ?? ''}
onChange={(e) => onItemChange(item.id, 'name', e.target.value)}
disabled={isViewMode}
className={`w-full min-w-[80px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
@@ -288,7 +337,7 @@ export function EstimateDetailTableSection({
{/* 02: 제품 */}
<TableCell>
<Select
value={item.material}
value={item.material ?? ''}
onValueChange={(val) => onItemChange(item.id, 'material', val)}
disabled={isViewMode}
>
@@ -296,7 +345,7 @@ export function EstimateDetailTableSection({
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{MOCK_MATERIALS.map((option) => (
{opts.materials.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
@@ -309,7 +358,7 @@ export function EstimateDetailTableSection({
<Input
type="number"
step="0.01"
value={item.width}
value={item.width ?? 0}
onChange={(e) => onItemChange(item.id, 'width', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
@@ -320,7 +369,7 @@ export function EstimateDetailTableSection({
<Input
type="number"
step="0.01"
value={item.height}
value={item.height ?? 0}
onChange={(e) => onItemChange(item.id, 'height', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
@@ -419,9 +468,11 @@ export function EstimateDetailTableSection({
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
<SelectItem value="50000">A</SelectItem>
<SelectItem value="80000">B</SelectItem>
{opts.paintings.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
@@ -436,8 +487,11 @@ export function EstimateDetailTableSection({
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="300000"> 300,000</SelectItem>
<SelectItem value="500000"> 500,000</SelectItem>
{opts.motors.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
@@ -452,8 +506,11 @@ export function EstimateDetailTableSection({
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="150000"> 150,000</SelectItem>
<SelectItem value="250000"> 250,000</SelectItem>
{opts.controllers.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
@@ -468,10 +525,11 @@ export function EstimateDetailTableSection({
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="300000">3.01~4.0M</SelectItem>
<SelectItem value="400000">4.01~5.0M</SelectItem>
<SelectItem value="500000">5.01~6.0M</SelectItem>
<SelectItem value="600000">6.01~7.0M</SelectItem>
{opts.widthConstructions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
@@ -486,9 +544,11 @@ export function EstimateDetailTableSection({
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5000">3.51~4.5M</SelectItem>
<SelectItem value="8000">4.51~5.5M</SelectItem>
<SelectItem value="10000">5.51~6.5M</SelectItem>
{opts.heightConstructions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
@@ -507,7 +567,7 @@ export function EstimateDetailTableSection({
<Input
type="number"
step="0.01"
value={item.expense}
value={item.expense ?? 0}
onChange={(e) => onItemChange(item.id, 'expense', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
@@ -528,7 +588,7 @@ export function EstimateDetailTableSection({
<Input
type="number"
min={1}
value={item.quantity}
value={item.quantity ?? 0}
onChange={(e) => onItemChange(item.id, 'quantity', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[40px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}

View File

@@ -1,10 +1,6 @@
// 목업 재료 목록
export const MOCK_MATERIALS = [
{ value: 'screen', label: '스크린' },
{ value: 'slat', label: '슬랫' },
{ value: 'bending', label: '벤딩' },
{ value: 'jointbar', label: '조인트바' },
];
// 공과 품목은 Items API (type=RM)에서 조회
// MOCK_EXPENSES 제거됨 - getExpenseItemOptions() 사용
// 견적서 상수 정의
// 모든 MOCK 데이터는 API로 대체됨:
// - 재료(material_type): getCommonCodeOptions('material_type')
// - 공과 품목: getExpenseItemOptions()
// - 도장/모터/제어기/시공비: getCommonCodeOptions('{group}')
// - 거래처/견적자: getClientOptions(), getUserOptions()