feat: [daily-logs] 일일 스크럼 기능 구현

주요 기능:
- 일일 로그 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 + 항목관리)
This commit is contained in:
2025-12-01 14:07:55 +09:00
parent 189376ffad
commit a2477837d0
14 changed files with 2474 additions and 0 deletions

View File

@@ -0,0 +1,261 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\DailyLog\StoreDailyLogRequest;
use App\Http\Requests\DailyLog\UpdateDailyLogRequest;
use App\Services\ProjectManagement\DailyLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DailyLogController extends Controller
{
public function __construct(
private readonly DailyLogService $dailyLogService
) {}
/**
* 일일 로그 목록 (HTMX용)
*/
public function index(Request $request): View|JsonResponse
{
$tenantId = session('current_tenant_id', 1);
$filters = $request->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,
]);
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers;
use App\Models\Admin\AdminPmDailyLogEntry;
use App\Services\ProjectManagement\DailyLogService;
use App\Services\ProjectManagement\ProjectService;
use Illuminate\View\View;
class DailyLogController extends Controller
{
public function __construct(
private readonly DailyLogService $dailyLogService,
private readonly ProjectService $projectService
) {}
/**
* 일일 로그 목록 화면
*/
public function index(): View
{
$tenantId = session('current_tenant_id', 1);
$projects = $this->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'
));
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\DailyLog;
use Illuminate\Foundation\Http\FormRequest;
class StoreDailyLogRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'project_id' => ['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' => '업무 내용은 필수입니다.',
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests\DailyLog;
use Illuminate\Foundation\Http\FormRequest;
class UpdateDailyLogRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'project_id' => ['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' => '업무 내용은 필수입니다.',
];
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Models\Admin;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 프로젝트 관리 - 일일 로그 모델
*
* @property int $id
* @property int $tenant_id
* @property int|null $project_id
* @property \Carbon\Carbon $log_date
* @property string|null $summary
* @property int $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property \Carbon\Carbon|null $deleted_at
*/
class AdminPmDailyLog extends Model
{
use SoftDeletes;
protected $table = 'admin_pm_daily_logs';
protected $fillable = [
'tenant_id',
'project_id',
'log_date',
'summary',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'log_date' => '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)');
}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Models\Admin;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 프로젝트 관리 - 일일 로그 항목 모델
*
* @property int $id
* @property int $daily_log_id
* @property string $assignee_type
* @property int|null $assignee_id
* @property string $assignee_name
* @property string $content
* @property string $status
* @property int $sort_order
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class AdminPmDailyLogEntry extends Model
{
protected $table = 'admin_pm_daily_log_entries';
protected $fillable = [
'daily_log_id',
'assignee_type',
'assignee_id',
'assignee_name',
'content',
'status',
'sort_order',
];
protected $casts = [
'daily_log_id' => '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',
};
}
}

View File

@@ -0,0 +1,432 @@
<?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([
'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,
];
}
}

View File

