Files
sam-manage/app/Services/ProjectManagement/DailyLogService.php
hskwon af5ecf3c4c feat: 프로젝트 대시보드 스크럼 칸반 스타일 개선
- 오늘의 활동을 3컬럼 칸반 레이아웃으로 변경 (예정/진행중/완료)
- 담당자별 항목 그룹핑 적용
- 인라인 상태 변경 버튼 추가 (hover 시 표시)
- 담당자별 다중 항목 편집 모달 구현
  - 담당자 이름 공통 입력
  - 항목별 textarea, 상태 버튼, 삭제 버튼
  - 항목 추가/삭제 기능
  - Promise.all로 일괄 저장
- 인라인 삭제 기능 추가
- 라우트 경로 수정 (pm.daily-logs.index → daily-logs.index)
2025-12-02 14:19:28 +09:00

445 lines
14 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,
];
}
}