feat(WEB): 견적서 V2 컴포넌트 개선 및 미리보기 모달 패턴 적용

- LocationDetailPanel: 6개 탭 구현 (본체, 가이드레일, 케이스, 하단마감재, 모터&제어기, 부자재)
- 각 탭별 다른 테이블 컬럼 구조 적용
- QuoteSummaryPanel: 개소별/상세별 합계 패널 개선
- QuotePreviewModal: EstimateDocumentModal 패턴 적용 (헤더+버튼 영역 분리)
- Input value → defaultValue 변경으로 React 경고 해결
- 팩스/카카오톡 버튼 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-12 15:26:17 +09:00
parent e56b7d53a4
commit d036ce4f42
40 changed files with 5292 additions and 141 deletions

View File

@@ -19,6 +19,8 @@ function getEstimateDetail(id: string): EstimateDetail {
projectName: '현장명',
estimatorId: 'hong',
estimatorName: '이름',
estimateCompanyManager: '홍길동',
estimateCompanyManagerContact: '01012341234',
itemCount: 21,
estimateAmount: 1420000,
completedDate: null,

View File

@@ -19,6 +19,8 @@ function getEstimateDetail(id: string): EstimateDetail {
projectName: '현장명',
estimatorId: 'hong',
estimatorName: '이름',
estimateCompanyManager: '홍길동',
estimateCompanyManagerContact: '01012341234',
itemCount: 21,
estimateAmount: 1420000,
completedDate: null,

View File

@@ -0,0 +1,45 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm';
import { getContractDetail } from '@/components/business/construction/contract';
import type { ContractDetail } from '@/components/business/construction/contract/types';
export default function ContractCreatePage() {
const searchParams = useSearchParams();
const baseContractId = searchParams.get('baseContractId');
const [baseData, setBaseData] = useState<ContractDetail | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!!baseContractId);
useEffect(() => {
if (baseContractId) {
// 변경 계약서 생성: 기존 계약 데이터 복사
getContractDetail(baseContractId)
.then(result => {
if (result.success && result.data) {
setBaseData(result.data);
}
})
.finally(() => setIsLoading(false));
}
}, [baseContractId]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}
return (
<ContractDetailForm
mode="create"
contractId=""
initialData={baseData}
isChangeContract={!!baseContractId}
/>
);
}

View File

@@ -0,0 +1,7 @@
'use client';
import { ProjectListClient } from '@/components/business/construction/management';
export default function ProjectManagementPage() {
return <ProjectListClient />;
}

View File

@@ -1,13 +1,14 @@
'use client';
import React, { useState, useMemo } from 'react';
import { ChevronDown, ChevronRight, Check, Search, X } from 'lucide-react';
import { ChevronDown, ChevronRight, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ChecklistCategory, ChecklistSubItem } from '../types';
interface Day1ChecklistPanelProps {
categories: ChecklistCategory[];
selectedSubItemId: string | null;
searchTerm: string;
onSubItemSelect: (categoryId: string, subItemId: string) => void;
onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void;
}
@@ -15,13 +16,13 @@ interface Day1ChecklistPanelProps {
export function Day1ChecklistPanel({
categories,
selectedSubItemId,
searchTerm,
onSubItemSelect,
onSubItemToggle,
}: Day1ChecklistPanelProps) {
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침
);
const [searchTerm, setSearchTerm] = useState('');
// 검색 필터링된 카테고리
const filteredCategories = useMemo(() => {
@@ -74,10 +75,6 @@ export function Day1ChecklistPanel({
return { completed, total: originalCategory.subItems.length };
};
const clearSearch = () => {
setSearchTerm('');
};
// 검색 결과 하이라이트
const highlightText = (text: string, term: string) => {
if (!term.trim()) return text;
@@ -96,29 +93,9 @@ export function Day1ChecklistPanel({
return (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
{/* 헤더 + 검색 */}
{/* 헤더 */}
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900 text-sm sm:text-base mb-2"> </h3>
{/* 검색 입력 */}
<div className="relative">
<Search className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="항목 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-8 sm:pl-9 pr-8 py-1.5 sm:py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{searchTerm && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 rounded-full transition-colors"
>
<X className="h-4 w-4 text-gray-400" />
</button>
)}
</div>
<h3 className="font-semibold text-gray-900 text-sm sm:text-base"> </h3>
{/* 검색 결과 카운트 */}
{searchTerm && (
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500">

View File

@@ -270,6 +270,16 @@ export default function QualityInspectionPage() {
</div>
)}
{/* 공통 필터 (1일차/2일차 모두 사용) */}
<Filters
selectedYear={selectedYear}
selectedQuarter={selectedQuarter}
searchTerm={searchTerm}
onYearChange={handleYearChange}
onQuarterChange={handleQuarterChange}
onSearchChange={handleSearchChange}
/>
{activeDay === 1 ? (
// ===== 1일차: 기준/매뉴얼 심사 =====
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0">
@@ -284,6 +294,7 @@ export default function QualityInspectionPage() {
<Day1ChecklistPanel
categories={filteredDay1Categories}
selectedSubItemId={selectedSubItemId}
searchTerm={searchTerm}
onSubItemSelect={handleSubItemSelect}
onSubItemToggle={handleSubItemToggle}
/>
@@ -315,17 +326,7 @@ export default function QualityInspectionPage() {
</div>
) : (
// ===== 2일차: 로트추적 심사 =====
<>
<Filters
selectedYear={selectedYear}
selectedQuarter={selectedQuarter}
searchTerm={searchTerm}
onYearChange={handleYearChange}
onQuarterChange={handleQuarterChange}
onSearchChange={handleSearchChange}
/>
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<ReportList
reports={filteredReports}
@@ -352,7 +353,6 @@ export default function QualityInspectionPage() {
/>
</div>
</div>
</>
)}
{/* 설정 패널 */}

View File

@@ -0,0 +1,54 @@
/**
* 견적 등록 테스트 페이지 (V2 UI)
*
* 새로운 자동 견적 산출 UI 테스트용
* 기존 견적 등록 페이지는 수정하지 않음
*/
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { QuoteRegistrationV2, QuoteFormDataV2 } from "@/components/quotes/QuoteRegistrationV2";
import { toast } from "sonner";
export default function QuoteTestNewPage() {
const router = useRouter();
const [isSaving, setIsSaving] = useState(false);
const handleBack = () => {
router.push("/sales/quote-management");
};
const handleSave = async (data: QuoteFormDataV2, saveType: "temporary" | "final") => {
setIsSaving(true);
try {
// TODO: API 연동 시 실제 저장 로직 구현
console.log("[테스트] 저장 데이터:", data);
console.log("[테스트] 저장 타입:", saveType);
// 테스트용 지연
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success(`[테스트] ${saveType === "temporary" ? "임시" : "최종"} 저장 완료`);
// 저장 후 상세 페이지로 이동 (테스트용으로 ID=1 사용)
if (saveType === "final") {
router.push("/sales/quote-management/test/1");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
};
return (
<QuoteRegistrationV2
mode="create"
onBack={handleBack}
onSave={handleSave}
isLoading={isSaving}
/>
);
}

View File

@@ -0,0 +1,152 @@
/**
* 견적 수정 테스트 페이지 (V2 UI)
*
* 새로운 자동 견적 산출 UI 테스트용
* 기존 견적 수정 페이지는 수정하지 않음
*/
"use client";
import { useRouter, useParams } from "next/navigation";
import { useState, useEffect } from "react";
import { QuoteRegistrationV2, QuoteFormDataV2 } from "@/components/quotes/QuoteRegistrationV2";
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
import { toast } from "sonner";
// 테스트용 목업 데이터
const MOCK_DATA: QuoteFormDataV2 = {
id: "1",
registrationDate: "2026-01-12",
writer: "드미트리",
clientId: "1",
clientName: "아크다이레드",
siteName: "강남 테스트 현장",
manager: "김담당",
contact: "010-1234-5678",
dueDate: "2026-02-01",
remarks: "테스트 비고 내용입니다.",
status: "draft",
locations: [
{
id: "loc-1",
floor: "1층",
code: "FSS-01",
openWidth: 5000,
openHeight: 3000,
productCode: "KSS01",
productName: "방화스크린",
quantity: 1,
guideRailType: "wall",
motorPower: "single",
controller: "basic",
wingSize: 50,
inspectionFee: 50000,
unitPrice: 1645200,
totalPrice: 1645200,
},
{
id: "loc-2",
floor: "3층",
code: "FST-30",
openWidth: 7500,
openHeight: 3300,
productCode: "KSS02",
productName: "방화스크린2",
quantity: 1,
guideRailType: "wall",
motorPower: "single",
controller: "smart",
wingSize: 50,
inspectionFee: 50000,
unitPrice: 2589198,
totalPrice: 2589198,
},
{
id: "loc-3",
floor: "5층",
code: "FSS-50",
openWidth: 6000,
openHeight: 2800,
productCode: "KSS01",
productName: "방화스크린",
quantity: 2,
guideRailType: "floor",
motorPower: "three",
controller: "premium",
wingSize: 50,
inspectionFee: 50000,
unitPrice: 1721214,
totalPrice: 3442428,
},
],
};
export default function QuoteTestEditPage() {
const router = useRouter();
const params = useParams();
const quoteId = params.id as string;
const [quote, setQuote] = useState<QuoteFormDataV2 | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
// 테스트용 데이터 로드 시뮬레이션
const loadQuote = async () => {
setIsLoading(true);
try {
// 실제로는 API 호출
await new Promise((resolve) => setTimeout(resolve, 500));
setQuote({ ...MOCK_DATA, id: quoteId });
} catch (error) {
toast.error("견적 정보를 불러오는데 실패했습니다.");
router.push("/sales/quote-management");
} finally {
setIsLoading(false);
}
};
loadQuote();
}, [quoteId, router]);
const handleBack = () => {
router.push("/sales/quote-management");
};
const handleSave = async (data: QuoteFormDataV2, saveType: "temporary" | "final") => {
setIsSaving(true);
try {
// TODO: API 연동 시 실제 저장 로직 구현
console.log("[테스트] 수정 데이터:", data);
console.log("[테스트] 저장 타입:", saveType);
// 테스트용 지연
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success(`[테스트] ${saveType === "temporary" ? "임시" : "최종"} 저장 완료`);
// 저장 후 상세 페이지로 이동
if (saveType === "final") {
router.push(`/sales/quote-management/test/${quoteId}`);
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
}
return (
<QuoteRegistrationV2
mode="edit"
onBack={handleBack}
onSave={handleSave}
initialData={quote}
isLoading={isSaving}
/>
);
}

View File

@@ -0,0 +1,126 @@
/**
* 견적 상세 테스트 페이지 (V2 UI)
*
* 새로운 자동 견적 산출 UI 테스트용
* 기존 견적 상세 페이지는 수정하지 않음
*/
"use client";
import { useRouter, useParams } from "next/navigation";
import { useState, useEffect } from "react";
import { QuoteRegistrationV2, QuoteFormDataV2, LocationItem } from "@/components/quotes/QuoteRegistrationV2";
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
import { toast } from "sonner";
// 테스트용 목업 데이터
const MOCK_DATA: QuoteFormDataV2 = {
id: "1",
registrationDate: "2026-01-12",
writer: "드미트리",
clientId: "1",
clientName: "아크다이레드",
siteName: "강남 테스트 현장",
manager: "김담당",
contact: "010-1234-5678",
dueDate: "2026-02-01",
remarks: "테스트 비고 내용입니다.",
status: "draft",
locations: [
{
id: "loc-1",
floor: "1층",
code: "FSS-01",
openWidth: 5000,
openHeight: 3000,
productCode: "KSS01",
productName: "방화스크린",
quantity: 1,
guideRailType: "wall",
motorPower: "single",
controller: "basic",
wingSize: 50,
inspectionFee: 50000,
unitPrice: 1645200,
totalPrice: 1645200,
},
{
id: "loc-2",
floor: "3층",
code: "FST-30",
openWidth: 7500,
openHeight: 3300,
productCode: "KSS02",
productName: "방화스크린2",
quantity: 1,
guideRailType: "wall",
motorPower: "single",
controller: "smart",
wingSize: 50,
inspectionFee: 50000,
unitPrice: 2589198,
totalPrice: 2589198,
},
{
id: "loc-3",
floor: "5층",
code: "FSS-50",
openWidth: 6000,
openHeight: 2800,
productCode: "KSS01",
productName: "방화스크린",
quantity: 2,
guideRailType: "floor",
motorPower: "three",
controller: "premium",
wingSize: 50,
inspectionFee: 50000,
unitPrice: 1721214,
totalPrice: 3442428,
},
],
};
export default function QuoteTestDetailPage() {
const router = useRouter();
const params = useParams();
const quoteId = params.id as string;
const [quote, setQuote] = useState<QuoteFormDataV2 | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// 테스트용 데이터 로드 시뮬레이션
const loadQuote = async () => {
setIsLoading(true);
try {
// 실제로는 API 호출
await new Promise((resolve) => setTimeout(resolve, 500));
setQuote({ ...MOCK_DATA, id: quoteId });
} catch (error) {
toast.error("견적 정보를 불러오는데 실패했습니다.");
router.push("/sales/quote-management");
} finally {
setIsLoading(false);
}
};
loadQuote();
}, [quoteId, router]);
const handleBack = () => {
router.push("/sales/quote-management");
};
if (isLoading) {
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
}
return (
<QuoteRegistrationV2
mode="view"
onBack={handleBack}
initialData={quote}
/>
);
}