@@ -0,0 +1,695 @@
@extends('layouts.app')
@section('title', '일일 스크럼')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">📅 일일 스크럼</h1>
<div class="flex gap-2">
<a href="{{ route('daily-logs.today') }}" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition">
오늘 작성
</a>
<button type="button"
onclick="openCreateModal()"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
+ 로그
</button>
</div>
</div>
<!-- 주간 타임라인 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<h2 class="text-sm font-medium text-gray-500 mb-3">최근 7</h2>
<div class="grid grid-cols-7 gap-2">
@foreach($weeklyTimeline as $index => $day)
<div class="relative group">
<button type="button"
onclick="{{ $day['log'] ? 'scrollToTableRow(' . $day['log']['id'] . ')' : 'openCreateModalWithDate(\'' . $day['date'] . '\')' }}"
data-log-id="{{ $day['log']['id'] ?? '' }}"
data-date="{{ $day['date'] }}"
class="day-card w-full text-left p-3 rounded-lg border-2 transition-all hover:shadow-md
{{ $day['is_today'] ? 'border-blue-500 bg-blue-50' : 'border-gray-200' }}
{{ $day['is_weekend'] && !$day['is_today'] ? 'bg-gray-50' : '' }}
{{ $day['log'] ? 'cursor-pointer' : 'cursor-pointer hover:border-blue-300' }}">
<!-- 요일 -->
<div class="text-xs font-medium {{ $day['is_weekend'] ? 'text-red-500' : 'text-gray-500' }} {{ $day['is_today'] ? 'text-blue-600' : '' }}">
{{ $day['day_name'] }}
</div>
<!-- 날짜 -->
<div class="text-lg font-bold {{ $day['is_today'] ? 'text-blue-600' : 'text-gray-800' }}">
{{ $day['day_number'] }}
</div>
@if($day['log'])
<!-- 로그 있음: 진행률 표시 -->
<div class="mt-2">
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div class="bg-green-500 h-1.5 rounded-full transition-all" style="width: {{ $day['log']['completion_rate'] }}%"></div>
</div>
<div class="flex justify-between items-center mt-1">
<span class="text-xs text-gray-500">{{ $day['log']['entry_stats']['total'] }}</span>
<span class="text-xs font-medium {{ $day['log']['completion_rate'] >= 100 ? 'text-green-600' : 'text-blue-600' }}">
{{ $day['log']['completion_rate'] }}%
</span>
</div>
</div>
@else
<!-- 로그 없음 -->
<div class="mt-2 text-center">
<span class="text-xs text-gray-400">미작성</span>
</div>
@endif
<!-- 오늘 표시 -->
@if($day['is_today'])
<div class="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 rounded-full animate-pulse"></div>
@endif
</button>
<!-- 툴팁 (호버 표시) -->
@if($day['log'] && $day['log']['entry_stats']['total'] > 0)
<div class="hidden group-hover:block absolute z-10 left-1/2 -translate-x-1/2 top-full mt-2 w-36 bg-gray-800 text-white text-xs rounded-lg p-2 shadow-lg pointer-events-none">
<div class="flex justify-between mb-1">
<span>예정</span>
<span>{{ $day['log']['entry_stats']['todo'] }}</span>
</div>
<div class="flex justify-between mb-1">
<span>진행중</span>
<span>{{ $day['log']['entry_stats']['in_progress'] }}</span>
</div>
<div class="flex justify-between">
<span>완료</span>
<span>{{ $day['log']['entry_stats']['done'] }}</span>
</div>
<div class="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-gray-800 rotate-45"></div>
</div>
@endif
</div>
@endforeach
</div>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">전체 로그</div>
<div class="text-2xl font-bold text-gray-800">{{ $stats['total_logs'] ?? 0 }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">최근 7</div>
<div class="text-2xl font-bold text-blue-600">{{ $stats['recent_logs'] ?? 0 }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">진행중 항목</div>
<div class="text-2xl font-bold text-yellow-600">{{ $stats['entries']['in_progress'] ?? 0 }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">완료 항목</div>
<div class="text-2xl font-bold text-green-600">{{ $stats['entries']['done'] ?? 0 }}</div>
</div>
</div>
<!-- 필터 영역 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form id="filterForm" class="flex gap-4 flex-wrap">
<!-- 검색 -->
<div class="flex-1 min-w-[200px]">
<input type="text"
name="search"
placeholder="요약, 담당자, 내용으로 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 프로젝트 필터 -->
<div class="w-48">
<select name="project_id" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 프로젝트</option>
@foreach($projects as $project)
<option value="{{ $project->id }}">{{ $project->name }}</option>
@endforeach
</select>
</div>
<!-- 날짜 범위 -->
<div class="w-40">
<input type="date"
name="start_date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<span class="self-center text-gray-500">~</span>
<div class="w-40">
<input type="date"
name="end_date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 삭제된 항목 포함 -->
<div class="w-36">
<select name="trashed" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">활성만</option>
<option value="with">삭제 포함</option>
<option value="only">삭제만</option>
</select>
</div>
<!-- 검색 버튼 -->
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
검색
</button>
</form>
</div>
<!-- 테이블 영역 (HTMX로 로드) -->
<div id="log-table"
hx-get="/api/admin/daily-logs"
hx-trigger="load, filterSubmit from:body"
hx-include="#filterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm overflow-hidden">
<!-- 로딩 스피너 -->
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
<!-- 생성/수정 모달 -->
@include('daily-logs.partials.modal-form', [
'projects' => $projects,
'assigneeTypes' => $assigneeTypes,
'entryStatuses' => $entryStatuses,
'assignees' => $assignees
])
@endsection
@push('scripts')
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
// 담당자 데이터
const assignees = @json($assignees);
// 폼 제출 시 HTMX 이벤트 트리거
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#log-table', 'filterSubmit');
});
// 모달 열기
function openCreateModal() {
document.getElementById('modalTitle').textContent = '새 일일 로그';
document.getElementById('logForm').reset();
document.getElementById('logId').value = '';
document.getElementById('logDate').value = new Date().toISOString().split('T')[0];
clearEntries();
document.getElementById('logModal').classList.remove('hidden');
}
// 특정 날짜로 모달 열기 (주간 타임라인에서 사용)
function openCreateModalWithDate(date) {
document.getElementById('modalTitle').textContent = '새 일일 로그';
document.getElementById('logForm').reset();
document.getElementById('logId').value = '';
document.getElementById('logDate').value = date;
clearEntries();
document.getElementById('logModal').classList.remove('hidden');
}
// 모달 닫기
function closeModal() {
document.getElementById('logModal').classList.add('hidden');
}
// 로그 수정 모달 열기
function editLog(id) {
fetch(`/api/admin/daily-logs/${id}`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
document.getElementById('modalTitle').textContent = '일일 로그 수정';
document.getElementById('logId').value = data.data.id;
document.getElementById('logDate').value = data.data.log_date;
document.getElementById('projectId').value = data.data.project_id || '';
document.getElementById('summary').value = data.data.summary || '';
// 항목들 로드
clearEntries();
if (data.data.entries && data.data.entries.length > 0) {
data.data.entries.forEach(entry => addEntry(entry));
}
document.getElementById('logModal').classList.remove('hidden');
}
});
}
// 항목 비우기
function clearEntries() {
document.getElementById('entriesContainer').innerHTML = '';
}
// 항목 추가
function addEntry(entry = null) {
const container = document.getElementById('entriesContainer');
const index = container.children.length;
const html = `
<div class="entry-item border rounded-lg p-4 bg-gray-50" data-index="${index}">
<div class="flex gap-4 mb-2">
<input type="hidden" name="entries[${index}][id]" value="${entry?.id || ''}">
<div class="w-24">
<select name="entries[${index}][assignee_type]" class="w-full px-2 py-1 border rounded text-sm" onchange="updateAssigneeOptions(this, ${index})">
<option value="user" ${(!entry || entry.assignee_type === 'user') ? 'selected' : ''}>개인</option>
<option value="team" ${entry?.assignee_type === 'team' ? 'selected' : ''}>팀</option>
</select>
</div>
<div class="flex-1">
<input type="text"
name="entries[${index}][assignee_name]"
value="${entry?.assignee_name || ''}"
placeholder="담당자 (직접입력 또는 선택)"
list="assigneeList${index}"
class="w-full px-2 py-1 border rounded text-sm"
required>
<datalist id="assigneeList${index}">
${getAssigneeOptions(entry?.assignee_type || 'user')}
</datalist>
<input type="hidden" name="entries[${index}][assignee_id]" value="${entry?.assignee_id || ''}">
</div>
<div class="w-24">
<select name="entries[${index}][status]" class="w-full px-2 py-1 border rounded text-sm">
<option value="todo" ${(!entry || entry.status === 'todo') ? 'selected' : ''}>예정</option>
<option value="in_progress" ${entry?.status === 'in_progress' ? 'selected' : ''}>진행중</option>
<option value="done" ${entry?.status === 'done' ? 'selected' : ''}>완료</option>
</select>
</div>
<button type="button" onclick="removeEntry(this)" class="text-red-500 hover:text-red-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<textarea name="entries[${index}][content]"
placeholder="업무 내용"
rows="2"
class="w-full px-2 py-1 border rounded text-sm"
required>${entry?.content || ''}</textarea>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
}
// 담당자 옵션 생성
function getAssigneeOptions(type) {
const list = type === 'team' ? assignees.teams : assignees.users;
return list.map(item => `<option value="${item.name}" data-id="${item.id}">`).join('');
}
// 담당자 타입 변경 시 옵션 업데이트
function updateAssigneeOptions(select, index) {
const datalist = document.getElementById(`assigneeList${index}`);
datalist.innerHTML = getAssigneeOptions(select.value);
}
// 항목 삭제
function removeEntry(btn) {
btn.closest('.entry-item').remove();
reindexEntries();
}
// 항목 인덱스 재정렬
function reindexEntries() {
const entries = document.querySelectorAll('.entry-item');
entries.forEach((entry, index) => {
entry.dataset.index = index;
entry.querySelectorAll('[name^="entries["]').forEach(input => {
input.name = input.name.replace(/entries\[\d+\]/, `entries[${index}]`);
});
const datalist = entry.querySelector('datalist');
if (datalist) {
datalist.id = `assigneeList${index}`;
}
const nameInput = entry.querySelector('input[list]');
if (nameInput) {
nameInput.setAttribute('list', `assigneeList${index}`);
}
});
}
// 폼 제출
document.getElementById('logForm').addEventListener('submit', function(e) {
e.preventDefault();
const logId = document.getElementById('logId').value;
const url = logId ? `/api/admin/daily-logs/${logId}` : '/api/admin/daily-logs';
const method = logId ? 'PUT' : 'POST';
const formData = new FormData(this);
const data = {};
// FormData를 객체로 변환
for (let [key, value] of formData.entries()) {
const match = key.match(/^entries\[(\d+)\]\[(.+)\]$/);
if (match) {
const idx = match[1];
const field = match[2];
if (!data.entries) data.entries = [];
if (!data.entries[idx]) data.entries[idx] = {};
data.entries[idx][field] = value;
} else {
data[key] = value;
}
}
// 빈 항목 제거
if (data.entries) {
data.entries = data.entries.filter(e => e && e.assignee_name && e.content);
}
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify(data)
})
.then(res => res.json())
.then(result => {
if (result.success) {
closeModal();
htmx.trigger('#log-table', 'filterSubmit');
} else {
alert(result.message || '오류가 발생했습니다.');
}
})
.catch(err => {
console.error(err);
alert('오류가 발생했습니다.');
});
});
// 삭제 확인
function confirmDelete(id, date) {
if (confirm(`"${date}" 일일 로그를 삭제하시겠습니까?`)) {
fetch(`/api/admin/daily-logs/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(result => {
if (result.success) {
htmx.trigger('#log-table', 'filterSubmit');
}
});
}
}
// 복원 확인
function confirmRestore(id, date) {
if (confirm(`"${date}" 일일 로그를 복원하시겠습니까?`)) {
fetch(`/api/admin/daily-logs/${id}/restore`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(result => {
if (result.success) {
htmx.trigger('#log-table', 'filterSubmit');
}
});
}
}
// 영구삭제 확인
function confirmForceDelete(id, date) {
if (confirm(`"${date}" 일일 로그를 영구 삭제하시겠습니까?\n\n⚠ 이 작업은 되돌릴 수 없습니다!`)) {
fetch(`/api/admin/daily-logs/${id}/force`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(result => {
if (result.success) {
htmx.trigger('#log-table', 'filterSubmit');
}
});
}
}
// ========================================
// 주간 타임라인 → 테이블 행 스크롤 및 아코디언 열기
// ========================================
function scrollToTableRow(logId) {
// 테이블 행 찾기
const row = document.querySelector(`tr.log-row[data-log-id="${logId}"]`);
if (row) {
// 선택된 카드 하이라이트
document.querySelectorAll('.day-card').forEach(card => {
card.classList.remove('ring-2', 'ring-indigo-500');
});
const selectedCard = document.querySelector(`.day-card[data-log-id="${logId}"]`);
if (selectedCard) {
selectedCard.classList.add('ring-2', 'ring-indigo-500');
}
// 테이블 행으로 스크롤
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
// 스크롤 완료 후 아코디언 열기 (약간의 딜레이)
setTimeout(() => {
// 가상 이벤트 객체 생성
const fakeEvent = { target: row };
toggleTableAccordion(logId, fakeEvent);
}, 300);
} else {
// 테이블에 해당 로그가 없는 경우 (필터링 등으로 인해)
alert('해당 로그가 현재 목록에 표시되지 않습니다.\n필터를 확인해주세요.');
}
}
// ========================================
// 테이블 리스트 아코디언 기능
// ========================================
let tableOpenAccordionId = null;
// 테이블 아코디언 토글
function toggleTableAccordion(logId, event) {
// 클릭한 요소가 버튼이면 무시 (이벤트 버블링 방지)
if (event.target.closest('button') || event.target.closest('a')) {
return;
}
const row = document.querySelector(`tr.log-row[data-log-id="${logId}"]`);
const accordionRow = document.querySelector(`tr.accordion-row[data-accordion-for="${logId}"]`);
const chevron = row.querySelector('.accordion-chevron');
// 같은 행을 다시 클릭하면 닫기
if (tableOpenAccordionId === logId) {
accordionRow.classList.add('hidden');
chevron.classList.remove('rotate-90');
row.classList.remove('bg-blue-50');
tableOpenAccordionId = null;
return;
}
// 다른 열린 아코디언 닫기
if (tableOpenAccordionId !== null) {
const prevRow = document.querySelector(`tr.log-row[data-log-id="${tableOpenAccordionId}"]`);
const prevAccordion = document.querySelector(`tr.accordion-row[data-accordion-for="${tableOpenAccordionId}"]`);
if (prevRow && prevAccordion) {
prevAccordion.classList.add('hidden');
prevRow.querySelector('.accordion-chevron')?.classList.remove('rotate-90');
prevRow.classList.remove('bg-blue-50');
}
}
// 현재 아코디언 열기
accordionRow.classList.remove('hidden');
chevron.classList.add('rotate-90');
row.classList.add('bg-blue-50');
tableOpenAccordionId = logId;
// 데이터 로드
loadTableAccordionContent(logId);
}
// 테이블 아코디언 콘텐츠 로드
function loadTableAccordionContent(logId) {
const contentDiv = document.getElementById(`accordion-content-${logId}`);
contentDiv.innerHTML = '<div class="text-center py-4 text-gray-500">로딩 중...</div>';
fetch(`/api/admin/daily-logs/${logId}`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
renderTableAccordionContent(logId, data.data);
} else {
contentDiv.innerHTML = '<div class="text-center py-4 text-red-500">데이터 로드 실패</div>';
}
})
.catch(err => {
contentDiv.innerHTML = '<div class="text-center py-4 text-red-500">데이터 로드 실패</div>';
});
}
// 테이블 아코디언 콘텐츠 렌더링
function renderTableAccordionContent(logId, log) {
const contentDiv = document.getElementById(`accordion-content-${logId}`);
const statusColors = {
'todo': 'bg-gray-100 text-gray-700',
'in_progress': 'bg-yellow-100 text-yellow-700',
'done': 'bg-green-100 text-green-700'
};
const statusLabels = {
'todo': '예정',
'in_progress': '진행중',
'done': '완료'
};
let entriesHtml = '';
if (log.entries && log.entries.length > 0) {
entriesHtml = log.entries.map(entry => `
<div class="flex items-start gap-3 p-3 bg-white rounded-lg border" data-entry-id="${entry.id}">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-900">${entry.assignee_name}</span>
<span class="px-2 py-0.5 text-xs rounded-full ${statusColors[entry.status]}">${statusLabels[entry.status]}</span>
</div>
<p class="text-sm text-gray-600">${entry.content}</p>
</div>
<div class="flex items-center gap-1">
${entry.status !== 'todo' ? `
<button onclick="updateTableEntryStatus(${logId}, ${entry.id}, 'todo')" class="p-1 text-gray-400 hover:text-gray-600" title="예정">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
</button>` : ''}
${entry.status !== 'in_progress' ? `
<button onclick="updateTableEntryStatus(${logId}, ${entry.id}, 'in_progress')" class="p-1 text-yellow-500 hover:text-yellow-700" title="진행중">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg>
</button>` : ''}
${entry.status !== 'done' ? `
<button onclick="updateTableEntryStatus(${logId}, ${entry.id}, 'done')" class="p-1 text-green-500 hover:text-green-700" title="완료">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</button>` : ''}
<button onclick="deleteTableEntry(${logId}, ${entry.id})" class="p-1 text-red-400 hover:text-red-600" title="삭제">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</div>
`).join('');
} else {
entriesHtml = '<div class="text-center py-4 text-gray-400">등록된 항목이 없습니다.</div>';
}
contentDiv.innerHTML = `
<div class="space-y-3">
${log.summary ? `<div class="text-sm text-gray-700 mb-3 pb-3 border-b">${log.summary}</div>` : ''}
<div class="space-y-2">
${entriesHtml}
</div>
<div class="pt-3 border-t flex justify-between items-center">
<button onclick="openQuickAddTableEntry(${logId})" class="text-sm text-blue-600 hover:text-blue-800">
+ 항목 추가
</button>
<button onclick="editLog(${logId})" class="text-sm text-indigo-600 hover:text-indigo-800">
전체 수정
</button>
</div>
</div>
`;
}
// 테이블 항목 상태 업데이트
function updateTableEntryStatus(logId, entryId, status) {
fetch(`/api/admin/daily-logs/entries/${entryId}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ status })
})
.then(res => res.json())
.then(result => {
if (result.success) {
loadTableAccordionContent(logId);
}
});
}
// 테이블 항목 삭제
function deleteTableEntry(logId, entryId) {
if (confirm('이 항목을 삭제하시겠습니까?')) {
fetch(`/api/admin/daily-logs/entries/${entryId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(result => {
if (result.success) {
loadTableAccordionContent(logId);
}
});
}
}
// 테이블 빠른 항목 추가
function openQuickAddTableEntry(logId) {
const content = prompt('업무 내용을 입력하세요:');
if (content && content.trim()) {
const assigneeName = prompt('담당자 이름을 입력하세요:');
if (assigneeName && assigneeName.trim()) {
fetch(`/api/admin/daily-logs/${logId}/entries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
assignee_type: 'user',
assignee_name: assigneeName.trim(),
content: content.trim(),
status: 'todo'
})
})
.then(res => res.json())
.then(result => {
if (result.success) {
loadTableAccordionContent(logId);
} else {
alert(result.message || '오류가 발생했습니다.');
}
});
}
}
}
// HTMX 로드 완료 후 테이블 아코디언 상태 리셋
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'log-table') {
tableOpenAccordionId = null;
}
});
</script>
@endpush

View File

@@ -0,0 +1,94 @@
<!-- 일일 로그 생성/수정 모달 -->
<div id="logModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<!-- 배경 -->
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeModal()"></div>
<!-- 모달 컨텐츠 -->
<div class="flex min-h-full items-center justify-center p-4">
<div class="relative transform overflow-hidden rounded-lg bg-white shadow-xl transition-all w-full max-w-2xl">
<!-- 헤더 -->
<div class="bg-gray-50 px-6 py-4 border-b">
<h3 id="modalTitle" class="text-lg font-semibold text-gray-900"> 일일 로그</h3>
<button type="button" onclick="closeModal()" class="absolute top-4 right-4 text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- -->
<form id="logForm" class="p-6">
<input type="hidden" id="logId" name="id">
<!-- 기본 정보 -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">날짜 <span class="text-red-500">*</span></label>
<input type="date"
id="logDate"
name="log_date"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
<select id="projectId"
name="project_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 (프로젝트 미지정)</option>
@foreach($projects as $project)
<option value="{{ $project->id }}">{{ $project->name }}</option>
@endforeach
</select>
</div>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1">일일 요약</label>
<textarea id="summary"
name="summary"
rows="2"
placeholder="오늘의 주요 활동 요약..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<!-- 항목 목록 -->
<div class="mb-6">
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-medium text-gray-700">업무 항목</label>
<button type="button"
onclick="addEntry()"
class="text-sm text-blue-600 hover:text-blue-800">
+ 항목 추가
</button>
</div>
<div id="entriesContainer" class="space-y-3">
<!-- 항목들이 여기에 동적으로 추가됨 -->
</div>
<div class="mt-3 text-center">
<button type="button"
onclick="addEntry()"
class="text-sm text-gray-500 hover:text-gray-700 py-2">
+ 항목 추가
</button>
</div>
</div>
<!-- 버튼 -->
<div class="flex justify-end space-x-3 pt-4 border-t">
<button type="button"
onclick="closeModal()"
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition">
취소
</button>
<button type="submit"
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition">
저장
</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,129 @@
<!-- 일일 로그 테이블 -->
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">날짜</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">프로젝트</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">요약</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">항목</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">작성자</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">액션</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($logs as $log)
<!-- 메인 (클릭 가능) -->
<tr class="log-row cursor-pointer hover:bg-gray-50 {{ $log->trashed() ? 'bg-red-50' : '' }}"
data-log-id="{{ $log->id }}"
onclick="toggleTableAccordion({{ $log->id }}, event)">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<svg class="accordion-chevron w-4 h-4 mr-2 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<div>
<div class="text-sm font-medium text-gray-900">
{{ $log->log_date->format('Y-m-d') }}
</div>
<div class="text-xs text-gray-500">
{{ $log->log_date->format('l') }}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($log->project)
<span class="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
{{ $log->project->name }}
</span>
@else
<span class="text-gray-400">전체</span>
@endif
</td>
<td class="px-6 py-4">
@if($log->summary)
<div class="text-sm text-gray-900 truncate max-w-xs" title="{{ $log->summary }}">
{{ Str::limit($log->summary, 50) }}
</div>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-900">{{ $log->entries_count }}</span>
@if($log->entries->count() > 0)
<div class="flex space-x-1">
@php
$stats = $log->entry_stats;
@endphp
@if($stats['todo'] > 0)
<span class="w-2 h-2 rounded-full bg-gray-400" title="예정: {{ $stats['todo'] }}"></span>
@endif
@if($stats['in_progress'] > 0)
<span class="w-2 h-2 rounded-full bg-yellow-400" title="진행중: {{ $stats['in_progress'] }}"></span>
@endif
@if($stats['done'] > 0)
<span class="w-2 h-2 rounded-full bg-green-400" title="완료: {{ $stats['done'] }}"></span>
@endif
</div>
@endif
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $log->creator?->name ?? '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($log->trashed())
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
삭제됨
</span>
@else
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
활성
</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2" onclick="event.stopPropagation()">
@if($log->trashed())
<!-- 삭제된 항목 -->
<button onclick="confirmRestore({{ $log->id }}, '{{ $log->log_date->format('Y-m-d') }}')"
class="text-green-600 hover:text-green-900">복원</button>
@if(auth()->user()?->is_super_admin)
<button onclick="confirmForceDelete({{ $log->id }}, '{{ $log->log_date->format('Y-m-d') }}')"
class="text-red-600 hover:text-red-900">영구삭제</button>
@endif
@else
<!-- 일반 항목 액션 -->
<button onclick="editLog({{ $log->id }})"
class="text-indigo-600 hover:text-indigo-900">수정</button>
<button onclick="confirmDelete({{ $log->id }}, '{{ $log->log_date->format('Y-m-d') }}')"
class="text-red-600 hover:text-red-900">삭제</button>
@endif
</td>
</tr>
<!-- 아코디언 상세 (숨겨진 상태) -->
<tr class="accordion-row hidden" data-accordion-for="{{ $log->id }}">
<td colspan="7" class="px-6 py-4 bg-gray-50">
<div class="accordion-content" id="accordion-content-{{ $log->id }}">
<div class="text-center py-4 text-gray-500">로딩 ...</div>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-12 text-center text-gray-500">
일일 로그가 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
<!-- 페이지네이션 -->
@if($logs->hasPages())
<div class="px-6 py-4 border-t border-gray-200">
{{ $logs->withQueryString()->links() }}
</div>
@endif

View File

@@ -0,0 +1,312 @@
@extends('layouts.app')
@section('title', '일일 스크럼 - ' . $log->log_date->format('Y-m-d'))
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<div>
<a href="{{ route('daily-logs.index') }}" class="text-blue-600 hover:text-blue-800 text-sm mb-2 inline-block">
목록으로
</a>
<h1 class="text-2xl font-bold text-gray-800">📅 {{ $log->log_date->format('Y-m-d (l)') }}</h1>
@if($log->project)
<span class="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
{{ $log->project->name }}
</span>
@endif
</div>
<div class="flex gap-2">
<button type="button"
onclick="openEditModal()"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg transition">
수정
</button>
</div>
</div>
<!-- 요약 카드 -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-800 mb-3">일일 요약</h2>
@if($log->summary)
<p class="text-gray-700 whitespace-pre-wrap">{{ $log->summary }}</p>
@else
<p class="text-gray-400">요약이 작성되지 않았습니다.</p>
@endif
<div class="mt-4 pt-4 border-t flex items-center text-sm text-gray-500">
<span>작성자: {{ $log->creator?->name ?? '-' }}</span>
<span class="mx-2"></span>
<span>작성일: {{ $log->created_at->format('Y-m-d H:i') }}</span>
@if($log->updater)
<span class="mx-2"></span>
<span>수정자: {{ $log->updater->name }} ({{ $log->updated_at->format('Y-m-d H:i') }})</span>
@endif
</div>
</div>
<!-- 항목 통계 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
@php $stats = $log->entry_stats; @endphp
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">전체</div>
<div class="text-2xl font-bold text-gray-800">{{ $stats['total'] }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">예정</div>
<div class="text-2xl font-bold text-gray-600">{{ $stats['todo'] }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">진행중</div>
<div class="text-2xl font-bold text-yellow-600">{{ $stats['in_progress'] }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">완료</div>
<div class="text-2xl font-bold text-green-600">{{ $stats['done'] }}</div>
</div>
</div>
<!-- 항목 목록 -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b flex justify-between items-center">
<h2 class="text-lg font-semibold text-gray-800">업무 항목</h2>
<button type="button"
onclick="openAddEntryModal()"
class="text-sm text-blue-600 hover:text-blue-800">
+ 항목 추가
</button>
</div>
<div id="entriesList" class="divide-y divide-gray-200">
@forelse($log->entries as $entry)
<div class="p-4 hover:bg-gray-50 entry-row" data-entry-id="{{ $entry->id }}">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<!-- 담당자 유형 배지 -->
<span class="px-2 py-0.5 text-xs font-medium rounded-full {{ $entry->assignee_type_color }}">
{{ $entry->assignee_type_label }}
</span>
<!-- 담당자 이름 -->
<span class="font-medium text-gray-900">{{ $entry->assignee_name }}</span>
<!-- 상태 배지 -->
<span class="px-2 py-0.5 text-xs font-medium rounded-full {{ $entry->status_color }}">
{{ $entry->status_label }}
</span>
</div>
<p class="text-gray-700 whitespace-pre-wrap">{{ $entry->content }}</p>
</div>
<div class="flex items-center gap-2 ml-4">
<!-- 상태 변경 버튼들 -->
<div class="flex gap-1">
@if($entry->status !== 'todo')
<button onclick="updateStatus({{ $entry->id }}, 'todo')"
class="p-1 text-gray-400 hover:text-gray-600" title="예정으로 변경">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
</svg>
</button>
@endif
@if($entry->status !== 'in_progress')
<button onclick="updateStatus({{ $entry->id }}, 'in_progress')"
class="p-1 text-yellow-400 hover:text-yellow-600" title="진행중으로 변경">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10"/>
</svg>
</button>
@endif
@if($entry->status !== 'done')
<button onclick="updateStatus({{ $entry->id }}, 'done')"
class="p-1 text-green-400 hover:text-green-600" title="완료로 변경">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
@endif
</div>
<!-- 삭제 버튼 -->
<button onclick="confirmDeleteEntry({{ $entry->id }})"
class="p-1 text-red-400 hover:text-red-600" title="삭제">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
</div>
@empty
<div class="p-12 text-center text-gray-500">
등록된 항목이 없습니다.
</div>
@endforelse
</div>
</div>
<!-- 항목 추가 모달 -->
<div id="addEntryModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="fixed inset-0 bg-black bg-opacity-50" onclick="closeAddEntryModal()"></div>
<div class="flex min-h-full items-center justify-center p-4">
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg">
<div class="bg-gray-50 px-6 py-4 border-b">
<h3 class="text-lg font-semibold text-gray-900">항목 추가</h3>
<button type="button" onclick="closeAddEntryModal()" class="absolute top-4 right-4 text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form id="addEntryForm" class="p-6">
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">담당자 유형</label>
<select name="assignee_type" id="newAssigneeType" onchange="updateNewAssigneeOptions()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg">
@foreach($assigneeTypes as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select name="status"
class="w-full px-3 py-2 border border-gray-300 rounded-lg">
@foreach($entryStatuses as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">담당자 <span class="text-red-500">*</span></label>
<input type="text"
name="assignee_name"
id="newAssigneeName"
list="newAssigneeList"
placeholder="담당자 (직접입력 또는 선택)"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg">
<datalist id="newAssigneeList">
@foreach($assignees['users'] as $user)
<option value="{{ $user['name'] }}" data-id="{{ $user['id'] }}">
@endforeach
</datalist>
<input type="hidden" name="assignee_id" id="newAssigneeId">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">업무 내용 <span class="text-red-500">*</span></label>
<textarea name="content"
rows="3"
required
placeholder="업무 내용을 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg"></textarea>
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="closeAddEntryModal()"
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300">
취소
</button>
<button type="submit"
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700">
추가
</button>
</div>
</form>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
const logId = {{ $log->id }};
const assignees = @json($assignees);
// 상태 업데이트
function updateStatus(entryId, status) {
fetch(`/api/admin/daily-logs/entries/${entryId}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ status })
})
.then(res => res.json())
.then(result => {
if (result.success) {
location.reload();
}
});
}
// 항목 삭제
function confirmDeleteEntry(entryId) {
if (confirm('이 항목을 삭제하시겠습니까?')) {
fetch(`/api/admin/daily-logs/entries/${entryId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(res => res.json())
.then(result => {
if (result.success) {
location.reload();
}
});
}
}
// 항목 추가 모달
function openAddEntryModal() {
document.getElementById('addEntryForm').reset();
document.getElementById('addEntryModal').classList.remove('hidden');
}
function closeAddEntryModal() {
document.getElementById('addEntryModal').classList.add('hidden');
}
// 담당자 옵션 업데이트
function updateNewAssigneeOptions() {
const type = document.getElementById('newAssigneeType').value;
const datalist = document.getElementById('newAssigneeList');
const list = type === 'team' ? assignees.teams : assignees.users;
datalist.innerHTML = list.map(item =>
`<option value="${item.name}" data-id="${item.id}">`
).join('');
}
// 항목 추가 폼 제출
document.getElementById('addEntryForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
fetch(`/api/admin/daily-logs/${logId}/entries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify(data)
})
.then(res => res.json())
.then(result => {
if (result.success) {
location.reload();
} else {
alert(result.message || '오류가 발생했습니다.');
}
});
});
// 로그 수정 모달 열기 (index.blade.php의 모달 재사용)
function openEditModal() {
// index 페이지로 리다이렉트하면서 수정 모달 열기
window.location.href = '{{ route('daily-logs.index') }}?edit={{ $log->id }}';
}
</script>
@endpush

View File

@@ -300,6 +300,16 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
<span class="font-medium">JSON Import</span>
</a>
</li>
<li>
<a href="{{ route('daily-logs.index') }}"
class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100 {{ request()->routeIs('daily-logs.*') ? 'bg-primary text-white hover:bg-primary' : '' }}"
style="padding-left: 2rem;">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="font-medium">일일 스크럼</span>
</a>
</li>
</ul>
</li>

View File

@@ -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');
});
});

View File

@@ -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');