Files
sam-manage/resources/views/finance/daily-work-log.blade.php
김보곤 50dff3b661 fix: [finance] 일일업무일지 Blade+React 이중중괄호 충돌 수정
- style={{ }} → JS 변수 분리 (Blade가 PHP echo로 해석하는 문제)
- 중첩 삼항연산자 괄호 추가 (PHP 8.4 호환)
2026-03-13 18:14:50 +09:00

442 lines
22 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 [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [message, setMessage] = useState(null);
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);
} else {
setLogId(null);
setItems([]);
setMemo('');
setReflection('');
}
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);
};
// 항목 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 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' };
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">
<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">
<button style={btnOutline} onClick={goToday}>오늘</button>
<button style={btnOutline} onClick={handleCopyPrev}>이전 복사</button>
<button style={btnOutline} onClick={handlePrint}>인쇄</button>
</div>
</div>
</div>
{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>
) : (
<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} />
</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} />
</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>
)}
{/* 하단 액션 */}
<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