Files
tk-factory-services/tkpurchase/web/static/js/tkpurchase-partner-portal.js
Hyungi Ahn b14448fc54 feat(tkpurchase): 체크인 worker_names 배열 저장 + 구매팀 체크인 관리 기능
- doCheckIn()에서 worker_names를 콤마 split 배열로 전송 (DB에 JSON 배열로 저장)
- 구매팀 일정 페이지에 체크인 조회/수정/삭제 모달 추가
- DELETE /checkins/:id endpoint + 트랜잭션 삭제 (reports cascade)
- PUT /checkins/:id에 requirePage 권한 guard 추가

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

471 lines
23 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 */
let portalSchedules = [];
let portalCheckins = {};
let partnerCompanyId = null;
let companyWorkersCache = null; // 작업자 목록 캐시
let editingReportId = null; // 수정 모드일 때 보고 ID
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;
const reportCount = checkin ? (parseInt(checkin.work_report_count) || 0) : 0;
// Step indicators
const step1Class = checkin ? 'text-emerald-600' : 'text-gray-400';
const step2Class = isCheckedIn || isCheckedOut ? 'text-emerald-600' : 'text-gray-400';
const step3Class = 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>
<!-- 3-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 ${(isCheckedIn || isCheckedOut) ? 'fa-check-circle' : 'fa-circle'}"></i>
<span>2. 업무현황${reportCount > 0 ? ' (' + reportCount + '건)' : ''}</span>
</div>
<div class="flex-1 border-t border-gray-300 mx-2"></div>
<div class="flex items-center gap-1 ${step3Class}">
<i class="fas ${isCheckedOut ? 'fa-check-circle' : 'fa-circle'}"></i>
<span>3. 작업 종료</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">
<h4 class="text-sm font-semibold text-gray-700 mb-3"><i class="fas fa-clipboard-list text-blue-500 mr-1"></i>업무현황</h4>
<!-- 제출된 보고 목록 -->
<div id="reportsList_${checkin.id}" class="mb-3"></div>
<!-- 추가/수정 폼 토글 버튼 -->
<div id="reportFormToggle_${checkin.id}">
<button onclick="showReportForm(${checkin.id}, ${s.id})" class="px-4 py-2 bg-blue-50 text-blue-600 rounded-lg text-sm hover:bg-blue-100 border border-blue-200">
<i class="fas fa-plus mr-1"></i>업무현황 추가
</button>
</div>
<!-- 입력 폼 (숨김) -->
<div id="reportForm_${checkin.id}" class="hidden mt-3"></div>
</div>
<!-- Step 3: 작업 종료 -->
<div class="p-5 border-t">
<button onclick="doCheckOut(${checkin.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>
<p class="text-xs text-gray-400 text-center mt-2">업무현황을 먼저 저장한 후 작업을 종료하세요.</p>
</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>
${reportCount > 0 ? '<div class="text-xs text-emerald-600 mt-1"><i class="fas fa-clipboard-check mr-1"></i>업무현황 ' + reportCount + '건 제출 완료</div>' : ''}
</div>
` : ''}
</div>`;
}).join('');
// 체크인된 카드의 보고 목록 로드 + 보고 0건이면 폼 자동 표시
for (const s of portalSchedules) {
const checkin = portalCheckins[s.id];
if (checkin && !checkin.check_out_time) {
const reportCount = parseInt(checkin.work_report_count) || 0;
loadReportsList(checkin.id, s.id);
if (reportCount === 0) {
showReportForm(checkin.id, s.id);
}
}
}
}
async function loadReportsList(checkinId, scheduleId) {
const container = document.getElementById('reportsList_' + checkinId);
if (!container) return;
try {
const r = await api('/work-reports?checkin_id=' + checkinId + '&limit=50');
const reports = (r.data || []).filter(rr => rr.checkin_id === checkinId);
renderReportsList(checkinId, scheduleId, reports);
} catch(e) {
container.innerHTML = '';
}
}
function renderReportsList(checkinId, scheduleId, reports) {
const container = document.getElementById('reportsList_' + checkinId);
if (!container) return;
if (!reports.length) {
container.innerHTML = '<p class="text-xs text-gray-400 mb-2">아직 등록된 업무현황이 없습니다.</p>';
return;
}
container.innerHTML = reports.map(r => {
const workerCount = r.workers ? r.workers.length : 0;
const totalHours = r.workers ? r.workers.reduce((sum, w) => sum + Number(w.hours_worked || 0), 0) : 0;
const isConfirmed = !!r.confirmed_by;
const isRejected = !!r.rejected_by;
const statusBadge = isConfirmed
? '<span class="text-xs text-emerald-600"><i class="fas fa-check-circle"></i> 확인완료</span>'
: isRejected
? '<span class="text-xs text-red-600"><i class="fas fa-times-circle"></i> 반려</span>'
: '<span class="text-xs text-amber-500">미확인</span>';
const canEdit = !isConfirmed;
return `<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg mb-2 text-sm">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-700">보고 #${r.report_seq || 1}</span>
${statusBadge}
</div>
<div class="text-xs text-gray-500 truncate">${escapeHtml((r.work_content || '').substring(0, 50))}${(r.work_content || '').length > 50 ? '...' : ''}</div>
<div class="text-xs text-gray-400 mt-1">${workerCount}명 · ${totalHours}h · 진행률 ${r.progress_rate || 0}%</div>
${isRejected && r.rejection_reason ? `<div class="text-xs text-red-600 mt-1"><i class="fas fa-exclamation-circle mr-1"></i>${escapeHtml(r.rejection_reason)}</div>` : ''}
</div>
${canEdit ? `<button onclick="openEditReport(${r.id}, ${checkinId}, ${scheduleId})" class="ml-2 px-3 py-1 text-xs bg-white border border-gray-300 text-gray-600 rounded hover:bg-gray-100 flex-shrink-0">
<i class="fas fa-edit mr-1"></i>${isRejected ? '수정 재제출' : '수정'}
</button>` : ''}
</div>`;
}).join('');
}
async function showReportForm(checkinId, scheduleId, editReport) {
editingReportId = editReport ? editReport.id : null;
const formContainer = document.getElementById('reportForm_' + checkinId);
const toggleBtn = document.getElementById('reportFormToggle_' + checkinId);
if (!formContainer) return;
// 작업자 목록 로드
const workers = await loadCompanyWorkers();
const datalistHtml = workers.map(w => `<option value="${escapeHtml(w.worker_name)}">`).join('');
// 첫 보고 여부 판단
const checkin = Object.values(portalCheckins).find(c => c.id === checkinId);
const isFirstReport = !editReport && checkin && parseInt(checkin.work_report_count) === 0;
// 기본 작업자 행: 체크인 시 입력한 작업자 명단으로 pre-populate
let existingWorkers = [];
if (editReport && editReport.workers) {
existingWorkers = editReport.workers;
} else if (isFirstReport && 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-blue-200 rounded-lg p-4 bg-blue-50/30">
<div class="flex items-center justify-between mb-1">
<h5 class="text-sm font-semibold text-gray-700">${editReport ? '보고 #' + (editReport.report_seq || 1) + ' 수정' : '새 업무현황'}</h5>
<button onclick="hideReportForm(${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>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">실투입 인원</label>
<input type="number" id="reportWorkers_${checkinId}" min="0" value="${editReport ? (editReport.actual_workers || 0) : (checkin ? checkin.actual_worker_count || 0 : 0)}" 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="number" id="reportProgress_${checkinId}" min="0" max="100" value="${editReport ? (editReport.progress_rate || 0) : 0}" class="input-field w-full px-3 py-2 rounded-lg text-sm">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">작업내용 <span class="text-red-400">*</span></label>
<textarea id="reportContent_${checkinId}" rows="3" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="오늘 수행한 작업 내용">${editReport ? escapeHtml(editReport.work_content || '') : ''}</textarea>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">이슈사항</label>
<textarea id="reportIssues_${checkinId}" rows="2" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="문제점이나 특이사항">${editReport ? escapeHtml(editReport.issues || '') : ''}</textarea>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">향후 계획</label>
<textarea id="reportNextPlan_${checkinId}" rows="2" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="다음 작업 계획">${editReport ? escapeHtml(editReport.next_plan || '') : ''}</textarea>
</div>
<div class="flex gap-2">
<button onclick="submitWorkReport(${checkinId}, ${scheduleId})" class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
<i class="fas fa-save mr-1"></i>${editReport ? '수정 저장' : '업무현황 저장'}
</button>
<button onclick="hideReportForm(${checkinId})" class="px-4 py-2 bg-gray-100 text-gray-600 rounded-lg text-sm hover:bg-gray-200">취소</button>
</div>
</div>`;
formContainer.classList.remove('hidden');
if (toggleBtn) toggleBtn.classList.add('hidden');
}
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);
// 최소 1행 유지
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;
// partner_worker_id 매칭
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;
}
function hideReportForm(checkinId) {
editingReportId = null;
const formContainer = document.getElementById('reportForm_' + checkinId);
const toggleBtn = document.getElementById('reportFormToggle_' + checkinId);
if (formContainer) formContainer.classList.add('hidden');
if (toggleBtn) toggleBtn.classList.remove('hidden');
}
async function submitWorkReport(checkinId, scheduleId) {
const workContent = document.getElementById('reportContent_' + checkinId).value.trim();
if (!workContent) { showToast('작업내용을 입력하세요', 'error'); return; }
const workers = collectWorkers(checkinId);
const body = {
checkin_id: checkinId,
schedule_id: scheduleId,
report_date: new Date().toISOString().substring(0, 10),
actual_workers: parseInt(document.getElementById('reportWorkers_' + checkinId).value) || 0,
work_content: workContent,
progress_rate: parseInt(document.getElementById('reportProgress_' + checkinId).value) || 0,
issues: document.getElementById('reportIssues_' + checkinId).value.trim() || null,
next_plan: document.getElementById('reportNextPlan_' + checkinId).value.trim() || null,
workers: workers
};
try {
if (editingReportId) {
await api('/work-reports/' + editingReportId, { method: 'PUT', body: JSON.stringify(body) });
showToast('업무현황이 수정되었습니다');
} else {
await api('/work-reports', { method: 'POST', body: JSON.stringify(body) });
showToast('업무현황이 저장되었습니다');
}
editingReportId = null;
renderScheduleCards();
} catch(e) {
showToast(e.message || '저장 실패', 'error');
}
}
async function openEditReport(reportId, checkinId, scheduleId) {
try {
const r = await api('/work-reports/' + reportId);
const report = r.data || r;
showReportForm(checkinId, scheduleId, report);
} catch(e) {
showToast('보고 정보를 불러올 수 없습니다', 'error');
}
}
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 doCheckOut(checkinId) {
if (!confirm('작업을 종료하시겠습니까? 업무현황을 먼저 저장했는지 확인하세요.')) return;
try {
await api('/checkins/' + checkinId + '/checkout', { method: 'PUT' });
showToast('작업 종료 (체크아웃) 완료');
renderScheduleCards();
} catch(e) {
showToast(e.message || '체크아웃 실패', 'error');
}
}
function initPartnerPortal() {
if (!initAuth()) return;
// Check if partner account
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();
}