- layouts/app.blade.php에 SweetAlert2 CDN 및 전역 헬퍼 함수 추가 - showToast(): 토스트 알림 (success, error, warning, info) - showConfirm(): 확인 대화상자 - showDeleteConfirm(): 삭제 확인 (경고 아이콘) - showPermanentDeleteConfirm(): 영구 삭제 확인 (빨간색 경고) - showSuccess(), showError(): 성공/에러 알림 - 변환된 파일 목록 (48개 Blade 파일): - menus/* (6개), boards/* (2개), posts/* (3개) - daily-logs/* (3개), project-management/* (6개) - dev-tools/flow-tester/* (6개) - quote-formulas/* (4개), permission-analyze/* (1개) - archived-records/* (1개), profile/* (1개) - roles/* (3개), permissions/* (3개) - departments/* (3개), tenants/* (3개), users/* (3개) - 주요 개선사항: - Tailwind CSS 테마와 일관된 디자인 - 비동기 콜백 패턴으로 리팩토링 - 삭제/복원/영구삭제 각각 다른 스타일 적용
370 lines
18 KiB
PHP
370 lines
18 KiB
PHP
<!-- 일일 로그 카드 리스트 -->
|
|
<div class="space-y-3 p-4">
|
|
@forelse($logs as $log)
|
|
<!-- 로그 카드 -->
|
|
<div class="log-card bg-white border rounded-lg overflow-hidden {{ $log->trashed() ? 'border-red-300 bg-red-50' : 'border-gray-200 hover:border-blue-300' }} transition-all"
|
|
data-log-id="{{ $log->id }}">
|
|
<!-- 카드 헤더 (클릭 가능) -->
|
|
<div class="card-header cursor-pointer p-4" onclick="toggleCardAccordion({{ $log->id }}, event)">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<!-- 좌측: 날짜 + 요약 -->
|
|
<div class="flex items-start gap-4 flex-1 min-w-0">
|
|
<!-- 날짜 영역 -->
|
|
<div class="flex-shrink-0 text-center">
|
|
<div class="flex items-center gap-2">
|
|
<svg class="accordion-chevron w-4 h-4 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-lg font-bold text-gray-900">
|
|
{{ $log->log_date->format('m/d') }}
|
|
</div>
|
|
<div class="text-xs text-gray-500">
|
|
{{ $log->log_date->format('D') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 요약 + 프로젝트 -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
@if($log->project)
|
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-800 flex-shrink-0">
|
|
{{ $log->project->name }}
|
|
</span>
|
|
@endif
|
|
@if($log->trashed())
|
|
<span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800 flex-shrink-0">
|
|
삭제됨
|
|
</span>
|
|
@endif
|
|
</div>
|
|
@if($log->summary)
|
|
<p class="text-sm text-gray-700 line-clamp-2" title="{{ $log->summary }}">
|
|
{!! nl2br(e($log->summary)) !!}
|
|
</p>
|
|
@else
|
|
<p class="text-sm text-gray-400">요약 없음</p>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 우측: 통계 + 액션 -->
|
|
<div class="flex items-center gap-4 flex-shrink-0">
|
|
<!-- 항목 통계 -->
|
|
<div class="text-right">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium text-gray-700">{{ $log->entries_count }}개</span>
|
|
@if($log->entries->count() > 0)
|
|
<div class="flex items-center gap-1">
|
|
@php $stats = $log->entry_stats; @endphp
|
|
@if($stats['todo'] > 0)
|
|
<span class="flex items-center gap-0.5 text-xs text-gray-500">
|
|
<span class="w-2 h-2 rounded-full bg-gray-400"></span>{{ $stats['todo'] }}
|
|
</span>
|
|
@endif
|
|
@if($stats['in_progress'] > 0)
|
|
<span class="flex items-center gap-0.5 text-xs text-yellow-600">
|
|
<span class="w-2 h-2 rounded-full bg-yellow-400"></span>{{ $stats['in_progress'] }}
|
|
</span>
|
|
@endif
|
|
@if($stats['done'] > 0)
|
|
<span class="flex items-center gap-0.5 text-xs text-green-600">
|
|
<span class="w-2 h-2 rounded-full bg-green-400"></span>{{ $stats['done'] }}
|
|
</span>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
<div class="text-xs text-gray-400 mt-0.5">
|
|
{{ $log->creator?->name ?? '-' }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 액션 버튼 -->
|
|
<div class="flex items-center gap-1" onclick="event.stopPropagation()">
|
|
@if($log->trashed())
|
|
<button onclick="confirmRestore({{ $log->id }}, '{{ $log->log_date->format('Y-m-d') }}')"
|
|
class="p-2 text-green-600 hover:bg-green-50 rounded-lg transition" 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
|
</svg>
|
|
</button>
|
|
@if(auth()->user()?->is_super_admin)
|
|
<button onclick="confirmForceDelete({{ $log->id }}, '{{ $log->log_date->format('Y-m-d') }}')"
|
|
class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition" 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>
|
|
@endif
|
|
@else
|
|
<button onclick="editLog({{ $log->id }})"
|
|
class="p-2 text-indigo-600 hover:bg-indigo-50 rounded-lg transition" 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
</svg>
|
|
</button>
|
|
<button onclick="confirmDelete({{ $log->id }}, '{{ $log->log_date->format('Y-m-d') }}')"
|
|
class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition" 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>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 아코디언 상세 내용 (숨겨진 상태) -->
|
|
<div class="card-accordion hidden border-t border-gray-200 bg-gray-50" data-accordion-for="{{ $log->id }}">
|
|
<div class="accordion-content p-4" id="card-accordion-content-{{ $log->id }}">
|
|
<div class="text-center py-4 text-gray-500">로딩 중...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@empty
|
|
<div class="text-center py-12 text-gray-500">
|
|
일일 로그가 없습니다.
|
|
</div>
|
|
@endforelse
|
|
</div>
|
|
|
|
<!-- 페이지네이션 -->
|
|
@if($logs->hasPages())
|
|
<div class="px-6 py-4 border-t border-gray-200">
|
|
{{ $logs->withQueryString()->links() }}
|
|
</div>
|
|
@endif
|
|
|
|
<script>
|
|
// 카드 아코디언 기능
|
|
let cardOpenAccordionId = null;
|
|
|
|
function toggleCardAccordion(logId, event) {
|
|
// 클릭한 요소가 버튼이면 무시
|
|
if (event.target.closest('button') || event.target.closest('a')) {
|
|
return;
|
|
}
|
|
|
|
const card = document.querySelector(`.log-card[data-log-id="${logId}"]`);
|
|
const accordion = card.querySelector('.card-accordion');
|
|
const chevron = card.querySelector('.accordion-chevron');
|
|
|
|
// 같은 카드를 다시 클릭하면 닫기
|
|
if (cardOpenAccordionId === logId) {
|
|
accordion.classList.add('hidden');
|
|
chevron.classList.remove('rotate-90');
|
|
card.classList.remove('ring-2', 'ring-blue-500');
|
|
cardOpenAccordionId = null;
|
|
return;
|
|
}
|
|
|
|
// 다른 열린 아코디언 닫기
|
|
if (cardOpenAccordionId !== null) {
|
|
const prevCard = document.querySelector(`.log-card[data-log-id="${cardOpenAccordionId}"]`);
|
|
if (prevCard) {
|
|
prevCard.querySelector('.card-accordion')?.classList.add('hidden');
|
|
prevCard.querySelector('.accordion-chevron')?.classList.remove('rotate-90');
|
|
prevCard.classList.remove('ring-2', 'ring-blue-500');
|
|
}
|
|
}
|
|
|
|
// 현재 아코디언 열기
|
|
accordion.classList.remove('hidden');
|
|
chevron.classList.add('rotate-90');
|
|
card.classList.add('ring-2', 'ring-blue-500');
|
|
cardOpenAccordionId = logId;
|
|
|
|
// 데이터 로드
|
|
loadCardAccordionContent(logId);
|
|
}
|
|
|
|
function loadCardAccordionContent(logId) {
|
|
const contentDiv = document.getElementById(`card-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': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
}
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
renderCardAccordionContent(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 renderCardAccordionContent(logId, log) {
|
|
const contentDiv = document.getElementById(`card-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': '완료'
|
|
};
|
|
|
|
// HTML 이스케이프 헬퍼
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
function nl2br(text) {
|
|
if (!text) return '';
|
|
return escapeHtml(text).replace(/\n/g, '<br>');
|
|
}
|
|
|
|
let entriesHtml = '';
|
|
if (log.entries && log.entries.length > 0) {
|
|
// 담당자별로 그룹핑
|
|
const grouped = {};
|
|
log.entries.forEach(entry => {
|
|
const name = entry.assignee_name || '미지정';
|
|
if (!grouped[name]) {
|
|
grouped[name] = [];
|
|
}
|
|
grouped[name].push(entry);
|
|
});
|
|
|
|
// 담당자별 카드 생성
|
|
entriesHtml = Object.entries(grouped).map(([assigneeName, entries]) => `
|
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
<!-- 담당자 헤더 -->
|
|
<div class="px-3 py-2 bg-gray-100 border-b border-gray-200 flex items-center justify-between">
|
|
<span class="text-sm font-semibold text-gray-900">${escapeHtml(assigneeName)}</span>
|
|
<span class="text-xs text-gray-500">${entries.length}건</span>
|
|
</div>
|
|
<!-- 항목 목록 -->
|
|
<div class="divide-y divide-gray-100">
|
|
${entries.map(entry => `
|
|
<div class="p-3 hover:bg-gray-50" data-entry-id="${entry.id}">
|
|
<div class="flex items-start gap-2">
|
|
<span class="px-1.5 py-0.5 text-[10px] rounded shrink-0 ${statusColors[entry.status]}">${statusLabels[entry.status]}</span>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm text-gray-700">${nl2br(entry.content)}</p>
|
|
</div>
|
|
<div class="flex items-center gap-0.5 shrink-0">
|
|
${entry.status !== 'todo' ? `
|
|
<button onclick="updateCardEntryStatus(${logId}, ${entry.id}, 'todo')" class="p-1 text-gray-400 hover:bg-gray-100 rounded" title="예정">
|
|
<svg class="w-3.5 h-3.5" 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="updateCardEntryStatus(${logId}, ${entry.id}, 'in_progress')" class="p-1 text-yellow-500 hover:bg-yellow-50 rounded" title="진행중">
|
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg>
|
|
</button>` : ''}
|
|
${entry.status !== 'done' ? `
|
|
<button onclick="updateCardEntryStatus(${logId}, ${entry.id}, 'done')" class="p-1 text-green-500 hover:bg-green-50 rounded" title="완료">
|
|
<svg class="w-3.5 h-3.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>` : ''}
|
|
<button onclick="deleteCardEntry(${logId}, ${entry.id})" class="p-1 text-red-400 hover:bg-red-50 rounded" title="삭제">
|
|
<svg class="w-3.5 h-3.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>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} else {
|
|
entriesHtml = '<div class="text-center py-4 text-gray-400">등록된 항목이 없습니다.</div>';
|
|
}
|
|
|
|
// 요약 섹션 (전체 내용)
|
|
const summaryHtml = log.summary ? `
|
|
<div class="mb-4 p-3 bg-white rounded-lg border border-gray-200">
|
|
<div class="text-xs font-medium text-gray-500 mb-1">요약</div>
|
|
<div class="text-sm text-gray-700">${nl2br(log.summary)}</div>
|
|
</div>
|
|
` : '';
|
|
|
|
contentDiv.innerHTML = `
|
|
<div class="space-y-3">
|
|
${summaryHtml}
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
${entriesHtml}
|
|
</div>
|
|
<div class="pt-3 border-t border-gray-200 flex justify-between items-center">
|
|
<button onclick="openQuickAddCardEntry(${logId})" class="text-sm text-blue-600 hover:text-blue-800 font-medium">
|
|
+ 항목 추가
|
|
</button>
|
|
<button onclick="editLog(${logId})" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">
|
|
전체 수정
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateCardEntryStatus(logId, entryId, status) {
|
|
fetch(`/api/admin/daily-logs/entries/${entryId}/status`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
},
|
|
body: JSON.stringify({ status })
|
|
})
|
|
.then(res => res.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
loadCardAccordionContent(logId);
|
|
}
|
|
});
|
|
}
|
|
|
|
function deleteCardEntry(logId, entryId) {
|
|
showConfirm('이 항목을 삭제하시겠습니까?', () => {
|
|
fetch(`/api/admin/daily-logs/entries/${entryId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
}
|
|
})
|
|
.then(res => res.json())
|
|
.then(result => {
|
|
if (result.success) {
|
|
loadCardAccordionContent(logId);
|
|
}
|
|
});
|
|
}, { title: '항목 삭제', icon: 'warning' });
|
|
}
|
|
|
|
function openQuickAddCardEntry(logId) {
|
|
// 부모 페이지의 openQuickAddModal 함수 사용 (prompt 대신 모달에서 담당자 입력)
|
|
if (typeof openQuickAddModal === 'function') {
|
|
openQuickAddModal(logId);
|
|
} else {
|
|
showToast('모달을 열 수 없습니다. 페이지를 새로고침해주세요.', 'warning');
|
|
}
|
|
}
|
|
|
|
// 주간 타임라인에서 카드로 스크롤
|
|
function scrollToCard(logId) {
|
|
const card = document.querySelector(`.log-card[data-log-id="${logId}"]`);
|
|
if (card) {
|
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
setTimeout(() => {
|
|
const fakeEvent = { target: card };
|
|
toggleCardAccordion(logId, fakeEvent);
|
|
}, 300);
|
|
}
|
|
}
|
|
</script>
|