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