From 92af11c787377ac97522015a449c4cfebb501c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 22 Jan 2026 19:31:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20FCM=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC,=20=EC=9E=85=EA=B8=88=20=EB=93=B1=EB=A1=9D,?= =?UTF-8?q?=20=EA=B2=AC=EC=A0=81=20=EC=A0=80=EC=9E=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수주 상세 페이지에서 수주확정 시 FCM 푸시 알림 발송 추가 - FCM 프리셋 함수 추가: 계약완료, 발주완료 알림 - 입금 등록 시 입금일, 입금계좌, 입금자명, 입금금액 입력 가능 - 견적 저장 시 토스트 메시지 정상 표시 수정 - ShipmentCreate SelectItem key prop 경고 수정 - DevToolbar 문법 오류 수정 --- .../order-management-sales/[id]/page.tsx | 15 ++ .../sales/quote-management/[id]/page.tsx | 11 +- .../sales/quote-management/new/page.tsx | 11 +- .../DepositManagement/depositDetailConfig.ts | 34 +-- src/components/dev/DevFillContext.tsx | 4 +- src/components/dev/DevToolbar.tsx | 73 +++++- .../dev/generators/accountingData.ts | 232 ++++++++++++++++++ src/components/dev/generators/quoteData.ts | 38 +-- src/components/dev/index.ts | 3 +- .../ShipmentManagement/ShipmentCreate.tsx | 12 +- src/components/quotes/QuoteRegistration.tsx | 51 ++-- src/lib/actions/fcm.ts | 30 +++ 12 files changed, 446 insertions(+), 68 deletions(-) create mode 100644 src/components/dev/generators/accountingData.ts diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx index 4b9d6295..c44c889b 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx @@ -67,6 +67,7 @@ import { type Order, type OrderStatus, } from "@/components/orders"; +import { sendSalesOrderNotification } from "@/lib/actions/fcm"; // 상태 뱃지 헬퍼 @@ -214,6 +215,20 @@ export default function OrderDetailPage() { setOrder(result.data); toast.success("수주가 확정되었습니다."); setIsConfirmDialogOpen(false); + + // FCM 푸시 알림 발송 + try { + const fcmResult = await sendSalesOrderNotification({ + body: `${order.client} - ${order.siteName} 수주가 확정되었습니다.`, + }); + if (fcmResult.success) { + console.log(`[FCM] 수주확정 알림 발송 성공 (${fcmResult.sentCount || 0}건)`); + } else { + console.warn('[FCM] 수주확정 알림 발송 실패:', fcmResult.error); + } + } catch (fcmError) { + console.error('[FCM] 수주확정 알림 발송 오류:', fcmError); + } } else { toast.error(result.error || "수주 확정에 실패했습니다."); } diff --git a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx index fa8c4ffe..e4f61ebd 100644 --- a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx @@ -151,8 +151,8 @@ export default function QuoteDetailPage() { }; // V2 패턴: 수정 저장 핸들러 - const handleSave = async (formData: QuoteFormData) => { - if (isSaving) return; + const handleSave = async (formData: QuoteFormData): Promise<{ success: boolean; error?: string }> => { + if (isSaving) return { success: false, error: '저장 중입니다.' }; setIsSaving(true); try { @@ -160,13 +160,14 @@ export default function QuoteDetailPage() { const result = await updateQuote(quoteId, apiData as any); if (result.success) { - toast.success("견적이 수정되었습니다."); + // toast는 IntegratedDetailTemplate에서 처리 router.push(`/sales/quote-management/${quoteId}`); + return { success: true }; } else { - toast.error(result.error || "견적 수정에 실패했습니다."); + return { success: false, error: result.error || "견적 수정에 실패했습니다." }; } } catch (error) { - toast.error("견적 수정에 실패했습니다."); + return { success: false, error: "견적 수정에 실패했습니다." }; } finally { setIsSaving(false); } diff --git a/src/app/[locale]/(protected)/sales/quote-management/new/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/new/page.tsx index fa808367..e3d589ba 100644 --- a/src/app/[locale]/(protected)/sales/quote-management/new/page.tsx +++ b/src/app/[locale]/(protected)/sales/quote-management/new/page.tsx @@ -18,8 +18,8 @@ export default function QuoteNewPage() { router.push("/sales/quote-management"); }; - const handleSave = async (formData: QuoteFormData) => { - if (isSaving) return; + const handleSave = async (formData: QuoteFormData): Promise<{ success: boolean; error?: string }> => { + if (isSaving) return { success: false, error: '저장 중입니다.' }; setIsSaving(true); try { @@ -45,13 +45,14 @@ export default function QuoteNewPage() { const result = await createQuote(apiData as any); if (result.success && result.data) { - toast.success("견적이 등록되었습니다."); + // toast는 IntegratedDetailTemplate에서 처리 router.push(`/sales/quote-management/${result.data.id}`); + return { success: true }; } else { - toast.error(result.error || "견적 등록에 실패했습니다."); + return { success: false, error: result.error || "견적 등록에 실패했습니다." }; } } catch (error) { - toast.error("견적 등록에 실패했습니다."); + return { success: false, error: "견적 등록에 실패했습니다." }; } finally { setIsSaving(false); } diff --git a/src/components/accounting/DepositManagement/depositDetailConfig.ts b/src/components/accounting/DepositManagement/depositDetailConfig.ts index 1400ccea..089deb83 100644 --- a/src/components/accounting/DepositManagement/depositDetailConfig.ts +++ b/src/components/accounting/DepositManagement/depositDetailConfig.ts @@ -6,37 +6,37 @@ import { getVendors } from './actions'; // ===== 필드 정의 ===== const fields: FieldDefinition[] = [ - // 입금일 (readonly) + // 입금일 { key: 'depositDate', label: '입금일', - type: 'text', - readonly: true, - placeholder: '-', + type: 'date', + placeholder: '입금일을 선택해주세요', + disabled: (mode) => mode === 'view', }, - // 입금계좌 (readonly) + // 입금계좌 { key: 'accountName', label: '입금계좌', type: 'text', - readonly: true, - placeholder: '-', + placeholder: '입금계좌를 입력해주세요', + disabled: (mode) => mode === 'view', }, - // 입금자명 (readonly) + // 입금자명 { key: 'depositorName', label: '입금자명', type: 'text', - readonly: true, - placeholder: '-', + placeholder: '입금자명을 입력해주세요', + disabled: (mode) => mode === 'view', }, - // 입금금액 (readonly) + // 입금금액 { key: 'depositAmount', label: '입금금액', - type: 'text', - readonly: true, - placeholder: '-', + type: 'number', + placeholder: '입금금액을 입력해주세요', + disabled: (mode) => mode === 'view', }, // 적요 (editable) { @@ -107,7 +107,7 @@ export const depositDetailConfig: DetailConfig = { depositDate: record.depositDate || '', accountName: record.accountName || '', depositorName: record.depositorName || '', - depositAmount: record.depositAmount ? record.depositAmount.toLocaleString() : '0', + depositAmount: record.depositAmount || 0, note: record.note || '', vendorId: record.vendorId || '', depositType: record.depositType || 'unset', @@ -115,6 +115,10 @@ export const depositDetailConfig: DetailConfig = { }, transformSubmitData: (formData: Record): Partial => { return { + depositDate: formData.depositDate as string, + accountName: formData.accountName as string, + depositorName: formData.depositorName as string, + depositAmount: formData.depositAmount ? Number(formData.depositAmount) : 0, note: formData.note as string, vendorId: formData.vendorId as string, depositType: formData.depositType as DepositRecord['depositType'], diff --git a/src/components/dev/DevFillContext.tsx b/src/components/dev/DevFillContext.tsx index a75abb7f..5426013b 100644 --- a/src/components/dev/DevFillContext.tsx +++ b/src/components/dev/DevFillContext.tsx @@ -15,7 +15,9 @@ import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; // 지원하는 페이지 타입 -export type DevFillPageType = 'quote' | 'order' | 'workOrder' | 'workOrderComplete' | 'shipment'; +export type DevFillPageType = + | 'quote' | 'order' | 'workOrder' | 'workOrderComplete' | 'shipment' // 판매/생산 플로우 + | 'deposit' | 'withdrawal' | 'purchaseApproval' | 'cardTransaction'; // 회계 플로우 // 폼 채우기 함수 타입 type FillFormFunction = (data?: unknown) => void | Promise; diff --git a/src/components/dev/DevToolbar.tsx b/src/components/dev/DevToolbar.tsx index 825f3ec1..0a125436 100644 --- a/src/components/dev/DevToolbar.tsx +++ b/src/components/dev/DevToolbar.tsx @@ -28,6 +28,11 @@ import { Loader2, Play, RotateCcw, + // 회계 아이콘 + ArrowDownToLine, // 입금 + ArrowUpFromLine, // 출금 + Receipt, // 매입(지출결의서) + CreditCard, // 카드 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -35,6 +40,7 @@ import { useDevFillContext, type DevFillPageType } from './DevFillContext'; // 페이지 경로와 타입 매핑 const PAGE_PATTERNS: { pattern: RegExp; type: DevFillPageType; label: string }[] = [ + // 판매/생산 플로우 { pattern: /\/quote-management\/new/, type: 'quote', label: '견적' }, { pattern: /\/quote-management\/\d+\/edit/, type: 'quote', label: '견적' }, { pattern: /\/order-management-sales\/new/, type: 'order', label: '수주' }, @@ -44,6 +50,11 @@ const PAGE_PATTERNS: { pattern: RegExp; type: DevFillPageType; label: string }[] { pattern: /\/work-orders\/\d+$/, type: 'workOrderComplete', label: '작업완료' }, { pattern: /\/shipments\/new/, type: 'shipment', label: '출하' }, { pattern: /\/shipments\/\d+\/edit/, type: 'shipment', label: '출하' }, + // 회계 플로우 + { pattern: /\/accounting\/deposits\/new/, type: 'deposit', label: '입금' }, + { pattern: /\/accounting\/withdrawals\/new/, type: 'withdrawal', label: '출금' }, + { pattern: /\/approval\/draft\/new/, type: 'purchaseApproval', label: '매입' }, + { pattern: /\/accounting\/card-transactions/, type: 'cardTransaction', label: '카드' }, ]; // 플로우 단계 정의 @@ -55,6 +66,14 @@ const FLOW_STEPS: { type: DevFillPageType; label: string; icon: typeof FileText; { type: 'shipment', label: '출하', icon: Truck, path: '/outbound/shipments/new' }, ]; +// 회계 단계 정의 +const ACCOUNTING_STEPS: { type: DevFillPageType; label: string; icon: typeof FileText; path: string; fillEnabled: boolean }[] = [ + { type: 'deposit', label: '입금', icon: ArrowDownToLine, path: '/accounting/deposits/new', fillEnabled: true }, + { type: 'withdrawal', label: '출금', icon: ArrowUpFromLine, path: '/accounting/withdrawals/new', fillEnabled: true }, + { type: 'purchaseApproval', label: '매입', icon: Receipt, path: '/approval/draft/new', fillEnabled: true }, + { type: 'cardTransaction', label: '카드', icon: CreditCard, path: '/accounting/card-transactions', fillEnabled: false }, // 이동만 +]; + export function DevToolbar() { const pathname = usePathname(); const router = useRouter(); @@ -181,7 +200,7 @@ export function DevToolbar() { - {/* 버튼 영역 */} + {/* 판매/생산 플로우 버튼 영역 */} {isExpanded && (
{FLOW_STEPS.map((step, index) => { @@ -255,11 +274,61 @@ export function DevToolbar() {
)} + {/* 회계 플로우 버튼 영역 */} + {isExpanded && ( +
+ 회계: + {ACCOUNTING_STEPS.map((step) => { + const Icon = step.icon; + const isActive = activePage === step.type; + const isRegistered = hasRegisteredForm(step.type); + const isCurrentLoading = isLoading === step.type; + + // 활성화된 페이지: 폼 채우기 (fillEnabled가 true인 경우만) + if (isActive && step.fillEnabled) { + return ( + + ); + } + + // 비활성화된 페이지 또는 이동만 가능한 버튼: 해당 페이지로 이동 + return ( + + ); + })} +
+ )} + {/* 안내 메시지 */} {isExpanded && !activePage && (

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

)} diff --git a/src/components/dev/generators/accountingData.ts b/src/components/dev/generators/accountingData.ts new file mode 100644 index 00000000..85494de1 --- /dev/null +++ b/src/components/dev/generators/accountingData.ts @@ -0,0 +1,232 @@ +/** + * 회계 샘플 데이터 생성기 (입금, 출금, 매입) + */ + +import { + randomPick, + randomInt, + today, + randomRemark, +} from './index'; + +// ===== 공통 상수 ===== + +// 거래처 목록 (실제로는 API에서 가져옴) +const SAMPLE_VENDORS = [ + { id: '1', name: '(주)삼성전자' }, + { id: '2', name: '(주)LG화학' }, + { id: '3', name: '현대중공업' }, + { id: '4', name: '(주)포스코' }, + { id: '5', name: '한화솔루션' }, +]; + +// 계좌명 목록 +const ACCOUNT_NAMES = [ + '기업은행 1234-5678-9012', + '국민은행 111-22-33333', + '신한은행 110-123-456789', + '우리은행 1002-123-456789', + '하나은행 888-123456-78901', +]; + +// ===== 입금 관련 ===== + +// 입금 유형 +const DEPOSIT_TYPES = ['revenue', 'deposit', 'sales', 'other', 'unset']; + +// 입금자명 +const DEPOSITOR_NAMES = [ + '홍길동', + '김철수', + '이영희', + '박민수', + '최지영', + '(주)삼성전자', + '(주)LG화학', + '현대중공업', +]; + +// 입금 적요 +const DEPOSIT_NOTES = [ + '제품 판매대금', + '선수금 입금', + '용역비 입금', + '대금 회수', + '계약금 입금', + '잔금 입금', + '기타 입금', + '', +]; + +export interface DepositFormData { + depositDate: string; + accountName: string; + depositorName: string; + depositAmount: number; + note: string; + vendorId: string; + depositType: string; +} + +export interface GenerateDepositDataOptions { + vendors?: Array<{ id: string; name: string }>; +} + +export function generateDepositData(options: GenerateDepositDataOptions = {}): DepositFormData { + const { vendors = SAMPLE_VENDORS } = options; + const vendor = randomPick(vendors); + + return { + depositDate: today(), + accountName: randomPick(ACCOUNT_NAMES), + depositorName: randomPick(DEPOSITOR_NAMES), + depositAmount: randomInt(100000, 10000000), + note: randomPick(DEPOSIT_NOTES), + vendorId: vendor.id, + depositType: randomPick(DEPOSIT_TYPES.filter(t => t !== 'unset')), // unset 제외 + }; +} + +// ===== 출금 관련 ===== + +// 출금 유형 +const WITHDRAWAL_TYPES = ['expense', 'payment', 'purchase', 'salary', 'other', 'unset']; + +// 수취인명 +const RECIPIENT_NAMES = [ + '(주)삼성전자', + '(주)LG화학', + '현대중공업', + '(주)포스코', + '한화솔루션', + '홍길동', + '김철수', + '국세청', +]; + +// 출금 적요 +const WITHDRAWAL_NOTES = [ + '자재 구매대금', + '외주 가공비', + '임대료 지급', + '전기요금', + '수도요금', + '통신비', + '급여 지급', + '세금 납부', + '', +]; + +export interface WithdrawalFormData { + withdrawalDate: string; + accountName: string; + recipientName: string; + withdrawalAmount: number; + note: string; + vendorId: string; + withdrawalType: string; +} + +export interface GenerateWithdrawalDataOptions { + vendors?: Array<{ id: string; name: string }>; +} + +export function generateWithdrawalData(options: GenerateWithdrawalDataOptions = {}): WithdrawalFormData { + const { vendors = SAMPLE_VENDORS } = options; + const vendor = randomPick(vendors); + + return { + withdrawalDate: today(), + accountName: randomPick(ACCOUNT_NAMES), + recipientName: randomPick(RECIPIENT_NAMES), + withdrawalAmount: randomInt(50000, 5000000), + note: randomPick(WITHDRAWAL_NOTES), + vendorId: vendor.id, + withdrawalType: randomPick(WITHDRAWAL_TYPES.filter(t => t !== 'unset')), // unset 제외 + }; +} + +// ===== 매입(지출결의서) 관련 ===== + +// 문서 유형 +const DOCUMENT_TYPES = ['proposal', 'expenseReport', 'expenseEstimate']; + +// 지출결의서 제목 +const PROPOSAL_TITLES = [ + '사무용품 구매 요청', + '장비 수리비 지출 요청', + '출장비 정산 요청', + '회의비 지출 요청', + '교육비 지출 요청', + '소프트웨어 라이선스 구매', + '마케팅 비용 지출 요청', + '복리후생비 지출 요청', +]; + +// 지출결의서 내용 +const PROPOSAL_DESCRIPTIONS = [ + '업무 효율 향상을 위한 사무용품 구매가 필요합니다.', + '노후화된 장비의 수리가 필요하여 지출을 요청드립니다.', + '고객 미팅을 위한 출장 경비를 정산해주시기 바랍니다.', + '팀 회의 진행을 위한 다과 비용입니다.', + '직원 역량 강화를 위한 교육비 지출입니다.', + '업무용 소프트웨어 라이선스 갱신 비용입니다.', + '신규 고객 유치를 위한 마케팅 활동 비용입니다.', + '직원 복지 증진을 위한 지출입니다.', +]; + +// 지출 사유 +const PROPOSAL_REASONS = [ + '업무 효율성 향상', + '고객 서비스 개선', + '비용 절감 효과', + '법적 의무 이행', + '안전 관리 필수', + '계약 조건 이행', + '직원 역량 강화', + '시설 유지보수', +]; + +export interface PurchaseApprovalFormData { + basicInfo: { + drafter: string; + draftDate: string; + documentNo: string; + documentType: string; + }; + proposalData: { + vendor: string; + vendorPaymentDate: string; + title: string; + description: string; + reason: string; + estimatedCost: number; + }; +} + +export interface GeneratePurchaseApprovalDataOptions { + vendors?: Array<{ id: string; name: string }>; + documentType?: string; +} + +export function generatePurchaseApprovalData(options: GeneratePurchaseApprovalDataOptions = {}): PurchaseApprovalFormData { + const { vendors = SAMPLE_VENDORS, documentType = 'proposal' } = options; + const vendor = randomPick(vendors); + + return { + basicInfo: { + drafter: '홍길동', + draftDate: new Date().toISOString().slice(0, 16).replace('T', ' '), + documentNo: '', + documentType, + }, + proposalData: { + vendor: vendor.name, + vendorPaymentDate: today(), + title: randomPick(PROPOSAL_TITLES), + description: randomPick(PROPOSAL_DESCRIPTIONS), + reason: randomPick(PROPOSAL_REASONS), + estimatedCost: randomInt(100000, 5000000), + }, + }; +} \ No newline at end of file diff --git a/src/components/dev/generators/quoteData.ts b/src/components/dev/generators/quoteData.ts index f0b8b32b..b6f23d98 100644 --- a/src/components/dev/generators/quoteData.ts +++ b/src/components/dev/generators/quoteData.ts @@ -36,37 +36,35 @@ const WRITERS = ['드미트리', '김철수', '이영희', '박지민', '최서 /** * 견적 품목 1개 생성 + * @param index 품목 인덱스 + * @param products 제품 목록 (code, name, category 속성 필요) + * @param category 제품 카테고리 (지정하지 않으면 랜덤 선택) */ export function generateQuoteItem( index: number, - products?: FinishedGoods[] + products?: Array<{ code: string; name: string; category?: string }>, + category?: string ): QuoteItem { - const category = randomPick(PRODUCT_CATEGORIES); + const selectedCategory = category || randomPick(PRODUCT_CATEGORIES); // 카테고리에 맞는 제품 필터링 - let productName = ''; + let productCode = ''; if (products && products.length > 0) { const categoryProducts = products.filter(p => - p.categoryCode?.toUpperCase() === category || !p.categoryCode + p.category?.toUpperCase() === selectedCategory || !p.category ); if (categoryProducts.length > 0) { - productName = randomPick(categoryProducts).name; + // item_code를 사용 (Select 컴포넌트의 value와 일치) + productCode = randomPick(categoryProducts).code; } } - // 제품명이 없으면 기본값 - if (!productName) { - productName = category === 'SCREEN' - ? randomPick(['방화스크린 FSC-1', '방화스크린 FSC-2', '방화스크린 FSC-3']) - : randomPick(['방화철재셔터 FSD-1', '방화철재셔터 FSD-2']); - } - return { id: tempId(), floor: randomFloor(), code: nextCode(), - productCategory: category, - productName, + productCategory: selectedCategory, + productName: productCode, // item_code 사용 openWidth: String(randomInt100(2000, 5000)), openHeight: String(randomInt100(2000, 5000)), guideRailType: randomPick(GUIDE_RAIL_TYPES), @@ -82,13 +80,14 @@ export function generateQuoteItem( * 견적 폼 데이터 생성 */ export interface GenerateQuoteDataOptions { - clients?: Vendor[]; // 거래처 목록 - products?: FinishedGoods[]; // 제품 목록 + clients?: Array<{ id: string | number; name: string }>; // 거래처 목록 + products?: Array<{ code: string; name: string; category?: string }>; // 제품 목록 (code=item_code) itemCount?: number; // 품목 수 (기본: 1~5개 랜덤) + category?: string; // 제품 카테고리 (지정하지 않으면 랜덤) } export function generateQuoteData(options: GenerateQuoteDataOptions = {}): QuoteFormData { - const { clients = [], products = [], itemCount } = options; + const { clients = [], products = [], itemCount, category } = options; // 부호 카운터 리셋 resetCodeCounter(); @@ -105,10 +104,11 @@ export function generateQuoteData(options: GenerateQuoteDataOptions = {}): Quote // 품목 수 결정 const count = itemCount ?? randomInt(1, 5); - // 품목 생성 + // 품목 생성 (동일 카테고리 사용) + const selectedCategory = category || randomPick(PRODUCT_CATEGORIES); const items: QuoteItem[] = []; for (let i = 0; i < count; i++) { - items.push(generateQuoteItem(i, products)); + items.push(generateQuoteItem(i, products, selectedCategory)); } return { diff --git a/src/components/dev/index.ts b/src/components/dev/index.ts index cb0e1f6f..cdbbabdd 100644 --- a/src/components/dev/index.ts +++ b/src/components/dev/index.ts @@ -13,4 +13,5 @@ export { DevToolbar } from './DevToolbar'; export { generateQuoteData, generateQuoteItem } from './generators/quoteData'; export { generateOrderData, generateOrderDataFull } from './generators/orderData'; export { generateWorkOrderData } from './generators/workOrderData'; -export { generateShipmentData } from './generators/shipmentData'; \ No newline at end of file +export { generateShipmentData } from './generators/shipmentData'; +export { generateDepositData, generateWithdrawalData, generatePurchaseApprovalData } from './generators/accountingData'; \ No newline at end of file diff --git a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx index 66046086..e46343a2 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx @@ -255,8 +255,8 @@ export function ShipmentCreate() { - {lotOptions.map((option) => ( - + {lotOptions.filter(o => o.value).map((option, index) => ( + {option.label} ({option.customerName} - {option.siteName}) ))} @@ -341,8 +341,8 @@ export function ShipmentCreate() { - {logisticsOptions.map((option) => ( - + {logisticsOptions.filter(o => o.value).map((option, index) => ( + {option.label} ))} @@ -360,8 +360,8 @@ export function ShipmentCreate() { - {vehicleTonnageOptions.map((option) => ( - + {vehicleTonnageOptions.filter(o => o.value).map((option, index) => ( + {option.label} ))} diff --git a/src/components/quotes/QuoteRegistration.tsx b/src/components/quotes/QuoteRegistration.tsx index b521e2ed..7cf655d5 100644 --- a/src/components/quotes/QuoteRegistration.tsx +++ b/src/components/quotes/QuoteRegistration.tsx @@ -160,7 +160,7 @@ const CONTROLLERS = [ interface QuoteRegistrationProps { onBack: () => void; - onSave: (quote: QuoteFormData) => Promise; + onSave: (quote: QuoteFormData) => Promise<{ success: boolean; error?: string }>; editingQuote?: QuoteFormData | null; isLoading?: boolean; } @@ -208,15 +208,39 @@ export function QuoteRegistration({ // DevToolbar용 폼 자동 채우기 등록 useDevFill( 'quote', - useCallback(() => { - // 실제 로드된 데이터를 기반으로 샘플 데이터 생성 + useCallback(async () => { + // 1. 카테고리 랜덤 선택 + const categories = ['SCREEN', 'STEEL']; + const selectedCategory = categories[Math.floor(Math.random() * categories.length)]; + + // 2. 해당 카테고리 제품 로드 (캐시에 없으면 API 호출) + let categoryProducts = categoryProductsCache[selectedCategory]; + if (!categoryProducts) { + try { + const result = await getFinishedGoods(selectedCategory); + if (result.success) { + categoryProducts = result.data; + setCategoryProductsCache(prev => ({ + ...prev, + [selectedCategory]: result.data + })); + } + } catch (error) { + console.error('[DevFill] 카테고리별 제품 로드 실패:', error); + } + } + + // 3. 로드된 제품 목록으로 샘플 데이터 생성 + const productsToUse = categoryProducts || finishedGoods; const sampleData = generateQuoteData({ clients: clients.map(c => ({ id: c.id, name: c.vendorName })), - products: finishedGoods.map(p => ({ code: p.item_code, name: p.item_name, category: p.category })), + products: productsToUse.map(p => ({ code: p.item_code, name: p.item_name, category: p.category })), + category: selectedCategory, }); + setFormData(sampleData); toast.success('[Dev] 견적 폼이 자동으로 채워졌습니다.'); - }, [clients, finishedGoods]) + }, [clients, finishedGoods, categoryProductsCache]) ); // 수량 반영 총합계 계산 (useMemo로 최적화) @@ -388,11 +412,12 @@ export function QuoteRegistration({ return Object.keys(newErrors).length === 0; }, [formData]); - const handleSubmit = useCallback(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleSubmit = useCallback(async (_data?: Record): Promise<{ success: boolean; error?: string }> => { if (!validateForm()) { // 페이지 상단으로 스크롤 window.scrollTo({ top: 0, behavior: 'smooth' }); - return; + return { success: false, error: '입력 정보를 확인해주세요.' }; } // 에러 초기화 @@ -405,18 +430,16 @@ export function QuoteRegistration({ ...formData, calculationResults: calculationResults || undefined, }; - await onSave(dataToSave); - toast.success( - editingQuote ? "견적이 수정되었습니다." : "견적이 등록되었습니다." - ); - onBack(); + const result = await onSave(dataToSave); + // IntegratedDetailTemplate에서 toast 처리 및 navigation 처리 + return result; } catch (error) { if (isNextRedirectError(error)) throw error; - toast.error("저장 중 오류가 발생했습니다."); + return { success: false, error: '저장 중 오류가 발생했습니다.' }; } finally { setIsSaving(false); } - }, [formData, calculationResults, validateForm, onSave, editingQuote, onBack]); + }, [formData, calculationResults, validateForm, onSave]); const handleFieldChange = ( field: keyof QuoteFormData, diff --git a/src/lib/actions/fcm.ts b/src/lib/actions/fcm.ts index 352c178f..b61bfc1a 100644 --- a/src/lib/actions/fcm.ts +++ b/src/lib/actions/fcm.ts @@ -175,4 +175,34 @@ export async function sendSalesOrderNotification( channel_id: 'push_sales_order', ...customParams, }); +} + +/** + * 계약완료 알림 발송 (프리셋) + */ +export async function sendContractCompletedNotification( + customParams?: Partial +): Promise { + return sendFcmNotification({ + title: '계약 완료 알림', + body: '계약이 완료되었습니다.', + type: 'contract_completed', + channel_id: 'push_contract', + ...customParams, + }); +} + +/** + * 발주완료 알림 발송 (프리셋) + */ +export async function sendPurchaseOrderNotification( + customParams?: Partial +): Promise { + return sendFcmNotification({ + title: '발주 완료 알림', + body: '발주가 완료되었습니다.', + type: 'purchase_order', + channel_id: 'push_purchase_order', + ...customParams, + }); } \ No newline at end of file