feat: 대시보드 API 및 FCM 푸시 알림 API 구현

Dashboard API:
- DashboardController, DashboardService 추가
- /dashboard/summary, /charts, /approvals 엔드포인트

Push Notification API:
- FCM 토큰 관리 (등록/해제/목록)
- 알림 설정 관리 (유형별 on/off, 알림음 설정)
- 알림 유형: deposit, withdrawal, order, approval, attendance, notice, system
- 알림음: default, deposit, withdrawal, order, approval, urgent
- PushDeviceToken, PushNotificationSetting 모델
- Swagger 문서 추가
This commit is contained in:
2025-12-18 11:16:24 +09:00
parent 7089dd1e46
commit 6477cf2c83
15 changed files with 1697 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Dashboard\DashboardApprovalsRequest;
use App\Http\Requests\V1\Dashboard\DashboardChartsRequest;
use App\Http\Responses\ApiResponse;
use App\Services\DashboardService;
use Illuminate\Http\JsonResponse;
class DashboardController extends Controller
{
public function __construct(
private readonly DashboardService $dashboardService
) {}
/**
* 대시보드 요약 데이터 조회
*/
public function summary(): JsonResponse
{
$data = $this->dashboardService->summary();
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
/**
* 대시보드 차트 데이터 조회
*/
public function charts(DashboardChartsRequest $request): JsonResponse
{
$data = $this->dashboardService->charts($request->validated());
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
/**
* 결재 현황 조회
*/
public function approvals(DashboardApprovalsRequest $request): JsonResponse
{
$data = $this->dashboardService->approvals($request->validated());
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Push\RegisterTokenRequest;
use App\Http\Requests\Push\UpdateSettingsRequest;
use App\Services\PushNotificationService;
use App\Utils\ApiResponse;
use Illuminate\Http\Request;
class PushNotificationController extends Controller
{
/**
* FCM 토큰 등록
*/
public function registerToken(RegisterTokenRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$service = new PushNotificationService;
return $service->registerToken($request->validated());
}, __('message.push.token_registered'));
}
/**
* FCM 토큰 해제
*/
public function unregisterToken(Request $request)
{
return ApiResponse::handle(function () use ($request) {
$token = $request->input('token');
if (! $token) {
throw new \InvalidArgumentException(__('error.push.token_required'));
}
$service = new PushNotificationService;
return ['unregistered' => $service->unregisterToken($token)];
}, __('message.push.token_unregistered'));
}
/**
* 사용자의 등록된 디바이스 토큰 목록
*/
public function getTokens()
{
return ApiResponse::handle(function () {
$service = new PushNotificationService;
return $service->getUserTokens();
});
}
/**
* 알림 설정 조회
*/
public function getSettings()
{
return ApiResponse::handle(function () {
$service = new PushNotificationService;
return $service->getSettings();
});
}
/**
* 알림 설정 업데이트
*/
public function updateSettings(UpdateSettingsRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$service = new PushNotificationService;
return $service->updateSettings($request->validated()['settings']);
}, __('message.push.settings_updated'));
}
/**
* 알림 유형 목록 조회
*/
public function getNotificationTypes()
{
return ApiResponse::handle(function () {
return [
'types' => \App\Models\PushNotificationSetting::getAllTypes(),
'sounds' => \App\Models\PushNotificationSetting::getAllSounds(),
];
});
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\Push;
use App\Models\PushDeviceToken;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RegisterTokenRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'token' => ['required', 'string', 'min:10'],
'platform' => [
'required',
'string',
Rule::in([
PushDeviceToken::PLATFORM_IOS,
PushDeviceToken::PLATFORM_ANDROID,
PushDeviceToken::PLATFORM_WEB,
]),
],
'device_name' => ['nullable', 'string', 'max:255'],
'app_version' => ['nullable', 'string', 'max:50'],
];
}
public function messages(): array
{
return [
'token.required' => __('error.push.token_required'),
'token.min' => __('error.push.token_invalid'),
'platform.required' => __('error.push.platform_required'),
'platform.in' => __('error.push.platform_invalid'),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\Push;
use App\Models\PushNotificationSetting;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateSettingsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'settings' => ['required', 'array'],
'settings.*.notification_type' => [
'required',
'string',
Rule::in(PushNotificationSetting::getAllTypes()),
],
'settings.*.is_enabled' => ['required', 'boolean'],
'settings.*.sound' => [
'nullable',
'string',
Rule::in(PushNotificationSetting::getAllSounds()),
],
'settings.*.vibrate' => ['nullable', 'boolean'],
'settings.*.show_preview' => ['nullable', 'boolean'],
];
}
public function messages(): array
{
return [
'settings.required' => __('error.push.settings_required'),
'settings.*.notification_type.required' => __('error.push.type_required'),
'settings.*.notification_type.in' => __('error.push.type_invalid'),
'settings.*.is_enabled.required' => __('error.push.enabled_required'),
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\V1\Dashboard;
use Illuminate\Foundation\Http\FormRequest;
class DashboardApprovalsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'limit' => ['nullable', 'integer', 'min:1', 'max:50'],
];
}
public function messages(): array
{
return [
'limit.min' => __('error.validation.min', ['min' => 1]),
'limit.max' => __('error.validation.max', ['max' => 50]),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\V1\Dashboard;
use Illuminate\Foundation\Http\FormRequest;
class DashboardChartsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'period' => ['nullable', 'string', 'in:week,month,quarter'],
];
}
public function messages(): array
{
return [
'period.in' => __('error.dashboard.invalid_period'),
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class PushDeviceToken extends Model
{
use BelongsToTenant;
use SoftDeletes;
protected $fillable = [
'tenant_id',
'user_id',
'token',
'platform',
'device_name',
'app_version',
'is_active',
'last_used_at',
];
protected $casts = [
'is_active' => 'boolean',
'last_used_at' => 'datetime',
];
/**
* 플랫폼 상수
*/
public const PLATFORM_IOS = 'ios';
public const PLATFORM_ANDROID = 'android';
public const PLATFORM_WEB = 'web';
/**
* 사용자 관계
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 테넌트 관계
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* Scope: 활성 토큰만
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope: 플랫폼별 필터
*/
public function scopePlatform($query, string $platform)
{
return $query->where('platform', $platform);
}
/**
* Scope: 특정 사용자의 토큰
*/
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PushNotificationSetting extends Model
{
use BelongsToTenant;
protected $fillable = [
'tenant_id',
'user_id',
'notification_type',
'is_enabled',
'sound',
'vibrate',
'show_preview',
];
protected $casts = [
'is_enabled' => 'boolean',
'vibrate' => 'boolean',
'show_preview' => 'boolean',
];
/**
* 알림 유형 상수
*/
public const TYPE_DEPOSIT = 'deposit'; // 입금
public const TYPE_WITHDRAWAL = 'withdrawal'; // 출금
public const TYPE_ORDER = 'order'; // 수주
public const TYPE_APPROVAL = 'approval'; // 결재
public const TYPE_ATTENDANCE = 'attendance'; // 근태
public const TYPE_NOTICE = 'notice'; // 공지사항
public const TYPE_SYSTEM = 'system'; // 시스템
/**
* 알림음 상수
*/
public const SOUND_DEFAULT = 'default';
public const SOUND_DEPOSIT = 'deposit.wav'; // 입금 알림음
public const SOUND_WITHDRAWAL = 'withdrawal.wav'; // 출금 알림음
public const SOUND_ORDER = 'order.wav'; // 수주 알림음
public const SOUND_APPROVAL = 'approval.wav'; // 결재 알림음
public const SOUND_URGENT = 'urgent.wav'; // 긴급 알림음
/**
* 모든 알림 유형 반환
*/
public static function getAllTypes(): array
{
return [
self::TYPE_DEPOSIT,
self::TYPE_WITHDRAWAL,
self::TYPE_ORDER,
self::TYPE_APPROVAL,
self::TYPE_ATTENDANCE,
self::TYPE_NOTICE,
self::TYPE_SYSTEM,
];
}
/**
* 모든 알림음 반환
*/
public static function getAllSounds(): array
{
return [
self::SOUND_DEFAULT,
self::SOUND_DEPOSIT,
self::SOUND_WITHDRAWAL,
self::SOUND_ORDER,
self::SOUND_APPROVAL,
self::SOUND_URGENT,
];
}
/**
* 사용자 관계
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 테넌트 관계
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* Scope: 활성화된 알림만
*/
public function scopeEnabled($query)
{
return $query->where('is_enabled', true);
}
/**
* Scope: 특정 사용자의 설정
*/
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope: 특정 알림 유형
*/
public function scopeOfType($query, string $type)
{
return $query->where('notification_type', $type);
}
}

View File

@@ -0,0 +1,354 @@
<?php
namespace App\Services;
use App\Models\Tenants\Approval;
use App\Models\Tenants\ApprovalStep;
use App\Models\Tenants\Attendance;
use App\Models\Tenants\Deposit;
use App\Models\Tenants\Leave;
use App\Models\Tenants\Purchase;
use App\Models\Tenants\Sale;
use App\Models\Tenants\Withdrawal;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class DashboardService extends Service
{
/**
* 대시보드 요약 데이터 조회
*/
public function summary(): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$today = Carbon::today();
$startOfMonth = Carbon::now()->startOfMonth();
$endOfMonth = Carbon::now()->endOfMonth();
return [
'today' => $this->getTodaySummary($tenantId, $today),
'finance' => $this->getFinanceSummary($tenantId, $startOfMonth, $endOfMonth),
'sales' => $this->getSalesSummary($tenantId, $startOfMonth, $endOfMonth),
'tasks' => $this->getTasksSummary($tenantId, $userId),
];
}
/**
* 대시보드 차트 데이터 조회
*
* @param array $params [period: week|month|quarter]
*/
public function charts(array $params): array
{
$tenantId = $this->tenantId();
$period = $params['period'] ?? 'month';
[$startDate, $endDate] = $this->getPeriodRange($period);
return [
'period' => $period,
'start_date' => $startDate->toDateString(),
'end_date' => $endDate->toDateString(),
'deposit_trend' => $this->getDepositTrend($tenantId, $startDate, $endDate),
'withdrawal_trend' => $this->getWithdrawalTrend($tenantId, $startDate, $endDate),
'sales_by_client' => $this->getSalesByClient($tenantId, $startDate, $endDate),
];
}
/**
* 결재 현황 조회
*
* @param array $params [limit: int]
*/
public function approvals(array $params): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$limit = $params['limit'] ?? 10;
// 내가 결재할 문서 (결재함)
$pendingApprovals = $this->getPendingApprovals($tenantId, $userId, $limit);
// 내가 기안한 문서 중 진행중인 것
$myDrafts = $this->getMyPendingDrafts($tenantId, $userId, $limit);
return [
'pending_approvals' => $pendingApprovals,
'my_drafts' => $myDrafts,
];
}
/**
* 오늘 요약 데이터
*/
private function getTodaySummary(int $tenantId, Carbon $today): array
{
// 오늘 출근자 수
$attendancesCount = Attendance::query()
->where('tenant_id', $tenantId)
->whereDate('work_date', $today)
->whereNotNull('check_in')
->count();
// 오늘 휴가자 수
$leavesCount = Leave::query()
->where('tenant_id', $tenantId)
->where('status', 'approved')
->whereDate('start_date', '<=', $today)
->whereDate('end_date', '>=', $today)
->count();
// 결재 대기 문서 수 (전체)
$approvalsPending = Approval::query()
->where('tenant_id', $tenantId)
->where('status', 'pending')
->count();
return [
'date' => $today->toDateString(),
'attendances_count' => $attendancesCount,
'leaves_count' => $leavesCount,
'approvals_pending' => $approvalsPending,
];
}
/**
* 재무 요약 데이터
*/
private function getFinanceSummary(int $tenantId, Carbon $startOfMonth, Carbon $endOfMonth): array
{
// 월간 입금 합계
$monthlyDeposit = Deposit::query()
->where('tenant_id', $tenantId)
->whereBetween('deposit_date', [$startOfMonth, $endOfMonth])
->sum('amount');
// 월간 출금 합계
$monthlyWithdrawal = Withdrawal::query()
->where('tenant_id', $tenantId)
->whereBetween('withdrawal_date', [$startOfMonth, $endOfMonth])
->sum('amount');
// 현재 잔액 (전체 입금 - 전체 출금)
$totalDeposits = Deposit::query()
->where('tenant_id', $tenantId)
->sum('amount');
$totalWithdrawals = Withdrawal::query()
->where('tenant_id', $tenantId)
->sum('amount');
$balance = $totalDeposits - $totalWithdrawals;
return [
'monthly_deposit' => (float) $monthlyDeposit,
'monthly_withdrawal' => (float) $monthlyWithdrawal,
'balance' => (float) $balance,
];
}
/**
* 매출/매입 요약 데이터
*/
private function getSalesSummary(int $tenantId, Carbon $startOfMonth, Carbon $endOfMonth): array
{
// 월간 매출 합계
$monthlySales = Sale::query()
->where('tenant_id', $tenantId)
->whereBetween('sale_date', [$startOfMonth, $endOfMonth])
->sum('total_amount');
// 월간 매입 합계
$monthlyPurchases = Purchase::query()
->where('tenant_id', $tenantId)
->whereBetween('purchase_date', [$startOfMonth, $endOfMonth])
->sum('total_amount');
return [
'monthly_sales' => (float) $monthlySales,
'monthly_purchases' => (float) $monthlyPurchases,
];
}
/**
* 할 일 요약 데이터
*/
private function getTasksSummary(int $tenantId, int $userId): array
{
// 내가 결재해야 할 문서 수
$pendingApprovals = ApprovalStep::query()
->whereHas('approval', function ($query) use ($tenantId) {
$query->where('tenant_id', $tenantId)
->where('status', 'pending');
})
->where('approver_id', $userId)
->where('status', 'pending')
->count();
// 승인 대기 휴가 신청 수 (관리자용)
$pendingLeaves = Leave::query()
->where('tenant_id', $tenantId)
->where('status', 'pending')
->count();
return [
'pending_approvals' => $pendingApprovals,
'pending_leaves' => $pendingLeaves,
];
}
/**
* 기간 범위 계산
*
* @return array [Carbon $startDate, Carbon $endDate]
*/
private function getPeriodRange(string $period): array
{
$endDate = Carbon::today();
switch ($period) {
case 'week':
$startDate = $endDate->copy()->subDays(6);
break;
case 'quarter':
$startDate = $endDate->copy()->subMonths(3)->startOfMonth();
break;
case 'month':
default:
$startDate = $endDate->copy()->subDays(29);
break;
}
return [$startDate, $endDate];
}
/**
* 입금 추이 데이터
*/
private function getDepositTrend(int $tenantId, Carbon $startDate, Carbon $endDate): array
{
$deposits = Deposit::query()
->where('tenant_id', $tenantId)
->whereBetween('deposit_date', [$startDate, $endDate])
->select(
DB::raw('DATE(deposit_date) as date'),
DB::raw('SUM(amount) as amount')
)
->groupBy(DB::raw('DATE(deposit_date)'))
->orderBy('date')
->get();
return $deposits->map(function ($item) {
return [
'date' => $item->date,
'amount' => (float) $item->amount,
];
})->toArray();
}
/**
* 출금 추이 데이터
*/
private function getWithdrawalTrend(int $tenantId, Carbon $startDate, Carbon $endDate): array
{
$withdrawals = Withdrawal::query()
->where('tenant_id', $tenantId)
->whereBetween('withdrawal_date', [$startDate, $endDate])
->select(
DB::raw('DATE(withdrawal_date) as date'),
DB::raw('SUM(amount) as amount')
)
->groupBy(DB::raw('DATE(withdrawal_date)'))
->orderBy('date')
->get();
return $withdrawals->map(function ($item) {
return [
'date' => $item->date,
'amount' => (float) $item->amount,
];
})->toArray();
}
/**
* 거래처별 매출 데이터
*/
private function getSalesByClient(int $tenantId, Carbon $startDate, Carbon $endDate): array
{
$sales = Sale::query()
->where('tenant_id', $tenantId)
->whereBetween('sale_date', [$startDate, $endDate])
->with('client:id,name')
->select(
'client_id',
DB::raw('SUM(total_amount) as amount')
)
->groupBy('client_id')
->orderByDesc('amount')
->limit(10)
->get();
return $sales->map(function ($item) {
return [
'client_id' => $item->client_id,
'client_name' => $item->client?->name ?? __('message.dashboard.unknown_client'),
'amount' => (float) $item->amount,
];
})->toArray();
}
/**
* 내가 결재해야 할 문서 목록
*/
private function getPendingApprovals(int $tenantId, int $userId, int $limit): array
{
$steps = ApprovalStep::query()
->whereHas('approval', function ($query) use ($tenantId) {
$query->where('tenant_id', $tenantId)
->where('status', 'pending');
})
->where('approver_id', $userId)
->where('status', 'pending')
->with(['approval' => function ($query) {
$query->with('drafter:id,name');
}])
->orderBy('created_at', 'desc')
->limit($limit)
->get();
return $steps->map(function ($step) {
return [
'id' => $step->approval->id,
'title' => $step->approval->title,
'drafter_name' => $step->approval->drafter?->name ?? '',
'status' => $step->approval->status,
'created_at' => $step->approval->created_at?->toDateTimeString(),
];
})->toArray();
}
/**
* 내가 기안한 진행중인 문서 목록
*/
private function getMyPendingDrafts(int $tenantId, int $userId, int $limit): array
{
$approvals = Approval::query()
->where('tenant_id', $tenantId)
->where('drafter_id', $userId)
->where('status', 'pending')
->orderBy('created_at', 'desc')
->limit($limit)
->get();
return $approvals->map(function ($approval) {
return [
'id' => $approval->id,
'title' => $approval->title,
'status' => $approval->status,
'current_step' => $approval->current_step,
'created_at' => $approval->created_at?->toDateTimeString(),
];
})->toArray();
}
}

View File

@@ -0,0 +1,277 @@
<?php
namespace App\Services;
use App\Models\PushDeviceToken;
use App\Models\PushNotificationSetting;
use Illuminate\Support\Facades\Log;
class PushNotificationService extends Service
{
/**
* FCM 토큰 등록/갱신
*/
public function registerToken(array $data): PushDeviceToken
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 동일 토큰이 있으면 업데이트, 없으면 생성
$token = PushDeviceToken::withoutGlobalScopes()
->where('token', $data['token'])
->first();
if ($token) {
// 기존 토큰 업데이트 (다른 사용자의 토큰이면 이전 것은 비활성화)
if ($token->user_id !== $userId || $token->tenant_id !== $tenantId) {
$token->update([
'tenant_id' => $tenantId,
'user_id' => $userId,
'platform' => $data['platform'],
'device_name' => $data['device_name'] ?? null,
'app_version' => $data['app_version'] ?? null,
'is_active' => true,
'last_used_at' => now(),
'deleted_at' => null,
]);
} else {
$token->update([
'platform' => $data['platform'],
'device_name' => $data['device_name'] ?? null,
'app_version' => $data['app_version'] ?? null,
'is_active' => true,
'last_used_at' => now(),
]);
}
} else {
// 새 토큰 생성
$token = PushDeviceToken::create([
'tenant_id' => $tenantId,
'user_id' => $userId,
'token' => $data['token'],
'platform' => $data['platform'],
'device_name' => $data['device_name'] ?? null,
'app_version' => $data['app_version'] ?? null,
'is_active' => true,
'last_used_at' => now(),
]);
}
// 사용자 기본 알림 설정 초기화 (없는 경우)
$this->initializeDefaultSettings($tenantId, $userId);
Log::info('FCM token registered', [
'user_id' => $userId,
'platform' => $data['platform'],
'token_id' => $token->id,
]);
return $token;
}
/**
* FCM 토큰 비활성화
*/
public function unregisterToken(string $tokenValue): bool
{
$token = PushDeviceToken::withoutGlobalScopes()
->where('token', $tokenValue)
->first();
if ($token) {
$token->update(['is_active' => false]);
Log::info('FCM token unregistered', [
'token_id' => $token->id,
]);
return true;
}
return false;
}
/**
* 사용자의 활성 토큰 목록 조회
*/
public function getUserTokens(?int $userId = null): array
{
$userId = $userId ?? $this->apiUserId();
return PushDeviceToken::forUser($userId)
->active()
->get()
->toArray();
}
/**
* 알림 설정 조회
*/
public function getSettings(?int $userId = null): array
{
$tenantId = $this->tenantId();
$userId = $userId ?? $this->apiUserId();
$settings = PushNotificationSetting::where('tenant_id', $tenantId)
->forUser($userId)
->get()
->keyBy('notification_type');
// 모든 알림 유형에 대한 설정 반환 (없으면 기본값)
$result = [];
foreach (PushNotificationSetting::getAllTypes() as $type) {
if ($settings->has($type)) {
$result[$type] = $settings->get($type)->toArray();
} else {
$result[$type] = [
'notification_type' => $type,
'is_enabled' => true,
'sound' => $this->getDefaultSound($type),
'vibrate' => true,
'show_preview' => true,
];
}
}
return $result;
}
/**
* 알림 설정 업데이트
*/
public function updateSettings(array $settings): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$updated = [];
foreach ($settings as $setting) {
$record = PushNotificationSetting::updateOrCreate(
[
'tenant_id' => $tenantId,
'user_id' => $userId,
'notification_type' => $setting['notification_type'],
],
[
'is_enabled' => $setting['is_enabled'],
'sound' => $setting['sound'] ?? $this->getDefaultSound($setting['notification_type']),
'vibrate' => $setting['vibrate'] ?? true,
'show_preview' => $setting['show_preview'] ?? true,
]
);
$updated[] = $record->toArray();
}
return $updated;
}
/**
* 특정 사용자에게 푸시 알림 전송 (FCM HTTP v1 API)
*/
public function sendToUser(int $userId, string $notificationType, array $notification): bool
{
$tenantId = $this->tenantIdOrNull() ?? 0;
// 사용자 알림 설정 확인
$setting = PushNotificationSetting::where('tenant_id', $tenantId)
->forUser($userId)
->ofType($notificationType)
->first();
// 알림이 비활성화된 경우 전송 안함
if ($setting && ! $setting->is_enabled) {
Log::info('Push notification skipped (disabled)', [
'user_id' => $userId,
'type' => $notificationType,
]);
return false;
}
// 사용자의 활성 토큰 조회
$tokens = PushDeviceToken::withoutGlobalScopes()
->forUser($userId)
->active()
->get();
if ($tokens->isEmpty()) {
Log::info('Push notification skipped (no tokens)', [
'user_id' => $userId,
]);
return false;
}
// 알림음 결정
$sound = $setting?->sound ?? $this->getDefaultSound($notificationType);
$successCount = 0;
foreach ($tokens as $token) {
$result = $this->sendFcmMessage($token, $notification, $sound, $notificationType);
if ($result) {
$successCount++;
}
}
return $successCount > 0;
}
/**
* FCM 메시지 전송 (실제 구현)
*/
protected function sendFcmMessage(
PushDeviceToken $token,
array $notification,
string $sound,
string $notificationType
): bool {
// TODO: FCM HTTP v1 API 구현
// 현재는 로그만 기록
Log::info('FCM message would be sent', [
'token_id' => $token->id,
'platform' => $token->platform,
'title' => $notification['title'] ?? '',
'body' => $notification['body'] ?? '',
'sound' => $sound,
'type' => $notificationType,
]);
return true;
}
/**
* 기본 알림 설정 초기화
*/
protected function initializeDefaultSettings(int $tenantId, int $userId): void
{
foreach (PushNotificationSetting::getAllTypes() as $type) {
PushNotificationSetting::firstOrCreate(
[
'tenant_id' => $tenantId,
'user_id' => $userId,
'notification_type' => $type,
],
[
'is_enabled' => true,
'sound' => $this->getDefaultSound($type),
'vibrate' => true,
'show_preview' => true,
]
);
}
}
/**
* 알림 유형별 기본 알림음
*/
protected function getDefaultSound(string $type): string
{
return match ($type) {
PushNotificationSetting::TYPE_DEPOSIT => PushNotificationSetting::SOUND_DEPOSIT,
PushNotificationSetting::TYPE_WITHDRAWAL => PushNotificationSetting::SOUND_WITHDRAWAL,
PushNotificationSetting::TYPE_ORDER => PushNotificationSetting::SOUND_ORDER,
PushNotificationSetting::TYPE_APPROVAL => PushNotificationSetting::SOUND_APPROVAL,
default => PushNotificationSetting::SOUND_DEFAULT,
};
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Dashboard", description="대시보드")
*
* @OA\Schema(
* schema="DashboardSummary",
* type="object",
* description="대시보드 요약 데이터",
*
* @OA\Property(property="today", type="object", description="오늘 요약",
* @OA\Property(property="date", type="string", format="date", example="2025-01-15", description="오늘 날짜"),
* @OA\Property(property="attendances_count", type="integer", example=25, description="오늘 출근자 수"),
* @OA\Property(property="leaves_count", type="integer", example=3, description="오늘 휴가자 수"),
* @OA\Property(property="approvals_pending", type="integer", example=5, description="결재 대기 문서 수")
* ),
* @OA\Property(property="finance", type="object", description="재무 요약",
* @OA\Property(property="monthly_deposit", type="number", format="float", example=50000000, description="월간 입금액"),
* @OA\Property(property="monthly_withdrawal", type="number", format="float", example=30000000, description="월간 출금액"),
* @OA\Property(property="balance", type="number", format="float", example=150000000, description="현재 잔액")
* ),
* @OA\Property(property="sales", type="object", description="매출/매입 요약",
* @OA\Property(property="monthly_sales", type="number", format="float", example=80000000, description="월간 매출"),
* @OA\Property(property="monthly_purchases", type="number", format="float", example=45000000, description="월간 매입")
* ),
* @OA\Property(property="tasks", type="object", description="할 일 요약",
* @OA\Property(property="pending_approvals", type="integer", example=3, description="내가 결재할 문서 수"),
* @OA\Property(property="pending_leaves", type="integer", example=2, description="승인 대기 휴가 신청 수")
* )
* )
*
* @OA\Schema(
* schema="DashboardCharts",
* type="object",
* description="대시보드 차트 데이터",
*
* @OA\Property(property="period", type="string", enum={"week","month","quarter"}, example="month", description="조회 기간"),
* @OA\Property(property="start_date", type="string", format="date", example="2024-12-17", description="시작일"),
* @OA\Property(property="end_date", type="string", format="date", example="2025-01-15", description="종료일"),
* @OA\Property(property="deposit_trend", type="array", description="입금 추이",
* @OA\Items(type="object",
* @OA\Property(property="date", type="string", format="date", example="2025-01-15"),
* @OA\Property(property="amount", type="number", format="float", example=5000000)
* )
* ),
* @OA\Property(property="withdrawal_trend", type="array", description="출금 추이",
* @OA\Items(type="object",
* @OA\Property(property="date", type="string", format="date", example="2025-01-15"),
* @OA\Property(property="amount", type="number", format="float", example=3000000)
* )
* ),
* @OA\Property(property="sales_by_client", type="array", description="거래처별 매출 (상위 10개)",
* @OA\Items(type="object",
* @OA\Property(property="client_id", type="integer", example=1),
* @OA\Property(property="client_name", type="string", example="(주)테스트"),
* @OA\Property(property="amount", type="number", format="float", example=15000000)
* )
* )
* )
*
* @OA\Schema(
* schema="DashboardApprovals",
* type="object",
* description="대시보드 결재 현황",
*
* @OA\Property(property="pending_approvals", type="array", description="결재 대기 문서 (내가 결재할 문서)",
* @OA\Items(type="object",
* @OA\Property(property="id", type="integer", example=1, description="결재문서 ID"),
* @OA\Property(property="title", type="string", example="출장 신청서", description="제목"),
* @OA\Property(property="drafter_name", type="string", example="홍길동", description="기안자명"),
* @OA\Property(property="status", type="string", example="pending", description="상태"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-01-15 10:30:00", description="생성일시")
* )
* ),
* @OA\Property(property="my_drafts", type="array", description="내가 기안한 진행중인 문서",
* @OA\Items(type="object",
* @OA\Property(property="id", type="integer", example=2, description="결재문서 ID"),
* @OA\Property(property="title", type="string", example="휴가 신청서", description="제목"),
* @OA\Property(property="status", type="string", example="pending", description="상태"),
* @OA\Property(property="current_step", type="integer", example=2, description="현재 결재 단계"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-01-14 09:00:00", description="생성일시")
* )
* )
* )
*/
class DashboardApi
{
/**
* @OA\Get(
* path="/api/v1/dashboard/summary",
* tags={"Dashboard"},
* summary="대시보드 요약 데이터 조회",
* description="오늘 현황, 재무 요약, 매출/매입 요약, 할 일 요약을 반환합니다.",
* operationId="getDashboardSummary",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
* @OA\Property(property="data", ref="#/components/schemas/DashboardSummary")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패")
* )
*/
public function summary() {}
/**
* @OA\Get(
* path="/api/v1/dashboard/charts",
* tags={"Dashboard"},
* summary="대시보드 차트 데이터 조회",
* description="입금/출금 추이, 거래처별 매출 차트 데이터를 반환합니다.",
* operationId="getDashboardCharts",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(
* name="period",
* in="query",
* description="조회 기간 (week: 7일, month: 30일, quarter: 3개월)",
* required=false,
*
* @OA\Schema(type="string", enum={"week","month","quarter"}, default="month")
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
* @OA\Property(property="data", ref="#/components/schemas/DashboardCharts")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=422, description="유효성 검사 실패")
* )
*/
public function charts() {}
/**
* @OA\Get(
* path="/api/v1/dashboard/approvals",
* tags={"Dashboard"},
* summary="결재 현황 조회",
* description="결재 대기 문서(결재함)와 내가 기안한 진행중인 문서 목록을 반환합니다.",
* operationId="getDashboardApprovals",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(
* name="limit",
* in="query",
* description="각 목록의 최대 항목 수 (1~50)",
* required=false,
*
* @OA\Schema(type="integer", minimum=1, maximum=50, default=10)
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
* @OA\Property(property="data", ref="#/components/schemas/DashboardApprovals")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=422, description="유효성 검사 실패")
* )
*/
public function approvals() {}
}

303
app/Swagger/v1/PushApi.php Normal file
View File

@@ -0,0 +1,303 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Push", description="푸시 알림 관리")
*/
/**
* Push 관련 스키마 정의
* -----------------------------------------------------------------------------
*/
/**
* @OA\Schema(
* schema="PushDeviceToken",
* type="object",
* description="푸시 디바이스 토큰",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="user_id", type="integer", example=5),
* @OA\Property(property="token", type="string", example="dGhpcyBpcyBhIHNhbXBsZSBGQ00gdG9rZW4..."),
* @OA\Property(property="platform", type="string", enum={"ios", "android", "web"}, example="android"),
* @OA\Property(property="device_name", type="string", nullable=true, example="Samsung Galaxy S24"),
* @OA\Property(property="app_version", type="string", nullable=true, example="1.0.0"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="last_used_at", type="string", format="date-time", example="2025-12-18 10:30:00"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-12-18 10:00:00"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-12-18 10:30:00")
* )
*
* @OA\Schema(
* schema="PushNotificationSetting",
* type="object",
* description="푸시 알림 설정",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="user_id", type="integer", example=5),
* @OA\Property(property="notification_type", type="string", enum={"deposit", "withdrawal", "order", "approval", "attendance", "notice", "system"}, example="deposit"),
* @OA\Property(property="is_enabled", type="boolean", example=true),
* @OA\Property(property="sound", type="string", enum={"default.wav", "deposit.wav", "withdrawal.wav", "order.wav", "approval.wav", "urgent.wav"}, example="deposit.wav"),
* @OA\Property(property="vibrate", type="boolean", example=true),
* @OA\Property(property="show_preview", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-12-18 10:00:00"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-12-18 10:30:00")
* )
*
* @OA\Schema(
* schema="RegisterTokenRequest",
* type="object",
* required={"token", "platform"},
* @OA\Property(property="token", type="string", minLength=10, example="dGhpcyBpcyBhIHNhbXBsZSBGQ00gdG9rZW4...", description="FCM 토큰"),
* @OA\Property(property="platform", type="string", enum={"ios", "android", "web"}, example="android", description="디바이스 플랫폼"),
* @OA\Property(property="device_name", type="string", nullable=true, maxLength=255, example="Samsung Galaxy S24", description="디바이스명"),
* @OA\Property(property="app_version", type="string", nullable=true, maxLength=50, example="1.0.0", description="앱 버전")
* )
*
* @OA\Schema(
* schema="UnregisterTokenRequest",
* type="object",
* required={"token"},
* @OA\Property(property="token", type="string", example="dGhpcyBpcyBhIHNhbXBsZSBGQ00gdG9rZW4...", description="해제할 FCM 토큰")
* )
*
* @OA\Schema(
* schema="UpdatePushSettingsRequest",
* type="object",
* required={"settings"},
* @OA\Property(
* property="settings",
* type="array",
* description="알림 설정 배열",
* @OA\Items(
* type="object",
* required={"notification_type", "is_enabled"},
* @OA\Property(property="notification_type", type="string", enum={"deposit", "withdrawal", "order", "approval", "attendance", "notice", "system"}, example="deposit"),
* @OA\Property(property="is_enabled", type="boolean", example=true),
* @OA\Property(property="sound", type="string", nullable=true, enum={"default.wav", "deposit.wav", "withdrawal.wav", "order.wav", "approval.wav", "urgent.wav"}, example="deposit.wav"),
* @OA\Property(property="vibrate", type="boolean", nullable=true, example=true),
* @OA\Property(property="show_preview", type="boolean", nullable=true, example=true)
* )
* )
* )
*
* @OA\Schema(
* schema="NotificationTypesResponse",
* type="object",
* @OA\Property(
* property="types",
* type="array",
* description="지원하는 알림 유형 목록",
* @OA\Items(type="string", example="deposit")
* ),
* @OA\Property(
* property="sounds",
* type="array",
* description="지원하는 알림음 목록",
* @OA\Items(type="string", example="deposit.wav")
* )
* )
*/
class PushApi
{
/**
* @OA\Post(
* path="/api/push/register-token",
* tags={"Push"},
* summary="FCM 토큰 등록",
* description="디바이스의 FCM 토큰을 등록합니다. 동일한 토큰이 이미 존재하면 업데이트됩니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/RegisterTokenRequest")
* ),
*
* @OA\Response(
* response=200,
* description="토큰 등록 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="message", type="string", example="푸시 토큰이 등록되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/PushDeviceToken")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function registerToken() {}
/**
* @OA\Post(
* path="/api/push/unregister-token",
* tags={"Push"},
* summary="FCM 토큰 해제",
* description="디바이스의 FCM 토큰을 비활성화합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/UnregisterTokenRequest")
* ),
*
* @OA\Response(
* response=200,
* description="토큰 해제 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="message", type="string", example="푸시 토큰이 해제되었습니다."),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="unregistered", type="boolean", example=true)
* )
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="토큰 누락", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function unregisterToken() {}
/**
* @OA\Get(
* path="/api/push/tokens",
* tags={"Push"},
* summary="사용자 토큰 목록 조회",
* description="현재 사용자의 활성화된 디바이스 토큰 목록을 조회합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/PushDeviceToken")
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function getTokens() {}
/**
* @OA\Get(
* path="/api/push/settings",
* tags={"Push"},
* summary="알림 설정 조회",
* description="현재 사용자의 알림 유형별 설정을 조회합니다. 설정이 없는 유형은 기본값으로 반환됩니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(
* property="data",
* type="object",
* description="알림 유형별 설정 (키: notification_type)",
* @OA\Property(property="deposit", ref="#/components/schemas/PushNotificationSetting"),
* @OA\Property(property="withdrawal", ref="#/components/schemas/PushNotificationSetting"),
* @OA\Property(property="order", ref="#/components/schemas/PushNotificationSetting"),
* @OA\Property(property="approval", ref="#/components/schemas/PushNotificationSetting"),
* @OA\Property(property="attendance", ref="#/components/schemas/PushNotificationSetting"),
* @OA\Property(property="notice", ref="#/components/schemas/PushNotificationSetting"),
* @OA\Property(property="system", ref="#/components/schemas/PushNotificationSetting")
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function getSettings() {}
/**
* @OA\Put(
* path="/api/push/settings",
* tags={"Push"},
* summary="알림 설정 업데이트",
* description="사용자의 알림 설정을 업데이트합니다. 여러 알림 유형을 한 번에 설정할 수 있습니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/UpdatePushSettingsRequest")
* ),
*
* @OA\Response(
* response=200,
* description="설정 업데이트 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="message", type="string", example="알림 설정이 업데이트되었습니다."),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/PushNotificationSetting")
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function updateSettings() {}
/**
* @OA\Get(
* path="/api/push/notification-types",
* tags={"Push"},
* summary="알림 유형 목록 조회",
* description="지원하는 알림 유형과 알림음 목록을 조회합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/NotificationTypesResponse")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function getNotificationTypes() {}
}

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('push_device_tokens', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('user_id')->comment('사용자 ID');
$table->text('token')->comment('FCM 디바이스 토큰');
$table->string('platform', 20)->comment('플랫폼: ios, android, web');
$table->string('device_name')->nullable()->comment('디바이스명');
$table->string('app_version', 50)->nullable()->comment('앱 버전');
$table->boolean('is_active')->default(true)->comment('활성화 여부');
$table->timestamp('last_used_at')->nullable()->comment('마지막 사용 시간');
$table->timestamps();
$table->softDeletes();
// 인덱스
$table->index(['tenant_id', 'user_id']);
$table->index(['user_id', 'is_active']);
$table->index('platform');
});
}
public function down(): void
{
Schema::dropIfExists('push_device_tokens');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('push_notification_settings', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('user_id')->comment('사용자 ID');
$table->string('notification_type', 50)->comment('알림 유형: deposit, withdrawal, order, approval 등');
$table->boolean('is_enabled')->default(true)->comment('알림 활성화 여부');
$table->string('sound', 100)->default('default')->comment('알림음 파일명');
$table->boolean('vibrate')->default(true)->comment('진동 여부');
$table->boolean('show_preview')->default(true)->comment('미리보기 표시 여부');
$table->timestamps();
// 복합 유니크 키
$table->unique(['tenant_id', 'user_id', 'notification_type'], 'push_settings_unique');
// 인덱스
$table->index(['tenant_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('push_notification_settings');
}
};

View File

@@ -15,6 +15,7 @@
use App\Http\Controllers\Api\V1\ClientController;
use App\Http\Controllers\Api\V1\ClientGroupController;
use App\Http\Controllers\Api\V1\CommonController;
use App\Http\Controllers\Api\V1\DashboardController;
use App\Http\Controllers\Api\V1\DepartmentController;
use App\Http\Controllers\Api\V1\DepositController;
use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController;
@@ -50,6 +51,7 @@
// use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨
// use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨
use App\Http\Controllers\Api\V1\PricingController;
use App\Http\Controllers\Api\V1\PushNotificationController;
use App\Http\Controllers\Api\V1\PurchaseController;
use App\Http\Controllers\Api\V1\QuoteController;
use App\Http\Controllers\Api\V1\RefreshController;
@@ -412,6 +414,13 @@
Route::get('/expense-estimate/export', [ReportController::class, 'expenseEstimateExport'])->name('v1.reports.expense-estimate.export');
});
// Dashboard API (대시보드)
Route::prefix('dashboard')->group(function () {
Route::get('/summary', [DashboardController::class, 'summary'])->name('v1.dashboard.summary');
Route::get('/charts', [DashboardController::class, 'charts'])->name('v1.dashboard.charts');
Route::get('/approvals', [DashboardController::class, 'approvals'])->name('v1.dashboard.approvals');
});
// Permission API
Route::prefix('permissions')->group(function () {
Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); // 부서별 권한 메트릭스
@@ -461,6 +470,16 @@
Route::delete('/common/{id}', [CommonController::class, 'destroy'])->name('v1.settings.common.destroy'); // 공통 코드 삭제
});
// Push Notification API (FCM 푸시 알림)
Route::prefix('push')->group(function () {
Route::post('/register-token', [PushNotificationController::class, 'registerToken'])->name('v1.push.register-token'); // FCM 토큰 등록
Route::post('/unregister-token', [PushNotificationController::class, 'unregisterToken'])->name('v1.push.unregister-token'); // FCM 토큰 해제
Route::get('/tokens', [PushNotificationController::class, 'getTokens'])->name('v1.push.tokens'); // 등록된 토큰 목록
Route::get('/settings', [PushNotificationController::class, 'getSettings'])->name('v1.push.settings'); // 알림 설정 조회
Route::put('/settings', [PushNotificationController::class, 'updateSettings'])->name('v1.push.settings.update'); // 알림 설정 수정
Route::get('/notification-types', [PushNotificationController::class, 'getNotificationTypes'])->name('v1.push.notification-types'); // 알림 유형 목록
});
// 회원 프로필(테넌트 기준)
Route::prefix('profiles')->group(function () {
Route::get('', [TenantUserProfileController::class, 'index'])->name('v1.profiles.index'); // 프로필 목록(테넌트 기준)