feat(tkpurchase): 업무현황 다건 입력 + 작업자 시간 추적 + 종합 페이지
- DB: 유니크 제약 제거, report_seq 컬럼, work_report_workers 테이블 - API: 트랜잭션 기반 다건 생성/수정, 작업자 CRUD, 요약/엑셀 엔드포인트 - 협력업체 포탈: 다건 보고 UI, 작업자+시간 입력(자동완성), 수정 기능 - 업무현황 페이지: 보고순번/작업자 상세 표시 - 종합 페이지(NEW): 업체별/프로젝트별 취합, 엑셀 추출 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
26
scripts/migration-work-report-enhancement.sql
Normal file
26
scripts/migration-work-report-enhancement.sql
Normal file
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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() {
|
||||
<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. 업무현황</span>
|
||||
<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}">
|
||||
@@ -114,39 +129,20 @@ 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 class="space-y-3">
|
||||
<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_${checkin.id}" min="0" value="${checkin.actual_worker_count || 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_${checkin.id}" min="0" max="100" value="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_${checkin.id}" rows="3" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="오늘 수행한 작업 내용"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">이슈사항</label>
|
||||
<textarea id="reportIssues_${checkin.id}" rows="2" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="문제점이나 특이사항"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">향후 계획</label>
|
||||
<textarea id="reportNextPlan_${checkin.id}" rows="2" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="다음 작업 계획"></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="submitWorkReport(${checkin.id}, ${s.id})" 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>업무현황 저장
|
||||
</button>
|
||||
</div>
|
||||
<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: 작업 종료 -->
|
||||
@@ -163,11 +159,238 @@ 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>
|
||||
${hasReport ? '<div class="text-xs text-emerald-600 mt-1"><i class="fas fa-clipboard-check mr-1"></i>업무현황 제출 완료</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('');
|
||||
|
||||
// 체크인된 카드의 보고 목록 로드
|
||||
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 = '<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;
|
||||
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>
|
||||
${isConfirmed ? '<span class="text-xs text-emerald-600"><i class="fas fa-check-circle"></i> 확인완료</span>' : '<span class="text-xs text-amber-500">미확인</span>'}
|
||||
</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>
|
||||
</div>
|
||||
${!isConfirmed ? `<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>수정
|
||||
</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;
|
||||
|
||||
// 기본 작업자 행
|
||||
let existingWorkers = [];
|
||||
if (editReport && editReport.workers) {
|
||||
existingWorkers = editReport.workers;
|
||||
} else if (isFirstReport && 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) {
|
||||
@@ -189,30 +412,6 @@ async function doCheckIn(scheduleId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitWorkReport(checkinId, scheduleId) {
|
||||
const workContent = document.getElementById('reportContent_' + checkinId).value.trim();
|
||||
if (!workContent) { showToast('작업내용을 입력하세요', 'error'); return; }
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
try {
|
||||
await api('/work-reports', { method: 'POST', body: JSON.stringify(body) });
|
||||
showToast('업무현황이 저장되었습니다');
|
||||
renderScheduleCards();
|
||||
} catch(e) {
|
||||
showToast(e.message || '저장 실패', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function doCheckOut(checkinId) {
|
||||
if (!confirm('작업을 종료하시겠습니까? 업무현황을 먼저 저장했는지 확인하세요.')) return;
|
||||
try {
|
||||
|
||||
149
tkpurchase/web/static/js/tkpurchase-workreport-summary.js
Normal file
149
tkpurchase/web/static/js/tkpurchase-workreport-summary.js
Normal file
@@ -0,0 +1,149 @@
|
||||
/* tkpurchase-workreport-summary.js - Work report summary page */
|
||||
|
||||
async function loadCompaniesForFilter() {
|
||||
try {
|
||||
const r = await api('/partners?limit=100');
|
||||
const list = r.data || [];
|
||||
const sel = document.getElementById('filterCompany');
|
||||
list.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.textContent = c.company_name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch(e) { console.warn('Load companies error:', e); }
|
||||
}
|
||||
|
||||
async function loadSchedulesForFilter() {
|
||||
try {
|
||||
const r = await api('/schedules?limit=200');
|
||||
const list = r.data || [];
|
||||
const sel = document.getElementById('filterSchedule');
|
||||
list.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.textContent = (s.workplace_name || '') + ' - ' + (s.work_description || '').substring(0, 30);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch(e) { console.warn('Load schedules error:', e); }
|
||||
}
|
||||
|
||||
function getFilterQuery() {
|
||||
const companyId = document.getElementById('filterCompany').value;
|
||||
const scheduleId = document.getElementById('filterSchedule').value;
|
||||
const dateFrom = document.getElementById('filterDateFrom').value;
|
||||
const dateTo = document.getElementById('filterDateTo').value;
|
||||
|
||||
if (!dateFrom || !dateTo) {
|
||||
showToast('기간을 선택해주세요', 'error');
|
||||
return null;
|
||||
}
|
||||
|
||||
let query = `?date_from=${dateFrom}&date_to=${dateTo}`;
|
||||
if (companyId) query += '&company_id=' + companyId;
|
||||
if (scheduleId) query += '&schedule_id=' + scheduleId;
|
||||
return query;
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
const query = getFilterQuery();
|
||||
if (!query) return;
|
||||
|
||||
const tbody = document.getElementById('summaryTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-8">로딩 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const r = await api('/work-reports/summary' + query);
|
||||
renderSummaryTable(r.data || []);
|
||||
} catch(e) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">' + escapeHtml(e.message || '로딩 실패') + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummaryTable(list) {
|
||||
const tbody = document.getElementById('summaryTableBody');
|
||||
if (!list.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-8">데이터가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
let totalReports = 0, totalWorkers = 0, totalHours = 0;
|
||||
|
||||
tbody.innerHTML = list.map(r => {
|
||||
totalReports += parseInt(r.report_count) || 0;
|
||||
totalWorkers += parseInt(r.total_workers) || 0;
|
||||
totalHours += parseFloat(r.total_hours) || 0;
|
||||
|
||||
const progressColor = r.avg_progress >= 80 ? 'bg-emerald-500' : r.avg_progress >= 50 ? 'bg-blue-500' : r.avg_progress >= 20 ? 'bg-amber-500' : 'bg-red-500';
|
||||
const confirmText = r.confirmed_count + '/' + r.report_count;
|
||||
|
||||
return `<tr class="hover:bg-gray-50">
|
||||
<td>${formatDate(r.report_date)}</td>
|
||||
<td class="font-medium">${escapeHtml(r.company_name || '')}</td>
|
||||
<td class="text-sm">${escapeHtml(r.workplace_name || '')}</td>
|
||||
<td class="text-center">${r.report_count}건</td>
|
||||
<td class="text-center">${r.total_workers}명</td>
|
||||
<td class="text-center">${Number(r.total_hours).toFixed(1)}h</td>
|
||||
<td class="text-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2 min-w-[2rem]">
|
||||
<div class="${progressColor} rounded-full h-2" style="width: ${r.avg_progress || 0}%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">${r.avg_progress || 0}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="${parseInt(r.confirmed_count) === parseInt(r.report_count) ? 'text-emerald-600' : 'text-amber-500'} text-sm">${confirmText}</span>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// 합계 행
|
||||
tbody.insertAdjacentHTML('beforeend', `<tr class="bg-gray-50 font-semibold border-t-2">
|
||||
<td colspan="3" class="text-right">합계</td>
|
||||
<td class="text-center">${totalReports}건</td>
|
||||
<td class="text-center">${totalWorkers}명</td>
|
||||
<td class="text-center">${totalHours.toFixed(1)}h</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
function exportExcel() {
|
||||
const query = getFilterQuery();
|
||||
if (!query) return;
|
||||
|
||||
const token = getToken();
|
||||
const url = API_BASE + '/work-reports/export' + query;
|
||||
|
||||
// fetch로 다운로드 (인증 헤더 포함)
|
||||
fetch(url, { headers: { 'Authorization': token ? 'Bearer ' + token : '' } })
|
||||
.then(res => {
|
||||
if (!res.ok) return res.json().then(d => { throw new Error(d.error || '다운로드 실패'); });
|
||||
return res.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
const dateFrom = document.getElementById('filterDateFrom').value;
|
||||
const dateTo = document.getElementById('filterDateTo').value;
|
||||
a.download = '업무현황_' + dateFrom + '_' + dateTo + '.xlsx';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
showToast('엑셀 다운로드 완료');
|
||||
})
|
||||
.catch(e => showToast(e.message || '다운로드 실패', 'error'));
|
||||
}
|
||||
|
||||
function initSummaryPage() {
|
||||
if (!initAuth()) return;
|
||||
|
||||
// 기본 기간: 이번 달
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
document.getElementById('filterDateFrom').value = firstDay.toISOString().substring(0, 10);
|
||||
document.getElementById('filterDateTo').value = now.toISOString().substring(0, 10);
|
||||
|
||||
loadCompaniesForFilter();
|
||||
loadSchedulesForFilter();
|
||||
loadSummary();
|
||||
}
|
||||
@@ -34,14 +34,14 @@ async function loadReports() {
|
||||
renderReportTable(r.data || [], r.total || 0);
|
||||
} catch(e) {
|
||||
console.warn('Report load error:', e);
|
||||
document.getElementById('reportTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">로딩 실패</td></tr>';
|
||||
document.getElementById('reportTableBody').innerHTML = '<tr><td colspan="9" class="text-center text-red-400 py-8">로딩 실패</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderReportTable(list, total) {
|
||||
const tbody = document.getElementById('reportTableBody');
|
||||
if (!list.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-8">업무현황이 없습니다</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-gray-400 py-8">업무현황이 없습니다</td></tr>';
|
||||
document.getElementById('reportPagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
@@ -54,6 +54,7 @@ function renderReportTable(list, total) {
|
||||
|
||||
return `<tr class="cursor-pointer hover:bg-gray-50" onclick="viewReportDetail(${r.id})">
|
||||
<td>${formatDate(r.report_date || r.created_at)}</td>
|
||||
<td class="text-center hide-mobile">${r.report_seq || 1}</td>
|
||||
<td class="font-medium">${escapeHtml(r.company_name || '')}</td>
|
||||
<td class="max-w-xs truncate">${escapeHtml(r.work_content || '')}</td>
|
||||
<td class="text-center">${r.actual_workers || 0}명</td>
|
||||
@@ -124,6 +125,24 @@ async function viewReportDetail(id) {
|
||||
|
||||
const progressColor = d.progress_rate >= 80 ? 'bg-emerald-500' : d.progress_rate >= 50 ? 'bg-blue-500' : d.progress_rate >= 20 ? 'bg-amber-500' : 'bg-red-500';
|
||||
|
||||
// 작업자 목록 테이블
|
||||
let workersHtml = '';
|
||||
if (d.workers && d.workers.length > 0) {
|
||||
const totalHours = d.workers.reduce((sum, w) => sum + Number(w.hours_worked || 0), 0);
|
||||
workersHtml = `<div class="sm:col-span-2">
|
||||
<div class="text-xs text-gray-500 mb-1">작업자 목록</div>
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-xs text-gray-500 border-b"><th class="text-left py-1">작업자</th><th class="text-left py-1">직위</th><th class="text-right py-1">투입시간</th></tr></thead>
|
||||
<tbody>
|
||||
${d.workers.map(w => `<tr class="border-b border-gray-100"><td class="py-1">${escapeHtml(w.worker_name)}</td><td class="py-1 text-gray-500">${escapeHtml(w.position || '')}</td><td class="py-1 text-right">${w.hours_worked || 0}h</td></tr>`).join('')}
|
||||
</tbody>
|
||||
<tfoot><tr class="font-medium"><td colspan="2" class="py-1">합계 (${d.workers.length}명)</td><td class="py-1 text-right">${totalHours}h</td></tr></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -131,7 +150,7 @@ async function viewReportDetail(id) {
|
||||
<div class="text-sm font-medium">${escapeHtml(d.company_name || '')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 mb-1">보고일</div>
|
||||
<div class="text-xs text-gray-500 mb-1">보고일 (보고 #${d.report_seq || 1})</div>
|
||||
<div class="text-sm">${formatDateTime(d.report_date || d.created_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -147,6 +166,7 @@ async function viewReportDetail(id) {
|
||||
<span class="text-sm font-medium">${d.progress_rate || 0}%</span>
|
||||
</div>
|
||||
</div>
|
||||
${workersHtml}
|
||||
<div class="sm:col-span-2">
|
||||
<div class="text-xs text-gray-500 mb-1">작업내용</div>
|
||||
<div class="text-sm whitespace-pre-wrap bg-gray-50 rounded-lg p-3">${escapeHtml(d.work_content || '-')}</div>
|
||||
|
||||
104
tkpurchase/web/workreport-summary.html
Normal file
104
tkpurchase/web/workreport-summary.html
Normal file
@@ -0,0 +1,104 @@
|
||||
<!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-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
|
||||
<div class="flex gap-6">
|
||||
<!-- Sidebar Nav -->
|
||||
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-48 flex-shrink-0 pt-2"></nav>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 필터 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-4 mb-5">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">업체</label>
|
||||
<select id="filterCompany" class="input-field px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">프로젝트(일정)</label>
|
||||
<select id="filterSchedule" class="input-field px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<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="loadSummary()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200">
|
||||
<i class="fas fa-search mr-1"></i>조회
|
||||
</button>
|
||||
<button onclick="exportExcel()" class="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm hover:bg-emerald-700">
|
||||
<i class="fas fa-file-excel mr-1"></i>엑셀 다운로드
|
||||
</button>
|
||||
<a href="/workreport.html" class="px-4 py-2 bg-gray-50 text-gray-600 rounded-lg text-sm hover:bg-gray-100 border border-gray-200">
|
||||
<i class="fas fa-arrow-left mr-1"></i>업무현황
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 종합 테이블 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4">
|
||||
<i class="fas fa-chart-bar text-purple-500 mr-2"></i>업무현황 종합
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="visit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>보고일</th>
|
||||
<th>업체</th>
|
||||
<th>작업장</th>
|
||||
<th class="text-center">보고건수</th>
|
||||
<th class="text-center">투입인원</th>
|
||||
<th class="text-center">총 작업시간</th>
|
||||
<th class="text-center">평균진행률</th>
|
||||
<th class="text-center">확인현황</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="summaryTableBody">
|
||||
<tr><td colspan="8" class="text-center text-gray-400 py-8">조회 조건을 선택하세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tkpurchase-core.js?v=20260313"></script>
|
||||
<script src="/static/js/tkpurchase-workreport-summary.js?v=20260313"></script>
|
||||
<script>initSummaryPage();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -61,6 +61,9 @@
|
||||
<button onclick="loadReports()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200">
|
||||
<i class="fas fa-search mr-1"></i>조회
|
||||
</button>
|
||||
<a href="/workreport-summary.html" class="px-4 py-2 bg-purple-50 text-purple-700 rounded-lg text-sm hover:bg-purple-100 border border-purple-200">
|
||||
<i class="fas fa-chart-bar mr-1"></i>종합
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,6 +77,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>보고일</th>
|
||||
<th class="text-center hide-mobile">보고#</th>
|
||||
<th>업체</th>
|
||||
<th>작업내용</th>
|
||||
<th class="text-center">실투입</th>
|
||||
@@ -84,7 +88,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reportTableBody">
|
||||
<tr><td colspan="8" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||
<tr><td colspan="9" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user