feat:자금계획일정 월별 복사 기능 추가

- POST /api/admin/fund-schedules/copy 엔드포인트 추가
- FundScheduleService에 copySchedulesToMonth() 메서드 추가
- 월 네비게이션 옆 일정복사 버튼 및 모달 UI 구현
- 날짜 조정 로직 (31일→28/29/30일) 포함

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-05 08:42:21 +09:00
parent 7361136605
commit 471ec88357
4 changed files with 234 additions and 2 deletions

View File

@@ -239,6 +239,47 @@ public function updateStatus(Request $request, int $id): JsonResponse|Response
]);
}
/**
* 월별 일정 복사
*/
public function copy(Request $request): JsonResponse
{
$validated = $request->validate([
'source_year' => 'required|integer|min:2000|max:2100',
'source_month' => 'required|integer|min:1|max:12',
'target_year' => 'required|integer|min:2000|max:2100',
'target_month' => 'required|integer|min:1|max:12',
]);
if ($validated['source_year'] === $validated['target_year']
&& $validated['source_month'] === $validated['target_month']) {
return response()->json([
'success' => false,
'message' => '원본 월과 대상 월이 동일합니다.',
], 422);
}
$copiedCount = $this->fundScheduleService->copySchedulesToMonth(
$validated['source_year'],
$validated['source_month'],
$validated['target_year'],
$validated['target_month']
);
if ($copiedCount === 0) {
return response()->json([
'success' => false,
'message' => '복사할 일정이 없습니다.',
], 422);
}
return response()->json([
'success' => true,
'message' => "{$copiedCount}건의 일정이 복사되었습니다.",
'data' => ['copied_count' => $copiedCount],
]);
}
/**
* 월별 요약 통계
*/

View File

@@ -218,6 +218,53 @@ public function updateStatus(FundSchedule $schedule, string $status): FundSchedu
return $schedule->fresh();
}
// =========================================================================
// 월별 복사
// =========================================================================
/**
* 원본 월의 일정을 대상 월로 복사
*/
public function copySchedulesToMonth(int $sourceYear, int $sourceMonth, int $targetYear, int $targetMonth): int
{
$schedules = $this->getSchedulesForMonth($sourceYear, $sourceMonth);
if ($schedules->isEmpty()) {
return 0;
}
$targetLastDay = cal_days_in_month(CAL_GREGORIAN, $targetMonth, $targetYear);
$copiedCount = 0;
foreach ($schedules as $schedule) {
$sourceDay = $schedule->scheduled_date->day;
$targetDay = min($sourceDay, $targetLastDay);
$targetDate = sprintf('%04d-%02d-%02d', $targetYear, $targetMonth, $targetDay);
$this->createSchedule([
'title' => $schedule->title,
'description' => $schedule->description,
'schedule_type' => $schedule->schedule_type,
'scheduled_date' => $targetDate,
'amount' => $schedule->amount,
'currency' => $schedule->currency,
'related_bank_account_id' => $schedule->related_bank_account_id,
'counterparty' => $schedule->counterparty,
'category' => $schedule->category,
'status' => FundSchedule::STATUS_PENDING,
'is_recurring' => $schedule->is_recurring,
'recurrence_rule' => $schedule->recurrence_rule,
'recurrence_end_date' => $schedule->recurrence_end_date,
'memo' => $schedule->memo,
]);
$copiedCount++;
}
return $copiedCount;
}
// =========================================================================
// 일괄 작업
// =========================================================================

View File

