Files
tk-factory-services/tkpurchase/web/static/js/tkpurchase-partner-portal.js
Hyungi Ahn 0a712813e2 fix(tkpurchase): 협력업체 포탈 활성 일정 전체 표시로 변경
오늘 날짜 범위 필터 제거 → 마감/취소되지 않은 모든 일정 표시.
체크인 날짜 제한도 상태 기반 검증으로 변경하여 일정 기간 외에도 체크인 가능.

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

405 lines
19 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 portalRequests = [];
let portalCheckins = {};
let partnerCompanyId = null;
let companyWorkersCache = null;
async function loadMySchedules() {
try {
const r = await api('/schedules/my');
const data = r.data || {};
portalSchedules = Array.isArray(data) ? data : (data.schedules || []);
portalRequests = Array.isArray(data) ? [] : (data.requests || []);
} catch(e) {
console.warn('Load schedules error:', e);
portalSchedules = [];
portalRequests = [];
}
}
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]) 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 requestCardsEl = document.getElementById('requestCards');
const workRequestFormEl = document.getElementById('workRequestForm');
if (!portalSchedules.length) {
container.innerHTML = '';
// 신청 건 표시
if (portalRequests.length) {
requestCardsEl.classList.remove('hidden');
requestCardsEl.innerHTML = portalRequests.map(r => {
const isRejected = r.status === 'rejected';
const statusBg = isRejected ? 'bg-red-50 border-red-200' : 'bg-amber-50 border-amber-200';
const statusIcon = isRejected ? 'fa-times-circle text-red-400' : 'fa-clock text-amber-400';
const statusText = isRejected ? '반려됨' : '승인 대기 중';
const statusTextClass = isRejected ? 'text-red-600' : 'text-amber-600';
return `<div class="bg-white rounded-xl shadow-sm overflow-hidden border ${isRejected ? 'border-red-100' : 'border-amber-100'}">
<div class="p-5">
<div class="flex items-center justify-between mb-2">
<h3 class="text-base font-semibold text-gray-800">${escapeHtml(r.workplace_name || '작업장 미지정')}</h3>
<span class="text-xs ${statusTextClass} font-medium px-2 py-1 rounded-full ${statusBg}">
<i class="fas ${statusIcon} mr-1"></i>${statusText}
</span>
</div>
<div class="text-sm text-gray-600 mb-2">${escapeHtml(r.work_description || '')}</div>
<div class="flex gap-4 text-xs text-gray-500">
<span><i class="fas fa-calendar mr-1"></i>${formatDate(r.start_date)}</span>
<span><i class="fas fa-users mr-1"></i>예상 ${r.expected_workers || 0}명</span>
</div>
</div>
</div>`;
}).join('');
// 반려 건만 있으면 재신청 폼도 표시
const hasOnlyRejected = portalRequests.every(r => r.status === 'rejected');
if (hasOnlyRejected) {
workRequestFormEl.classList.remove('hidden');
workRequestFormEl.querySelector('p').textContent = '반려된 신청 건이 있습니다. 필요시 재신청해주세요.';
} else {
workRequestFormEl.classList.add('hidden');
}
} else {
requestCardsEl.classList.add('hidden');
workRequestFormEl.classList.remove('hidden');
workRequestFormEl.querySelector('p').textContent = '등록된 작업 일정이 없습니다. 작업이 필요하시면 아래에서 신청해주세요.';
}
// 기본 날짜 설정
const today = new Date().toISOString().substring(0, 10);
const reqDate = document.getElementById('reqStartDate');
if (reqDate && !reqDate.value) reqDate.value = today;
return;
}
// 오늘 일정 있으면 기존 카드 렌더
requestCardsEl.classList.add('hidden');
workRequestFormEl.classList.add('hidden');
const today = new Date().toISOString().substring(0, 10);
container.innerHTML = portalSchedules.map(s => {
const checkin = portalCheckins[s.id];
const isCheckedIn = checkin && !checkin.check_out_time;
const isCheckedOut = checkin && checkin.check_out_time;
const sStart = s.start_date ? s.start_date.substring(0, 10) : '';
const sEnd = s.end_date ? s.end_date.substring(0, 10) : '';
const isToday = sStart <= today && sEnd >= today;
// 2-step indicators
const step1Class = checkin ? 'text-emerald-600' : 'text-gray-400';
const step2Class = isCheckedOut ? 'text-emerald-600' : 'text-gray-400';
const dateBadge = isToday
? `<span class="text-xs text-white bg-emerald-500 px-2 py-0.5 rounded-full font-medium">오늘</span>`
: `<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>`;
return `<div class="bg-white rounded-xl shadow-sm overflow-hidden ${!isToday ? 'border border-gray-200' : ''}">
<!-- 일정 정보 -->
<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>
${dateBadge}
</div>
${!isToday ? `<div class="text-xs text-gray-400 mb-1"><i class="fas fa-calendar-alt mr-1"></i>${formatDate(s.start_date)}${formatDate(s.start_date) !== formatDate(s.end_date) ? ' ~ ' + formatDate(s.end_date) : ''}</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 {
const count = Math.max(checkin.actual_worker_count || 1, 1);
for (let i = 0; i < count; i++) {
existingWorkers.push({ worker_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');
}
}
async function doWorkRequest() {
const startDate = document.getElementById('reqStartDate').value;
if (!startDate) { showToast('작업일을 선택하세요', 'error'); return; }
const workDescription = document.getElementById('reqWorkDescription').value.trim();
if (!workDescription) { showToast('작업 내용을 입력하세요', 'error'); return; }
const body = {
start_date: startDate,
expected_workers: parseInt(document.getElementById('reqExpectedWorkers').value) || 1,
work_description: workDescription,
workplace_name: document.getElementById('reqWorkplaceName').value.trim() || null
};
try {
await api('/schedules/request', { method: 'POST', body: JSON.stringify(body) });
showToast('작업 신청이 완료되었습니다');
// 폼 초기화
document.getElementById('reqWorkDescription').value = '';
document.getElementById('reqWorkplaceName').value = '';
document.getElementById('reqExpectedWorkers').value = '1';
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();
}