Files
tk-factory-services/tkpurchase/web/static/js/tkpurchase-schedule.js
Hyungi Ahn 0211889636 feat(tkpurchase): 협력업체 작업 신청 기능 추가
협력업체 포탈에서 오늘 일정이 없을 때 직접 작업을 신청할 수 있는 기능.
구매팀이 승인하면 일정이 생성되고, 반려 시 재신청 가능.

- DB: status ENUM에 requested/rejected 추가, requested_by 컬럼 추가
- API: POST /schedules/request, PUT /:id/approve, PUT /:id/reject
- 포탈: 신청 폼 + 승인 대기/반려 상태 카드
- 관리자: 신청 배지 + 승인 모달 (프로젝트 배정, 작업장 보정)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:40:20 +09:00

457 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* tkpurchase-schedule.js - Schedule management */
let schedulePage = 1;
const scheduleLimit = 20;
let companySearchTimer = null;
let projectList = [];
async function loadProjects() {
try {
const r = await api('/projects/active');
projectList = r.data || [];
} catch(e) {
console.warn('Load projects error:', e);
projectList = [];
}
}
function populateProjectDropdown(selectId, selectedId) {
const select = document.getElementById(selectId);
select.innerHTML = '<option value="">선택 안함</option>';
projectList.forEach(p => {
const label = p.job_no ? `[${p.job_no}] ${p.project_name}` : p.project_name;
select.innerHTML += `<option value="${p.project_id}" ${p.project_id == selectedId ? 'selected' : ''}>${escapeHtml(label)}</option>`;
});
}
function formatDateRange(startDate, endDate) {
const s = formatDate(startDate);
const e = formatDate(endDate);
if (s === e) return s;
// 같은 연도이면 월/일만 표시
const sd = new Date(startDate);
const ed = new Date(endDate);
if (sd.getFullYear() === ed.getFullYear()) {
return `${sd.getMonth()+1}/${sd.getDate()} ~ ${ed.getMonth()+1}/${ed.getDate()}`;
}
return `${s} ~ ${e}`;
}
async function loadSchedules() {
const company = document.getElementById('filterCompany').value.trim();
const dateFrom = document.getElementById('filterDateFrom').value;
const dateTo = document.getElementById('filterDateTo').value;
const status = document.getElementById('filterStatus').value;
let query = `?page=${schedulePage}&limit=${scheduleLimit}`;
if (company) query += '&company=' + encodeURIComponent(company);
if (dateFrom) query += '&date_from=' + dateFrom;
if (dateTo) query += '&date_to=' + dateTo;
if (status) query += '&status=' + status;
try {
const r = await api('/schedules' + query);
renderScheduleTable(r.data || [], r.total || 0);
} catch(e) {
console.warn('Schedule load error:', e);
document.getElementById('scheduleTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">로딩 실패</td></tr>';
}
}
function renderScheduleTable(list, total) {
const tbody = document.getElementById('scheduleTableBody');
if (!list.length) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-8">일정이 없습니다</td></tr>';
document.getElementById('schedulePagination').innerHTML = '';
return;
}
const statusMap = {
requested: ['badge-orange', '신청'],
scheduled: ['badge-amber', '예정'],
in_progress: ['badge-green', '진행중'],
completed: ['badge-blue', '완료'],
cancelled: ['badge-gray', '취소'],
rejected: ['badge-red', '반려']
};
tbody.innerHTML = list.map(s => {
const [cls, label] = statusMap[s.status] || ['badge-gray', s.status];
const canEdit = s.status === 'scheduled';
const isRequest = s.status === 'requested';
const projectLabel = s.project_name ? (s.job_no ? `[${s.job_no}] ${s.project_name}` : s.project_name) : '';
return `<tr${isRequest ? ' class="bg-amber-50"' : ''}>
<td class="font-medium">${escapeHtml(s.company_name || '')}</td>
<td class="whitespace-nowrap">${formatDateRange(s.start_date, s.end_date)}</td>
<td class="text-xs text-gray-500">${escapeHtml(projectLabel)}</td>
<td class="max-w-xs truncate">${escapeHtml(s.work_description || '')}</td>
<td class="hide-mobile">${escapeHtml(s.workplace_name || '')}</td>
<td class="text-center">${s.expected_workers || 0}명</td>
<td><span class="badge ${cls}">${label}</span></td>
<td class="text-right">
${isRequest ? `<button onclick="openApproveModal(${s.id})" class="text-amber-600 hover:text-amber-800 text-xs mr-1 font-medium" title="승인/반려"><i class="fas fa-check-circle mr-1"></i>처리</button>` : ''}
${(s.status === 'in_progress' || s.status === 'completed') ? `<button onclick="viewCheckins(${s.id})" class="text-emerald-600 hover:text-emerald-800 text-xs mr-1" title="체크인 현황"><i class="fas fa-clipboard-check"></i></button>` : ''}
${canEdit ? `<button onclick="openEditSchedule(${s.id})" class="text-blue-600 hover:text-blue-800 text-xs mr-1" title="수정"><i class="fas fa-edit"></i></button>
<button onclick="deleteSchedule(${s.id})" class="text-red-500 hover:text-red-700 text-xs" title="삭제"><i class="fas fa-trash"></i></button>` : ''}
</td>
</tr>`;
}).join('');
// Pagination
const totalPages = Math.ceil(total / scheduleLimit);
renderSchedulePagination(totalPages);
}
function renderSchedulePagination(totalPages) {
const container = document.getElementById('schedulePagination');
if (totalPages <= 1) { container.innerHTML = ''; return; }
let html = '';
if (schedulePage > 1) {
html += `<button onclick="goToSchedulePage(${schedulePage - 1})" class="px-3 py-1 border rounded text-sm hover:bg-gray-50">&laquo;</button>`;
}
for (let i = 1; i <= totalPages; i++) {
if (i === schedulePage) {
html += `<button class="px-3 py-1 bg-emerald-600 text-white rounded text-sm">${i}</button>`;
} else if (Math.abs(i - schedulePage) <= 2 || i === 1 || i === totalPages) {
html += `<button onclick="goToSchedulePage(${i})" class="px-3 py-1 border rounded text-sm hover:bg-gray-50">${i}</button>`;
} else if (Math.abs(i - schedulePage) === 3) {
html += '<span class="text-gray-400">...</span>';
}
}
if (schedulePage < totalPages) {
html += `<button onclick="goToSchedulePage(${schedulePage + 1})" class="px-3 py-1 border rounded text-sm hover:bg-gray-50">&raquo;</button>`;
}
container.innerHTML = html;
}
function goToSchedulePage(p) {
schedulePage = p;
loadSchedules();
}
/* ===== Company Autocomplete ===== */
function setupCompanyAutocomplete(inputId, dropdownId, hiddenId) {
const input = document.getElementById(inputId);
const dropdown = document.getElementById(dropdownId);
const hidden = document.getElementById(hiddenId);
input.addEventListener('input', function() {
hidden.value = '';
clearTimeout(companySearchTimer);
const q = this.value.trim();
if (q.length < 1) { dropdown.classList.add('hidden'); return; }
companySearchTimer = setTimeout(async () => {
try {
const r = await api('/partners/search?q=' + encodeURIComponent(q));
const list = r.data || [];
if (!list.length) {
dropdown.innerHTML = '<div class="px-3 py-2 text-sm text-gray-400">결과 없음</div>';
} else {
dropdown.innerHTML = list.map(c =>
`<div class="px-3 py-2 text-sm hover:bg-emerald-50 cursor-pointer" onclick="selectCompany('${inputId}','${hiddenId}','${dropdownId}',${c.id},'${escapeHtml(c.company_name)}')">${escapeHtml(c.company_name)}</div>`
).join('');
}
dropdown.classList.remove('hidden');
} catch(e) {
dropdown.classList.add('hidden');
}
}, 300);
});
input.addEventListener('blur', function() {
setTimeout(() => dropdown.classList.add('hidden'), 200);
});
}
function selectCompany(inputId, hiddenId, dropdownId, id, name) {
document.getElementById(inputId).value = name;
document.getElementById(hiddenId).value = id;
document.getElementById(dropdownId).classList.add('hidden');
}
/* ===== Add Schedule ===== */
async function openAddSchedule() {
document.getElementById('addScheduleForm').reset();
document.getElementById('addCompanyId').value = '';
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = tomorrow.toISOString().substring(0, 10);
document.getElementById('addStartDate').value = tomorrowStr;
document.getElementById('addEndDate').value = tomorrowStr;
await loadProjects();
populateProjectDropdown('addProject', '');
document.getElementById('addScheduleModal').classList.remove('hidden');
}
function closeAddSchedule() {
document.getElementById('addScheduleModal').classList.add('hidden');
}
async function submitAddSchedule(e) {
e.preventDefault();
const companyId = document.getElementById('addCompanyId').value;
if (!companyId) { showToast('업체를 선택하세요', 'error'); return; }
const startDate = document.getElementById('addStartDate').value;
const endDate = document.getElementById('addEndDate').value;
if (endDate < startDate) { showToast('종료일은 시작일 이후여야 합니다', 'error'); return; }
const projectId = document.getElementById('addProject').value;
const body = {
company_id: parseInt(companyId),
start_date: startDate,
end_date: endDate,
project_id: projectId ? parseInt(projectId) : null,
work_description: document.getElementById('addWorkDescription').value.trim(),
workplace_name: document.getElementById('addWorkplaceName').value.trim(),
expected_workers: parseInt(document.getElementById('addExpectedWorkers').value) || 0,
notes: document.getElementById('addNotes').value.trim()
};
try {
await api('/schedules', { method: 'POST', body: JSON.stringify(body) });
showToast('일정이 등록되었습니다');
closeAddSchedule();
loadSchedules();
} catch(e) {
showToast(e.message || '등록 실패', 'error');
}
}
/* ===== Edit Schedule ===== */
let scheduleCache = {};
async function openEditSchedule(id) {
try {
const r = await api('/schedules/' + id);
const s = r.data || r;
scheduleCache[id] = s;
document.getElementById('editScheduleId').value = id;
document.getElementById('editCompanySearch').value = s.company_name || '';
document.getElementById('editCompanyId').value = s.company_id || '';
document.getElementById('editStartDate').value = formatDate(s.start_date);
document.getElementById('editEndDate').value = formatDate(s.end_date);
document.getElementById('editWorkDescription').value = s.work_description || '';
document.getElementById('editWorkplaceName').value = s.workplace_name || '';
document.getElementById('editExpectedWorkers').value = s.expected_workers || 0;
document.getElementById('editNotes').value = s.notes || '';
await loadProjects();
populateProjectDropdown('editProject', s.project_id || '');
document.getElementById('editScheduleModal').classList.remove('hidden');
} catch(e) {
showToast('일정 정보를 불러올 수 없습니다', 'error');
}
}
function closeEditSchedule() {
document.getElementById('editScheduleModal').classList.add('hidden');
}
async function submitEditSchedule(e) {
e.preventDefault();
const id = document.getElementById('editScheduleId').value;
const companyId = document.getElementById('editCompanyId').value;
if (!companyId) { showToast('업체를 선택하세요', 'error'); return; }
const startDate = document.getElementById('editStartDate').value;
const endDate = document.getElementById('editEndDate').value;
if (endDate < startDate) { showToast('종료일은 시작일 이후여야 합니다', 'error'); return; }
const projectId = document.getElementById('editProject').value;
const body = {
company_id: parseInt(companyId),
start_date: startDate,
end_date: endDate,
project_id: projectId ? parseInt(projectId) : null,
work_description: document.getElementById('editWorkDescription').value.trim(),
workplace_name: document.getElementById('editWorkplaceName').value.trim(),
expected_workers: parseInt(document.getElementById('editExpectedWorkers').value) || 0,
notes: document.getElementById('editNotes').value.trim()
};
try {
await api('/schedules/' + id, { method: 'PUT', body: JSON.stringify(body) });
showToast('일정이 수정되었습니다');
closeEditSchedule();
loadSchedules();
} catch(e) {
showToast(e.message || '수정 실패', 'error');
}
}
/* ===== Delete Schedule ===== */
async function deleteSchedule(id) {
if (!confirm('이 일정을 삭제하시겠습니까?')) return;
try {
await api('/schedules/' + id, { method: 'DELETE' });
showToast('일정이 삭제되었습니다');
loadSchedules();
} catch(e) {
showToast(e.message || '삭제 실패', 'error');
}
}
/* ===== Checkin Management ===== */
async function viewCheckins(scheduleId) {
document.getElementById('checkinModal').classList.remove('hidden');
const body = document.getElementById('checkinModalBody');
body.innerHTML = '<p class="text-gray-400 text-center py-4">로딩 중...</p>';
try {
const r = await api('/checkins/schedule/' + scheduleId);
const list = r.data || [];
if (!list.length) {
body.innerHTML = '<p class="text-gray-400 text-center py-4">체크인 기록이 없습니다</p>';
return;
}
body.innerHTML = list.map(c => {
let names = '';
if (c.worker_names) {
try {
const parsed = JSON.parse(c.worker_names);
names = Array.isArray(parsed) ? parsed.join(', ') : parsed;
} catch { names = c.worker_names; }
}
const checkinTime = c.check_in_time ? new Date(c.check_in_time).toLocaleString('ko-KR', { month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit' }) : '-';
const checkoutTime = c.check_out_time ? new Date(c.check_out_time).toLocaleString('ko-KR', { hour:'2-digit', minute:'2-digit' }) : '작업중';
return `<div class="border rounded-lg p-3" data-checkin-id="${c.id}">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="text-sm font-medium">${escapeHtml(c.company_name || '')} <span class="text-gray-400 text-xs">${checkinTime} ~ ${checkoutTime}</span></div>
<div class="text-xs text-gray-500 mt-1">인원: ${c.actual_worker_count || 0}명</div>
<div class="text-xs text-gray-600 mt-1" id="checkinNames_${c.id}">작업자: ${escapeHtml(names) || '-'}</div>
</div>
<div class="flex gap-1 ml-2">
<button onclick="editCheckinWorkers(${c.id})" class="text-blue-500 hover:text-blue-700 text-xs px-1" title="수정"><i class="fas fa-edit"></i></button>
<button onclick="deleteCheckin(${c.id},${scheduleId})" class="text-red-500 hover:text-red-700 text-xs px-1" title="삭제"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>`;
}).join('');
} catch(e) {
body.innerHTML = '<p class="text-red-400 text-center py-4">로딩 실패</p>';
}
}
function closeCheckinModal() {
document.getElementById('checkinModal').classList.add('hidden');
}
async function deleteCheckin(checkinId, scheduleId) {
if (!confirm('이 체크인을 삭제하시겠습니까?\n관련 업무현황도 함께 삭제됩니다.')) return;
try {
await api('/checkins/' + checkinId, { method: 'DELETE' });
showToast('체크인이 삭제되었습니다');
viewCheckins(scheduleId);
loadSchedules();
} catch(e) {
showToast(e.message || '삭제 실패', 'error');
}
}
async function editCheckinWorkers(checkinId) {
const el = document.getElementById('checkinNames_' + checkinId);
const current = el.textContent.replace('작업자: ', '').trim();
const input = prompt('작업자 명단 (콤마로 구분)', current === '-' ? '' : current);
if (input === null) return;
const names = input.split(/[,]\s*/).map(n => n.trim()).filter(Boolean);
try {
await api('/checkins/' + checkinId, {
method: 'PUT',
body: JSON.stringify({ worker_names: names.length ? names : null })
});
el.textContent = '작업자: ' + (names.length ? names.join(', ') : '-');
showToast('작업자 명단이 수정되었습니다');
} catch(e) {
showToast(e.message || '수정 실패', 'error');
}
}
/* ===== Approve/Reject Request ===== */
async function openApproveModal(id) {
try {
const r = await api('/schedules/' + id);
const s = r.data || r;
scheduleCache[id] = s;
document.getElementById('approveScheduleId').value = id;
document.getElementById('approveInfo').innerHTML = `
<div><strong>업체:</strong> ${escapeHtml(s.company_name || '')}</div>
<div><strong>작업일:</strong> ${formatDate(s.start_date)}</div>
<div><strong>예상 인원:</strong> ${s.expected_workers || 0}명</div>
<div><strong>작업 내용:</strong> ${escapeHtml(s.work_description || '-')}</div>
<div><strong>작업장:</strong> ${escapeHtml(s.workplace_name || '-')}</div>
`;
document.getElementById('approveWorkplace').value = s.workplace_name || '';
document.getElementById('approveStartDate').value = formatDate(s.start_date);
document.getElementById('approveEndDate').value = formatDate(s.end_date);
await loadProjects();
populateProjectDropdown('approveProject', '');
document.getElementById('approveModal').classList.remove('hidden');
} catch(e) {
showToast('신청 정보를 불러올 수 없습니다', 'error');
}
}
function closeApproveModal() {
document.getElementById('approveModal').classList.add('hidden');
}
async function doApprove() {
const id = document.getElementById('approveScheduleId').value;
if (!confirm('이 작업 신청을 승인하시겠습니까?')) return;
const projectId = document.getElementById('approveProject').value;
const body = {
project_id: projectId ? parseInt(projectId) : null,
workplace_name: document.getElementById('approveWorkplace').value.trim(),
start_date: document.getElementById('approveStartDate').value,
end_date: document.getElementById('approveEndDate').value
};
try {
await api('/schedules/' + id + '/approve', { method: 'PUT', body: JSON.stringify(body) });
showToast('승인 완료');
closeApproveModal();
loadSchedules();
} catch(e) {
showToast(e.message || '승인 실패', 'error');
}
}
async function doReject() {
const id = document.getElementById('approveScheduleId').value;
if (!confirm('이 작업 신청을 반려하시겠습니까?')) return;
try {
await api('/schedules/' + id + '/reject', { method: 'PUT' });
showToast('반려 완료');
closeApproveModal();
loadSchedules();
} catch(e) {
showToast(e.message || '반려 실패', 'error');
}
}
/* ===== Init ===== */
function initSchedulePage() {
if (!initAuth()) return;
// Set default date range
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
document.getElementById('filterDateFrom').value = firstDay.toISOString().substring(0, 10);
document.getElementById('filterDateTo').value = lastDay.toISOString().substring(0, 10);
// Setup autocomplete for both modals
setupCompanyAutocomplete('addCompanySearch', 'addCompanyDropdown', 'addCompanyId');
setupCompanyAutocomplete('editCompanySearch', 'editCompanyDropdown', 'editCompanyId');
loadSchedules();
}