@@ -122,6 +122,15 @@ class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
{{-- 일정 복사 버튼 --}}
<button type="button" onclick="openCopyModal()"
class="ml-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-indigo-50 text-indigo-700 hover:bg-indigo-100 border border-indigo-200 rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
일정복사
</button>
</div>
</div>
@@ -144,6 +153,60 @@ class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
</div>
</div>
{{-- 일정 복사 모달 --}}
<div id="copyModal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeCopyModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-800">일정 복사</h3>
<button type="button" onclick="closeCopyModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-6">
<div id="copy-modal-message"></div>
<div class="mb-5">
<p class="text-sm text-gray-600 mb-3">
<span class="font-semibold text-gray-800">{{ $year }} {{ str_pad($month, 2, '0', STR_PAD_LEFT) }}</span> 일정을 아래 선택한 월로 복사합니다.
</p>
<p class="text-xs text-gray-400">복사된 일정의 상태는 모두 '대기' 설정됩니다.</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="copy_target_year" class="block text-sm font-medium text-gray-700 mb-1">대상 연도</label>
<select id="copy_target_year" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
@for($y = $year - 1; $y <= $year + 2; $y++)
<option value="{{ $y }}" {{ $y === $nextYear ? 'selected' : '' }}>{{ $y }}</option>
@endfor
</select>
</div>
<div>
<label for="copy_target_month" class="block text-sm font-medium text-gray-700 mb-1">대상 </label>
<select id="copy_target_month" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
@for($m = 1; $m <= 12; $m++)
<option value="{{ $m }}" {{ $m === $nextMonth ? 'selected' : '' }}>{{ $m }}</option>
@endfor
</select>
</div>
</div>
</div>
<div class="px-6 py-4 border-t bg-gray-50 flex justify-end gap-3 rounded-b-xl">
<button type="button" onclick="closeCopyModal()" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
취소
</button>
<button type="button" onclick="copySchedules()" id="copySubmitBtn" class="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors">
복사
</button>
</div>
</div>
</div>
</div>
{{-- 일정 편집/등록 모달 --}}
<div id="scheduleModal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeScheduleModal()"></div>
@@ -463,9 +526,89 @@ function formatModalAmount(input) {
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !document.getElementById('scheduleModal').classList.contains('hidden')) {
closeScheduleModal();
if (e.key === 'Escape') {
if (!document.getElementById('copyModal').classList.contains('hidden')) {
closeCopyModal();
} else if (!document.getElementById('scheduleModal').classList.contains('hidden')) {
closeScheduleModal();
}
}
});
// =========================================
// 일정 복사 기능
// =========================================
function openCopyModal() {
document.getElementById('copy-modal-message').innerHTML = '';
document.getElementById('copySubmitBtn').disabled = false;
document.getElementById('copyModal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeCopyModal() {
document.getElementById('copyModal').classList.add('hidden');
document.body.style.overflow = '';
}
async function copySchedules() {
const targetYear = parseInt(document.getElementById('copy_target_year').value);
const targetMonth = parseInt(document.getElementById('copy_target_month').value);
const sourceYear = {{ $year }};
const sourceMonth = {{ $month }};
if (sourceYear === targetYear && sourceMonth === targetMonth) {
document.getElementById('copy-modal-message').innerHTML =
'<div class="p-3 mb-4 bg-red-50 text-red-700 rounded-lg text-sm">원본 월과 대상 월이 동일합니다.</div>';
return;
}
if (!confirm(`${sourceYear}년 ${sourceMonth}월의 일정을 ${targetYear}년 ${targetMonth}월로 복사하시겠습니까?`)) {
return;
}
const btn = document.getElementById('copySubmitBtn');
btn.disabled = true;
btn.textContent = '복사 중...';
try {
const response = await fetch('/api/admin/fund-schedules/copy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
source_year: sourceYear,
source_month: sourceMonth,
target_year: targetYear,
target_month: targetMonth
})
});
const result = await response.json();
if (result.success) {
closeCopyModal();
if (confirm(result.message + '\n\n대상 월로 이동하시겠습니까?')) {
window.location.href = `{{ route('finance.fund-schedules.index') }}?year=${targetYear}&month=${targetMonth}`;
} else {
window.location.reload();
}
} else {
document.getElementById('copy-modal-message').innerHTML =
`<div class="p-3 mb-4 bg-red-50 text-red-700 rounded-lg text-sm">${result.message || '복사에 실패했습니다.'}</div>`;
btn.disabled = false;
btn.textContent = '복사';
}
} catch (error) {
console.error('Error:', error);
document.getElementById('copy-modal-message').innerHTML =
'<div class="p-3 mb-4 bg-red-50 text-red-700 rounded-lg text-sm">복사 중 오류가 발생했습니다.</div>';
btn.disabled = false;
btn.textContent = '복사';
}
}
</script>
@endpush

View File

@@ -76,6 +76,7 @@
Route::get('/calendar', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'calendar'])->name('calendar');
Route::get('/summary', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'summary'])->name('summary');
Route::get('/upcoming', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'upcoming'])->name('upcoming');
Route::post('/copy', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'copy'])->name('copy');
// 기본 CRUD
Route::get('/', [\App\Http\Controllers\Api\Admin\FundScheduleController::class, 'index'])->name('index');