오늘 날짜 범위 필터 제거 → 마감/취소되지 않은 모든 일정 표시. 체크인 날짜 제한도 상태 기반 검증으로 변경하여 일정 기간 외에도 체크인 가능. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
405 lines
19 KiB
JavaScript
405 lines
19 KiB
JavaScript
/* 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();
|
||
}
|