From 976e55d672f23772ad1ed60112bf4a8041739144 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 13 Mar 2026 09:43:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(tkpurchase):=20=EC=97=85=EB=AC=B4=ED=98=84?= =?UTF-8?q?=ED=99=A9=20=EB=8B=A4=EA=B1=B4=20=EC=9E=85=EB=A0=A5=20+=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EC=9E=90=20=EC=8B=9C=EA=B0=84=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=20+=20=EC=A2=85=ED=95=A9=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 - DB: 유니크 제약 제거, report_seq 컬럼, work_report_workers 테이블 - API: 트랜잭션 기반 다건 생성/수정, 작업자 CRUD, 요약/엑셀 엔드포인트 - 협력업체 포탈: 다건 보고 UI, 작업자+시간 입력(자동완성), 수정 기능 - 업무현황 페이지: 보고순번/작업자 상세 표시 - 종합 페이지(NEW): 업체별/프로젝트별 취합, 엑셀 추출 Co-Authored-By: Claude Opus 4.6 --- scripts/migration-work-report-enhancement.sql | 26 ++ .../api/controllers/checkinController.js | 4 +- .../api/controllers/workReportController.js | 140 +++++++- tkpurchase/api/models/checkinModel.js | 3 +- tkpurchase/api/models/workReportModel.js | 198 +++++++++-- tkpurchase/api/package.json | 1 + tkpurchase/api/routes/workReportRoutes.js | 2 + tkpurchase/web/Dockerfile | 1 + tkpurchase/web/static/js/tkpurchase-core.js | 1 + .../static/js/tkpurchase-partner-portal.js | 313 ++++++++++++++---- .../js/tkpurchase-workreport-summary.js | 149 +++++++++ .../web/static/js/tkpurchase-workreport.js | 26 +- tkpurchase/web/workreport-summary.html | 104 ++++++ tkpurchase/web/workreport.html | 6 +- 14 files changed, 881 insertions(+), 93 deletions(-) create mode 100644 scripts/migration-work-report-enhancement.sql create mode 100644 tkpurchase/web/static/js/tkpurchase-workreport-summary.js create mode 100644 tkpurchase/web/workreport-summary.html diff --git a/scripts/migration-work-report-enhancement.sql b/scripts/migration-work-report-enhancement.sql new file mode 100644 index 0000000..19842f3 --- /dev/null +++ b/scripts/migration-work-report-enhancement.sql @@ -0,0 +1,26 @@ +-- ============================================================ +-- 업무현황 다건 입력 + 작업자 시간 추적 마이그레이션 +-- 실행: MariaDB (tkpurchase DB) +-- 날짜: 2026-03-13 +-- ============================================================ + +-- 1) 유니크 제약 제거 (1일정-1보고 제한 해제) +ALTER TABLE partner_work_reports DROP INDEX uq_pwr_schedule_report_date; + +-- 2) 보고 순번 컬럼 추가 +ALTER TABLE partner_work_reports + ADD COLUMN report_seq TINYINT NOT NULL DEFAULT 1 AFTER report_date; + +-- 3) 작업자별 투입시간 테이블 +CREATE TABLE IF NOT EXISTS work_report_workers ( + id INT AUTO_INCREMENT PRIMARY KEY, + report_id INT NOT NULL, + partner_worker_id INT, + worker_name VARCHAR(100) NOT NULL, + hours_worked DECIMAL(4,1) DEFAULT 8.0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_wrw_report FOREIGN KEY (report_id) + REFERENCES partner_work_reports(id) ON DELETE CASCADE, + CONSTRAINT fk_wrw_partner_worker FOREIGN KEY (partner_worker_id) + REFERENCES partner_workers(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/tkpurchase/api/controllers/checkinController.js b/tkpurchase/api/controllers/checkinController.js index c9467ff..9f29520 100644 --- a/tkpurchase/api/controllers/checkinController.js +++ b/tkpurchase/api/controllers/checkinController.js @@ -72,8 +72,8 @@ async function checkIn(req, res) { // 체크아웃 async function checkOut(req, res) { try { - const report = await workReportModel.findByCheckin(req.params.id); - if (!report) { + const reports = await workReportModel.findByCheckin(req.params.id); + if (!reports || reports.length === 0) { return res.status(400).json({ success: false, error: '업무현황을 먼저 입력해주세요' }); } const row = await checkinModel.checkOut(req.params.id); diff --git a/tkpurchase/api/controllers/workReportController.js b/tkpurchase/api/controllers/workReportController.js index 8e5e511..426aadc 100644 --- a/tkpurchase/api/controllers/workReportController.js +++ b/tkpurchase/api/controllers/workReportController.js @@ -1,5 +1,6 @@ const workReportModel = require('../models/workReportModel'); const checkinModel = require('../models/checkinModel'); +const ExcelJS = require('exceljs'); // 작업보고 목록 async function list(req, res) { @@ -58,7 +59,7 @@ async function myReports(req, res) { // 작업보고 등록 async function create(req, res) { try { - const { checkin_id, schedule_id, company_id, report_date } = req.body; + const { checkin_id, schedule_id, company_id, report_date, workers } = req.body; if (!report_date) { return res.status(400).json({ success: false, error: '보고일은 필수입니다' }); @@ -85,7 +86,8 @@ async function create(req, res) { const data = { ...req.body, company_id: resolvedCompanyId, - reporter_id: req.user.user_id || req.user.id + reporter_id: req.user.user_id || req.user.id, + workers: workers || [] }; const row = await workReportModel.create(data); res.status(201).json({ success: true, data: row }); @@ -98,8 +100,22 @@ async function create(req, res) { // 작업보고 수정 async function update(req, res) { try { + const existing = await workReportModel.findById(req.params.id); + if (!existing) { + return res.status(404).json({ success: false, error: '작업보고를 찾을 수 없습니다' }); + } + + // 소유권 검증 (협력업체 포탈에서 호출 시) + if (req.user.partner_company_id && existing.reporter_id !== (req.user.user_id || req.user.id)) { + return res.status(403).json({ success: false, error: '본인이 작성한 보고만 수정할 수 있습니다' }); + } + + // 확인 완료된 보고 수정 불가 + if (existing.confirmed_by) { + return res.status(400).json({ success: false, error: '확인 완료된 보고는 수정할 수 없습니다' }); + } + const row = await workReportModel.update(req.params.id, req.body); - if (!row) return res.status(404).json({ success: false, error: '작업보고를 찾을 수 없습니다' }); res.json({ success: true, data: row }); } catch (err) { console.error('WorkReport update error:', err); @@ -120,4 +136,120 @@ async function confirm(req, res) { } } -module.exports = { list, getById, myReports, create, update, confirm }; +// 종합 요약 +async function summary(req, res) { + try { + const { company_id, schedule_id, date_from, date_to } = req.query; + + if (!date_from || !date_to) { + return res.status(400).json({ success: false, error: '기간(date_from, date_to)은 필수입니다' }); + } + + // 최대 3개월 검증 + const from = new Date(date_from); + const to = new Date(date_to); + const diffMs = to - from; + if (diffMs < 0) { + return res.status(400).json({ success: false, error: '시작일이 종료일보다 늦을 수 없습니다' }); + } + if (diffMs > 92 * 24 * 60 * 60 * 1000) { + return res.status(400).json({ success: false, error: '조회 기간은 최대 3개월입니다' }); + } + + const rows = await workReportModel.findAllAggregated({ + company_id: company_id ? parseInt(company_id) : undefined, + schedule_id: schedule_id ? parseInt(schedule_id) : undefined, + date_from, + date_to + }); + res.json({ success: true, data: rows }); + } catch (err) { + console.error('WorkReport summary error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 엑셀 추출 +async function exportExcel(req, res) { + try { + const { company_id, schedule_id, date_from, date_to } = req.query; + + if (!date_from || !date_to) { + return res.status(400).json({ success: false, error: '기간(date_from, date_to)은 필수입니다' }); + } + + const from = new Date(date_from); + const to = new Date(date_to); + const diffMs = to - from; + if (diffMs < 0) { + return res.status(400).json({ success: false, error: '시작일이 종료일보다 늦을 수 없습니다' }); + } + if (diffMs > 92 * 24 * 60 * 60 * 1000) { + return res.status(400).json({ success: false, error: '조회 기간은 최대 3개월입니다' }); + } + + const rows = await workReportModel.exportData({ + company_id: company_id ? parseInt(company_id) : undefined, + schedule_id: schedule_id ? parseInt(schedule_id) : undefined, + date_from, + date_to + }); + + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('업무현황'); + + sheet.columns = [ + { header: '보고일', key: 'report_date', width: 12 }, + { header: '순번', key: 'report_seq', width: 6 }, + { header: '업체', key: 'company_name', width: 18 }, + { header: '작업장', key: 'workplace_name', width: 15 }, + { header: '작업내용', key: 'schedule_description', width: 20 }, + { header: '보고내용', key: 'work_content', width: 30 }, + { header: '진행률(%)', key: 'progress_rate', width: 10 }, + { header: '작업자', key: 'worker_name', width: 12 }, + { header: '투입시간', key: 'hours_worked', width: 10 }, + { header: '이슈사항', key: 'issues', width: 25 }, + { header: '향후계획', key: 'next_plan', width: 25 }, + { header: '보고자', key: 'reporter_name', width: 10 }, + { header: '확인상태', key: 'confirm_status', width: 8 }, + { header: '확인자', key: 'confirmed_by_name', width: 10 }, + ]; + + // 헤더 스타일 + sheet.getRow(1).eachCell(cell => { + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF059669' } }; + cell.font = { color: { argb: 'FFFFFFFF' }, bold: true }; + cell.alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + rows.forEach(r => { + sheet.addRow({ + report_date: r.report_date ? String(r.report_date).substring(0, 10) : '', + report_seq: r.report_seq, + company_name: r.company_name || '', + workplace_name: r.workplace_name || '', + schedule_description: r.schedule_description || '', + work_content: r.work_content || '', + progress_rate: r.progress_rate || 0, + worker_name: r.worker_name || '', + hours_worked: r.hours_worked != null ? Number(r.hours_worked) : '', + issues: r.issues || '', + next_plan: r.next_plan || '', + reporter_name: r.reporter_name || '', + confirm_status: r.confirm_status || '', + confirmed_by_name: r.confirmed_by_name || '', + }); + }); + + const filename = `업무현황_${date_from}_${date_to}.xlsx`; + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`); + await workbook.xlsx.write(res); + res.end(); + } catch (err) { + console.error('WorkReport exportExcel error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +module.exports = { list, getById, myReports, create, update, confirm, summary, exportExcel }; diff --git a/tkpurchase/api/models/checkinModel.js b/tkpurchase/api/models/checkinModel.js index 7eae82d..1875317 100644 --- a/tkpurchase/api/models/checkinModel.js +++ b/tkpurchase/api/models/checkinModel.js @@ -27,10 +27,9 @@ async function findTodayByCompany(companyId) { const db = getPool(); const [rows] = await db.query( `SELECT pc.*, ps.work_description, ps.workplace_name, - (dwr.id IS NOT NULL) AS has_work_report + (SELECT COUNT(*) FROM partner_work_reports WHERE checkin_id = pc.id) AS work_report_count FROM partner_work_checkins pc LEFT JOIN partner_schedules ps ON pc.schedule_id = ps.id - LEFT JOIN partner_work_reports dwr ON dwr.checkin_id = pc.id WHERE pc.company_id = ? AND DATE(pc.check_in_time) = CURDATE() ORDER BY pc.check_in_time DESC`, [companyId]); return rows; diff --git a/tkpurchase/api/models/workReportModel.js b/tkpurchase/api/models/workReportModel.js index 1b90801..5725c23 100644 --- a/tkpurchase/api/models/workReportModel.js +++ b/tkpurchase/api/models/workReportModel.js @@ -17,7 +17,7 @@ async function findAll({ company_id, date_from, date_to, schedule_id, confirmed, if (schedule_id) { sql += ' AND wr.schedule_id = ?'; params.push(schedule_id); } if (confirmed === 'true' || confirmed === '1') { sql += ' AND wr.confirmed_by IS NOT NULL'; } if (confirmed === 'false' || confirmed === '0') { sql += ' AND wr.confirmed_by IS NULL'; } - sql += ' ORDER BY wr.report_date DESC, wr.created_at DESC'; + sql += ' ORDER BY wr.report_date DESC, wr.report_seq ASC, wr.created_at DESC'; const offset = (page - 1) * limit; sql += ' LIMIT ? OFFSET ?'; params.push(limit, offset); @@ -36,7 +36,17 @@ async function findById(id) { LEFT JOIN sso_users su_reporter ON wr.reporter_id = su_reporter.user_id LEFT JOIN sso_users su_confirmer ON wr.confirmed_by = su_confirmer.user_id WHERE wr.id = ?`, [id]); - return rows[0] || null; + const report = rows[0] || null; + if (report) { + const [workers] = await db.query( + `SELECT wrw.*, pw.position + FROM work_report_workers wrw + LEFT JOIN partner_workers pw ON wrw.partner_worker_id = pw.id + WHERE wrw.report_id = ? + ORDER BY wrw.id`, [id]); + report.workers = workers; + } + return report; } async function findByCheckin(checkinId) { @@ -45,35 +55,110 @@ async function findByCheckin(checkinId) { `SELECT wr.*, pc.company_name FROM partner_work_reports wr LEFT JOIN partner_companies pc ON wr.company_id = pc.id - WHERE wr.checkin_id = ?`, [checkinId]); - return rows[0] || null; + WHERE wr.checkin_id = ? + ORDER BY wr.report_seq ASC`, [checkinId]); + // 각 보고에 workers 첨부 + for (const row of rows) { + const [workers] = await db.query( + `SELECT wrw.*, pw.position + FROM work_report_workers wrw + LEFT JOIN partner_workers pw ON wrw.partner_worker_id = pw.id + WHERE wrw.report_id = ? + ORDER BY wrw.id`, [row.id]); + row.workers = workers; + } + return rows; +} + +async function countByCheckin(checkinId) { + const db = getPool(); + const [rows] = await db.query( + 'SELECT COUNT(*) AS cnt FROM partner_work_reports WHERE checkin_id = ?', [checkinId]); + return rows[0].cnt; } async function create(data) { const db = getPool(); - const [result] = await db.query( - `INSERT INTO partner_work_reports (schedule_id, checkin_id, company_id, report_date, reporter_id, actual_workers, work_content, progress_rate, issues, next_plan) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [data.schedule_id || null, data.checkin_id || null, data.company_id, - data.report_date, data.reporter_id, data.actual_workers || null, - data.work_content || null, data.progress_rate || null, - data.issues || null, data.next_plan || null]); - return findById(result.insertId); + 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 = ?', + [data.checkin_id]); + const reportSeq = seqRows[0].next_seq; + + const [result] = 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, issues, next_plan) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [data.schedule_id || null, data.checkin_id || null, data.company_id, + data.report_date, reportSeq, data.reporter_id, data.actual_workers || null, + data.work_content || null, data.progress_rate || null, + data.issues || null, data.next_plan || null]); + + const reportId = result.insertId; + + // workers 삽입 + if (data.workers && data.workers.length > 0) { + for (const w of data.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.commit(); + return findById(reportId); + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } } async function update(id, data) { const db = getPool(); - const fields = []; - const values = []; - if (data.actual_workers !== undefined) { fields.push('actual_workers = ?'); values.push(data.actual_workers || null); } - if (data.work_content !== undefined) { fields.push('work_content = ?'); values.push(data.work_content || null); } - if (data.progress_rate !== undefined) { fields.push('progress_rate = ?'); values.push(data.progress_rate || null); } - if (data.issues !== undefined) { fields.push('issues = ?'); values.push(data.issues || null); } - if (data.next_plan !== undefined) { fields.push('next_plan = ?'); values.push(data.next_plan || null); } - if (fields.length === 0) return findById(id); - values.push(id); - await db.query(`UPDATE partner_work_reports SET ${fields.join(', ')} WHERE id = ?`, values); - return findById(id); + const conn = await db.getConnection(); + try { + await conn.beginTransaction(); + + const fields = []; + const values = []; + if (data.actual_workers !== undefined) { fields.push('actual_workers = ?'); values.push(data.actual_workers || null); } + if (data.work_content !== undefined) { fields.push('work_content = ?'); values.push(data.work_content || null); } + if (data.progress_rate !== undefined) { fields.push('progress_rate = ?'); values.push(data.progress_rate || null); } + if (data.issues !== undefined) { fields.push('issues = ?'); values.push(data.issues || null); } + if (data.next_plan !== undefined) { fields.push('next_plan = ?'); values.push(data.next_plan || null); } + + if (fields.length > 0) { + values.push(id); + await conn.query(`UPDATE partner_work_reports SET ${fields.join(', ')} WHERE id = ?`, values); + } + + // workers 교체 (있으면) + if (data.workers !== undefined) { + await conn.query('DELETE FROM work_report_workers WHERE report_id = ?', [id]); + if (data.workers && data.workers.length > 0) { + for (const w of data.workers) { + await conn.query( + `INSERT INTO work_report_workers (report_id, partner_worker_id, worker_name, hours_worked) + VALUES (?, ?, ?, ?)`, + [id, w.partner_worker_id || null, w.worker_name, w.hours_worked ?? 8.0]); + } + } + } + + await conn.commit(); + return findById(id); + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } } async function confirm(id, confirmedBy) { @@ -84,4 +169,69 @@ async function confirm(id, confirmedBy) { return findById(id); } -module.exports = { findAll, findById, findByCheckin, create, update, confirm }; +async function findAllAggregated({ company_id, schedule_id, date_from, date_to } = {}) { + const db = getPool(); + let sql = `SELECT + wr.report_date, + wr.company_id, + pc.company_name, + ps.id AS schedule_id, + ps.work_description AS schedule_description, + ps.workplace_name, + COUNT(wr.id) AS report_count, + (SELECT COUNT(DISTINCT wrw.worker_name) FROM work_report_workers wrw WHERE wrw.report_id IN ( + SELECT wr2.id FROM partner_work_reports wr2 + WHERE wr2.report_date = wr.report_date AND wr2.company_id = wr.company_id AND wr2.schedule_id = wr.schedule_id + )) AS total_workers, + (SELECT COALESCE(SUM(wrw.hours_worked), 0) FROM work_report_workers wrw WHERE wrw.report_id IN ( + SELECT wr2.id FROM partner_work_reports wr2 + WHERE wr2.report_date = wr.report_date AND wr2.company_id = wr.company_id AND wr2.schedule_id = wr.schedule_id + )) AS total_hours, + ROUND(AVG(wr.progress_rate), 1) AS avg_progress, + SUM(CASE WHEN wr.confirmed_by IS NOT NULL THEN 1 ELSE 0 END) AS confirmed_count + FROM partner_work_reports wr + LEFT JOIN partner_companies pc ON wr.company_id = pc.id + LEFT JOIN partner_schedules ps ON wr.schedule_id = ps.id + WHERE 1=1`; + const params = []; + if (company_id) { sql += ' AND wr.company_id = ?'; params.push(company_id); } + if (schedule_id) { sql += ' AND wr.schedule_id = ?'; params.push(schedule_id); } + if (date_from) { sql += ' AND wr.report_date >= ?'; params.push(date_from); } + if (date_to) { sql += ' AND wr.report_date <= ?'; params.push(date_to); } + sql += ' GROUP BY wr.report_date, wr.company_id, wr.schedule_id ORDER BY wr.report_date DESC, pc.company_name'; + const [rows] = await db.query(sql, params); + return rows; +} + +async function exportData({ company_id, schedule_id, date_from, date_to } = {}) { + const db = getPool(); + let sql = `SELECT + wr.report_date, wr.report_seq, + pc.company_name, + ps.work_description AS schedule_description, + ps.workplace_name, + wr.work_content, wr.progress_rate, wr.issues, wr.next_plan, + wr.actual_workers, + su_reporter.name AS reporter_name, + wrw.worker_name, wrw.hours_worked, + CASE WHEN wr.confirmed_by IS NOT NULL THEN '확인' ELSE '미확인' END AS confirm_status, + su_confirmer.name AS confirmed_by_name, + wr.confirmed_at + FROM partner_work_reports wr + LEFT JOIN partner_companies pc ON wr.company_id = pc.id + LEFT JOIN partner_schedules ps ON wr.schedule_id = ps.id + LEFT JOIN sso_users su_reporter ON wr.reporter_id = su_reporter.user_id + LEFT JOIN sso_users su_confirmer ON wr.confirmed_by = su_confirmer.user_id + LEFT JOIN work_report_workers wrw ON wrw.report_id = wr.id + WHERE 1=1`; + const params = []; + if (company_id) { sql += ' AND wr.company_id = ?'; params.push(company_id); } + if (schedule_id) { sql += ' AND wr.schedule_id = ?'; params.push(schedule_id); } + if (date_from) { sql += ' AND wr.report_date >= ?'; params.push(date_from); } + if (date_to) { sql += ' AND wr.report_date <= ?'; params.push(date_to); } + sql += ' ORDER BY wr.report_date DESC, pc.company_name, wr.report_seq, wrw.id'; + const [rows] = await db.query(sql, params); + return rows; +} + +module.exports = { findAll, findById, findByCheckin, countByCheckin, create, update, confirm, findAllAggregated, exportData }; diff --git a/tkpurchase/api/package.json b/tkpurchase/api/package.json index 5a2c700..0c7157c 100644 --- a/tkpurchase/api/package.json +++ b/tkpurchase/api/package.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "express": "^4.18.2", "jsonwebtoken": "^9.0.0", + "exceljs": "^4.4.0", "mysql2": "^3.14.1", "node-cron": "^3.0.3" } diff --git a/tkpurchase/api/routes/workReportRoutes.js b/tkpurchase/api/routes/workReportRoutes.js index 9e54ce9..aaecdc1 100644 --- a/tkpurchase/api/routes/workReportRoutes.js +++ b/tkpurchase/api/routes/workReportRoutes.js @@ -7,6 +7,8 @@ router.use(requireAuth); router.get('/', ctrl.list); router.get('/my', ctrl.myReports); // partner portal +router.get('/summary', requirePage('purchasing_workreport'), ctrl.summary); +router.get('/export', requirePage('purchasing_workreport'), ctrl.exportExcel); router.get('/:id', ctrl.getById); router.post('/', ctrl.create); // partner can create router.put('/:id', ctrl.update); diff --git a/tkpurchase/web/Dockerfile b/tkpurchase/web/Dockerfile index dde77e5..65a21ec 100644 --- a/tkpurchase/web/Dockerfile +++ b/tkpurchase/web/Dockerfile @@ -5,6 +5,7 @@ COPY index.html /usr/share/nginx/html/index.html COPY daylabor.html /usr/share/nginx/html/daylabor.html COPY schedule.html /usr/share/nginx/html/schedule.html 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 static/ /usr/share/nginx/html/static/ diff --git a/tkpurchase/web/static/js/tkpurchase-core.js b/tkpurchase/web/static/js/tkpurchase-core.js index 29e3857..b242a84 100644 --- a/tkpurchase/web/static/js/tkpurchase-core.js +++ b/tkpurchase/web/static/js/tkpurchase-core.js @@ -90,6 +90,7 @@ function renderNavbar() { { href: '/daylabor.html', icon: 'fa-hard-hat', label: '일용공 신청', match: ['daylabor.html'] }, { href: '/schedule.html', icon: 'fa-calendar-alt', label: '작업일정', match: ['schedule.html'] }, { href: '/workreport.html', icon: 'fa-clipboard-list', label: '업무현황', match: ['workreport.html'] }, + { href: '/workreport-summary.html', icon: 'fa-chart-bar', label: '업무 종합', match: ['workreport-summary.html'] }, { href: '/accounts.html', icon: 'fa-user-shield', label: '계정 관리', match: ['accounts.html'] }, ]; const nav = document.getElementById('sideNav'); diff --git a/tkpurchase/web/static/js/tkpurchase-partner-portal.js b/tkpurchase/web/static/js/tkpurchase-partner-portal.js index 2158fb0..136dfbd 100644 --- a/tkpurchase/web/static/js/tkpurchase-partner-portal.js +++ b/tkpurchase/web/static/js/tkpurchase-partner-portal.js @@ -3,6 +3,8 @@ let portalSchedules = []; let portalCheckins = {}; let partnerCompanyId = null; +let companyWorkersCache = null; // 작업자 목록 캐시 +let editingReportId = null; // 수정 모드일 때 보고 ID async function loadMySchedules() { try { @@ -28,6 +30,19 @@ async function loadMyCheckins() { } } +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()]); @@ -46,7 +61,7 @@ async function renderScheduleCards() { const checkin = portalCheckins[s.id]; const isCheckedIn = checkin && !checkin.check_out_time; const isCheckedOut = checkin && checkin.check_out_time; - const hasReport = checkin && checkin.has_work_report; + const reportCount = checkin ? (parseInt(checkin.work_report_count) || 0) : 0; // Step indicators const step1Class = checkin ? 'text-emerald-600' : 'text-gray-400'; @@ -77,7 +92,7 @@ async function renderScheduleCards() {
- 2. 업무현황 + 2. 업무현황${reportCount > 0 ? ' (' + reportCount + '건)' : ''}
@@ -114,39 +129,20 @@ async function renderScheduleCards() { `}
- + ${isCheckedIn ? `
-

업무현황 입력

-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- -
+

업무현황

+ +
+ +
+
+ +
@@ -163,11 +159,238 @@ async function renderScheduleCards() {
작업 종료 완료 (${formatTime(checkin.check_out_time)})
- ${hasReport ? '
업무현황 제출 완료
' : ''} + ${reportCount > 0 ? '
업무현황 ' + reportCount + '건 제출 완료
' : ''}
` : ''} `; }).join(''); + + // 체크인된 카드의 보고 목록 로드 + for (const s of portalSchedules) { + const checkin = portalCheckins[s.id]; + if (checkin && !checkin.check_out_time) { + loadReportsList(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; + return `
+
+
+ 보고 #${r.report_seq || 1} + ${isConfirmed ? ' 확인완료' : '미확인'} +
+
${escapeHtml((r.work_content || '').substring(0, 50))}${(r.work_content || '').length > 50 ? '...' : ''}
+
${workerCount}명 · ${totalHours}h · 진행률 ${r.progress_rate || 0}%
+
+ ${!isConfirmed ? `` : ''} +
`; + }).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 => `