Files
sam-manage/app/Services/ProjectManagement/DailyLogService.php
hskwon 1930c2ef9f feat(daily-logs, pm): 스크럼 UI/UX 개선
Daily Logs 페이지:
- 미완료 항목 상태 변경 시 카드 유지 (done만 제거)
- 카드 정렬을 날짜 오래된 순으로 변경
- 요약 내용 nl2br 적용 및 접힘 시 2줄 제한
- 아코디언 항목 담당자별 그룹핑으로 통합

Project Management 페이지:
- 오늘의 활동을 칸반(3열) → 담당자 카드 스타일로 변경
- 완료 항목도 함께 표시 (취소선, 초록 배지)
- 미완료/완료 건수 헤더에 표시
2025-12-04 22:25:50 +09:00

496 lines
16 KiB
PHP

<?php
namespace App\Services\ProjectManagement;
use App\Models\Admin\AdminPmDailyLog;
use App\Models\Admin\AdminPmDailyLogEntry;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class DailyLogService
{
/**
* 일일 로그 목록 조회 (페이지네이션)
*/
public function getDailyLogs(int $tenantId, array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$query = AdminPmDailyLog::query()
->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([
'log_date' => $data['log_date'] ?? $log->log_date,
'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 updateEntry(int $entryId, array $data): AdminPmDailyLogEntry
{
$entry = AdminPmDailyLogEntry::findOrFail($entryId);
$entry->update($data);
return $entry->fresh();
}
/**
* 항목 상태 변경
*/
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,
];
}
/**
* 미완료 항목(예정, 진행중) 조회 - 담당자별 그룹핑, 날짜 오래된 순 정렬
*/
public function getPendingEntries(int $tenantId, ?int $projectId = null, int $limit = 100): array
{
$query = AdminPmDailyLogEntry::query()
->select('admin_pm_daily_log_entries.*')
->with(['dailyLog:id,log_date,project_id', 'dailyLog.project:id,name'])
->join('admin_pm_daily_logs', 'admin_pm_daily_log_entries.daily_log_id', '=', 'admin_pm_daily_logs.id')
->where('admin_pm_daily_logs.tenant_id', $tenantId)
->when($projectId, fn ($q) => $q->where('admin_pm_daily_logs.project_id', $projectId))
->whereIn('admin_pm_daily_log_entries.status', ['todo', 'in_progress'])
->orderBy('admin_pm_daily_logs.log_date', 'asc') // 날짜 오래된 순
->orderBy('admin_pm_daily_log_entries.id', 'asc')
->limit($limit);
$entries = $query->get();
// 담당자별로 그룹핑
$grouped = $entries->groupBy('assignee_name')->map(function ($items, $assigneeName) {
$todoItems = $items->where('status', 'todo');
$inProgressItems = $items->where('status', 'in_progress');
// 항목들을 날짜 오래된 순으로 정렬
$sortedEntries = $items->sortBy(fn ($e) => $e->dailyLog?->log_date)->values();
return [
'assignee_name' => $assigneeName,
'total_count' => $items->count(),
'todo_count' => $todoItems->count(),
'in_progress_count' => $inProgressItems->count(),
'oldest_date' => $sortedEntries->first()?->dailyLog?->log_date, // 카드 정렬용
'entries' => $sortedEntries->map(function ($entry) {
return [
'id' => $entry->id,
'daily_log_id' => $entry->daily_log_id,
'log_date' => $entry->dailyLog?->log_date,
'project_name' => $entry->dailyLog?->project?->name,
'content' => $entry->content,
'status' => $entry->status,
];
})->values()->toArray(),
];
});
// 담당자 카드도 가장 오래된 항목 날짜 기준으로 정렬
$sorted = $grouped->sortBy('oldest_date')->values()->toArray();
return $sorted;
}
}