feat(tkpurchase): 협력업체 포탈 3→2단계 흐름 단순화 + 작업 이력 페이지

- 체크아웃 시 work_report 자동 생성 (checkout-with-report 통합 엔드포인트)
- 업무현황 입력 단계 제거, 작업자+시간만 입력하면 체크아웃 완료
- 협력업체 작업 이력 조회 페이지 신규 추가 (partner-history)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-13 13:50:07 +09:00
parent e2def8ab14
commit 6e5c1554d0
9 changed files with 433 additions and 211 deletions

View File

@@ -120,4 +120,59 @@ async function deleteCheckin(req, res) {
}
}
module.exports = { list, myCheckins, checkIn, checkOut, update, stats, deleteCheckin };
// 체크아웃 + 보고 통합 (협력업체 포탈 전용)
async function checkOutWithReport(req, res) {
try {
const checkinId = parseInt(req.params.id);
const checkin = await checkinModel.findById(checkinId);
if (!checkin) return res.status(404).json({ success: false, error: '체크인 기록을 찾을 수 없습니다' });
// 소유권 검증: 협력업체 본인 체크인만 가능
const companyId = req.user.partner_company_id;
if (companyId && checkin.company_id !== companyId) {
return res.status(403).json({ success: false, error: '권한이 없습니다' });
}
// schedule에서 work_description 가져오기
let workContent = '작업 완료';
if (checkin.schedule_id) {
const schedule = await scheduleModel.findById(checkin.schedule_id);
if (schedule && schedule.work_description) {
workContent = schedule.work_description;
}
}
const reportData = {
reporter_id: req.user.user_id || req.user.id,
workers: req.body.workers || [],
work_content: workContent
};
const row = await checkinModel.checkOutWithReport(checkinId, reportData);
res.json({ success: true, data: row });
} catch (err) {
console.error('Checkin checkOutWithReport error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
// 작업 이력 (협력업체 포탈)
async function myHistory(req, res) {
try {
const companyId = req.user.partner_company_id;
if (!companyId) {
return res.status(403).json({ success: false, error: '협력업체 계정이 아닙니다' });
}
const { date_from, date_to, page, limit } = req.query;
const result = await checkinModel.findHistoryByCompany(companyId, {
dateFrom: date_from, dateTo: date_to,
page: parseInt(page) || 1, limit: parseInt(limit) || 20
});
res.json({ success: true, ...result });
} catch (err) {
console.error('Checkin myHistory error:', err);
res.status(500).json({ success: false, error: err.message });
}
}
module.exports = { list, myCheckins, checkIn, checkOut, update, stats, deleteCheckin, checkOutWithReport, myHistory };

View File

@@ -106,4 +106,114 @@ async function deleteCheckin(id) {
}
}
module.exports = { findBySchedule, findById, findTodayByCompany, checkIn, checkOut, update, resetCheckout, countActive, deleteCheckin };
async function checkOutWithReport(id, reportData) {
const db = getPool();
const checkin = await findById(id);
if (!checkin) throw new Error('체크인 기록을 찾을 수 없습니다');
if (checkin.check_out_time) throw new Error('이미 체크아웃된 기록입니다');
const conn = await db.getConnection();
try {
await conn.beginTransaction();
// report_seq: 같은 checkin_id 내 최대값 + 1
const [seqRows] = await conn.query(
'SELECT COALESCE(MAX(report_seq), 0) + 1 AS next_seq FROM partner_work_reports WHERE checkin_id = ?',
[id]);
const reportSeq = seqRows[0].next_seq;
const workers = reportData.workers || [];
const [reportResult] = await conn.query(
`INSERT INTO partner_work_reports (schedule_id, checkin_id, company_id, report_date, report_seq, reporter_id, actual_workers, work_content, progress_rate)
VALUES (?, ?, ?, CURDATE(), ?, ?, ?, ?, ?)`,
[checkin.schedule_id, id, checkin.company_id, reportSeq,
reportData.reporter_id, workers.length,
reportData.work_content || '작업 완료', 100]);
const reportId = reportResult.insertId;
// workers 삽입
for (const w of workers) {
await conn.query(
`INSERT INTO work_report_workers (report_id, partner_worker_id, worker_name, hours_worked)
VALUES (?, ?, ?, ?)`,
[reportId, w.partner_worker_id || null, w.worker_name, w.hours_worked ?? 8.0]);
}
// 체크아웃
await conn.query('UPDATE partner_work_checkins SET check_out_time = NOW() WHERE id = ?', [id]);
await conn.commit();
return findById(id);
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
async function findHistoryByCompany(companyId, { dateFrom, dateTo, page = 1, limit = 20 } = {}) {
const db = getPool();
// 1. 체크인 페이지네이션 조회
let sql = `SELECT pc.*, ps.work_description, ps.workplace_name, ps.start_date, ps.end_date
FROM partner_work_checkins pc
LEFT JOIN partner_schedules ps ON pc.schedule_id = ps.id
WHERE pc.company_id = ?`;
const params = [companyId];
if (dateFrom) { sql += ' AND DATE(pc.check_in_time) >= ?'; params.push(dateFrom); }
if (dateTo) { sql += ' AND DATE(pc.check_in_time) <= ?'; params.push(dateTo); }
sql += ' ORDER BY pc.check_in_time DESC';
// count
const countSql = sql.replace(/SELECT pc\.\*.*?FROM/, 'SELECT COUNT(*) AS total FROM');
const [countRows] = await db.query(countSql, params);
const total = countRows[0].total;
const offset = (page - 1) * limit;
sql += ' LIMIT ? OFFSET ?';
params.push(limit, offset);
const [checkins] = await db.query(sql, params);
if (checkins.length === 0) return { data: [], total, page, limit };
// 2. 해당 체크인들의 reports 일괄 조회
const checkinIds = checkins.map(c => c.id);
const [reports] = await db.query(
`SELECT * FROM partner_work_reports WHERE checkin_id IN (?) ORDER BY report_seq ASC`,
[checkinIds]);
// 3. 해당 reports의 workers 일괄 조회
const reportIds = reports.map(r => r.id);
let workers = [];
if (reportIds.length > 0) {
const [workerRows] = await db.query(
`SELECT * FROM work_report_workers WHERE report_id IN (?) ORDER BY id`,
[reportIds]);
workers = workerRows;
}
// 조립
const workersByReport = {};
workers.forEach(w => {
if (!workersByReport[w.report_id]) workersByReport[w.report_id] = [];
workersByReport[w.report_id].push(w);
});
const reportsByCheckin = {};
reports.forEach(r => {
r.workers = workersByReport[r.id] || [];
if (!reportsByCheckin[r.checkin_id]) reportsByCheckin[r.checkin_id] = [];
reportsByCheckin[r.checkin_id].push(r);
});
checkins.forEach(c => {
c.reports = reportsByCheckin[c.id] || [];
});
return { data: checkins, total, page, limit };
}
module.exports = { findBySchedule, findById, findTodayByCompany, checkIn, checkOut, update, resetCheckout, countActive, deleteCheckin, checkOutWithReport, findHistoryByCompany };

View File

@@ -8,7 +8,9 @@ router.use(requireAuth);
router.get('/', ctrl.stats); // dashboard stats
router.get('/schedule/:scheduleId', ctrl.list);
router.get('/my', ctrl.myCheckins); // partner portal
router.get('/my-history', ctrl.myHistory); // partner history
router.post('/', ctrl.checkIn); // partner can do this
router.put('/:id/checkout-with-report', ctrl.checkOutWithReport); // partner portal simplified
router.put('/:id/checkout', ctrl.checkOut);
router.put('/:id', requirePage('purchasing_schedule'), ctrl.update);
router.delete('/:id', requirePage('purchasing_schedule'), ctrl.deleteCheckin);

View File

@@ -8,6 +8,7 @@ COPY workreport.html /usr/share/nginx/html/workreport.html
COPY workreport-summary.html /usr/share/nginx/html/workreport-summary.html
COPY accounts.html /usr/share/nginx/html/accounts.html
COPY partner-portal.html /usr/share/nginx/html/partner-portal.html
COPY partner-history.html /usr/share/nginx/html/partner-history.html
COPY static/ /usr/share/nginx/html/static/
EXPOSE 80

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업 이력 - TK 구매관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkpurchase.css?v=20260313">
</head>
<body>
<!-- Header -->
<header class="bg-emerald-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<i class="fas fa-truck text-xl text-emerald-200"></i>
<h1 class="text-lg font-semibold">TK 구매관리</h1>
</div>
<div class="flex items-center gap-4">
<div id="headerUserName" class="text-sm font-medium hidden sm:block">-</div>
<div id="headerUserAvatar" class="w-8 h-8 bg-emerald-600 rounded-full flex items-center justify-center text-sm font-semibold">-</div>
<button onclick="doLogout()" class="text-emerald-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<!-- 네비게이션 -->
<div class="flex items-center justify-between mb-4">
<a href="/partner-portal.html" class="flex items-center gap-1 text-sm text-emerald-700 hover:text-emerald-900">
<i class="fas fa-arrow-left"></i> 포탈로 돌아가기
</a>
<h2 class="text-lg font-semibold text-gray-800"><i class="fas fa-history text-emerald-600 mr-1"></i>작업 이력</h2>
</div>
<!-- 필터 -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-4">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">시작일</label>
<input type="date" id="filterDateFrom" class="input-field 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="date" id="filterDateTo" class="input-field px-3 py-2 rounded-lg text-sm">
</div>
<button onclick="loadHistory()" class="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm hover:bg-emerald-700">
<i class="fas fa-search mr-1"></i>조회
</button>
</div>
</div>
<!-- 결과 -->
<div id="historyList" class="space-y-3">
<p class="text-gray-400 text-center py-8 text-sm">로딩 중...</p>
</div>
<!-- 페이지네이션 -->
<div id="historyPagination" class="mt-4 flex justify-center gap-2"></div>
</div>
<script src="/static/js/tkpurchase-core.js?v=20260313"></script>
<script src="/static/js/tkpurchase-partner-history.js?v=20260313"></script>
<script>initPartnerHistory();</script>
</body>
</html>

View File

@@ -6,7 +6,7 @@
<title>협력업체 포털 - TK 구매관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkpurchase.css?v=20260312">
<link rel="stylesheet" href="/static/css/tkpurchase.css?v=20260313">
</head>
<body>
<!-- Header -->
@@ -33,10 +33,13 @@
<div class="w-12 h-12 bg-emerald-100 rounded-full flex items-center justify-center">
<i class="fas fa-building text-emerald-600 text-xl"></i>
</div>
<div>
<div class="flex-1">
<h2 class="text-lg font-semibold text-emerald-800" id="welcomeCompanyName">-</h2>
<p class="text-sm text-emerald-600">오늘의 작업 일정을 확인하고 업무현황을 입력해주세요.</p>
<p class="text-sm text-emerald-600">오늘의 작업 일정을 확인하세요.</p>
</div>
<a href="/partner-history.html" class="flex items-center gap-1 px-3 py-2 bg-white text-emerald-700 rounded-lg text-sm hover:bg-emerald-100 border border-emerald-200 flex-shrink-0">
<i class="fas fa-history"></i><span class="hidden sm:inline">작업 이력</span>
</a>
</div>
</div>
@@ -52,8 +55,8 @@
</div>
</div>
<script src="/static/js/tkpurchase-core.js?v=20260312"></script>
<script src="/static/js/tkpurchase-partner-portal.js?v=20260312"></script>
<script src="/static/js/tkpurchase-core.js?v=20260313"></script>
<script src="/static/js/tkpurchase-partner-portal.js?v=20260313"></script>
<script>initPartnerPortal();</script>
</body>
</html>

View File

@@ -132,7 +132,7 @@ function initAuth() {
department_id: decoded.department_id || null
};
// 협력업체 계정 → partner-portal로 분기
if (currentUser.partner_company_id && !location.pathname.includes('partner-portal')) {
if (currentUser.partner_company_id && !location.pathname.includes('partner-portal') && !location.pathname.includes('partner-history')) {
location.href = '/partner-portal.html';
return false;
}

View File

@@ -0,0 +1,136 @@
/* tkpurchase-partner-history.js - Partner work history */
let historyPage = 1;
const historyLimit = 20;
function initPartnerHistory() {
if (!initAuth()) return;
const token = getToken();
const decoded = decodeToken(token);
if (!decoded || !decoded.partner_company_id) {
location.href = '/';
return;
}
// 기본 날짜: 최근 30일
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
document.getElementById('filterDateTo').value = today.toISOString().substring(0, 10);
document.getElementById('filterDateFrom').value = thirtyDaysAgo.toISOString().substring(0, 10);
loadHistory();
}
async function loadHistory(page) {
historyPage = page || 1;
const dateFrom = document.getElementById('filterDateFrom').value;
const dateTo = document.getElementById('filterDateTo').value;
const params = new URLSearchParams();
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
params.set('page', historyPage);
params.set('limit', historyLimit);
const container = document.getElementById('historyList');
container.innerHTML = '<p class="text-gray-400 text-center py-8 text-sm">로딩 중...</p>';
try {
const r = await api('/checkins/my-history?' + params.toString());
const checkins = r.data || [];
const total = r.total || 0;
renderHistoryList(checkins);
renderPagination(total);
} catch(e) {
container.innerHTML = '<p class="text-red-400 text-center py-8 text-sm">데이터를 불러올 수 없습니다.</p>';
}
}
function renderHistoryList(checkins) {
const container = document.getElementById('historyList');
if (!checkins.length) {
container.innerHTML = `<div class="bg-white rounded-xl shadow-sm p-8 text-center">
<i class="fas fa-inbox text-gray-300 text-3xl mb-3"></i>
<p class="text-gray-500 text-sm">조회 기간에 작업 이력이 없습니다.</p>
</div>`;
return;
}
container.innerHTML = checkins.map(c => {
const checkinDate = formatDate(c.check_in_time);
const checkinTime = formatTime(c.check_in_time);
const checkoutTime = c.check_out_time ? formatTime(c.check_out_time) : null;
const reports = c.reports || [];
// 상태 배지
let statusHtml = '';
if (!c.check_out_time) {
statusHtml = '<span class="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700">진행중</span>';
} else {
statusHtml = '<span class="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">완료</span>';
}
// 보고 정보
let reportHtml = '';
if (reports.length > 0) {
reportHtml = reports.map(r => {
const rWorkers = r.workers || [];
const totalHours = rWorkers.reduce((sum, w) => sum + Number(w.hours_worked || 0), 0);
const isConfirmed = !!r.confirmed_by;
const isRejected = !!r.rejected_by;
const rStatus = 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 workersDetail = rWorkers.length > 0
? `<div class="mt-1 text-xs text-gray-500">${rWorkers.map(w => escapeHtml(w.worker_name) + ' ' + w.hours_worked + 'h').join(', ')}</div>`
: '';
return `<div class="p-2 bg-gray-50 rounded text-sm">
<div class="flex items-center justify-between">
<span class="text-gray-700">${escapeHtml((r.work_content || '').substring(0, 60))}${(r.work_content || '').length > 60 ? '...' : ''}</span>
${rStatus}
</div>
<div class="text-xs text-gray-400 mt-1">${rWorkers.length}명 · ${totalHours}h</div>
${workersDetail}
</div>`;
}).join('');
}
return `<div class="bg-white rounded-xl shadow-sm overflow-hidden">
<div class="p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-gray-800">${checkinDate}</span>
${statusHtml}
</div>
<span class="text-xs text-gray-500">${escapeHtml(c.workplace_name || '')}</span>
</div>
${c.work_description ? `<p class="text-sm text-gray-600 mb-2">${escapeHtml(c.work_description)}</p>` : ''}
<div class="flex gap-4 text-xs text-gray-500 mb-2">
<span><i class="fas fa-clock mr-1"></i>${checkinTime}${checkoutTime ? ' ~ ' + checkoutTime : ' ~'}</span>
<span><i class="fas fa-users mr-1"></i>${c.actual_worker_count || 0}명</span>
</div>
${reportHtml ? `<div class="space-y-2 mt-3 border-t pt-3">${reportHtml}</div>` : ''}
</div>
</div>`;
}).join('');
}
function renderPagination(total) {
const container = document.getElementById('historyPagination');
const totalPages = Math.ceil(total / historyLimit);
if (totalPages <= 1) { container.innerHTML = ''; return; }
let html = '';
for (let i = 1; i <= totalPages; i++) {
const active = i === historyPage;
html += `<button onclick="loadHistory(${i})" class="px-3 py-1 rounded text-sm ${active ? 'bg-emerald-600 text-white' : 'bg-white text-gray-600 border hover:bg-gray-50'}">${i}</button>`;
}
container.innerHTML = html;
}

View File

@@ -1,10 +1,9 @@
/* tkpurchase-partner-portal.js - Partner portal logic */
/* tkpurchase-partner-portal.js - Partner portal logic (2-step flow) */
let portalSchedules = [];
let portalCheckins = {};
let partnerCompanyId = null;
let companyWorkersCache = null; // 작업자 목록 캐시
let editingReportId = null; // 수정 모드일 때 보고 ID
let companyWorkersCache = null;
async function loadMySchedules() {
try {
@@ -61,12 +60,10 @@ async function renderScheduleCards() {
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
// 2-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';
const step2Class = isCheckedOut ? 'text-emerald-600' : 'text-gray-400';
return `<div class="bg-white rounded-xl shadow-sm overflow-hidden">
<!-- 일정 정보 -->
@@ -82,7 +79,7 @@ async function renderScheduleCards() {
</div>
</div>
<!-- 3-step 진행 표시 -->
<!-- 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}">
@@ -91,13 +88,8 @@ async function renderScheduleCards() {
</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>
<span>2. 작업 종료</span>
</div>
</div>
</div>
@@ -129,28 +121,15 @@ async function renderScheduleCards() {
`}
</div>
<!-- Step 2: 업무현황 (체크인 후 표시) -->
<!-- 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>업무현황 추가
<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="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 id="checkoutForm_${checkin.id}" class="hidden mt-3"></div>
</div>
` : ''}
@@ -159,94 +138,25 @@ async function renderScheduleCards() {
<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);
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);
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) {
if (checkin) {
let workerNames = [];
if (checkin.worker_names) {
let parsed = checkin.worker_names;
@@ -265,13 +175,12 @@ async function showReportForm(checkinId, scheduleId, editReport) {
}
formContainer.innerHTML = `
<div class="space-y-3 border border-blue-200 rounded-lg p-4 bg-blue-50/30">
<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">${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>
<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">
@@ -279,40 +188,43 @@ async function showReportForm(checkinId, scheduleId, editReport) {
</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>
<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) {
@@ -338,7 +250,6 @@ 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();
}
@@ -353,7 +264,6 @@ function collectWorkers(checkinId) {
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);
@@ -368,57 +278,6 @@ function collectWorkers(checkinId) {
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();
@@ -441,21 +300,9 @@ async function doCheckIn(scheduleId) {
}
}
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) {