feat(purchase): 구매 취소/반품 + 입고일 기준 월별 분석

- 상태 추가: cancelled(구매취소), returned(반품)
- API: PUT /:id/cancel, /:id/return, /:id/revert-cancel
- 데스크탑: 구매완료→취소 버튼, 입고완료→반품 버튼, 취소→되돌리기
- 분석 페이지: 구매일/입고일 기준 전환 토글, 입고일 기준 월간 분류 집계 + 입고 목록
- Settlement API: GET /received-summary, /received-list (입고일 기준)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-01 11:07:19 +09:00
parent 2c032bd9ea
commit 7c1369a1be
12 changed files with 352 additions and 37 deletions

View File

@@ -7,8 +7,8 @@ const CAT_LABELS = { consumable: '소모품', safety: '안전용품', repair: '
const CAT_COLORS = { consumable: 'badge-blue', safety: 'badge-green', repair: 'badge-amber', equipment: 'badge-purple' };
const CAT_BG = { consumable: '#dbeafe', safety: '#dcfce7', repair: '#fef3c7', equipment: '#f3e8ff' };
const CAT_FG = { consumable: '#1e40af', safety: '#166534', repair: '#92400e', equipment: '#7e22ce' };
const STATUS_LABELS = { pending: '대기', grouped: '구매진행중', purchased: '구매완료', received: '입고완료', hold: '보류' };
const STATUS_COLORS = { pending: 'badge-amber', grouped: 'badge-blue', purchased: 'badge-green', received: 'badge-teal', hold: 'badge-gray' };
const STATUS_LABELS = { pending: '대기', grouped: '구매진행중', purchased: '구매완료', received: '입고완료', cancelled: '취소', returned: '반품', hold: '보류' };
const STATUS_COLORS = { pending: 'badge-amber', grouped: 'badge-blue', purchased: 'badge-green', received: 'badge-teal', cancelled: 'badge-red', returned: 'badge-red', hold: 'badge-gray' };
function _fmtSpec(spec) { return spec ? ' [' + spec + ']' : ''; }
@@ -359,7 +359,12 @@ function renderRequests() {
actions = `<button onclick="openPurchaseModal(${r.request_id})" class="px-2 py-1 bg-orange-500 text-white rounded text-xs hover:bg-orange-600 mr-1" title="구매 처리"><i class="fas fa-shopping-cart"></i></button>
<button onclick="openHoldModal(${r.request_id})" class="px-2 py-1 bg-gray-400 text-white rounded text-xs hover:bg-gray-500" title="보류"><i class="fas fa-pause"></i></button>`;
} else if (isAdmin && r.status === 'purchased') {
actions = `<button onclick="openReceiveModal(${r.request_id})" class="px-2 py-1 bg-teal-500 text-white rounded text-xs hover:bg-teal-600" title="입고 처리"><i class="fas fa-box-open"></i></button>`;
actions = `<button onclick="openReceiveModal(${r.request_id})" class="px-2 py-1 bg-teal-500 text-white rounded text-xs hover:bg-teal-600 mr-1" title="입고 처리"><i class="fas fa-box-open"></i></button>
<button onclick="cancelPurchase(${r.request_id})" class="px-2 py-1 bg-red-400 text-white rounded text-xs hover:bg-red-500" title="구매 취소"><i class="fas fa-times"></i></button>`;
} else if (isAdmin && r.status === 'received') {
actions = `<button onclick="returnItem(${r.request_id})" class="px-2 py-1 bg-red-500 text-white rounded text-xs hover:bg-red-600" title="반품"><i class="fas fa-undo-alt"></i></button>`;
} else if (isAdmin && r.status === 'cancelled') {
actions = `<button onclick="revertCancel(${r.request_id})" class="px-2 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600" title="대기로 되돌리기"><i class="fas fa-undo"></i></button>`;
} else if (isAdmin && r.status === 'hold') {
actions = `<button onclick="revertRequest(${r.request_id})" class="px-2 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600" title="대기로 되돌리기"><i class="fas fa-undo"></i></button>`;
}
@@ -660,6 +665,40 @@ async function batchReceive(batchId) {
} catch (e) { showToast(e.message, 'error'); }
}
/* ===== 구매 취소 / 반품 / 되돌리기 ===== */
async function cancelPurchase(requestId) {
const reason = prompt('취소 사유를 입력하세요 (선택):');
if (reason === null) return;
try {
await api(`/purchase-requests/${requestId}/cancel`, {
method: 'PUT', body: JSON.stringify({ cancel_reason: reason || null })
});
showToast('구매가 취소되었습니다.');
await loadRequests();
} catch (e) { showToast(e.message, 'error'); }
}
async function returnItem(requestId) {
const reason = prompt('반품 사유를 입력하세요:');
if (reason === null) return;
try {
await api(`/purchase-requests/${requestId}/return`, {
method: 'PUT', body: JSON.stringify({ cancel_reason: reason || null })
});
showToast('반품 처리되었습니다.');
await loadRequests();
} catch (e) { showToast(e.message, 'error'); }
}
async function revertCancel(requestId) {
if (!confirm('이 신청을 대기 상태로 되돌리시겠습니까?')) return;
try {
await api(`/purchase-requests/${requestId}/revert-cancel`, { method: 'PUT' });
showToast('대기 상태로 되돌렸습니다.');
await loadRequests();
} catch (e) { showToast(e.message, 'error'); }
}
/* ===== 입고 처리 모달 ===== */
let currentRequestForReceive = null;
let receivePhotoBase64 = null;