From 6e5c1554d0fbd0dc1e3c1823185ad1c30cfb20c6 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 13 Mar 2026 13:50:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(tkpurchase):=20=ED=98=91=EB=A0=A5=EC=97=85?= =?UTF-8?q?=EC=B2=B4=20=ED=8F=AC=ED=83=88=203=E2=86=922=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=20=ED=9D=90=EB=A6=84=20=EB=8B=A8=EC=88=9C=ED=99=94=20+=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=9D=B4=EB=A0=A5=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 체크아웃 시 work_report 자동 생성 (checkout-with-report 통합 엔드포인트) - 업무현황 입력 단계 제거, 작업자+시간만 입력하면 체크아웃 완료 - 협력업체 작업 이력 조회 페이지 신규 추가 (partner-history) Co-Authored-By: Claude Opus 4.6 --- .../api/controllers/checkinController.js | 57 +++- tkpurchase/api/models/checkinModel.js | 112 +++++++- tkpurchase/api/routes/checkinRoutes.js | 2 + tkpurchase/web/Dockerfile | 1 + tkpurchase/web/partner-history.html | 68 +++++ tkpurchase/web/partner-portal.html | 13 +- tkpurchase/web/static/js/tkpurchase-core.js | 2 +- .../static/js/tkpurchase-partner-history.js | 136 ++++++++++ .../static/js/tkpurchase-partner-portal.js | 253 ++++-------------- 9 files changed, 433 insertions(+), 211 deletions(-) create mode 100644 tkpurchase/web/partner-history.html create mode 100644 tkpurchase/web/static/js/tkpurchase-partner-history.js diff --git a/tkpurchase/api/controllers/checkinController.js b/tkpurchase/api/controllers/checkinController.js index 27455c6..2c7c3d4 100644 --- a/tkpurchase/api/controllers/checkinController.js +++ b/tkpurchase/api/controllers/checkinController.js @@ -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 }; diff --git a/tkpurchase/api/models/checkinModel.js b/tkpurchase/api/models/checkinModel.js index d9f128c..75acaef 100644 --- a/tkpurchase/api/models/checkinModel.js +++ b/tkpurchase/api/models/checkinModel.js @@ -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 }; diff --git a/tkpurchase/api/routes/checkinRoutes.js b/tkpurchase/api/routes/checkinRoutes.js index f0193cc..03b8fa2 100644 --- a/tkpurchase/api/routes/checkinRoutes.js +++ b/tkpurchase/api/routes/checkinRoutes.js @@ -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); diff --git a/tkpurchase/web/Dockerfile b/tkpurchase/web/Dockerfile index 65a21ec..1835df5 100644 --- a/tkpurchase/web/Dockerfile +++ b/tkpurchase/web/Dockerfile @@ -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 diff --git a/tkpurchase/web/partner-history.html b/tkpurchase/web/partner-history.html new file mode 100644 index 0000000..52497f2 --- /dev/null +++ b/tkpurchase/web/partner-history.html @@ -0,0 +1,68 @@ + + + + + + 작업 이력 - TK 구매관리 + + + + + + +
+
+
+
+ +

TK 구매관리

+
+
+ +
-
+ +
+
+
+
+ +
+ +
+ + 포탈로 돌아가기 + +

작업 이력

+
+ + +
+
+
+ + +
+
+ + +
+ +
+
+ + +
+

로딩 중...

+
+ + +
+
+ + + + + + diff --git a/tkpurchase/web/partner-portal.html b/tkpurchase/web/partner-portal.html index 8e9b12e..537c00e 100644 --- a/tkpurchase/web/partner-portal.html +++ b/tkpurchase/web/partner-portal.html @@ -6,7 +6,7 @@ 협력업체 포털 - TK 구매관리 - + @@ -33,10 +33,13 @@
-
+

-

-

오늘의 작업 일정을 확인하고 업무현황을 입력해주세요.

+

오늘의 작업 일정을 확인하세요.

