724 lines
38 KiB
PHP
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
|