From 0211889636da4fbfd858a67a3c40781367cf54fb Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 13 Mar 2026 14:40:20 +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=EC=9E=91=EC=97=85=20=EC=8B=A0=EC=B2=AD=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 협력업체 포탈에서 오늘 일정이 없을 때 직접 작업을 신청할 수 있는 기능. 구매팀이 승인하면 일정이 생성되고, 반려 시 재신청 가능. - DB: status ENUM에 requested/rejected 추가, requested_by 컬럼 추가 - API: POST /schedules/request, PUT /:id/approve, PUT /:id/reject - 포탈: 신청 폼 + 승인 대기/반려 상태 카드 - 관리자: 신청 배지 + 승인 모달 (프로젝트 배정, 작업장 보정) Co-Authored-By: Claude Opus 4.6 --- scripts/migration-purchase-safety-patch2.sql | 10 ++ .../api/controllers/scheduleController.js | 96 ++++++++++++++++++- tkpurchase/api/models/scheduleModel.js | 22 ++++- tkpurchase/api/routes/scheduleRoutes.js | 3 + tkpurchase/web/partner-portal.html | 34 ++++++- tkpurchase/web/schedule.html | 47 +++++++++ tkpurchase/web/static/css/tkpurchase.css | 1 + .../static/js/tkpurchase-partner-portal.js | 82 +++++++++++++++- .../web/static/js/tkpurchase-schedule.js | 74 +++++++++++++- 9 files changed, 352 insertions(+), 17 deletions(-) create mode 100644 scripts/migration-purchase-safety-patch2.sql diff --git a/scripts/migration-purchase-safety-patch2.sql b/scripts/migration-purchase-safety-patch2.sql new file mode 100644 index 0000000..35553d2 --- /dev/null +++ b/scripts/migration-purchase-safety-patch2.sql @@ -0,0 +1,10 @@ +-- migration-purchase-safety-patch2.sql +-- 협력업체 작업 신청 기능: status ENUM 확장 + requested_by 필드 추가 + +-- status ENUM에 requested, rejected 추가 (기존 값 모두 포함, DEFAULT 'scheduled' 유지) +ALTER TABLE partner_schedules + MODIFY COLUMN status ENUM('requested','scheduled','in_progress','completed','cancelled','rejected') + NOT NULL DEFAULT 'scheduled'; + +-- 신청자 필드 추가 +ALTER TABLE partner_schedules ADD COLUMN requested_by INT NULL AFTER registered_by; diff --git a/tkpurchase/api/controllers/scheduleController.js b/tkpurchase/api/controllers/scheduleController.js index ba1ac98..7502f5a 100644 --- a/tkpurchase/api/controllers/scheduleController.js +++ b/tkpurchase/api/controllers/scheduleController.js @@ -40,14 +40,104 @@ async function mySchedules(req, res) { if (!companyId) { return res.status(403).json({ success: false, error: '협력업체 계정이 아닙니다' }); } - const rows = await scheduleModel.findByCompanyToday(companyId); - res.json({ success: true, data: rows }); + const [schedules, requests] = await Promise.all([ + scheduleModel.findByCompanyToday(companyId), + scheduleModel.findRequestsByCompany(companyId) + ]); + res.json({ success: true, data: { schedules, requests } }); } catch (err) { console.error('Schedule mySchedules error:', err); res.status(500).json({ success: false, error: err.message }); } } +// 협력업체 작업 신청 +async function requestSchedule(req, res) { + try { + const companyId = req.user.partner_company_id; + if (!companyId) { + return res.status(403).json({ success: false, error: '협력업체 계정이 아닙니다' }); + } + const { start_date, work_description, workplace_name, expected_workers } = req.body; + if (!start_date) { + return res.status(400).json({ success: false, error: '작업일은 필수입니다' }); + } + const data = { + company_id: companyId, + project_id: null, + start_date, + end_date: start_date, + work_description: work_description || null, + workplace_name: workplace_name || null, + expected_workers: expected_workers || null, + requested_by: req.user.user_id || req.user.id, + status: 'requested' + }; + const row = await scheduleModel.create(data); + res.status(201).json({ success: true, data: row }); + } catch (err) { + console.error('Schedule requestSchedule error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 구매팀 승인 +async function approveRequest(req, res) { + try { + const schedule = await scheduleModel.findById(req.params.id); + if (!schedule) return res.status(404).json({ success: false, error: '일정을 찾을 수 없습니다' }); + if (schedule.status !== 'requested') { + return res.status(400).json({ success: false, error: '신청 상태가 아닙니다' }); + } + const updateData = { + status: 'scheduled', + registered_by: req.user.user_id || req.user.id + }; + if (req.body.project_id !== undefined) updateData.project_id = req.body.project_id; + if (req.body.workplace_name !== undefined) updateData.workplace_name = req.body.workplace_name; + if (req.body.start_date) updateData.start_date = req.body.start_date; + if (req.body.end_date) updateData.end_date = req.body.end_date; + if (req.body.expected_workers !== undefined) updateData.expected_workers = req.body.expected_workers; + if (req.body.work_description !== undefined) updateData.work_description = req.body.work_description; + + // update() handles dynamic fields including status and registered_by via the fields map + // But registered_by is not in the update() fields list, so use raw query for it + const db = require('../models/partnerModel').getPool(); + const fields = ['status = ?', 'registered_by = ?']; + const values = ['scheduled', updateData.registered_by]; + if (updateData.project_id !== undefined) { fields.push('project_id = ?'); values.push(updateData.project_id || null); } + if (updateData.workplace_name !== undefined) { fields.push('workplace_name = ?'); values.push(updateData.workplace_name || null); } + if (updateData.start_date) { fields.push('start_date = ?'); values.push(updateData.start_date); } + if (updateData.end_date) { fields.push('end_date = ?'); values.push(updateData.end_date); } + if (updateData.expected_workers !== undefined) { fields.push('expected_workers = ?'); values.push(updateData.expected_workers || null); } + if (updateData.work_description !== undefined) { fields.push('work_description = ?'); values.push(updateData.work_description || null); } + values.push(req.params.id); + await db.query(`UPDATE partner_schedules SET ${fields.join(', ')} WHERE id = ?`, values); + + const row = await scheduleModel.findById(req.params.id); + res.json({ success: true, data: row }); + } catch (err) { + console.error('Schedule approveRequest error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + +// 구매팀 반려 +async function rejectRequest(req, res) { + try { + const schedule = await scheduleModel.findById(req.params.id); + if (!schedule) return res.status(404).json({ success: false, error: '일정을 찾을 수 없습니다' }); + if (schedule.status !== 'requested') { + return res.status(400).json({ success: false, error: '신청 상태가 아닙니다' }); + } + const row = await scheduleModel.updateStatus(req.params.id, 'rejected'); + res.json({ success: true, data: row }); + } catch (err) { + console.error('Schedule rejectRequest error:', err); + res.status(500).json({ success: false, error: err.message }); + } +} + // 일정 등록 async function create(req, res) { try { @@ -119,4 +209,4 @@ async function deleteSchedule(req, res) { } } -module.exports = { list, getById, mySchedules, create, update, updateStatus, deleteSchedule }; +module.exports = { list, getById, mySchedules, create, update, updateStatus, deleteSchedule, requestSchedule, approveRequest, rejectRequest }; diff --git a/tkpurchase/api/models/scheduleModel.js b/tkpurchase/api/models/scheduleModel.js index 20de57a..b271873 100644 --- a/tkpurchase/api/models/scheduleModel.js +++ b/tkpurchase/api/models/scheduleModel.js @@ -45,6 +45,19 @@ async function findByCompanyToday(companyId) { LEFT JOIN partner_companies pc ON ps.company_id = pc.id LEFT JOIN projects p ON ps.project_id = p.project_id WHERE ps.company_id = ? AND ps.start_date <= CURDATE() AND ps.end_date >= CURDATE() + AND ps.status NOT IN ('cancelled','rejected','requested') + ORDER BY ps.created_at DESC`, [companyId]); + return rows; +} + +async function findRequestsByCompany(companyId) { + const db = getPool(); + const [rows] = await db.query( + `SELECT ps.*, pc.company_name, su.name AS requested_by_name + FROM partner_schedules ps + LEFT JOIN partner_companies pc ON ps.company_id = pc.id + LEFT JOIN sso_users su ON ps.requested_by = su.user_id + WHERE ps.company_id = ? AND ps.status IN ('requested','rejected') ORDER BY ps.created_at DESC`, [companyId]); return rows; } @@ -63,13 +76,14 @@ async function findByProject(projectId) { async function create(data) { const db = getPool(); const [result] = await db.query( - `INSERT INTO partner_schedules (company_id, project_id, start_date, end_date, work_description, workplace_name, expected_workers, registered_by, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO partner_schedules (company_id, project_id, start_date, end_date, work_description, workplace_name, expected_workers, registered_by, requested_by, status, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [data.company_id, data.project_id || null, data.start_date, data.end_date || data.start_date, data.work_description || null, data.workplace_name || null, data.expected_workers || null, - data.registered_by, data.notes || null]); + data.registered_by || null, data.requested_by || null, + data.status || 'scheduled', data.notes || null]); return findById(result.insertId); } @@ -103,4 +117,4 @@ async function deleteSchedule(id) { await db.query('DELETE FROM partner_schedules WHERE id = ?', [id]); } -module.exports = { findAll, findById, findByCompanyToday, findByProject, create, update, updateStatus, deleteSchedule }; +module.exports = { findAll, findById, findByCompanyToday, findRequestsByCompany, findByProject, create, update, updateStatus, deleteSchedule }; diff --git a/tkpurchase/api/routes/scheduleRoutes.js b/tkpurchase/api/routes/scheduleRoutes.js index 33feb92..da4da61 100644 --- a/tkpurchase/api/routes/scheduleRoutes.js +++ b/tkpurchase/api/routes/scheduleRoutes.js @@ -9,8 +9,11 @@ router.get('/', ctrl.list); router.get('/my', ctrl.mySchedules); // partner portal router.get('/:id', ctrl.getById); router.post('/', requirePage('purchasing_schedule'), ctrl.create); +router.post('/request', ctrl.requestSchedule); // 협력업체 작업 신청 router.put('/:id', requirePage('purchasing_schedule'), ctrl.update); router.put('/:id/status', requirePage('purchasing_schedule'), ctrl.updateStatus); +router.put('/:id/approve', requirePage('purchasing_schedule'), ctrl.approveRequest); +router.put('/:id/reject', requirePage('purchasing_schedule'), ctrl.rejectRequest); router.delete('/:id', requirePage('purchasing_schedule'), ctrl.deleteSchedule); module.exports = router; diff --git a/tkpurchase/web/partner-portal.html b/tkpurchase/web/partner-portal.html index 537c00e..a79943e 100644 --- a/tkpurchase/web/partner-portal.html +++ b/tkpurchase/web/partner-portal.html @@ -48,10 +48,36 @@

