feat(WEB): FCM 푸시 알림 시스템 구현
- FCMProvider 컨텍스트 및 useFCM 훅 추가 - Capacitor FCM 플러그인 통합 - 알림 사운드 파일 추가 (default.wav, push_notification.wav) - Firebase 메시징 패키지 의존성 추가
This commit is contained in:
296
src/lib/api/positions.ts
Normal file
296
src/lib/api/positions.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 직급/직책 통합 API 클라이언트
|
||||
*
|
||||
* Laravel 백엔드 positions 테이블과 통신
|
||||
* type: 'rank' = 직급, 'title' = 직책
|
||||
*/
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
|
||||
export type PositionType = 'rank' | 'title';
|
||||
|
||||
export interface Position {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
type: PositionType;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface PositionCreateRequest {
|
||||
type: PositionType;
|
||||
name: string;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface PositionUpdateRequest {
|
||||
name?: string;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface PositionReorderItem {
|
||||
id: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface PositionListParams {
|
||||
type?: PositionType;
|
||||
is_active?: boolean;
|
||||
q?: string;
|
||||
per_page?: number;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// ===== 환경 변수 =====
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://api.sam.kr';
|
||||
|
||||
// ===== 유틸리티 함수 =====
|
||||
|
||||
/**
|
||||
* 인증 토큰 가져오기
|
||||
*/
|
||||
function getAuthToken(): string | null {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 가져오기
|
||||
*/
|
||||
function getApiKey(): string {
|
||||
return process.env.NEXT_PUBLIC_API_KEY || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch 옵션 생성
|
||||
*/
|
||||
function createFetchOptions(options: RequestInit = {}): RequestInit {
|
||||
const token = getAuthToken();
|
||||
const apiKey = getApiKey();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
// API Key 추가
|
||||
if (apiKey) {
|
||||
headers['X-API-KEY'] = apiKey;
|
||||
}
|
||||
|
||||
// Bearer 토큰 추가
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Merge existing headers
|
||||
if (options.headers && typeof options.headers === 'object' && !Array.isArray(options.headers)) {
|
||||
Object.assign(headers, options.headers);
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 에러 처리
|
||||
*/
|
||||
async function handleApiResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({
|
||||
message: 'API 요청 실패',
|
||||
}));
|
||||
|
||||
throw new Error(error.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
// ===== Position CRUD API =====
|
||||
|
||||
/**
|
||||
* 직급/직책 목록 조회
|
||||
*
|
||||
* @param params - 필터 파라미터
|
||||
* @example
|
||||
* // 직급만 조회
|
||||
* const ranks = await fetchPositions({ type: 'rank' });
|
||||
* // 직책만 조회
|
||||
* const titles = await fetchPositions({ type: 'title' });
|
||||
*/
|
||||
export async function fetchPositions(
|
||||
params?: PositionListParams
|
||||
): Promise<Position[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
queryParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = `${API_URL}/v1/positions${queryParams.toString() ? `?${queryParams}` : ''}`;
|
||||
|
||||
const response = await fetch(url, createFetchOptions());
|
||||
const result = await handleApiResponse<ApiResponse<Position[]>>(response);
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 단건 조회
|
||||
*
|
||||
* @param id - Position ID
|
||||
*/
|
||||
export async function fetchPosition(id: number): Promise<Position> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/v1/positions/${id}`,
|
||||
createFetchOptions()
|
||||
);
|
||||
|
||||
const result = await handleApiResponse<ApiResponse<Position>>(response);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 생성
|
||||
*
|
||||
* @param data - 생성 데이터
|
||||
* @example
|
||||
* const newRank = await createPosition({
|
||||
* type: 'rank',
|
||||
* name: '차장',
|
||||
* });
|
||||
*/
|
||||
export async function createPosition(
|
||||
data: PositionCreateRequest
|
||||
): Promise<Position> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/v1/positions`,
|
||||
createFetchOptions({
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await handleApiResponse<ApiResponse<Position>>(response);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 수정
|
||||
*
|
||||
* @param id - Position ID
|
||||
* @param data - 수정 데이터
|
||||
*/
|
||||
export async function updatePosition(
|
||||
id: number,
|
||||
data: PositionUpdateRequest
|
||||
): Promise<Position> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/v1/positions/${id}`,
|
||||
createFetchOptions({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await handleApiResponse<ApiResponse<Position>>(response);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 삭제
|
||||
*
|
||||
* @param id - Position ID
|
||||
*/
|
||||
export async function deletePosition(id: number): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/v1/positions/${id}`,
|
||||
createFetchOptions({
|
||||
method: 'DELETE',
|
||||
})
|
||||
);
|
||||
|
||||
await handleApiResponse<ApiResponse<{ id: number; deleted_at: string }>>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 순서 일괄 변경
|
||||
*
|
||||
* @param items - 정렬할 아이템 목록
|
||||
* @example
|
||||
* await reorderPositions([
|
||||
* { id: 1, sort_order: 1 },
|
||||
* { id: 2, sort_order: 2 },
|
||||
* ]);
|
||||
*/
|
||||
export async function reorderPositions(
|
||||
items: PositionReorderItem[]
|
||||
): Promise<{ success: boolean; updated: number }> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/v1/positions/reorder`,
|
||||
createFetchOptions({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ items }),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await handleApiResponse<ApiResponse<{ success: boolean; updated: number }>>(response);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// ===== 헬퍼 함수 =====
|
||||
|
||||
/**
|
||||
* 직급 목록 조회 (헬퍼)
|
||||
*/
|
||||
export async function fetchRanks(params?: Omit<PositionListParams, 'type'>): Promise<Position[]> {
|
||||
return fetchPositions({ ...params, type: 'rank' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 직책 목록 조회 (헬퍼)
|
||||
*/
|
||||
export async function fetchTitles(params?: Omit<PositionListParams, 'type'>): Promise<Position[]> {
|
||||
return fetchPositions({ ...params, type: 'title' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급 생성 (헬퍼)
|
||||
*/
|
||||
export async function createRank(
|
||||
data: Omit<PositionCreateRequest, 'type'>
|
||||
): Promise<Position> {
|
||||
return createPosition({ ...data, type: 'rank' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 직책 생성 (헬퍼)
|
||||
*/
|
||||
export async function createTitle(
|
||||
data: Omit<PositionCreateRequest, 'type'>
|
||||
): Promise<Position> {
|
||||
return createPosition({ ...data, type: 'title' });
|
||||
}
|
||||
380
src/lib/capacitor/fcm.ts
Normal file
380
src/lib/capacitor/fcm.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* FCM (Firebase Cloud Messaging) Push Notification Handler
|
||||
* Capacitor 앱에서만 동작, 웹 브라우저에서는 무시됨
|
||||
*
|
||||
* 포팅 원본: mng/public/js/fcm.js
|
||||
*
|
||||
* Payload data 스키마:
|
||||
* - type: 알림 타입 (invoice_failed, order_completed 등)
|
||||
* - url: 클릭 시 이동 URL
|
||||
* - sound_key: 사운드 파일 키 (sounds/{sound_key}.wav)
|
||||
*
|
||||
* NOTE: Capacitor 모듈은 동적 import로 로드됨 (웹 빌드 에러 방지)
|
||||
*/
|
||||
|
||||
// 동적 로드된 모듈 캐시 (any 타입 - 웹 빌드 시 모듈 없음)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let Capacitor: any = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let PushNotifications: any = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let App: any = null;
|
||||
|
||||
/**
|
||||
* Capacitor 모듈 동적 로드
|
||||
*/
|
||||
async function loadCapacitorModules(): Promise<boolean> {
|
||||
if (Capacitor && PushNotifications && App) return true;
|
||||
|
||||
try {
|
||||
const [coreModule, pushModule, appModule] = await Promise.all([
|
||||
import('@capacitor/core'),
|
||||
import('@capacitor/push-notifications'),
|
||||
import('@capacitor/app'),
|
||||
]);
|
||||
Capacitor = coreModule.Capacitor;
|
||||
PushNotifications = pushModule.PushNotifications;
|
||||
App = appModule.App;
|
||||
return true;
|
||||
} catch {
|
||||
console.log('[FCM] Capacitor modules not available (web environment)');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 설정 =====
|
||||
|
||||
const CONFIG = {
|
||||
// Next.js 프록시 경로 (HttpOnly 쿠키 자동 포함)
|
||||
proxyBasePath: '/api/proxy/v1',
|
||||
fcmTokenKey: 'fcm_token',
|
||||
soundBasePath: '/sounds/',
|
||||
defaultSound: 'default',
|
||||
};
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
|
||||
export interface PushNotificationData {
|
||||
type?: string;
|
||||
url?: string;
|
||||
sound_key?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FCMNotification {
|
||||
title?: string;
|
||||
body?: string;
|
||||
data?: PushNotificationData;
|
||||
}
|
||||
|
||||
export type ForegroundNotificationHandler = (notification: FCMNotification) => void;
|
||||
|
||||
// ===== 상태 =====
|
||||
|
||||
let isAppForeground = true;
|
||||
let isInitialized = false;
|
||||
|
||||
// ===== 유틸리티 함수 =====
|
||||
|
||||
/**
|
||||
* Capacitor 네이티브 환경인지 확인
|
||||
* 동기 함수 - Capacitor가 로드되지 않은 경우 false 반환
|
||||
*/
|
||||
export function isCapacitorNative(): boolean {
|
||||
if (!Capacitor) return false;
|
||||
const platform = Capacitor.getPlatform();
|
||||
return platform === 'ios' || platform === 'android';
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 플랫폼 반환
|
||||
*/
|
||||
export function getDevicePlatform(): 'ios' | 'android' | 'web' {
|
||||
if (!Capacitor) return 'web';
|
||||
const platform = Capacitor.getPlatform();
|
||||
if (platform === 'ios' || platform === 'android') return platform;
|
||||
return 'web';
|
||||
}
|
||||
|
||||
/**
|
||||
* 디바이스 이름 반환 (User-Agent 기반)
|
||||
*/
|
||||
function getDeviceName(): string | null {
|
||||
return navigator.userAgent?.substring(0, 100) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 버전 반환
|
||||
*/
|
||||
function getAppVersion(): string | null {
|
||||
return process.env.NEXT_PUBLIC_APP_VERSION || null;
|
||||
}
|
||||
|
||||
// ===== FCM 핵심 함수 =====
|
||||
|
||||
/**
|
||||
* FCM 초기화 (Capacitor 네이티브 환경에서만 동작)
|
||||
*
|
||||
* @param onForegroundNotification 포그라운드 알림 핸들러 (sonner toast 등)
|
||||
* @returns 초기화 성공 여부
|
||||
*/
|
||||
export async function initializeFCM(
|
||||
onForegroundNotification?: ForegroundNotificationHandler
|
||||
): Promise<boolean> {
|
||||
// Capacitor 모듈 동적 로드
|
||||
const modulesLoaded = await loadCapacitorModules();
|
||||
if (!modulesLoaded || !Capacitor || !PushNotifications || !App) {
|
||||
console.log('[FCM] Not running in native app');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 네이티브 환경 체크
|
||||
if (!isCapacitorNative()) {
|
||||
console.log('[FCM] Not running in native app');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Capacitor.isPluginAvailable('PushNotifications')) {
|
||||
console.log('[FCM] PushNotifications plugin not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isInitialized) {
|
||||
console.log('[FCM] Already initialized');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 앱 상태 리스너 (포그라운드/백그라운드)
|
||||
if (Capacitor.isPluginAvailable('App')) {
|
||||
App.addListener('appStateChange', ({ isActive }) => {
|
||||
isAppForeground = isActive;
|
||||
console.log('[FCM] App state:', isActive ? 'foreground' : 'background');
|
||||
});
|
||||
}
|
||||
|
||||
// 기존 리스너 제거
|
||||
await PushNotifications.removeAllListeners();
|
||||
|
||||
// 리스너 등록 (register() 호출 전에 등록해야 함)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
PushNotifications.addListener('registration', async (token: any) => {
|
||||
console.log('[FCM] Token received:', token.value?.substring(0, 20) + '...');
|
||||
await handleTokenRegistration(token.value);
|
||||
});
|
||||
|
||||
PushNotifications.addListener('registrationError', (err) => {
|
||||
console.error('[FCM] Registration error:', err);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
PushNotifications.addListener('pushNotificationReceived', (notification: any) => {
|
||||
console.log('[FCM] Push received (foreground):', notification);
|
||||
|
||||
const fcmNotification: FCMNotification = {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
data: notification.data as PushNotificationData,
|
||||
};
|
||||
|
||||
// 포그라운드 알림 콜백 호출
|
||||
if (onForegroundNotification) {
|
||||
onForegroundNotification(fcmNotification);
|
||||
}
|
||||
|
||||
// 사운드 재생
|
||||
handleForegroundSound(notification.data?.sound_key);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
PushNotifications.addListener('pushNotificationActionPerformed', (action: any) => {
|
||||
console.log('[FCM] Push action performed:', action);
|
||||
const url = action.notification?.data?.url;
|
||||
if (url && typeof url === 'string') {
|
||||
// URL 이동 (router.push 대신 window.location.href 사용 - 확실한 이동)
|
||||
window.location.href = url;
|
||||
}
|
||||
});
|
||||
|
||||
// 권한 요청
|
||||
const perm = await PushNotifications.requestPermissions();
|
||||
console.log('[FCM] Push permission:', perm.receive);
|
||||
|
||||
if (perm.receive !== 'granted') {
|
||||
console.log('[FCM] Push permission not granted');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 토큰 발급 요청 (registration 이벤트 트리거)
|
||||
await PushNotifications.register();
|
||||
|
||||
isInitialized = true;
|
||||
console.log('[FCM] Initialization completed');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FCM] Initialization error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 등록 처리
|
||||
*/
|
||||
async function handleTokenRegistration(newToken: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const oldToken = sessionStorage.getItem(CONFIG.fcmTokenKey);
|
||||
|
||||
if (oldToken === newToken) {
|
||||
console.log('[FCM] Token unchanged, skip');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await registerTokenToServer(newToken);
|
||||
|
||||
if (success) {
|
||||
sessionStorage.setItem(CONFIG.fcmTokenKey, newToken);
|
||||
console.log('[FCM] Token saved to sessionStorage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버에 토큰 등록 (Next.js 프록시 사용)
|
||||
*/
|
||||
async function registerTokenToServer(token: string): Promise<boolean> {
|
||||
try {
|
||||
// Next.js 프록시 경로 사용 (HttpOnly 쿠키 자동 포함)
|
||||
const response = await fetch(`${CONFIG.proxyBasePath}/push/register-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'include', // 쿠키 포함
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
platform: getDevicePlatform(),
|
||||
device_name: getDeviceName(),
|
||||
app_version: getAppVersion(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[FCM] Token registered successfully');
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error('[FCM] Token registration failed:', response.status);
|
||||
return false;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FCM] Failed to send token:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 해제 (로그아웃 시 호출)
|
||||
*/
|
||||
export async function unregisterFCMToken(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return true;
|
||||
|
||||
const token = sessionStorage.getItem(CONFIG.fcmTokenKey);
|
||||
if (!token) return true;
|
||||
|
||||
try {
|
||||
// Next.js 프록시 경로 사용
|
||||
await fetch(`${CONFIG.proxyBasePath}/push/unregister-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[FCM] Unregister failed, clearing local token');
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(CONFIG.fcmTokenKey);
|
||||
isInitialized = false;
|
||||
console.log('[FCM] Token unregistered');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 포그라운드 사운드 재생
|
||||
*/
|
||||
function handleForegroundSound(soundKey?: string): void {
|
||||
if (!isAppForeground) return;
|
||||
if (!soundKey) return;
|
||||
|
||||
try {
|
||||
const soundPath = `${CONFIG.soundBasePath}${soundKey}.wav`;
|
||||
const audio = new Audio(soundPath);
|
||||
|
||||
audio.volume = 0.5;
|
||||
audio.play().catch((err) => {
|
||||
console.warn('[FCM] Sound play failed, trying default:', err.message);
|
||||
// 기본 사운드 시도
|
||||
if (soundKey !== CONFIG.defaultSound) {
|
||||
const defaultAudio = new Audio(`${CONFIG.soundBasePath}${CONFIG.defaultSound}.wav`);
|
||||
defaultAudio.volume = 0.5;
|
||||
defaultAudio.play().catch(() => {});
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[FCM] Sound error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FCM 재초기화 (테스트/디버그용)
|
||||
*/
|
||||
export async function reinitializeFCM(
|
||||
onForegroundNotification?: ForegroundNotificationHandler
|
||||
): Promise<boolean> {
|
||||
isInitialized = false;
|
||||
return initializeFCM(onForegroundNotification);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 사운드 재생
|
||||
*/
|
||||
export function testPlaySound(soundKey: string): void {
|
||||
handleForegroundSound(soundKey);
|
||||
}
|
||||
|
||||
// ===== 알림 타입별 스타일 =====
|
||||
|
||||
export type ToastType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
/**
|
||||
* 알림 타입에 따른 토스트 스타일 결정
|
||||
*/
|
||||
export function getToastTypeByNotificationType(type?: string): ToastType {
|
||||
if (!type) return 'info';
|
||||
|
||||
const typeMap: Record<string, ToastType> = {
|
||||
// 긴급/에러
|
||||
invoice_failed: 'error',
|
||||
payment_failed: 'error',
|
||||
order_cancelled: 'error',
|
||||
|
||||
// 경고
|
||||
approval_required: 'warning',
|
||||
stock_low: 'warning',
|
||||
|
||||
// 성공
|
||||
order_completed: 'success',
|
||||
payment_completed: 'success',
|
||||
approval_approved: 'success',
|
||||
|
||||
// 기본
|
||||
default: 'info',
|
||||
};
|
||||
|
||||
return typeMap[type] || 'info';
|
||||
}
|
||||
Reference in New Issue
Block a user