feat: [finance] 일일업무일지 기간 보기 기능 추가
- 시작일~종료일 설정하여 기간 내 업무일지 한번에 조회 - 데이터 없는 날짜는 자동 필터링 (작성된 일지만 표시) - 카드형 읽기 뷰 (날짜/항목/메모/회고/달성률) - 편집 버튼으로 단건 모드 전환 가능
This commit is contained in:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 업무일지 저장 (일지 + 항목 일괄)
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user