신규 독립 시스템 tkpurchase (구매/방문 관리) 구축: - 협력업체 CRUD + 소속 작업자 관리 (마스터 데이터 소유) - 당일 방문 등록/체크인/체크아웃 + 일괄 마감 - 업체 자동완성, CSV 내보내기, 집계 통계 - 자정 자동 체크아웃 (node-cron) - tkuser 협력업체 읽기 전용 탭 + 권한 그리드(tkpurchase-perms) 추가 - docker-compose에 tkpurchase-api/web 서비스 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
273 lines
12 KiB
JavaScript
273 lines
12 KiB
JavaScript
/* ===== Visit Management ===== */
|
|
let todayVisits = [];
|
|
let editingVisitId = null;
|
|
|
|
async function loadTodayVisits() {
|
|
try {
|
|
const r = await api('/daily-visits/today');
|
|
const { visits, stats } = r.data;
|
|
todayVisits = visits;
|
|
renderStats(stats);
|
|
renderVisitTable(visits);
|
|
} catch (e) {
|
|
showToast('데이터 로드 실패: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderStats(s) {
|
|
document.getElementById('statTotal').textContent = s.total || 0;
|
|
document.getElementById('statCheckedIn').textContent = s.checked_in || 0;
|
|
document.getElementById('statCheckedOut').textContent = s.checked_out || 0;
|
|
document.getElementById('statVisitors').textContent = s.total_visitors || 0;
|
|
}
|
|
|
|
function renderVisitTable(visits) {
|
|
const tbody = document.getElementById('visitTableBody');
|
|
if (!visits.length) {
|
|
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-8">오늘 방문 기록이 없습니다</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = visits.map(v => {
|
|
const companyName = v.partner_company_name || v.company_name || '-';
|
|
const safetyIcon = v.safety_education_yn
|
|
? '<i class="fas fa-check-circle text-emerald-500"></i>'
|
|
: '<i class="fas fa-exclamation-triangle text-amber-500 safety-warning" title="안전교육 미이수"></i>';
|
|
const actions = v.status === 'checked_in'
|
|
? `<button onclick="doCheckout(${v.id})" class="text-blue-600 hover:text-blue-800 text-xs px-2 py-1 border border-blue-200 rounded hover:bg-blue-50">체크아웃</button>`
|
|
: '';
|
|
return `<tr>
|
|
<td>${escapeHtml(companyName)}</td>
|
|
<td>${escapeHtml(v.visitor_name)}</td>
|
|
<td class="text-center">${v.visitor_count}</td>
|
|
<td>${purposeBadge(v.purpose)}</td>
|
|
<td class="hide-mobile">${safetyIcon}</td>
|
|
<td>${formatTime(v.check_in_time)}</td>
|
|
<td>${statusBadge(v.status)}</td>
|
|
<td class="text-right">
|
|
${actions}
|
|
<button onclick="openEditVisit(${v.id})" class="text-gray-400 hover:text-gray-600 text-xs ml-1" title="수정"><i class="fas fa-pen"></i></button>
|
|
<button onclick="doDeleteVisit(${v.id})" class="text-gray-400 hover:text-red-500 text-xs ml-1" title="삭제"><i class="fas fa-trash"></i></button>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
/* ===== 업체 자동완성 ===== */
|
|
let companySearchTimeout = null;
|
|
let selectedCompanyId = null;
|
|
|
|
function initCompanySearch() {
|
|
const input = document.getElementById('companySearch');
|
|
const dropdown = document.getElementById('companyDropdown');
|
|
const manualToggle = document.getElementById('manualCompanyToggle');
|
|
const manualInput = document.getElementById('manualCompanyName');
|
|
|
|
input.addEventListener('input', () => {
|
|
clearTimeout(companySearchTimeout);
|
|
selectedCompanyId = null;
|
|
const q = input.value.trim();
|
|
if (q.length < 1) { dropdown.classList.add('hidden'); return; }
|
|
companySearchTimeout = setTimeout(async () => {
|
|
try {
|
|
const r = await api('/partners/search?q=' + encodeURIComponent(q));
|
|
const items = r.data || [];
|
|
if (items.length === 0) {
|
|
dropdown.innerHTML = '<div class="px-3 py-2 text-sm text-gray-400">검색 결과 없음</div>';
|
|
} else {
|
|
dropdown.innerHTML = items.map(c =>
|
|
`<div class="px-3 py-2 text-sm hover:bg-emerald-50 cursor-pointer" onclick="selectCompany(${c.id}, '${escapeHtml(c.company_name).replace(/'/g, "\\'")}')">
|
|
<span class="font-medium">${escapeHtml(c.company_name)}</span>
|
|
${c.business_number ? `<span class="text-gray-400 text-xs ml-2">${c.business_number}</span>` : ''}
|
|
</div>`
|
|
).join('');
|
|
}
|
|
dropdown.classList.remove('hidden');
|
|
} catch (e) { dropdown.classList.add('hidden'); }
|
|
}, 300);
|
|
});
|
|
|
|
input.addEventListener('blur', () => setTimeout(() => dropdown.classList.add('hidden'), 200));
|
|
|
|
manualToggle.addEventListener('change', () => {
|
|
if (manualToggle.checked) {
|
|
input.parentElement.classList.add('hidden');
|
|
manualInput.parentElement.classList.remove('hidden');
|
|
selectedCompanyId = null;
|
|
input.value = '';
|
|
} else {
|
|
input.parentElement.classList.remove('hidden');
|
|
manualInput.parentElement.classList.add('hidden');
|
|
manualInput.value = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
function selectCompany(id, name) {
|
|
selectedCompanyId = id;
|
|
document.getElementById('companySearch').value = name;
|
|
document.getElementById('companyDropdown').classList.add('hidden');
|
|
}
|
|
|
|
/* ===== 인원수 +- ===== */
|
|
function initCounterButtons() {
|
|
document.getElementById('countMinus').addEventListener('click', () => {
|
|
const el = document.getElementById('visitorCount');
|
|
const v = parseInt(el.value) || 1;
|
|
if (v > 1) el.value = v - 1;
|
|
});
|
|
document.getElementById('countPlus').addEventListener('click', () => {
|
|
const el = document.getElementById('visitorCount');
|
|
el.value = (parseInt(el.value) || 0) + 1;
|
|
});
|
|
}
|
|
|
|
/* ===== 추가정보 접이식 ===== */
|
|
function toggleExtra() {
|
|
document.getElementById('extraFields').classList.toggle('open');
|
|
const icon = document.getElementById('extraToggleIcon');
|
|
icon.classList.toggle('fa-chevron-down');
|
|
icon.classList.toggle('fa-chevron-up');
|
|
}
|
|
|
|
/* ===== 방문 등록 ===== */
|
|
async function submitVisit(e) {
|
|
e.preventDefault();
|
|
const manualMode = document.getElementById('manualCompanyToggle').checked;
|
|
const company_id = manualMode ? null : selectedCompanyId;
|
|
const company_name = manualMode ? document.getElementById('manualCompanyName').value.trim() : null;
|
|
|
|
if (!company_id && !company_name) {
|
|
showToast('업체를 선택하거나 입력해주세요', 'error'); return;
|
|
}
|
|
|
|
const data = {
|
|
company_id,
|
|
company_name: company_name || document.getElementById('companySearch').value.trim(),
|
|
visitor_name: document.getElementById('visitorName').value.trim(),
|
|
visitor_count: parseInt(document.getElementById('visitorCount').value) || 1,
|
|
purpose: document.getElementById('visitPurpose').value,
|
|
purpose_detail: document.getElementById('purposeDetail').value.trim() || null,
|
|
workplace_name: document.getElementById('workplaceName').value.trim() || null,
|
|
safety_education_yn: document.getElementById('safetyCheck').checked,
|
|
vehicle_number: document.getElementById('vehicleNumber').value.trim() || null,
|
|
notes: document.getElementById('visitNotes').value.trim() || null,
|
|
managing_department: document.getElementById('managingDept').value || null,
|
|
};
|
|
|
|
if (!data.visitor_name) { showToast('방문자명을 입력해주세요', 'error'); return; }
|
|
if (!data.purpose) { showToast('방문 목적을 선택해주세요', 'error'); return; }
|
|
|
|
try {
|
|
await api('/daily-visits/', { method: 'POST', body: JSON.stringify(data) });
|
|
showToast('방문이 등록되었습니다');
|
|
document.getElementById('visitForm').reset();
|
|
selectedCompanyId = null;
|
|
document.getElementById('manualCompanyToggle').checked = false;
|
|
document.getElementById('companySearch').parentElement.classList.remove('hidden');
|
|
document.getElementById('manualCompanyName').parentElement.classList.add('hidden');
|
|
document.getElementById('visitorCount').value = '1';
|
|
await loadTodayVisits();
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
/* ===== 체크아웃 ===== */
|
|
async function doCheckout(id) {
|
|
try {
|
|
await api(`/daily-visits/${id}/checkout`, { method: 'PUT', body: JSON.stringify({}) });
|
|
showToast('체크아웃 완료');
|
|
await loadTodayVisits();
|
|
} catch (e) { showToast(e.message, 'error'); }
|
|
}
|
|
|
|
async function doBulkCheckout() {
|
|
const checkedIn = todayVisits.filter(v => v.status === 'checked_in');
|
|
if (checkedIn.length === 0) { showToast('체크인 중인 방문이 없습니다', 'error'); return; }
|
|
if (!confirm(`체크인 중인 ${checkedIn.length}건을 모두 체크아웃 하시겠습니까?`)) return;
|
|
try {
|
|
const r = await api('/daily-visits/bulk-checkout', { method: 'POST', body: JSON.stringify({}) });
|
|
showToast(`${r.data.affected}건 체크아웃 완료`);
|
|
await loadTodayVisits();
|
|
} catch (e) { showToast(e.message, 'error'); }
|
|
}
|
|
|
|
/* ===== 수정 ===== */
|
|
function openEditVisit(id) {
|
|
const v = todayVisits.find(x => x.id === id);
|
|
if (!v) return;
|
|
editingVisitId = id;
|
|
document.getElementById('editVisitorName').value = v.visitor_name;
|
|
document.getElementById('editVisitorCount').value = v.visitor_count;
|
|
document.getElementById('editPurpose').value = v.purpose;
|
|
document.getElementById('editPurposeDetail').value = v.purpose_detail || '';
|
|
document.getElementById('editWorkplace').value = v.workplace_name || '';
|
|
document.getElementById('editSafetyCheck').checked = v.safety_education_yn;
|
|
document.getElementById('editVehicle').value = v.vehicle_number || '';
|
|
document.getElementById('editNotes').value = v.notes || '';
|
|
document.getElementById('editVisitModal').classList.remove('hidden');
|
|
}
|
|
|
|
function closeEditVisit() {
|
|
document.getElementById('editVisitModal').classList.add('hidden');
|
|
editingVisitId = null;
|
|
}
|
|
|
|
async function submitEditVisit(e) {
|
|
e.preventDefault();
|
|
if (!editingVisitId) return;
|
|
const data = {
|
|
visitor_name: document.getElementById('editVisitorName').value.trim(),
|
|
visitor_count: parseInt(document.getElementById('editVisitorCount').value) || 1,
|
|
purpose: document.getElementById('editPurpose').value,
|
|
purpose_detail: document.getElementById('editPurposeDetail').value.trim() || null,
|
|
workplace_name: document.getElementById('editWorkplace').value.trim() || null,
|
|
safety_education_yn: document.getElementById('editSafetyCheck').checked,
|
|
vehicle_number: document.getElementById('editVehicle').value.trim() || null,
|
|
notes: document.getElementById('editNotes').value.trim() || null,
|
|
};
|
|
try {
|
|
await api(`/daily-visits/${editingVisitId}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
showToast('수정되었습니다');
|
|
closeEditVisit();
|
|
await loadTodayVisits();
|
|
} catch (e) { showToast(e.message, 'error'); }
|
|
}
|
|
|
|
/* ===== 삭제 ===== */
|
|
async function doDeleteVisit(id) {
|
|
if (!confirm('이 방문 기록을 삭제하시겠습니까?')) return;
|
|
try {
|
|
await api(`/daily-visits/${id}`, { method: 'DELETE' });
|
|
showToast('삭제되었습니다');
|
|
await loadTodayVisits();
|
|
} catch (e) { showToast(e.message, 'error'); }
|
|
}
|
|
|
|
/* ===== CSV 내보내기 ===== */
|
|
async function exportVisits() {
|
|
const token = getToken();
|
|
const dateFrom = document.getElementById('exportDateFrom')?.value || '';
|
|
const dateTo = document.getElementById('exportDateTo')?.value || '';
|
|
let url = API_BASE + '/daily-visits/export?';
|
|
if (dateFrom) url += 'date_from=' + dateFrom + '&';
|
|
if (dateTo) url += 'date_to=' + dateTo + '&';
|
|
const res = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } });
|
|
if (!res.ok) { showToast('내보내기 실패', 'error'); return; }
|
|
const blob = await res.blob();
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = `visits_${dateFrom || 'all'}_${dateTo || 'all'}.csv`;
|
|
a.click();
|
|
}
|
|
|
|
/* ===== Init ===== */
|
|
function initVisitPage() {
|
|
if (!initAuth()) return;
|
|
initCompanySearch();
|
|
initCounterButtons();
|
|
document.getElementById('visitForm').addEventListener('submit', submitVisit);
|
|
document.getElementById('editVisitForm').addEventListener('submit', submitEditVisit);
|
|
loadTodayVisits();
|
|
}
|