주요 기능:
- 일일 로그 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 + 항목관리)
312 lines
14 KiB
PHP
312 lines
14 KiB
PHP
@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 |