feat(WEB): FCM 푸시 알림, 입금 등록, 견적 저장 개선

- 수주 상세 페이지에서 수주확정 시 FCM 푸시 알림 발송 추가
- FCM 프리셋 함수 추가: 계약완료, 발주완료 알림
- 입금 등록 시 입금일, 입금계좌, 입금자명, 입금금액 입력 가능
- 견적 저장 시 토스트 메시지 정상 표시 수정
- ShipmentCreate SelectItem key prop 경고 수정
- DevToolbar 문법 오류 수정
This commit is contained in:
2026-01-22 19:31:19 +09:00
parent 5a00828568
commit 92af11c787
12 changed files with 446 additions and 68 deletions

View File

@@ -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 || "수주 확정에 실패했습니다.");
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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<string, unknown>): Partial<DepositRecord> => {
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'],

View File

@@ -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<void>;

View File

@@ -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() {
</div>
</div>
{/* 버튼 영역 */}
{/* 판매/생산 플로우 버튼 영역 */}
{isExpanded && (
<div className="flex items-center gap-2 px-3 py-3">
{FLOW_STEPS.map((step, index) => {
@@ -255,11 +274,61 @@ export function DevToolbar() {
</div>
)}
{/* 회계 플로우 버튼 영역 */}
{isExpanded && (
<div className="flex items-center gap-2 px-3 pb-3 border-t border-yellow-300 pt-3">
<span className="text-xs text-yellow-600 font-medium mr-1">:</span>
{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 (
<Button
key={step.type}
size="sm"
variant="default"
disabled={!isRegistered || isCurrentLoading}
className="bg-blue-500 hover:bg-blue-600 text-white border-blue-600"
onClick={() => handleFillForm(step.type)}
title="폼 자동 채우기"
>
{isCurrentLoading ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Icon className="w-4 h-4 mr-1" />
)}
{step.label}
</Button>
);
}
// 비활성화된 페이지 또는 이동만 가능한 버튼: 해당 페이지로 이동
return (
<Button
key={step.type}
size="sm"
variant="outline"
className={`border-blue-300 text-blue-700 hover:bg-blue-100 hover:border-blue-500 ${isActive ? 'bg-blue-100 border-blue-500' : ''}`}
onClick={() => handleNavigate(step.path)}
title={`${step.label} 페이지로 이동`}
>
<Icon className="w-4 h-4 mr-1" />
{step.label}
</Button>
);
})}
</div>
)}
{/* 안내 메시지 */}
{isExpanded && !activePage && (
<div className="px-3 pb-3">
<p className="text-xs text-yellow-600">
///
//////
</p>
</div>
)}

View File

@@ -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),
},
};
}

View File

@@ -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 {

View File

@@ -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';
export { generateShipmentData } from './generators/shipmentData';
export { generateDepositData, generateWithdrawalData, generatePurchaseApprovalData } from './generators/accountingData';

View File

@@ -255,8 +255,8 @@ export function ShipmentCreate() {
<SelectValue placeholder="로트 선택" />
</SelectTrigger>
<SelectContent>
{lotOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{lotOptions.filter(o => o.value).map((option, index) => (
<SelectItem key={`${option.value}-${index}`} value={option.value}>
{option.label} ({option.customerName} - {option.siteName})
</SelectItem>
))}
@@ -341,8 +341,8 @@ export function ShipmentCreate() {
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{logisticsOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{logisticsOptions.filter(o => o.value).map((option, index) => (
<SelectItem key={`${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))}
@@ -360,8 +360,8 @@ export function ShipmentCreate() {
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{vehicleTonnageOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{vehicleTonnageOptions.filter(o => o.value).map((option, index) => (
<SelectItem key={`${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))}

View File

@@ -160,7 +160,7 @@ const CONTROLLERS = [
interface QuoteRegistrationProps {
onBack: () => void;
onSave: (quote: QuoteFormData) => Promise<void>;
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<string, unknown>): 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,

View File

@@ -175,4 +175,34 @@ export async function sendSalesOrderNotification(
channel_id: 'push_sales_order',
...customParams,
});
}
/**
* 계약완료 알림 발송 (프리셋)
*/
export async function sendContractCompletedNotification(
customParams?: Partial<FcmNotificationParams>
): Promise<FcmResult> {
return sendFcmNotification({
title: '계약 완료 알림',
body: '계약이 완료되었습니다.',
type: 'contract_completed',
channel_id: 'push_contract',
...customParams,
});
}
/**
* 발주완료 알림 발송 (프리셋)
*/
export async function sendPurchaseOrderNotification(
customParams?: Partial<FcmNotificationParams>
): Promise<FcmResult> {
return sendFcmNotification({
title: '발주 완료 알림',
body: '발주가 완료되었습니다.',
type: 'purchase_order',
channel_id: 'push_purchase_order',
...customParams,
});
}