From a2477837d0300ba9f88428e34fe8ea75f676b28a Mon Sep 17 00:00:00 2001 From: hskwon Date: Mon, 1 Dec 2025 14:07:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[daily-logs]=20=EC=9D=BC=EC=9D=BC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=9F=BC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 기능: - 일일 로그 CRUD (생성, 조회, 수정, 삭제, 복원, 영구삭제) - 로그 항목(Entry) 관리 (추가, 상태변경, 삭제, 순서변경) - 주간 타임라인 (최근 7일 진행률 표시) - 테이블 리스트 아코디언 상세보기 - 담당자 자동완성 (일반 사용자는 슈퍼관리자 목록 제외) - HTMX 기반 동적 테이블 로딩 및 필터링 - Soft Delete 지원 파일 추가: - Models: AdminPmDailyLog, AdminPmDailyLogEntry - Controllers: DailyLogController (Web, API) - Service: DailyLogService - Requests: StoreDailyLogRequest, UpdateDailyLogRequest - Views: index, show, table partial, modal-form partial 라우트 추가: - Web: /daily-logs, /daily-logs/today, /daily-logs/{id} - API: /api/admin/daily-logs/* (CRUD + 항목관리) --- .../Api/Admin/DailyLogController.php | 261 +++++++ app/Http/Controllers/DailyLogController.php | 91 +++ .../DailyLog/StoreDailyLogRequest.php | 53 ++ .../DailyLog/UpdateDailyLogRequest.php | 51 ++ app/Models/Admin/AdminPmDailyLog.php | 146 ++++ app/Models/Admin/AdminPmDailyLogEntry.php | 154 ++++ .../ProjectManagement/DailyLogService.php | 432 +++++++++++ resources/views/daily-logs/index.blade.php | 695 ++++++++++++++++++ .../daily-logs/partials/modal-form.blade.php | 94 +++ .../views/daily-logs/partials/table.blade.php | 129 ++++ resources/views/daily-logs/show.blade.php | 312 ++++++++ resources/views/partials/sidebar.blade.php | 10 + routes/api.php | 38 + routes/web.php | 8 + 14 files changed, 2474 insertions(+) create mode 100644 app/Http/Controllers/Api/Admin/DailyLogController.php create mode 100644 app/Http/Controllers/DailyLogController.php create mode 100644 app/Http/Requests/DailyLog/StoreDailyLogRequest.php create mode 100644 app/Http/Requests/DailyLog/UpdateDailyLogRequest.php create mode 100644 app/Models/Admin/AdminPmDailyLog.php create mode 100644 app/Models/Admin/AdminPmDailyLogEntry.php create mode 100644 app/Services/ProjectManagement/DailyLogService.php create mode 100644 resources/views/daily-logs/index.blade.php create mode 100644 resources/views/daily-logs/partials/modal-form.blade.php create mode 100644 resources/views/daily-logs/partials/table.blade.php create mode 100644 resources/views/daily-logs/show.blade.php diff --git a/app/Http/Controllers/Api/Admin/DailyLogController.php b/app/Http/Controllers/Api/Admin/DailyLogController.php new file mode 100644 index 00000000..b2937048 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/DailyLogController.php @@ -0,0 +1,261 @@ +only([ + 'search', + 'project_id', + 'start_date', + 'end_date', + 'trashed', + 'sort_by', + 'sort_direction', + ]); + + $logs = $this->dailyLogService->getDailyLogs($tenantId, $filters, 15); + + // HTMX 요청이면 HTML 파셜 반환 + if ($request->header('HX-Request')) { + return view('daily-logs.partials.table', compact('logs')); + } + + // 일반 요청이면 JSON + return response()->json([ + 'success' => true, + 'data' => $logs, + ]); + } + + /** + * 일일 로그 통계 + */ + public function stats(Request $request): JsonResponse + { + $tenantId = session('current_tenant_id', 1); + $projectId = $request->input('project_id'); + + $stats = $this->dailyLogService->getStats($tenantId, $projectId); + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } + + /** + * 일일 로그 상세 조회 + */ + public function show(int $id): JsonResponse + { + $log = $this->dailyLogService->getDailyLogById($id, true); + + if (! $log) { + return response()->json([ + 'success' => false, + 'message' => '일일 로그를 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $log, + ]); + } + + /** + * 일일 로그 생성 + */ + public function store(StoreDailyLogRequest $request): JsonResponse + { + $tenantId = session('current_tenant_id', 1); + + $log = $this->dailyLogService->createDailyLog($tenantId, $request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '일일 로그가 생성되었습니다.', + 'data' => $log, + ]); + } + + /** + * 일일 로그 수정 + */ + public function update(UpdateDailyLogRequest $request, int $id): JsonResponse + { + $log = $this->dailyLogService->updateDailyLog($id, $request->validated()); + + return response()->json([ + 'success' => true, + 'message' => '일일 로그가 수정되었습니다.', + 'data' => $log, + ]); + } + + /** + * 일일 로그 삭제 (Soft Delete) + */ + public function destroy(int $id): JsonResponse + { + $this->dailyLogService->deleteDailyLog($id); + + return response()->json([ + 'success' => true, + 'message' => '일일 로그가 삭제되었습니다.', + ]); + } + + /** + * 일일 로그 복원 + */ + public function restore(int $id): JsonResponse + { + $this->dailyLogService->restoreDailyLog($id); + + return response()->json([ + 'success' => true, + 'message' => '일일 로그가 복원되었습니다.', + ]); + } + + /** + * 일일 로그 영구 삭제 + */ + public function forceDestroy(int $id): JsonResponse + { + $this->dailyLogService->forceDeleteDailyLog($id); + + return response()->json([ + 'success' => true, + 'message' => '일일 로그가 영구 삭제되었습니다.', + ]); + } + + // ========================================================================= + // 항목(Entry) 관리 API + // ========================================================================= + + /** + * 항목 추가 + */ + public function addEntry(Request $request, int $logId): JsonResponse + { + $validated = $request->validate([ + 'assignee_type' => 'required|in:team,user', + 'assignee_id' => 'nullable|integer', + 'assignee_name' => 'required|string|max:100', + 'content' => 'required|string|max:2000', + 'status' => 'nullable|in:todo,in_progress,done', + ]); + + $entry = $this->dailyLogService->addEntry($logId, $validated); + + return response()->json([ + 'success' => true, + 'message' => '항목이 추가되었습니다.', + 'data' => $entry, + ]); + } + + /** + * 항목 상태 변경 + */ + public function updateEntryStatus(Request $request, int $entryId): JsonResponse + { + $validated = $request->validate([ + 'status' => 'required|in:todo,in_progress,done', + ]); + + $entry = $this->dailyLogService->updateEntryStatus($entryId, $validated['status']); + + return response()->json([ + 'success' => true, + 'message' => '상태가 변경되었습니다.', + 'data' => $entry, + ]); + } + + /** + * 항목 삭제 + */ + public function deleteEntry(int $entryId): JsonResponse + { + $this->dailyLogService->deleteEntry($entryId); + + return response()->json([ + 'success' => true, + 'message' => '항목이 삭제되었습니다.', + ]); + } + + /** + * 항목 순서 변경 + */ + public function reorderEntries(Request $request, int $logId): JsonResponse + { + $validated = $request->validate([ + 'entry_ids' => 'required|array', + 'entry_ids.*' => 'integer', + ]); + + $this->dailyLogService->reorderEntries($logId, $validated['entry_ids']); + + return response()->json([ + 'success' => true, + 'message' => '순서가 변경되었습니다.', + ]); + } + + /** + * 담당자 목록 조회 (자동완성용) + */ + public function assignees(): JsonResponse + { + $tenantId = session('current_tenant_id', 1); + + $assignees = $this->dailyLogService->getAssigneeList($tenantId); + + return response()->json([ + 'success' => true, + 'data' => $assignees, + ]); + } + + /** + * 오늘 날짜 로그 조회 또는 생성 + */ + public function today(Request $request): JsonResponse + { + $tenantId = session('current_tenant_id', 1); + $projectId = $request->input('project_id'); + $today = now()->format('Y-m-d'); + + $log = $this->dailyLogService->getOrCreateByDate($tenantId, $today, $projectId); + + return response()->json([ + 'success' => true, + 'data' => $log, + ]); + } +} diff --git a/app/Http/Controllers/DailyLogController.php b/app/Http/Controllers/DailyLogController.php new file mode 100644 index 00000000..6fa56587 --- /dev/null +++ b/app/Http/Controllers/DailyLogController.php @@ -0,0 +1,91 @@ +projectService->getActiveProjects(); + $stats = $this->dailyLogService->getStats($tenantId); + $weeklyTimeline = $this->dailyLogService->getWeeklyTimeline($tenantId); + $assigneeTypes = AdminPmDailyLogEntry::getAssigneeTypes(); + $entryStatuses = AdminPmDailyLogEntry::getStatuses(); + $assignees = $this->dailyLogService->getAssigneeList($tenantId); + + return view('daily-logs.index', compact( + 'projects', + 'stats', + 'weeklyTimeline', + 'assigneeTypes', + 'entryStatuses', + 'assignees' + )); + } + + /** + * 일일 로그 상세 화면 + */ + public function show(int $id): View + { + $tenantId = session('current_tenant_id', 1); + + $log = $this->dailyLogService->getDailyLogById($id, true); + + if (! $log) { + abort(404, '일일 로그를 찾을 수 없습니다.'); + } + + $projects = $this->projectService->getActiveProjects(); + $assigneeTypes = AdminPmDailyLogEntry::getAssigneeTypes(); + $entryStatuses = AdminPmDailyLogEntry::getStatuses(); + $assignees = $this->dailyLogService->getAssigneeList($tenantId); + + return view('daily-logs.show', compact( + 'log', + 'projects', + 'assigneeTypes', + 'entryStatuses', + 'assignees' + )); + } + + /** + * 오늘 날짜 로그 화면 (자동 생성) + */ + public function today(): View + { + $tenantId = session('current_tenant_id', 1); + $today = now()->format('Y-m-d'); + + $log = $this->dailyLogService->getOrCreateByDate($tenantId, $today); + + $projects = $this->projectService->getActiveProjects(); + $assigneeTypes = AdminPmDailyLogEntry::getAssigneeTypes(); + $entryStatuses = AdminPmDailyLogEntry::getStatuses(); + $assignees = $this->dailyLogService->getAssigneeList($tenantId); + + return view('daily-logs.show', compact( + 'log', + 'projects', + 'assigneeTypes', + 'entryStatuses', + 'assignees' + )); + } +} diff --git a/app/Http/Requests/DailyLog/StoreDailyLogRequest.php b/app/Http/Requests/DailyLog/StoreDailyLogRequest.php new file mode 100644 index 00000000..597f132a --- /dev/null +++ b/app/Http/Requests/DailyLog/StoreDailyLogRequest.php @@ -0,0 +1,53 @@ + ['nullable', 'integer', 'exists:admin_pm_projects,id'], + 'log_date' => ['required', 'date', 'date_format:Y-m-d'], + 'summary' => ['nullable', 'string', 'max:5000'], + 'entries' => ['nullable', 'array'], + 'entries.*.assignee_type' => ['required_with:entries', 'in:team,user'], + 'entries.*.assignee_id' => ['nullable', 'integer'], + 'entries.*.assignee_name' => ['required_with:entries', 'string', 'max:100'], + 'entries.*.content' => ['required_with:entries', 'string', 'max:2000'], + 'entries.*.status' => ['nullable', 'in:todo,in_progress,done'], + ]; + } + + public function attributes(): array + { + return [ + 'project_id' => '프로젝트', + 'log_date' => '로그 날짜', + 'summary' => '요약', + 'entries' => '항목', + 'entries.*.assignee_type' => '담당자 유형', + 'entries.*.assignee_id' => '담당자 ID', + 'entries.*.assignee_name' => '담당자 이름', + 'entries.*.content' => '업무 내용', + 'entries.*.status' => '상태', + ]; + } + + public function messages(): array + { + return [ + 'log_date.required' => '로그 날짜는 필수입니다.', + 'log_date.date_format' => '로그 날짜 형식이 올바르지 않습니다. (YYYY-MM-DD)', + 'entries.*.assignee_name.required_with' => '담당자 이름은 필수입니다.', + 'entries.*.content.required_with' => '업무 내용은 필수입니다.', + ]; + } +} diff --git a/app/Http/Requests/DailyLog/UpdateDailyLogRequest.php b/app/Http/Requests/DailyLog/UpdateDailyLogRequest.php new file mode 100644 index 00000000..097f48f6 --- /dev/null +++ b/app/Http/Requests/DailyLog/UpdateDailyLogRequest.php @@ -0,0 +1,51 @@ + ['nullable', 'integer', 'exists:admin_pm_projects,id'], + 'summary' => ['nullable', 'string', 'max:5000'], + 'entries' => ['nullable', 'array'], + 'entries.*.id' => ['nullable', 'integer', 'exists:admin_pm_daily_log_entries,id'], + 'entries.*.assignee_type' => ['required_with:entries', 'in:team,user'], + 'entries.*.assignee_id' => ['nullable', 'integer'], + 'entries.*.assignee_name' => ['required_with:entries', 'string', 'max:100'], + 'entries.*.content' => ['required_with:entries', 'string', 'max:2000'], + 'entries.*.status' => ['nullable', 'in:todo,in_progress,done'], + ]; + } + + public function attributes(): array + { + return [ + 'project_id' => '프로젝트', + 'summary' => '요약', + 'entries' => '항목', + 'entries.*.id' => '항목 ID', + 'entries.*.assignee_type' => '담당자 유형', + 'entries.*.assignee_id' => '담당자 ID', + 'entries.*.assignee_name' => '담당자 이름', + 'entries.*.content' => '업무 내용', + 'entries.*.status' => '상태', + ]; + } + + public function messages(): array + { + return [ + 'entries.*.assignee_name.required_with' => '담당자 이름은 필수입니다.', + 'entries.*.content.required_with' => '업무 내용은 필수입니다.', + ]; + } +} diff --git a/app/Models/Admin/AdminPmDailyLog.php b/app/Models/Admin/AdminPmDailyLog.php new file mode 100644 index 00000000..dc8f2369 --- /dev/null +++ b/app/Models/Admin/AdminPmDailyLog.php @@ -0,0 +1,146 @@ + 'date', + 'tenant_id' => 'integer', + 'project_id' => 'integer', + 'created_by' => 'integer', + 'updated_by' => 'integer', + 'deleted_by' => 'integer', + ]; + + /** + * 관계: 프로젝트 + */ + public function project(): BelongsTo + { + return $this->belongsTo(AdminPmProject::class, 'project_id'); + } + + /** + * 관계: 항목들 + */ + public function entries(): HasMany + { + return $this->hasMany(AdminPmDailyLogEntry::class, 'daily_log_id')->orderBy('sort_order'); + } + + /** + * 관계: 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 관계: 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + /** + * 스코프: 테넌트 필터 + */ + public function scopeTenant($query, int $tenantId) + { + return $query->where('tenant_id', $tenantId); + } + + /** + * 스코프: 날짜 범위 필터 + */ + public function scopeDateRange($query, ?string $startDate, ?string $endDate) + { + if ($startDate) { + $query->where('log_date', '>=', $startDate); + } + if ($endDate) { + $query->where('log_date', '<=', $endDate); + } + + return $query; + } + + /** + * 스코프: 프로젝트 필터 + */ + public function scopeProject($query, ?int $projectId) + { + if ($projectId) { + return $query->where('project_id', $projectId); + } + + return $query; + } + + /** + * 항목 통계 + */ + public function getEntryStatsAttribute(): array + { + return [ + 'total' => $this->entries()->count(), + 'todo' => $this->entries()->where('status', AdminPmDailyLogEntry::STATUS_TODO)->count(), + 'in_progress' => $this->entries()->where('status', AdminPmDailyLogEntry::STATUS_IN_PROGRESS)->count(), + 'done' => $this->entries()->where('status', AdminPmDailyLogEntry::STATUS_DONE)->count(), + ]; + } + + /** + * 프로젝트명 (없으면 '전체') + */ + public function getProjectNameAttribute(): string + { + return $this->project?->name ?? '전체'; + } + + /** + * 포맷된 날짜 + */ + public function getFormattedDateAttribute(): string + { + return $this->log_date->format('Y-m-d (D)'); + } +} diff --git a/app/Models/Admin/AdminPmDailyLogEntry.php b/app/Models/Admin/AdminPmDailyLogEntry.php new file mode 100644 index 00000000..a6fa29c6 --- /dev/null +++ b/app/Models/Admin/AdminPmDailyLogEntry.php @@ -0,0 +1,154 @@ + 'integer', + 'assignee_id' => 'integer', + 'sort_order' => 'integer', + ]; + + /** + * 담당자 유형 상수 + */ + public const TYPE_TEAM = 'team'; + + public const TYPE_USER = 'user'; + + /** + * 상태 상수 + */ + public const STATUS_TODO = 'todo'; + + public const STATUS_IN_PROGRESS = 'in_progress'; + + public const STATUS_DONE = 'done'; + + /** + * 담당자 유형 목록 + */ + public static function getAssigneeTypes(): array + { + return [ + self::TYPE_TEAM => '팀', + self::TYPE_USER => '개인', + ]; + } + + /** + * 상태 목록 + */ + public static function getStatuses(): array + { + return [ + self::STATUS_TODO => '예정', + self::STATUS_IN_PROGRESS => '진행중', + self::STATUS_DONE => '완료', + ]; + } + + /** + * 관계: 일일 로그 + */ + public function dailyLog(): BelongsTo + { + return $this->belongsTo(AdminPmDailyLog::class, 'daily_log_id'); + } + + /** + * 관계: 사용자 담당자 (assignee_type이 user인 경우) + */ + public function assigneeUser(): BelongsTo + { + return $this->belongsTo(User::class, 'assignee_id'); + } + + /** + * 스코프: 상태별 필터 + */ + public function scopeStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 스코프: 담당자 유형별 필터 + */ + public function scopeAssigneeType($query, string $type) + { + return $query->where('assignee_type', $type); + } + + /** + * 상태 라벨 (한글) + */ + public function getStatusLabelAttribute(): string + { + return self::getStatuses()[$this->status] ?? $this->status; + } + + /** + * 담당자 유형 라벨 (한글) + */ + public function getAssigneeTypeLabelAttribute(): string + { + return self::getAssigneeTypes()[$this->assignee_type] ?? $this->assignee_type; + } + + /** + * 상태별 색상 클래스 + */ + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_TODO => 'bg-gray-100 text-gray-800', + self::STATUS_IN_PROGRESS => 'bg-yellow-100 text-yellow-800', + self::STATUS_DONE => 'bg-green-100 text-green-800', + default => 'bg-gray-100 text-gray-800', + }; + } + + /** + * 담당자 유형별 색상 클래스 + */ + public function getAssigneeTypeColorAttribute(): string + { + return match ($this->assignee_type) { + self::TYPE_TEAM => 'bg-purple-100 text-purple-800', + self::TYPE_USER => 'bg-blue-100 text-blue-800', + default => 'bg-gray-100 text-gray-800', + }; + } +} diff --git a/app/Services/ProjectManagement/DailyLogService.php b/app/Services/ProjectManagement/DailyLogService.php new file mode 100644 index 00000000..ae84abd2 --- /dev/null +++ b/app/Services/ProjectManagement/DailyLogService.php @@ -0,0 +1,432 @@ +tenant($tenantId) + ->with(['project:id,name', 'creator:id,name', 'entries']) + ->withCount('entries') + ->withTrashed(); + + // 날짜 범위 필터 + if (! empty($filters['start_date']) || ! empty($filters['end_date'])) { + $query->dateRange($filters['start_date'] ?? null, $filters['end_date'] ?? null); + } + + // 프로젝트 필터 + if (! empty($filters['project_id'])) { + $query->project($filters['project_id']); + } + + // 검색 필터 (요약 내용) + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('summary', 'like', "%{$search}%") + ->orWhereHas('entries', function ($eq) use ($search) { + $eq->where('content', 'like', "%{$search}%") + ->orWhere('assignee_name', 'like', "%{$search}%"); + }); + }); + } + + // Soft Delete 필터 + if (isset($filters['trashed'])) { + if ($filters['trashed'] === 'only') { + $query->onlyTrashed(); + } elseif ($filters['trashed'] === 'with') { + $query->withTrashed(); + } + } + + // 정렬 (기본: 날짜 내림차순) + $sortBy = $filters['sort_by'] ?? 'log_date'; + $sortDirection = $filters['sort_direction'] ?? 'desc'; + $query->orderBy($sortBy, $sortDirection); + + return $query->paginate($perPage); + } + + /** + * 특정 일일 로그 조회 + */ + public function getDailyLogById(int $id, bool $withTrashed = false): ?AdminPmDailyLog + { + $query = AdminPmDailyLog::query() + ->with(['project:id,name', 'creator:id,name', 'updater:id,name', 'entries']); + + if ($withTrashed) { + $query->withTrashed(); + } + + return $query->find($id); + } + + /** + * 특정 날짜의 로그 조회 (또는 생성) + */ + public function getOrCreateByDate(int $tenantId, string $date, ?int $projectId = null): AdminPmDailyLog + { + $log = AdminPmDailyLog::query() + ->tenant($tenantId) + ->where('log_date', $date) + ->where('project_id', $projectId) + ->first(); + + if (! $log) { + $log = AdminPmDailyLog::create([ + 'tenant_id' => $tenantId, + 'project_id' => $projectId, + 'log_date' => $date, + 'created_by' => auth()->id(), + ]); + } + + return $log->load('entries'); + } + + /** + * 일일 로그 생성 + */ + public function createDailyLog(int $tenantId, array $data): AdminPmDailyLog + { + return DB::transaction(function () use ($tenantId, $data) { + $log = AdminPmDailyLog::create([ + 'tenant_id' => $tenantId, + 'project_id' => $data['project_id'] ?? null, + 'log_date' => $data['log_date'], + 'summary' => $data['summary'] ?? null, + 'created_by' => auth()->id(), + ]); + + // 항목이 있으면 함께 생성 + if (! empty($data['entries'])) { + $this->syncEntries($log, $data['entries']); + } + + return $log->load('entries'); + }); + } + + /** + * 일일 로그 수정 + */ + public function updateDailyLog(int $id, array $data): AdminPmDailyLog + { + return DB::transaction(function () use ($id, $data) { + $log = AdminPmDailyLog::findOrFail($id); + + $log->update([ + 'project_id' => $data['project_id'] ?? $log->project_id, + 'summary' => $data['summary'] ?? $log->summary, + 'updated_by' => auth()->id(), + ]); + + // 항목 동기화 + if (isset($data['entries'])) { + $this->syncEntries($log, $data['entries']); + } + + return $log->fresh('entries'); + }); + } + + /** + * 항목 동기화 (bulk upsert/delete) + */ + protected function syncEntries(AdminPmDailyLog $log, array $entries): void + { + $existingIds = $log->entries()->pluck('id')->toArray(); + $incomingIds = []; + + foreach ($entries as $index => $entryData) { + if (! empty($entryData['id'])) { + // 기존 항목 업데이트 + $entry = AdminPmDailyLogEntry::find($entryData['id']); + if ($entry && $entry->daily_log_id === $log->id) { + $entry->update([ + 'assignee_type' => $entryData['assignee_type'] ?? $entry->assignee_type, + 'assignee_id' => $entryData['assignee_id'] ?? null, + 'assignee_name' => $entryData['assignee_name'], + 'content' => $entryData['content'], + 'status' => $entryData['status'] ?? $entry->status, + 'sort_order' => $index, + ]); + $incomingIds[] = $entry->id; + } + } else { + // 새 항목 생성 + $entry = $log->entries()->create([ + 'assignee_type' => $entryData['assignee_type'] ?? 'user', + 'assignee_id' => $entryData['assignee_id'] ?? null, + 'assignee_name' => $entryData['assignee_name'], + 'content' => $entryData['content'], + 'status' => $entryData['status'] ?? 'todo', + 'sort_order' => $index, + ]); + $incomingIds[] = $entry->id; + } + } + + // 제거된 항목 삭제 + $toDelete = array_diff($existingIds, $incomingIds); + if (! empty($toDelete)) { + AdminPmDailyLogEntry::whereIn('id', $toDelete)->delete(); + } + } + + /** + * 일일 로그 삭제 (Soft Delete) + */ + public function deleteDailyLog(int $id): bool + { + $log = AdminPmDailyLog::findOrFail($id); + + $log->deleted_by = auth()->id(); + $log->save(); + + return $log->delete(); + } + + /** + * 일일 로그 복원 + */ + public function restoreDailyLog(int $id): bool + { + $log = AdminPmDailyLog::onlyTrashed()->findOrFail($id); + + $log->deleted_by = null; + + return $log->restore(); + } + + /** + * 일일 로그 영구 삭제 + */ + public function forceDeleteDailyLog(int $id): bool + { + $log = AdminPmDailyLog::withTrashed()->findOrFail($id); + + return $log->forceDelete(); + } + + /** + * 항목 상태 변경 + */ + public function updateEntryStatus(int $entryId, string $status): AdminPmDailyLogEntry + { + $entry = AdminPmDailyLogEntry::findOrFail($entryId); + $entry->update(['status' => $status]); + + return $entry; + } + + /** + * 항목 추가 + */ + public function addEntry(int $logId, array $data): AdminPmDailyLogEntry + { + $log = AdminPmDailyLog::findOrFail($logId); + + // 마지막 정렬 순서 가져오기 + $maxOrder = $log->entries()->max('sort_order') ?? -1; + + return $log->entries()->create([ + 'assignee_type' => $data['assignee_type'] ?? 'user', + 'assignee_id' => $data['assignee_id'] ?? null, + 'assignee_name' => $data['assignee_name'], + 'content' => $data['content'], + 'status' => $data['status'] ?? 'todo', + 'sort_order' => $maxOrder + 1, + ]); + } + + /** + * 항목 삭제 + */ + public function deleteEntry(int $entryId): bool + { + return AdminPmDailyLogEntry::findOrFail($entryId)->delete(); + } + + /** + * 항목 순서 변경 + */ + public function reorderEntries(int $logId, array $entryIds): void + { + foreach ($entryIds as $index => $entryId) { + AdminPmDailyLogEntry::where('id', $entryId) + ->where('daily_log_id', $logId) + ->update(['sort_order' => $index]); + } + } + + /** + * 통계 조회 + */ + public function getStats(int $tenantId, ?int $projectId = null): array + { + $query = AdminPmDailyLog::query()->tenant($tenantId); + + if ($projectId) { + $query->project($projectId); + } + + $totalLogs = $query->count(); + + // 최근 7일 로그 수 + $recentLogs = (clone $query) + ->where('log_date', '>=', now()->subDays(7)->format('Y-m-d')) + ->count(); + + // 전체 항목 통계 + $entryStats = AdminPmDailyLogEntry::query() + ->whereHas('dailyLog', function ($q) use ($tenantId, $projectId) { + $q->tenant($tenantId); + if ($projectId) { + $q->project($projectId); + } + }) + ->selectRaw(" + COUNT(*) as total, + SUM(CASE WHEN status = 'todo' THEN 1 ELSE 0 END) as todo, + SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress, + SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done + ") + ->first(); + + return [ + 'total_logs' => $totalLogs, + 'recent_logs' => $recentLogs, + 'entries' => [ + 'total' => (int) ($entryStats->total ?? 0), + 'todo' => (int) ($entryStats->todo ?? 0), + 'in_progress' => (int) ($entryStats->in_progress ?? 0), + 'done' => (int) ($entryStats->done ?? 0), + ], + ]; + } + + /** + * 주간 타임라인 데이터 조회 (최근 7일) + */ + public function getWeeklyTimeline(int $tenantId, ?int $projectId = null): array + { + $days = []; + $today = now(); + + // 최근 7일 날짜 목록 생성 + for ($i = 6; $i >= 0; $i--) { + $date = $today->copy()->subDays($i); + $days[$date->format('Y-m-d')] = [ + 'date' => $date->format('Y-m-d'), + 'day_name' => $date->locale('ko')->isoFormat('ddd'), + 'day_number' => $date->format('d'), + 'is_today' => $i === 0, + 'is_weekend' => $date->isWeekend(), + 'log' => null, + ]; + } + + // 해당 기간의 로그 조회 + $startDate = $today->copy()->subDays(6)->format('Y-m-d'); + $endDate = $today->format('Y-m-d'); + + $query = AdminPmDailyLog::query() + ->tenant($tenantId) + ->whereBetween('log_date', [$startDate, $endDate]) + ->with(['entries' => function ($q) { + $q->select('id', 'daily_log_id', 'status'); + }]); + + if ($projectId) { + $query->project($projectId); + } + + $logs = $query->get(); + + // 로그 데이터 매핑 + foreach ($logs as $log) { + $dateKey = $log->log_date->format('Y-m-d'); + if (isset($days[$dateKey])) { + $entryStats = [ + 'total' => $log->entries->count(), + 'done' => $log->entries->where('status', 'done')->count(), + 'in_progress' => $log->entries->where('status', 'in_progress')->count(), + 'todo' => $log->entries->where('status', 'todo')->count(), + ]; + + $days[$dateKey]['log'] = [ + 'id' => $log->id, + 'summary' => $log->summary, + 'entry_stats' => $entryStats, + 'completion_rate' => $entryStats['total'] > 0 + ? round(($entryStats['done'] / $entryStats['total']) * 100) + : 0, + ]; + } + } + + return array_values($days); + } + + /** + * 담당자 목록 조회 (자동완성용) + * - 일반 사용자는 슈퍼관리자 목록을 볼 수 없음 + */ + public function getAssigneeList(int $tenantId): array + { + // 사용자 목록 + $query = User::query() + ->whereHas('tenants', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId); + }) + ->where('is_active', true); + + // 슈퍼관리자가 아니면 슈퍼관리자 목록 제외 + if (! auth()->user()?->is_super_admin) { + $query->where('is_super_admin', false); + } + + $users = $query + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn ($u) => [ + 'type' => 'user', + 'id' => $u->id, + 'name' => $u->name, + ]) + ->toArray(); + + // 팀 목록 (부서 활용) + $teams = DB::table('departments') + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn ($d) => [ + 'type' => 'team', + 'id' => $d->id, + 'name' => $d->name, + ]) + ->toArray(); + + return [ + 'users' => $users, + 'teams' => $teams, + ]; + } +} diff --git a/resources/views/daily-logs/index.blade.php b/resources/views/daily-logs/index.blade.php new file mode 100644 index 00000000..4940cbb5 --- /dev/null +++ b/resources/views/daily-logs/index.blade.php @@ -0,0 +1,695 @@ +@extends('layouts.app') + +@section('title', '일일 스크럼') + +@section('content') + +
+

📅 일일 스크럼

+
+ + 오늘 작성 + + +
+
+ + +
+

최근 7일

+
+ @foreach($weeklyTimeline as $index => $day) +
+ + + + @if($day['log'] && $day['log']['entry_stats']['total'] > 0) + + @endif +
+ @endforeach +
+
+ + +
+
+
전체 로그
+
{{ $stats['total_logs'] ?? 0 }}
+
+
+
최근 7일
+
{{ $stats['recent_logs'] ?? 0 }}
+
+
+
진행중 항목
+
{{ $stats['entries']['in_progress'] ?? 0 }}
+
+
+
완료 항목
+
{{ $stats['entries']['done'] ?? 0 }}
+
+
+ + +
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ ~ +
+ +
+ + +
+ +
+ + + +
+
+ + +
+ +
+
+
+
+ + +@include('daily-logs.partials.modal-form', [ + 'projects' => $projects, + 'assigneeTypes' => $assigneeTypes, + 'entryStatuses' => $entryStatuses, + 'assignees' => $assignees +]) +@endsection + +@push('scripts') + + +@endpush \ No newline at end of file diff --git a/resources/views/daily-logs/partials/modal-form.blade.php b/resources/views/daily-logs/partials/modal-form.blade.php new file mode 100644 index 00000000..674e6074 --- /dev/null +++ b/resources/views/daily-logs/partials/modal-form.blade.php @@ -0,0 +1,94 @@ + + \ No newline at end of file diff --git a/resources/views/daily-logs/partials/table.blade.php b/resources/views/daily-logs/partials/table.blade.php new file mode 100644 index 00000000..48502be4 --- /dev/null +++ b/resources/views/daily-logs/partials/table.blade.php @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + @forelse($logs as $log) + + + + + + + + + + + + + + + @empty + + + + @endforelse + +
날짜프로젝트요약항목작성자상태액션
+
+ + + +
+
+ {{ $log->log_date->format('Y-m-d') }} +
+
+ {{ $log->log_date->format('l') }} +
+
+
+
+ @if($log->project) + + {{ $log->project->name }} + + @else + 전체 + @endif + + @if($log->summary) +
+ {{ Str::limit($log->summary, 50) }} +
+ @else + - + @endif +
+
+ {{ $log->entries_count }}개 + @if($log->entries->count() > 0) +
+ @php + $stats = $log->entry_stats; + @endphp + @if($stats['todo'] > 0) + + @endif + @if($stats['in_progress'] > 0) + + @endif + @if($stats['done'] > 0) + + @endif +
+ @endif +
+
+ {{ $log->creator?->name ?? '-' }} + + @if($log->trashed()) + + 삭제됨 + + @else + + 활성 + + @endif + + @if($log->trashed()) + + + @if(auth()->user()?->is_super_admin) + + @endif + @else + + + + @endif +
+ 일일 로그가 없습니다. +
+ + +@if($logs->hasPages()) +
+ {{ $logs->withQueryString()->links() }} +
+@endif diff --git a/resources/views/daily-logs/show.blade.php b/resources/views/daily-logs/show.blade.php new file mode 100644 index 00000000..f802effe --- /dev/null +++ b/resources/views/daily-logs/show.blade.php @@ -0,0 +1,312 @@ +@extends('layouts.app') + +@section('title', '일일 스크럼 - ' . $log->log_date->format('Y-m-d')) + +@section('content') + +
+
+ + ← 목록으로 + +

📅 {{ $log->log_date->format('Y-m-d (l)') }}

+ @if($log->project) + + {{ $log->project->name }} + + @endif +
+
+ +
+
+ + +
+

일일 요약

+ @if($log->summary) +

{{ $log->summary }}

+ @else +

요약이 작성되지 않았습니다.

+ @endif + +
+ 작성자: {{ $log->creator?->name ?? '-' }} + + 작성일: {{ $log->created_at->format('Y-m-d H:i') }} + @if($log->updater) + + 수정자: {{ $log->updater->name }} ({{ $log->updated_at->format('Y-m-d H:i') }}) + @endif +
+
+ + +
+ @php $stats = $log->entry_stats; @endphp +
+
전체
+
{{ $stats['total'] }}
+
+
+
예정
+
{{ $stats['todo'] }}
+
+
+
진행중
+
{{ $stats['in_progress'] }}
+
+
+
완료
+
{{ $stats['done'] }}
+
+
+ + +
+
+

업무 항목

+ +
+ +
+ @forelse($log->entries as $entry) +
+
+
+
+ + + {{ $entry->assignee_type_label }} + + + {{ $entry->assignee_name }} + + + {{ $entry->status_label }} + +
+

{{ $entry->content }}

+
+
+ +
+ @if($entry->status !== 'todo') + + @endif + @if($entry->status !== 'in_progress') + + @endif + @if($entry->status !== 'done') + + @endif +
+ + +
+
+
+ @empty +
+ 등록된 항목이 없습니다. +
+ @endforelse +
+
+ + + +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 0308f0b2..43911e71 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -300,6 +300,16 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover: JSON Import +
  • + + + + + 일일 스크럼 + +
  • diff --git a/routes/api.php b/routes/api.php index 54919968..3b92221c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Api\Admin\DepartmentController; use App\Http\Controllers\Api\Admin\MenuController; use App\Http\Controllers\Api\Admin\PermissionController; +use App\Http\Controllers\Api\Admin\DailyLogController; use App\Http\Controllers\Api\Admin\ProjectManagement\ImportController as PmImportController; use App\Http\Controllers\Api\Admin\ProjectManagement\IssueController as PmIssueController; use App\Http\Controllers\Api\Admin\ProjectManagement\ProjectController as PmProjectController; @@ -317,4 +318,41 @@ Route::post('/project/{projectId}/tasks', [PmImportController::class, 'importTasks'])->name('importTasks'); }); }); + + /* + |-------------------------------------------------------------------------- + | 일일 스크럼 API + |-------------------------------------------------------------------------- + */ + Route::prefix('daily-logs')->name('daily-logs.')->group(function () { + // 고정 경로 + Route::get('/stats', [DailyLogController::class, 'stats'])->name('stats'); + Route::get('/today', [DailyLogController::class, 'today'])->name('today'); + Route::get('/assignees', [DailyLogController::class, 'assignees'])->name('assignees'); + + // 기본 CRUD + Route::get('/', [DailyLogController::class, 'index'])->name('index'); + Route::post('/', [DailyLogController::class, 'store'])->name('store'); + Route::get('/{id}', [DailyLogController::class, 'show'])->name('show'); + Route::put('/{id}', [DailyLogController::class, 'update'])->name('update'); + Route::delete('/{id}', [DailyLogController::class, 'destroy'])->name('destroy'); + + // 복원 (일반관리자 가능) + Route::post('/{id}/restore', [DailyLogController::class, 'restore'])->name('restore'); + + // 슈퍼관리자 전용 액션 (영구삭제) + Route::middleware('super.admin')->group(function () { + Route::delete('/{id}/force', [DailyLogController::class, 'forceDestroy'])->name('forceDestroy'); + }); + + // 항목(Entry) 관리 + Route::post('/{logId}/entries', [DailyLogController::class, 'addEntry'])->name('addEntry'); + Route::post('/{logId}/entries/reorder', [DailyLogController::class, 'reorderEntries'])->name('reorderEntries'); + }); + + // 항목 개별 API (로그 ID 없이 직접 접근) + Route::prefix('daily-logs/entries')->name('daily-logs.entries.')->group(function () { + Route::put('/{entryId}/status', [DailyLogController::class, 'updateEntryStatus'])->name('updateStatus'); + Route::delete('/{entryId}', [DailyLogController::class, 'deleteEntry'])->name('delete'); + }); }); diff --git a/routes/web.php b/routes/web.php index 437e95b7..88d07ce7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use App\Http\Controllers\DevTools\FlowTesterController; use App\Http\Controllers\MenuController; use App\Http\Controllers\PermissionController; +use App\Http\Controllers\DailyLogController; use App\Http\Controllers\ProjectManagementController; use App\Http\Controllers\RoleController; use App\Http\Controllers\RolePermissionController; @@ -123,6 +124,13 @@ Route::get('/import', [ProjectManagementController::class, 'import'])->name('import'); }); + // 일일 스크럼 (Blade 화면만) + Route::prefix('daily-logs')->name('daily-logs.')->group(function () { + Route::get('/', [DailyLogController::class, 'index'])->name('index'); + Route::get('/today', [DailyLogController::class, 'today'])->name('today'); + Route::get('/{id}', [DailyLogController::class, 'show'])->name('show'); + }); + // 대시보드 Route::get('/dashboard', function () { return view('dashboard.index');