feat(WEB): FCM 푸시 알림 시스템 구현

- FCMProvider 컨텍스트 및 useFCM 훅 추가
- Capacitor FCM 플러그인 통합
- 알림 사운드 파일 추가 (default.wav, push_notification.wav)
- Firebase 메시징 패키지 의존성 추가
This commit is contained in:
2025-12-30 17:16:47 +09:00
parent d38b1242d7
commit f400f01db7
12 changed files with 927 additions and 1039 deletions

296
src/lib/api/positions.ts Normal file
View 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' });
}