feat(WEB): FCM 푸시 알림, 입금 등록, 견적 저장 개선
- 수주 상세 페이지에서 수주확정 시 FCM 푸시 알림 발송 추가 - FCM 프리셋 함수 추가: 계약완료, 발주완료 알림 - 입금 등록 시 입금일, 입금계좌, 입금자명, 입금금액 입력 가능 - 견적 저장 시 토스트 메시지 정상 표시 수정 - ShipmentCreate SelectItem key prop 경고 수정 - DevToolbar 문법 오류 수정
This commit is contained in:
@@ -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 || "수주 확정에 실패했습니다.");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
232
src/components/dev/generators/accountingData.ts
Normal file
232
src/components/dev/generators/accountingData.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user