Files
sam-manage/resources/views/finance/daily-work-log.blade.php
김보곤 f07db81968 fix: [finance] 일일업무일지 하단 액션 JSX 닫는 괄호 누락 수정
- {viewMode === 'single' && ( 블록의 닫는 괄호 ) → )} 수정
2026-03-16 16:48:12 +09:00

724 lines
38 KiB
PHP

@extends('layouts.app')
@section('title', '일일업무일지')
@push('styles')
<style>
@media print {
.no-print { display: none !important; }
body { background: white !important; }
.print-area { box-shadow: none !important; border: 1px solid #000 !important; }
}
</style>
@endpush
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="daily-work-log-root"></div>
@endsection
@push('scripts')
@include('partials.react-cdn')
<script type="text/babel">
const { useState, useEffect, useCallback, useRef } = React;
const DAYS = ['일요일','월요일','화요일','수요일','목요일','금요일','토요일'];
const PRIORITIES = ['', '높음', '중간', '낮음'];
const HIGHLIGHTS = [
{ value: '', label: '없음', color: '' },
{ value: 'pink', label: '분홍', color: '#fce4ec' },
{ value: 'yellow', label: '노랑', color: '#fff9c4' },
{ value: 'blue', label: '파랑', color: '#e3f2fd' },
{ value: 'green', label: '초록', color: '#e8f5e9' },
];
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
const fetchOpts = (method, body) => ({
method,
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify(body),
});
const toLocalDateString = (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return y + '-' + m + '-' + d;
};
const parseDate = (str) => {
const [y, m, d] = str.split('-').map(Number);
return new Date(y, m - 1, d);
};
const getHighlightColor = (val) => {
const h = HIGHLIGHTS.find(x => x.value === val);
return h ? h.color : '';
};
// ===== 메인 컴포넌트 =====
function DailyWorkLog() {
const [currentDate, setCurrentDate] = useState(toLocalDateString(new Date()));
const [logId, setLogId] = useState(null);
const [items, setItems] = useState([]);
const [memo, setMemo] = useState('');
const [reflection, setReflection] = useState('');
const [memoFiles, setMemoFiles] = useState([]);
const [reflectionFiles, setReflectionFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [dirty, setDirty] = useState(false);
const [message, setMessage] = useState(null);
// 기간 보기 모드
const [viewMode, setViewMode] = useState('single'); // 'single' | 'range'
const [rangeStart, setRangeStart] = useState(() => {
const d = new Date(); d.setDate(d.getDate() - 6);
return toLocalDateString(d);
});
const [rangeEnd, setRangeEnd] = useState(toLocalDateString(new Date()));
const [rangeLogs, setRangeLogs] = useState([]);
const [rangeLoading, setRangeLoading] = useState(false);
const dateObj = parseDate(currentDate);
const dayName = DAYS[dateObj.getDay()];
// 데이터 조회
const fetchLog = useCallback(async (date) => {
setLoading(true);
try {
const res = await fetch('/finance/daily-work-log/show?date=' + date);
const json = await res.json();
if (json.success && json.data) {
setLogId(json.data.id);
setItems(json.data.items.map(it => ({...it, _key: Math.random()})));
setMemo(json.data.memo);
setReflection(json.data.reflection);
const allFiles = json.data.files || [];
setMemoFiles(allFiles.filter(f => f.field_key === 'memo'));
setReflectionFiles(allFiles.filter(f => f.field_key === 'reflection'));
} else {
setLogId(null);
setItems([]);
setMemo('');
setReflection('');
setMemoFiles([]);
setReflectionFiles([]);
}
setDirty(false);
} catch (e) {
console.error('조회 실패:', e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchLog(currentDate); }, [currentDate, fetchLog]);
// 날짜 이동
const moveDate = (offset) => {
const d = parseDate(currentDate);
d.setDate(d.getDate() + offset);
setCurrentDate(toLocalDateString(d));
};
const goToday = () => setCurrentDate(toLocalDateString(new Date()));
// 저장
const handleSave = async () => {
setSaving(true);
try {
const body = {
log_date: currentDate,
memo,
reflection,
items: items.map((it, i) => ({
category: it.category,
task: it.task,
priority: it.priority,
is_completed: it.is_completed,
note: it.note,
highlight: it.highlight,
})),
};
const res = await fetch('/finance/daily-work-log/store', fetchOpts('POST', body));
const json = await res.json();
if (json.success) {
showMessage('저장되었습니다.', 'success');
fetchLog(currentDate);
}
} catch (e) {
showMessage('저장 실패', 'error');
} finally {
setSaving(false);
}
};
// 삭제
const handleDelete = async () => {
if (!logId) return;
if (!confirm('이 날짜의 업무일지를 삭제하시겠습니까?')) return;
try {
const res = await fetch('/finance/daily-work-log/' + logId, fetchOpts('DELETE'));
const json = await res.json();
if (json.success) {
showMessage('삭제되었습니다.', 'success');
fetchLog(currentDate);
}
} catch (e) {
showMessage('삭제 실패', 'error');
}
};
// 이전 일지 복사
const handleCopyPrev = async () => {
try {
const res = await fetch('/finance/daily-work-log/copy-previous', fetchOpts('POST', { date: currentDate }));
const json = await res.json();
if (json.success) {
setItems(json.data.items.map(it => ({...it, _key: Math.random()})));
setDirty(true);
showMessage(json.data.source_date + ' 일지에서 복사했습니다.', 'success');
} else {
showMessage(json.message, 'error');
}
} catch (e) {
showMessage('복사 실패', 'error');
}
};
// 메시지
const showMessage = (text, type) => {
setMessage({ text, type });
setTimeout(() => setMessage(null), 3000);
};
// 파일 업로드
const handleFileUpload = async (fieldKey, files) => {
if (!files || files.length === 0) return;
setUploading(true);
try {
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
formData.append('log_date', currentDate);
formData.append('field_key', fieldKey);
const res = await fetch('/finance/daily-work-log/upload-file', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken },
body: formData,
});
const json = await res.json();
if (json.success) {
const setter = fieldKey === 'memo' ? setMemoFiles : setReflectionFiles;
setter(prev => [...prev, json.file]);
} else {
showMessage('업로드 실패: ' + (json.message || ''), 'error');
}
}
showMessage('파일이 업로드되었습니다.', 'success');
} catch (e) {
showMessage('파일 업로드 실패', 'error');
} finally {
setUploading(false);
}
};
// 파일 삭제
const handleFileDelete = async (fileId, fieldKey) => {
if (!confirm('파일을 삭제하시겠습니까?')) return;
try {
const res = await fetch('/finance/daily-work-log/file/' + fileId, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
});
const json = await res.json();
if (json.success) {
const setter = fieldKey === 'memo' ? setMemoFiles : setReflectionFiles;
setter(prev => prev.filter(f => f.id !== fileId));
showMessage('파일이 삭제되었습니다.', 'success');
}
} catch (e) {
showMessage('파일 삭제 실패', 'error');
}
};
// 항목 CRUD
const addItem = () => {
setItems([...items, { _key: Math.random(), category: '', task: '', priority: '', is_completed: false, note: '', highlight: '' }]);
setDirty(true);
};
const updateItem = (index, field, value) => {
const next = [...items];
next[index] = { ...next[index], [field]: value };
setItems(next);
setDirty(true);
};
const removeItem = (index) => {
setItems(items.filter((_, i) => i !== index));
setDirty(true);
};
const toggleComplete = (index) => {
updateItem(index, 'is_completed', !items[index].is_completed);
};
// 항목 순서 이동
const moveItem = (index, direction) => {
const next = [...items];
const target = index + direction;
if (target < 0 || target >= next.length) return;
[next[index], next[target]] = [next[target], next[index]];
setItems(next);
setDirty(true);
};
// 달성률
const total = items.length;
const completed = items.filter(it => it.is_completed).length;
const rate = total > 0 ? ((completed / total) * 100).toFixed(2) : '0.00';
// 기간 조회
const fetchRange = useCallback(async () => {
setRangeLoading(true);
try {
const res = await fetch('/finance/daily-work-log/range?start_date=' + rangeStart + '&end_date=' + rangeEnd);
const json = await res.json();
if (json.success) {
setRangeLogs(json.data || []);
if (json.data.length === 0) {
showMessage('해당 기간에 작성된 업무일지가 없습니다.', 'error');
}
}
} catch (e) {
console.error('기간 조회 실패:', e);
} finally {
setRangeLoading(false);
}
}, [rangeStart, rangeEnd]);
// 모드 전환
const toggleViewMode = () => {
const next = viewMode === 'single' ? 'range' : 'single';
setViewMode(next);
if (next === 'range') fetchRange();
};
// 인쇄
const handlePrint = () => window.print();
// 스타일 변수 (Blade 이중중괄호 충돌 방지)
const containerStyle = { maxWidth: '960px', margin: '0 auto' };
const headerBg = { background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' };
const dateCellStyle = { width: '180px', textAlign: 'center', fontWeight: 'bold', fontSize: '14px', padding: '8px', borderBottom: '2px solid #333' };
const dayStyle = { width: '100px', textAlign: 'center', fontWeight: 'bold', padding: '8px', borderBottom: '2px solid #333' };
const thStyle = { padding: '8px 4px', fontSize: '13px', fontWeight: '600', textAlign: 'center', borderBottom: '2px solid #4a5568', background: '#edf2f7' };
const tdStyle = { padding: '4px', fontSize: '13px', borderBottom: '1px solid #e2e8f0' };
const tdCenter = { ...tdStyle, textAlign: 'center' };
const sectionHeader = { background: '#edf2f7', padding: '8px 12px', fontWeight: '600', fontSize: '14px', textAlign: 'center', borderTop: '2px solid #4a5568', borderBottom: '1px solid #cbd5e0' };
const rateBar = { height: '8px', borderRadius: '4px', background: '#e2e8f0', overflow: 'hidden' };
const rateColor = rate >= 80 ? '#48bb78' : (rate >= 50 ? '#ecc94b' : '#f56565');
const rateFill = { height: '100%', borderRadius: '4px', background: rateColor, width: rate + '%', transition: 'width 0.3s' };
const inputBase = { width: '100%', padding: '4px 6px', fontSize: '13px', border: '1px solid #e2e8f0', borderRadius: '4px', outline: 'none' };
const btnNav = { padding: '6px 12px', background: 'white', border: '1px solid #d1d5db', borderRadius: '6px', cursor: 'pointer', fontSize: '16px', lineHeight: '1' };
const btnPrimary = { padding: '8px 20px', background: '#4f46e5', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', fontSize: '14px' };
const btnDanger = { padding: '8px 16px', background: '#ef4444', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '14px' };
const btnOutline = { padding: '8px 16px', background: 'white', color: '#374151', border: '1px solid #d1d5db', borderRadius: '6px', cursor: 'pointer', fontSize: '14px' };
const selectStyle = { ...inputBase, cursor: 'pointer', background: 'white' };
const checkboxStyle = { width: '18px', height: '18px', cursor: 'pointer', accentColor: '#4f46e5' };
const textareaStyle = { ...inputBase, minHeight: '60px', resize: 'vertical' };
const msgSuccess = { position: 'fixed', top: '20px', right: '20px', padding: '12px 20px', background: '#48bb78', color: 'white', borderRadius: '8px', zIndex: 9999, fontWeight: '500', boxShadow: '0 4px 12px rgba(0,0,0,0.15)' };
const msgError = { ...msgSuccess, background: '#f56565' };
const noCol = { width: '40px' };
const catCol = { width: '80px' };
const taskCol = { width: 'auto' };
const prioCol = { width: '80px' };
const doneCol = { width: '50px' };
const noteCol = { width: '160px' };
const actCol = { width: '80px' };
const colorSelectStyle = { padding: '2px', fontSize: '11px', width: '32px' };
const rateContainerStyle = { flex: '1', maxWidth: '400px', marginLeft: '20px' };
const rateTextStyle = { color: rateColor, minWidth: '80px', textAlign: 'right' };
const tableStyle = { width: '100%', borderCollapse: 'collapse' };
const btnActive = { padding: '8px 16px', background: '#4f46e5', color: 'white', border: '1px solid #4f46e5', borderRadius: '6px', cursor: 'pointer', fontSize: '14px', fontWeight: '600' };
const rangeDateInput = { padding: '6px 12px', border: '1px solid #d1d5db', borderRadius: '6px', fontSize: '14px', fontWeight: '600', color: '#374151' };
const rangeCardStyle = { marginBottom: '24px', background: 'white', borderRadius: '12px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', border: '1px solid #e5e7eb', overflow: 'hidden' };
const rangeDateHeader = { padding: '12px 16px', background: '#f8fafc', borderBottom: '2px solid #4a5568', display: 'flex', alignItems: 'center', justifyContent: 'space-between' };
const rangeDateText = { fontSize: '16px', fontWeight: '700', color: '#1e293b' };
const rangeDayBadge = { fontSize: '13px', fontWeight: '600', color: '#6366f1', background: '#eef2ff', padding: '2px 10px', borderRadius: '12px' };
const rangeMemoBlock = { padding: '10px 16px', fontSize: '13px', color: '#374151', background: '#fefce8', borderTop: '1px solid #e5e7eb', whiteSpace: 'pre-wrap' };
const rangeReflBlock = { padding: '10px 16px', fontSize: '13px', color: '#374151', background: '#f0fdf4', borderTop: '1px solid #e5e7eb', whiteSpace: 'pre-wrap' };
const rangeRateBox = { padding: '10px 16px', borderTop: '2px solid #4a5568', display: 'flex', alignItems: 'center', justifyContent: 'space-between' };
const fileFlexContent = { flex: 1, minWidth: 0 };
const fileLinkStyle = { display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '12px' };
const fileSizeStyle = { fontSize: '11px', color: '#9ca3af' };
const fileInputHidden = { display: 'none' };
const fileZone = { border: '1px dashed #cbd5e0', borderRadius: '8px', padding: '12px', background: '#f9fafb', cursor: 'pointer', textAlign: 'center', color: '#9ca3af', fontSize: '13px', transition: 'border-color 0.2s' };
const fileItem = { display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 10px', background: 'white', border: '1px solid #e5e7eb', borderRadius: '6px', fontSize: '13px' };
const fileThumb = { width: '36px', height: '36px', objectFit: 'cover', borderRadius: '4px', border: '1px solid #e5e7eb' };
const fileIcon = { width: '36px', height: '36px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#eef2ff', borderRadius: '4px', color: '#6366f1', fontSize: '16px', flexShrink: 0 };
const fileDel = { background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', padding: '2px', flexShrink: 0, lineHeight: '1' };
// 기간 보기 카드 렌더
const renderRangeCard = (log) => {
const logDate = parseDate(log.log_date);
const logDayName = DAYS[logDate.getDay()];
const logTotal = log.items.length;
const logCompleted = log.items.filter(it => it.is_completed).length;
const logRate = logTotal > 0 ? ((logCompleted / logTotal) * 100).toFixed(0) : '0';
const logRateColor = logRate >= 80 ? '#48bb78' : (logRate >= 50 ? '#ecc94b' : '#f56565');
const logRateFill = { height: '100%', borderRadius: '4px', background: logRateColor, width: logRate + '%', transition: 'width 0.3s' };
const logRateText = { fontSize: '15px', color: logRateColor };
return (
<div key={log.id} style={rangeCardStyle}>
<div style={rangeDateHeader}>
<div className="flex items-center gap-3">
<span style={rangeDateText}>{log.log_date}</span>
<span style={rangeDayBadge}>{logDayName}</span>
</div>
<button style={btnOutline} onClick={() => { setViewMode('single'); setCurrentDate(log.log_date); }}>
편집
</button>
</div>
<table style={tableStyle}>
<colgroup>
<col style={noCol} />
<col style={catCol} />
<col style={taskCol} />
<col style={prioCol} />
<col style={doneCol} />
<col style={noteCol} />
</colgroup>
<thead>
<tr>
<th style={thStyle}>No.</th>
<th style={thStyle}>구분</th>
<th style={thStyle}>업무</th>
<th style={thStyle}>우선순위</th>
<th style={thStyle}>완료</th>
<th style={thStyle}>비고</th>
</tr>
</thead>
<tbody>
{log.items.map((item, idx) => {
const bgColor = getHighlightColor(item.highlight);
const rowStyle = bgColor ? { background: bgColor } : {};
return (
<tr key={item.id} style={rowStyle}>
<td style={tdCenter}>{idx + 1}</td>
<td style={tdStyle}>{item.category}</td>
<td style={tdStyle}>{item.task}</td>
<td style={tdCenter}>{item.priority || '-'}</td>
<td style={tdCenter}>{item.is_completed ? '\u2705' : '\u2B1C'}</td>
<td style={tdStyle}>{item.note}</td>
</tr>
);
})}
{log.items.length === 0 && (
<tr><td colSpan={6} className="text-center text-gray-400 py-4" style={tdCenter}>항목 없음</td></tr>
)}
</tbody>
</table>
{log.memo && (
<div style={rangeMemoBlock}>
<span className="font-semibold text-xs text-amber-700 mr-2">메모</span>
{log.memo}
</div>
)}
{log.reflection && (
<div style={rangeReflBlock}>
<span className="font-semibold text-xs text-green-700 mr-2">회고</span>
{log.reflection}
</div>
)}
<div style={rangeRateBox}>
<span className="font-bold text-gray-600 text-sm">달성률</span>
<div className="flex items-center gap-3" style={rateContainerStyle}>
<div style={rateBar} className="flex-1">
<div style={logRateFill}></div>
</div>
<span className="font-bold" style={logRateText}>{logRate}%</span>
</div>
</div>
</div>
);
};
// 파일 첨부 영역 렌더
const renderFileArea = (fieldKey, files) => {
const inputId = 'file-input-' + fieldKey;
return (
<div className="mt-2">
{files.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{files.map(f => (
<div key={f.id} style={fileItem}>
{f.is_image && f.url ? (
<img src={f.url} alt={f.original_name} style={fileThumb} />
) : (
<div style={fileIcon}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
)}
<div style={fileFlexContent}>
<a href={'/finance/daily-work-log/file/' + f.id + '/download'} className="text-indigo-600 hover:underline" style={fileLinkStyle} title={f.original_name}>{f.original_name}</a>
<span style={fileSizeStyle}>{f.formatted_size}</span>
</div>
<button style={fileDel} onClick={() => handleFileDelete(f.id, fieldKey)} title="삭제" className="no-print">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
))}
</div>
)}
<div style={fileZone} className="no-print"
onClick={() => document.getElementById(inputId).click()}
onDragOver={e => { e.preventDefault(); e.currentTarget.style.borderColor = '#6366f1'; }}
onDragLeave={e => { e.currentTarget.style.borderColor = '#cbd5e0'; }}
onDrop={e => { e.preventDefault(); e.currentTarget.style.borderColor = '#cbd5e0'; handleFileUpload(fieldKey, e.dataTransfer.files); }}>
<input type="file" id={inputId} multiple style={fileInputHidden}
onChange={e => { handleFileUpload(fieldKey, e.target.files); e.target.value = ''; }} />
<div className="flex items-center justify-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
{uploading ? '업로드 중...' : '클릭 또는 드래그하여 파일 첨부'}
</div>
</div>
</div>
);
};
return (
<div className="px-4 py-6" style={containerStyle}>
{/* 메시지 토스트 */}
{message && <div style={message.type === 'success' ? msgSuccess : msgError}>{message.text}</div>}
{/* 헤더 */}
<div className="rounded-xl p-6 text-white mb-6 no-print" style={headerBg}>
<h1 className="text-2xl font-bold">일일업무일지</h1>
<p className="text-sm mt-1 opacity-80">Daily Work Log</p>
</div>
{/* 날짜 네비게이션 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4 no-print">
<div className="flex items-center justify-between">
{viewMode === 'single' ? (
<div className="flex items-center gap-2">
<button style={btnNav} onClick={() => moveDate(-1)} title="이전 날짜">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<input type="date" value={currentDate} onChange={e => setCurrentDate(e.target.value)}
className="text-lg font-bold text-gray-800 border border-gray-300 rounded-lg px-3 py-1" />
<span className="text-lg font-semibold text-indigo-600 ml-1">{dayName}</span>
<button style={btnNav} onClick={() => moveDate(1)} title="다음 날짜">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M9 18l6-6-6-6"/></svg>
</button>
</div>
) : (
<div className="flex items-center gap-2">
<input type="date" value={rangeStart} onChange={e => setRangeStart(e.target.value)} style={rangeDateInput} />
<span className="text-gray-400 font-bold">~</span>
<input type="date" value={rangeEnd} onChange={e => setRangeEnd(e.target.value)} style={rangeDateInput} />
<button style={btnPrimary} onClick={fetchRange}>조회</button>
<span className="text-sm text-gray-500 ml-2">
{rangeLogs.length > 0 ? rangeLogs.length + '건' : ''}
</span>
</div>
)}
<div className="flex items-center gap-2">
<button style={viewMode === 'range' ? btnActive : btnOutline} onClick={toggleViewMode}>
{viewMode === 'single' ? '기간 보기' : '일별 보기'}
</button>
{viewMode === 'single' && (
<>
<button style={btnOutline} onClick={goToday}>오늘</button>
<button style={btnOutline} onClick={handleCopyPrev}>이전 복사</button>
</>
)}
<button style={btnOutline} onClick={handlePrint}>인쇄</button>
</div>
</div>
</div>
{/* 단건 보기 - 로딩 */}
{viewMode === 'single' && loading && (
<div className="text-center py-12 text-gray-400">
<svg className="animate-spin mx-auto mb-2" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
로딩 ...
</div>
)}
{/* 단건 보기 - 내용 */}
{viewMode === 'single' && !loading && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden print-area">
{/* 날짜/요일 인쇄 헤더 */}
<table style={tableStyle}>
<thead>
<tr>
<th style={dateCellStyle}>날짜</th>
<td style={dateCellStyle}>{currentDate}</td>
<th style={dayStyle}>요일</th>
<td style={dayStyle}>{dayName}</td>
</tr>
</thead>
</table>
{/* 주요 일정 섹션 */}
<div style={sectionHeader}>주요 일정</div>
<table style={tableStyle}>
<colgroup>
<col style={noCol} />
<col style={catCol} />
<col style={taskCol} />
<col style={prioCol} />
<col style={doneCol} />
<col style={noteCol} />
<col style={actCol} className="no-print" />
</colgroup>
<thead>
<tr>
<th style={thStyle}>No.</th>
<th style={thStyle}>구분</th>
<th style={thStyle}>업무</th>
<th style={thStyle}>우선순위</th>
<th style={thStyle}>완료</th>
<th style={thStyle}>비고</th>
<th style={thStyle} className="no-print"></th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => {
const bgColor = getHighlightColor(item.highlight);
const rowStyle = bgColor ? { background: bgColor } : {};
return (
<tr key={item._key || item.id} style={rowStyle}>
<td style={tdCenter}>{idx + 1}</td>
<td style={tdStyle}>
<input style={inputBase} value={item.category} placeholder="" onChange={e => updateItem(idx, 'category', e.target.value)} />
</td>
<td style={tdStyle}>
<input style={inputBase} value={item.task} placeholder="업무 내용" onChange={e => updateItem(idx, 'task', e.target.value)} />
</td>
<td style={tdCenter}>
<select style={selectStyle} value={item.priority} onChange={e => updateItem(idx, 'priority', e.target.value)}>
{PRIORITIES.map(p => <option key={p} value={p}>{p || '-'}</option>)}
</select>
</td>
<td style={tdCenter}>
<input type="checkbox" style={checkboxStyle} checked={item.is_completed} onChange={() => toggleComplete(idx)} />
</td>
<td style={tdStyle}>
<input style={inputBase} value={item.note} placeholder="" onChange={e => updateItem(idx, 'note', e.target.value)} />
</td>
<td style={tdCenter} className="no-print">
<div className="flex items-center gap-1 justify-center">
<button onClick={() => moveItem(idx, -1)} className="text-gray-400 hover:text-gray-600" title="위로">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6"/></svg>
</button>
<button onClick={() => moveItem(idx, 1)} className="text-gray-400 hover:text-gray-600" title="아래로">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<select value={item.highlight || ''} onChange={e => updateItem(idx, 'highlight', e.target.value)}
className="text-xs border border-gray-200 rounded" style={colorSelectStyle} title="행 색상">
{HIGHLIGHTS.map(h => <option key={h.value} value={h.value}>{h.value ? '\u25CF' : '-'}</option>)}
</select>
<button onClick={() => removeItem(idx)} className="text-red-400 hover:text-red-600" title="삭제">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
</td>
</tr>
);
})}
{items.length === 0 && (
<tr>
<td colSpan={7} className="text-center text-gray-400 py-6" style={tdCenter}>
업무 항목이 없습니다. 아래 버튼으로 추가하세요.
</td>
</tr>
)}
</tbody>
</table>
{/* 항목 추가 버튼 */}
<div className="p-3 border-t border-gray-200 no-print">
<button onClick={addItem} className="flex items-center gap-1 text-indigo-600 hover:text-indigo-800 text-sm font-medium">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12h14"/></svg>
항목 추가
</button>
</div>
{/* 메모 섹션 */}
<div style={sectionHeader}>메모</div>
<div className="p-3">
<textarea style={textareaStyle} value={memo} onChange={e => { setMemo(e.target.value); setDirty(true); }}
placeholder="메모를 입력하세요..." rows={3} />
{renderFileArea('memo', memoFiles)}
</div>
{/* 회고 섹션 */}
<div style={sectionHeader}>회고 (잘한 / 보완할 / 배운 )</div>
<div className="p-3">
<textarea style={textareaStyle} value={reflection} onChange={e => { setReflection(e.target.value); setDirty(true); }}
placeholder="오늘의 회고를 작성하세요..." rows={3} />
{renderFileArea('reflection', reflectionFiles)}
</div>
{/* 달성률 */}
<div className="border-t-2 border-gray-700">
<div className="flex items-center justify-between p-4">
<span className="font-bold text-gray-700 text-lg">달성률</span>
<div className="flex items-center gap-4" style={rateContainerStyle}>
<div style={rateBar} className="flex-1">
<div style={rateFill}></div>
</div>
<span className="text-xl font-bold" style={rateTextStyle}>
{rate}%
</span>
</div>
</div>
</div>
</div>
)}
{/* 기간 보기 - 로딩 */}
{viewMode === 'range' && rangeLoading && (
<div className="text-center py-12 text-gray-400">
<svg className="animate-spin mx-auto mb-2" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
기간 조회 ...
</div>
)}
{/* 기간 보기 - 결과 */}
{viewMode === 'range' && !rangeLoading && rangeLogs.length > 0 && (
<div className="mt-2">
{rangeLogs.map(renderRangeCard)}
</div>
)}
{/* 하단 액션 (단건 모드에서만) */}
{viewMode === 'single' && (
<div className="flex items-center justify-between mt-4 no-print">
<div>
{logId && (
<button style={btnDanger} onClick={handleDelete}>삭제</button>
)}
</div>
<div className="flex items-center gap-2">
{dirty && <span className="text-sm text-amber-600 font-medium">변경사항이 있습니다</span>}
<button style={btnPrimary} onClick={handleSave} disabled={saving}>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
)}
</div>
);
}
ReactDOM.createRoot(document.getElementById('daily-work-log-root')).render(<DailyWorkLog />);
</script>
@endpush