feat: [finance] 일일업무일지 기간 보기 기능 추가

- 시작일~종료일 설정하여 기간 내 업무일지 한번에 조회
- 데이터 없는 날짜는 자동 필터링 (작성된 일지만 표시)
- 카드형 읽기 뷰 (날짜/항목/메모/회고/달성률)
- 편집 버튼으로 단건 모드 전환 가능
This commit is contained in:
김보곤
2026-03-16 16:33:07 +09:00
parent 540ce35ec1
commit a04a10f15c
3 changed files with 226 additions and 15 deletions

View File

@@ -72,6 +72,55 @@ public function show(Request $request): JsonResponse
]);
}
/**
* 기간별 업무일지 조회 (데이터 있는 날짜만)
*/
public function range(Request $request): JsonResponse
{
$request->validate([
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
]);
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
$logs = DailyWorkLog::whereBetween('log_date', [$startDate, $endDate])
->orderBy('log_date')
->get();
$result = $logs->map(function (DailyWorkLog $log) {
$items = $log->items()->get()->map(fn ($item) => [
'id' => $item->id,
'sort_order' => $item->sort_order,
'category' => $item->category ?? '',
'task' => $item->task,
'priority' => $item->priority ?? '',
'is_completed' => $item->is_completed,
'note' => $item->note ?? '',
'highlight' => $item->getOption('highlight', ''),
]);
$total = $items->count();
$completed = $items->where('is_completed', true)->count();
return [
'id' => $log->id,
'log_date' => $log->log_date->format('Y-m-d'),
'memo' => $log->memo ?? '',
'reflection' => $log->reflection ?? '',
'items' => $items->values(),
'achievement_rate' => $total > 0 ? round(($completed / $total) * 100, 2) : 0,
];
});
return response()->json([
'success' => true,
'data' => $result->values(),
'count' => $result->count(),
]);
}
/**
* 업무일지 저장 (일지 + 항목 일괄)
*/

View File

@@ -71,6 +71,16 @@ function DailyWorkLog() {
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()];
@@ -271,6 +281,32 @@ function DailyWorkLog() {
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();
@@ -307,6 +343,15 @@ function DailyWorkLog() {
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' };
@@ -374,31 +419,50 @@ function DailyWorkLog() {
{/* 날짜 네비게이션 */}
<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={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 style={viewMode === 'range' ? btnActive : btnOutline} onClick={toggleViewMode}>
{viewMode === 'single' ? '기간 보기' : '일별 보기'}
</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>
{viewMode === 'single' && (
<>
<button style={btnOutline} onClick={goToday}>오늘</button>
<button style={btnOutline} onClick={handleCopyPrev}>이전 복사</button>
</>
)}
<button style={btnOutline} onClick={handlePrint}>인쇄</button>
</div>
</div>
</div>
{loading ? (
{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' ? (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden print-area">
{/* 날짜/요일 인쇄 헤더 */}
<table style={tableStyle}>
@@ -528,9 +592,105 @@ className="text-xs border border-gray-200 rounded" style={colorSelectStyle} titl
</div>
</div>
</div>
) : null}
{/* 기간 보기 모드 */}
{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>
) : rangeLogs.length > 0 && (
<div className="mt-2">
{rangeLogs.map(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' };
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={Object.assign({}, {fontSize: '15px', color: logRateColor})}>{logRate}%</span>
</div>
</div>
</div>
);
})}
</div>
)
)}
{/* 하단 액션 */}
{/* 하단 액션 (단건 모드에서만) */}
{viewMode === 'single' && (
<div className="flex items-center justify-between mt-4 no-print">
<div>
{logId && (
@@ -544,6 +704,7 @@ className="text-xs border border-gray-200 rounded" style={colorSelectStyle} titl
</button>
</div>
</div>
)
</div>
);
}

View File

@@ -1199,6 +1199,7 @@
Route::prefix('daily-work-log')->name('daily-work-log.')->group(function () {
Route::get('/show', [\App\Http\Controllers\Finance\DailyWorkLogController::class, 'show'])->name('show');
Route::get('/range', [\App\Http\Controllers\Finance\DailyWorkLogController::class, 'range'])->name('range');
Route::post('/store', [\App\Http\Controllers\Finance\DailyWorkLogController::class, 'store'])->name('store');
Route::post('/toggle-item/{id}', [\App\Http\Controllers\Finance\DailyWorkLogController::class, 'toggleItem'])->name('toggle-item');
Route::delete('/{id}', [\App\Http\Controllers\Finance\DailyWorkLogController::class, 'destroy'])->name('destroy');