+ + +
@@ -52,8 +55,8 @@ - - + + diff --git a/tkpurchase/web/static/js/tkpurchase-core.js b/tkpurchase/web/static/js/tkpurchase-core.js index b242a84..bbc7387 100644 --- a/tkpurchase/web/static/js/tkpurchase-core.js +++ b/tkpurchase/web/static/js/tkpurchase-core.js @@ -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; } diff --git a/tkpurchase/web/static/js/tkpurchase-partner-history.js b/tkpurchase/web/static/js/tkpurchase-partner-history.js new file mode 100644 index 0000000..acab6fa --- /dev/null +++ b/tkpurchase/web/static/js/tkpurchase-partner-history.js @@ -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 = '

로딩 중...

'; + + 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 = '

데이터를 불러올 수 없습니다.

'; + } +} + +function renderHistoryList(checkins) { + const container = document.getElementById('historyList'); + + if (!checkins.length) { + container.innerHTML = `
+ +

조회 기간에 작업 이력이 없습니다.

+
`; + 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 = '진행중'; + } else { + statusHtml = '완료'; + } + + // 보고 정보 + 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 + ? ' 확인완료' + : isRejected + ? ' 반려' + : '미확인'; + + const workersDetail = rWorkers.length > 0 + ? `
${rWorkers.map(w => escapeHtml(w.worker_name) + ' ' + w.hours_worked + 'h').join(', ')}
` + : ''; + + return `
+
+ ${escapeHtml((r.work_content || '').substring(0, 60))}${(r.work_content || '').length > 60 ? '...' : ''} + ${rStatus} +
+
${rWorkers.length}명 · ${totalHours}h
+ ${workersDetail} +
`; + }).join(''); + } + + return `
+
+
+
+ ${checkinDate} + ${statusHtml} +
+ ${escapeHtml(c.workplace_name || '')} +
+ ${c.work_description ? `

${escapeHtml(c.work_description)}

` : ''} +
+ ${checkinTime}${checkoutTime ? ' ~ ' + checkoutTime : ' ~'} + ${c.actual_worker_count || 0}명 +
+ ${reportHtml ? `
${reportHtml}
` : ''} +
+
`; + }).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 += ``; + } + container.innerHTML = html; +} diff --git a/tkpurchase/web/static/js/tkpurchase-partner-portal.js b/tkpurchase/web/static/js/tkpurchase-partner-portal.js index 3182252..e14984e 100644 --- a/tkpurchase/web/static/js/tkpurchase-partner-portal.js +++ b/tkpurchase/web/static/js/tkpurchase-partner-portal.js @@ -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 `
@@ -82,7 +79,7 @@ async function renderScheduleCards() {
- +
@@ -91,13 +88,8 @@ async function renderScheduleCards() {
- - 2. 업무현황${reportCount > 0 ? ' (' + reportCount + '건)' : ''} -
-
-
- 3. 작업 종료 + 2. 작업 종료
@@ -129,28 +121,15 @@ async function renderScheduleCards() { `} - + ${isCheckedIn ? `
-

업무현황

- -
- -
-
- - -
- - -
- -

업무현황을 먼저 저장한 후 작업을 종료하세요.

+
` : ''} @@ -159,94 +138,25 @@ async function renderScheduleCards() {
작업 종료 완료 (${formatTime(checkin.check_out_time)})
- ${reportCount > 0 ? '
업무현황 ' + reportCount + '건 제출 완료
' : ''} ` : ''} `; }).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 = '

아직 등록된 업무현황이 없습니다.

'; - 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 - ? ' 확인완료' - : isRejected - ? ' 반려' - : '미확인'; - const canEdit = !isConfirmed; - return `
-
-
- 보고 #${r.report_seq || 1} - ${statusBadge} -
-
${escapeHtml((r.work_content || '').substring(0, 50))}${(r.work_content || '').length > 50 ? '...' : ''}
-
${workerCount}명 · ${totalHours}h · 진행률 ${r.progress_rate || 0}%
- ${isRejected && r.rejection_reason ? `
${escapeHtml(r.rejection_reason)}
` : ''} -
- ${canEdit ? `` : ''} -
`; - }).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 => `