Files
tk-factory-services/tkpurchase/web/static/js/tkpurchase-partner-portal.js
Hyungi Ahn 6e5c1554d0 feat(tkpurchase): 협력업체 포탈 3→2단계 흐름 단순화 + 작업 이력 페이지
- 체크아웃 시 work_report 자동 생성 (checkout-with-report 통합 엔드포인트)
- 업무현황 입력 단계 제거, 작업자+시간만 입력하면 체크아웃 완료
- 협력업체 작업 이력 조회 페이지 신규 추가 (partner-history)

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

318 lines
14 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-partner-portal.js - Partner portal logic (2-step flow) */
let portalSchedules = [];
let portalCheckins = {};
let partnerCompanyId = null;
let companyWorkersCache = null;
async function loadMySchedules() {
try {
const r = await api('/schedules/my');
portalSchedules = r.data || [];
} catch(e) {
console.warn('Load schedules error:', e);
portalSchedules = [];
}
}
async function loadMyCheckins() {
try {
const r = await api('/checkins/my');
const list = r.data || [];
portalCheckins = {};
list.forEach(c => {
if (c.schedule_id) portalCheckins[c.schedule_id] = c;
});
} catch(e) {
console.warn('Load checkins error:', e);
portalCheckins = {};
}
}
async function loadCompanyWorkers() {
if (companyWorkersCache) return companyWorkersCache;
try {
const r = await api('/partners/' + partnerCompanyId + '/workers');
companyWorkersCache = (r.data || []).filter(w => w.is_active !== 0);
return companyWorkersCache;
} catch(e) {
console.warn('Load workers error:', e);
companyWorkersCache = [];
return [];
}
}
async function renderScheduleCards() {
await Promise.all([loadMySchedules(), loadMyCheckins()]);
const container = document.getElementById('scheduleCards');
const noMsg = document.getElementById('noScheduleMessage');
if (!portalSchedules.length) {
container.innerHTML = '';
noMsg.classList.remove('hidden');
return;
}
noMsg.classList.add('hidden');
container.innerHTML = portalSchedules.map(s => {
const checkin = portalCheckins[s.id];
const isCheckedIn = checkin && !checkin.check_out_time;
const isCheckedOut = checkin && checkin.check_out_time;
// 2-step indicators
const step1Class = checkin ? 'text-emerald-600' : 'text-gray-400';
const step2Class = isCheckedOut ? 'text-emerald-600' : 'text-gray-400';
return `<div class="bg-white rounded-xl shadow-sm overflow-hidden">
<!-- 일정 정보 -->
<div class="p-5 border-b">
<div class="flex items-center justify-between mb-2">
<h3 class="text-base font-semibold text-gray-800">${escapeHtml(s.workplace_name || '작업장 미지정')}</h3>
<span class="text-xs text-gray-500">${formatDate(s.start_date) === formatDate(s.end_date) ? formatDate(s.start_date) : formatDate(s.start_date) + ' ~ ' + formatDate(s.end_date)}</span>
</div>
${s.work_description ? `<p class="text-sm text-gray-600 mb-2">${escapeHtml(s.work_description)}</p>` : ''}
<div class="flex gap-4 text-xs text-gray-500">
<span><i class="fas fa-users mr-1"></i>예상 ${s.expected_workers || 0}명</span>
${s.notes ? `<span><i class="fas fa-sticky-note mr-1"></i>${escapeHtml(s.notes)}</span>` : ''}
</div>
</div>
<!-- 2-step 진행 표시 -->
<div class="px-5 py-3 bg-gray-50 border-b">
<div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-1 ${step1Class}">
<i class="fas ${checkin ? 'fa-check-circle' : 'fa-circle'}"></i>
<span>1. 작업 시작</span>
</div>
<div class="flex-1 border-t border-gray-300 mx-2"></div>
<div class="flex items-center gap-1 ${step2Class}">
<i class="fas ${isCheckedOut ? 'fa-check-circle' : 'fa-circle'}"></i>
<span>2. 작업 종료</span>
</div>
</div>
</div>
<!-- Step 1: 작업 시작 (체크인) -->
<div class="p-5 ${checkin ? 'bg-gray-50' : ''}">
${!checkin ? `
<div id="checkinForm_${s.id}">
<h4 class="text-sm font-semibold text-gray-700 mb-3"><i class="fas fa-play-circle text-emerald-500 mr-1"></i>작업 시작</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">실투입 인원 <span class="text-red-400">*</span></label>
<input type="number" id="checkinWorkers_${s.id}" min="1" value="${s.expected_workers || 1}" class="input-field w-full px-3 py-2 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">작업자 명단</label>
<input type="text" id="checkinNames_${s.id}" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="홍길동, 김철수">
</div>
</div>
<button onclick="doCheckIn(${s.id})" class="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm hover:bg-emerald-700">
<i class="fas fa-play mr-1"></i>작업 시작
</button>
</div>
` : `
<div class="text-sm text-emerald-600 mb-1">
<i class="fas fa-check-circle mr-1"></i>체크인 완료 (${formatTime(checkin.check_in_time)})
· ${checkin.actual_worker_count || 0}
</div>
`}
</div>
<!-- Step 2: 작업 종료 (체크아웃 폼) -->
${isCheckedIn ? `
<div class="p-5 border-t">
<div id="checkoutFormToggle_${checkin.id}">
<button onclick="showCheckoutForm(${checkin.id}, ${s.id})" class="w-full px-4 py-3 bg-gray-800 text-white rounded-lg text-sm hover:bg-gray-900 font-medium">
<i class="fas fa-stop-circle mr-1"></i>작업 종료
</button>
</div>
<div id="checkoutForm_${checkin.id}" class="hidden mt-3"></div>
</div>
` : ''}
${isCheckedOut ? `
<div class="p-5 border-t bg-gray-50">
<div class="text-sm text-blue-600">
<i class="fas fa-check-double mr-1"></i>작업 종료 완료 (${formatTime(checkin.check_out_time)})
</div>
</div>
` : ''}
</div>`;
}).join('');
}
async function showCheckoutForm(checkinId, scheduleId) {
const formContainer = document.getElementById('checkoutForm_' + checkinId);
const toggleBtn = document.getElementById('checkoutFormToggle_' + checkinId);
if (!formContainer) return;
// 작업자 목록 로드
const workers = await loadCompanyWorkers();
const datalistHtml = workers.map(w => `<option value="${escapeHtml(w.worker_name)}">`).join('');
// 체크인 시 입력한 작업자 명단으로 pre-populate
const checkin = Object.values(portalCheckins).find(c => c.id === checkinId);
let existingWorkers = [];
if (checkin) {
let workerNames = [];
if (checkin.worker_names) {
let parsed = checkin.worker_names;
try { parsed = JSON.parse(parsed); } catch(e) { /* 그대로 사용 */ }
if (Array.isArray(parsed)) {
workerNames = parsed.map(n => String(n).trim()).filter(Boolean);
} else {
workerNames = String(parsed).split(/[,]\s*/).map(n => n.trim()).filter(Boolean);
}
}
if (workerNames.length > 0) {
existingWorkers = workerNames.map(name => ({ worker_name: name, hours_worked: 8.0 }));
} else if (currentUser) {
existingWorkers = [{ worker_name: currentUser.name || '', hours_worked: 8.0 }];
}
}
formContainer.innerHTML = `
<div class="space-y-3 border border-gray-200 rounded-lg p-4 bg-gray-50/30">
<div class="flex items-center justify-between mb-1">
<h5 class="text-sm font-semibold text-gray-700"><i class="fas fa-stop-circle text-gray-600 mr-1"></i>작업 종료</h5>
<button onclick="hideCheckoutForm(${checkinId})" class="text-gray-400 hover:text-gray-600 text-xs"><i class="fas fa-times"></i> 취소</button>
</div>
<datalist id="workerDatalist_${checkinId}">${datalistHtml}</datalist>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">작업자 및 투입시간</label>
<div id="workerRows_${checkinId}" class="space-y-2">
${existingWorkers.length > 0 ? existingWorkers.map((w, i) => workerRowHtml(checkinId, i, w)).join('') : workerRowHtml(checkinId, 0, null)}
</div>
<button onclick="addWorkerRow(${checkinId})" class="mt-2 text-xs text-blue-600 hover:text-blue-800"><i class="fas fa-plus mr-1"></i>작업자 추가</button>
</div>
<button onclick="submitCheckout(${checkinId})" class="w-full px-4 py-3 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 font-medium">
<i class="fas fa-stop-circle mr-1"></i>작업 종료 확인
</button>
</div>`;
formContainer.classList.remove('hidden');
if (toggleBtn) toggleBtn.classList.add('hidden');
}
function hideCheckoutForm(checkinId) {
const formContainer = document.getElementById('checkoutForm_' + checkinId);
const toggleBtn = document.getElementById('checkoutFormToggle_' + checkinId);
if (formContainer) formContainer.classList.add('hidden');
if (toggleBtn) toggleBtn.classList.remove('hidden');
}
async function submitCheckout(checkinId) {
if (!confirm('작업을 종료하시겠습니까?')) return;
const workers = collectWorkers(checkinId);
if (workers.length === 0) {
showToast('작업자를 1명 이상 입력하세요', 'error');
return;
}
try {
await api('/checkins/' + checkinId + '/checkout-with-report', {
method: 'PUT',
body: JSON.stringify({ workers })
});
showToast('작업 종료 완료');
renderScheduleCards();
} catch(e) {
showToast(e.message || '체크아웃 실패', 'error');
}
}
let workerRowCounter = 100;
function workerRowHtml(checkinId, idx, worker) {
const rowId = 'wr_' + checkinId + '_' + (workerRowCounter++);
const name = worker ? (worker.worker_name || '') : '';
const hours = worker ? (worker.hours_worked ?? 8.0) : 8.0;
const pwId = worker ? (worker.partner_worker_id || '') : '';
return `<div id="${rowId}" class="flex items-center gap-2 worker-row">
<input type="text" list="workerDatalist_${checkinId}" value="${escapeHtml(name)}" placeholder="작업자명" class="worker-name input-field flex-1 px-3 py-1.5 rounded-lg text-sm" data-pw-id="${pwId}">
<input type="number" value="${hours}" step="0.5" min="0" max="24" class="worker-hours input-field w-20 px-2 py-1.5 rounded-lg text-sm text-center" placeholder="시간">
<span class="text-xs text-gray-400">h</span>
<button onclick="removeWorkerRow('${rowId}', ${checkinId})" class="text-gray-400 hover:text-red-500 text-sm"><i class="fas fa-times"></i></button>
</div>`;
}
function addWorkerRow(checkinId) {
const container = document.getElementById('workerRows_' + checkinId);
if (!container) return;
container.insertAdjacentHTML('beforeend', workerRowHtml(checkinId, 0, null));
}
function removeWorkerRow(rowId, checkinId) {
const row = document.getElementById(rowId);
if (!row) return;
const container = document.getElementById('workerRows_' + checkinId);
if (container && container.querySelectorAll('.worker-row').length <= 1) return;
row.remove();
}
function collectWorkers(checkinId) {
const container = document.getElementById('workerRows_' + checkinId);
if (!container) return [];
const rows = container.querySelectorAll('.worker-row');
const workers = [];
rows.forEach(row => {
const nameInput = row.querySelector('.worker-name');
const hoursInput = row.querySelector('.worker-hours');
const name = nameInput ? nameInput.value.trim() : '';
if (!name) return;
let pwId = nameInput.dataset.pwId ? parseInt(nameInput.dataset.pwId) : null;
if (companyWorkersCache) {
const match = companyWorkersCache.find(w => w.worker_name === name);
if (match) pwId = match.id;
}
workers.push({
partner_worker_id: pwId || null,
worker_name: name,
hours_worked: parseFloat(hoursInput ? hoursInput.value : 8) || 8.0
});
});
return workers;
}
async function doCheckIn(scheduleId) {
const workerCount = parseInt(document.getElementById('checkinWorkers_' + scheduleId).value) || 1;
const rawNames = document.getElementById('checkinNames_' + scheduleId).value.trim();
const workerNames = rawNames
? rawNames.split(/[,]\s*/).map(n => n.trim()).filter(Boolean)
: null;
const body = {
schedule_id: scheduleId,
actual_worker_count: workerCount,
worker_names: workerNames
};
try {
await api('/checkins', { method: 'POST', body: JSON.stringify(body) });
showToast('체크인 완료');
renderScheduleCards();
} catch(e) {
showToast(e.message || '체크인 실패', 'error');
}
}
function initPartnerPortal() {
if (!initAuth()) return;
const token = getToken();
const decoded = decodeToken(token);
if (!decoded || !decoded.partner_company_id) {
location.href = '/';
return;
}
partnerCompanyId = decoded.partner_company_id;
document.getElementById('welcomeCompanyName').textContent = decoded.partner_company_name || decoded.name || '협력업체';
renderScheduleCards();
}