로딩 중...

- - diff --git a/tkpurchase/web/schedule.html b/tkpurchase/web/schedule.html index 0b20ab7..3575d9d 100644 --- a/tkpurchase/web/schedule.html +++ b/tkpurchase/web/schedule.html @@ -52,10 +52,12 @@ + + +
+
+
신청 정보
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+ + + diff --git a/tkpurchase/web/static/css/tkpurchase.css b/tkpurchase/web/static/css/tkpurchase.css index dd95b10..c558ad7 100644 --- a/tkpurchase/web/static/css/tkpurchase.css +++ b/tkpurchase/web/static/css/tkpurchase.css @@ -30,6 +30,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b .badge-blue { background: #eff6ff; color: #2563eb; } .badge-amber { background: #fffbeb; color: #d97706; } .badge-red { background: #fef2f2; color: #dc2626; } +.badge-orange { background: #fff7ed; color: #ea580c; } .badge-gray { background: #f3f4f6; color: #6b7280; } /* Purpose badges */ diff --git a/tkpurchase/web/static/js/tkpurchase-partner-portal.js b/tkpurchase/web/static/js/tkpurchase-partner-portal.js index acc9e97..9b134da 100644 --- a/tkpurchase/web/static/js/tkpurchase-partner-portal.js +++ b/tkpurchase/web/static/js/tkpurchase-partner-portal.js @@ -1,6 +1,7 @@ /* tkpurchase-partner-portal.js - Partner portal logic (2-step flow) */ let portalSchedules = []; +let portalRequests = []; let portalCheckins = {}; let partnerCompanyId = null; let companyWorkersCache = null; @@ -8,10 +9,13 @@ let companyWorkersCache = null; async function loadMySchedules() { try { const r = await api('/schedules/my'); - portalSchedules = r.data || []; + const data = r.data || {}; + portalSchedules = data.schedules || []; + portalRequests = data.requests || []; } catch(e) { console.warn('Load schedules error:', e); portalSchedules = []; + portalRequests = []; } } @@ -46,15 +50,59 @@ async function renderScheduleCards() { await Promise.all([loadMySchedules(), loadMyCheckins()]); const container = document.getElementById('scheduleCards'); - const noMsg = document.getElementById('noScheduleMessage'); + const requestCardsEl = document.getElementById('requestCards'); + const workRequestFormEl = document.getElementById('workRequestForm'); if (!portalSchedules.length) { container.innerHTML = ''; - noMsg.classList.remove('hidden'); + // 신청 건 표시 + if (portalRequests.length) { + requestCardsEl.classList.remove('hidden'); + requestCardsEl.innerHTML = portalRequests.map(r => { + const isRejected = r.status === 'rejected'; + const statusBg = isRejected ? 'bg-red-50 border-red-200' : 'bg-amber-50 border-amber-200'; + const statusIcon = isRejected ? 'fa-times-circle text-red-400' : 'fa-clock text-amber-400'; + const statusText = isRejected ? '반려됨' : '승인 대기 중'; + const statusTextClass = isRejected ? 'text-red-600' : 'text-amber-600'; + return `
+
+
+

${escapeHtml(r.workplace_name || '작업장 미지정')}

+ + ${statusText} + +
+
${escapeHtml(r.work_description || '')}
+
+ ${formatDate(r.start_date)} + 예상 ${r.expected_workers || 0}명 +
+
+
`; + }).join(''); + // 반려 건만 있으면 재신청 폼도 표시 + const hasOnlyRejected = portalRequests.every(r => r.status === 'rejected'); + if (hasOnlyRejected) { + workRequestFormEl.classList.remove('hidden'); + workRequestFormEl.querySelector('p').textContent = '반려된 신청 건이 있습니다. 필요시 재신청해주세요.'; + } else { + workRequestFormEl.classList.add('hidden'); + } + } else { + requestCardsEl.classList.add('hidden'); + workRequestFormEl.classList.remove('hidden'); + workRequestFormEl.querySelector('p').textContent = '오늘 예정된 작업 일정이 없습니다. 작업이 필요하시면 아래에서 신청해주세요.'; + } + // 기본 날짜 설정 + const today = new Date().toISOString().substring(0, 10); + const reqDate = document.getElementById('reqStartDate'); + if (reqDate && !reqDate.value) reqDate.value = today; return; } - noMsg.classList.add('hidden'); + // 오늘 일정 있으면 기존 카드 렌더 + requestCardsEl.classList.add('hidden'); + workRequestFormEl.classList.add('hidden'); container.innerHTML = portalSchedules.map(s => { const checkin = portalCheckins[s.id]; @@ -303,6 +351,32 @@ async function doCheckIn(scheduleId) { } } +async function doWorkRequest() { + const startDate = document.getElementById('reqStartDate').value; + if (!startDate) { showToast('작업일을 선택하세요', 'error'); return; } + const workDescription = document.getElementById('reqWorkDescription').value.trim(); + if (!workDescription) { showToast('작업 내용을 입력하세요', 'error'); return; } + + const body = { + start_date: startDate, + expected_workers: parseInt(document.getElementById('reqExpectedWorkers').value) || 1, + work_description: workDescription, + workplace_name: document.getElementById('reqWorkplaceName').value.trim() || null + }; + + try { + await api('/schedules/request', { method: 'POST', body: JSON.stringify(body) }); + showToast('작업 신청이 완료되었습니다'); + // 폼 초기화 + document.getElementById('reqWorkDescription').value = ''; + document.getElementById('reqWorkplaceName').value = ''; + document.getElementById('reqExpectedWorkers').value = '1'; + renderScheduleCards(); + } catch(e) { + showToast(e.message || '작업 신청 실패', 'error'); + } +} + function initPartnerPortal() { if (!initAuth()) return; diff --git a/tkpurchase/web/static/js/tkpurchase-schedule.js b/tkpurchase/web/static/js/tkpurchase-schedule.js index e665bd2..9652b13 100644 --- a/tkpurchase/web/static/js/tkpurchase-schedule.js +++ b/tkpurchase/web/static/js/tkpurchase-schedule.js @@ -67,17 +67,20 @@ function renderScheduleTable(list, total) { } const statusMap = { + requested: ['badge-orange', '신청'], scheduled: ['badge-amber', '예정'], in_progress: ['badge-green', '진행중'], completed: ['badge-blue', '완료'], - cancelled: ['badge-gray', '취소'] + cancelled: ['badge-gray', '취소'], + rejected: ['badge-red', '반려'] }; tbody.innerHTML = list.map(s => { const [cls, label] = statusMap[s.status] || ['badge-gray', s.status]; const canEdit = s.status === 'scheduled'; + const isRequest = s.status === 'requested'; const projectLabel = s.project_name ? (s.job_no ? `[${s.job_no}] ${s.project_name}` : s.project_name) : ''; - return ` + return ` ${escapeHtml(s.company_name || '')} ${formatDateRange(s.start_date, s.end_date)} ${escapeHtml(projectLabel)} @@ -86,6 +89,7 @@ function renderScheduleTable(list, total) { ${s.expected_workers || 0}명 ${label} + ${isRequest ? `` : ''} ${(s.status === 'in_progress' || s.status === 'completed') ? `` : ''} ${canEdit ? ` ` : ''} @@ -368,6 +372,72 @@ async function editCheckinWorkers(checkinId) { } } +/* ===== Approve/Reject Request ===== */ +async function openApproveModal(id) { + try { + const r = await api('/schedules/' + id); + const s = r.data || r; + scheduleCache[id] = s; + + document.getElementById('approveScheduleId').value = id; + document.getElementById('approveInfo').innerHTML = ` +
업체: ${escapeHtml(s.company_name || '')}
+
작업일: ${formatDate(s.start_date)}
+
예상 인원: ${s.expected_workers || 0}명
+
작업 내용: ${escapeHtml(s.work_description || '-')}
+
작업장: ${escapeHtml(s.workplace_name || '-')}
+ `; + document.getElementById('approveWorkplace').value = s.workplace_name || ''; + document.getElementById('approveStartDate').value = formatDate(s.start_date); + document.getElementById('approveEndDate').value = formatDate(s.end_date); + await loadProjects(); + populateProjectDropdown('approveProject', ''); + document.getElementById('approveModal').classList.remove('hidden'); + } catch(e) { + showToast('신청 정보를 불러올 수 없습니다', 'error'); + } +} + +function closeApproveModal() { + document.getElementById('approveModal').classList.add('hidden'); +} + +async function doApprove() { + const id = document.getElementById('approveScheduleId').value; + if (!confirm('이 작업 신청을 승인하시겠습니까?')) return; + + const projectId = document.getElementById('approveProject').value; + const body = { + project_id: projectId ? parseInt(projectId) : null, + workplace_name: document.getElementById('approveWorkplace').value.trim(), + start_date: document.getElementById('approveStartDate').value, + end_date: document.getElementById('approveEndDate').value + }; + + try { + await api('/schedules/' + id + '/approve', { method: 'PUT', body: JSON.stringify(body) }); + showToast('승인 완료'); + closeApproveModal(); + loadSchedules(); + } catch(e) { + showToast(e.message || '승인 실패', 'error'); + } +} + +async function doReject() { + const id = document.getElementById('approveScheduleId').value; + if (!confirm('이 작업 신청을 반려하시겠습니까?')) return; + + try { + await api('/schedules/' + id + '/reject', { method: 'PUT' }); + showToast('반려 완료'); + closeApproveModal(); + loadSchedules(); + } catch(e) { + showToast(e.message || '반려 실패', 'error'); + } +} + /* ===== Init ===== */ function initSchedulePage() { if (!initAuth()) return;