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

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