feat(tkpurchase): 협력업체 작업 신청 기능 추가

협력업체 포탈에서 오늘 일정이 없을 때 직접 작업을 신청할 수 있는 기능.
구매팀이 승인하면 일정이 생성되고, 반려 시 재신청 가능.

- DB: status ENUM에 requested/rejected 추가, requested_by 컬럼 추가
- API: POST /schedules/request, PUT /:id/approve, PUT /:id/reject
- 포탈: 신청 폼 + 승인 대기/반려 상태 카드
- 관리자: 신청 배지 + 승인 모달 (프로젝트 배정, 작업장 보정)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-13 14:40:20 +09:00
parent 03119a0849
commit 0211889636
9 changed files with 352 additions and 17 deletions

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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;

View File

@@ -48,10 +48,36 @@
<p class="text-gray-400 text-center py-8 text-sm">로딩 중...</p>
</div>
<!-- 일정 없을 때 -->
<div id="noScheduleMessage" class="hidden bg-white rounded-xl shadow-sm p-8 text-center">
<i class="fas fa-calendar-times text-gray-300 text-4xl mb-3"></i>
<p class="text-gray-500">오늘 예정된 작업 일정이 없습니다.</p>
<!-- 신청 대기 / 반려 카드 영역 -->
<div id="requestCards" class="hidden space-y-4"></div>
<!-- 작업 신청 폼 -->
<div id="workRequestForm" class="hidden bg-white rounded-xl shadow-sm p-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">
<i class="fas fa-paper-plane text-emerald-500 mr-2"></i>작업 신청
</h3>
<p class="text-sm text-gray-500 mb-4">오늘 예정된 작업 일정이 없습니다. 작업이 필요하시면 아래에서 신청해주세요.</p>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">작업일 <span class="text-red-400">*</span></label>
<input type="date" id="reqStartDate" 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="reqExpectedWorkers" min="1" value="1" 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">작업 내용 <span class="text-red-400">*</span></label>
<textarea id="reqWorkDescription" 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>
<input type="text" id="reqWorkplaceName" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="작업 장소">
</div>
<button onclick="doWorkRequest()" class="w-full px-4 py-3 bg-emerald-600 text-white rounded-lg text-sm hover:bg-emerald-700 font-medium">
<i class="fas fa-paper-plane mr-1"></i>작업 신청
</button>
</div>
</div>
</div>

View File

@@ -52,10 +52,12 @@
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
<select id="filterStatus" class="input-field px-3 py-2 rounded-lg text-sm">
<option value="">전체</option>
<option value="requested">신청</option>
<option value="scheduled">예정</option>
<option value="in_progress">진행중</option>
<option value="completed">완료</option>
<option value="cancelled">취소</option>
<option value="rejected">반려</option>
</select>
</div>
<button onclick="loadSchedules()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200">
@@ -224,6 +226,51 @@
</div>
</div>
<!-- 승인 모달 -->
<div id="approveModal" class="hidden modal-overlay" onclick="if(event.target===this)closeApproveModal()">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">작업 신청 승인</h3>
<button onclick="closeApproveModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<input type="hidden" id="approveScheduleId">
<div class="space-y-3">
<div class="bg-amber-50 rounded-lg p-4 text-sm">
<div class="font-medium text-amber-800 mb-2"><i class="fas fa-info-circle mr-1"></i>신청 정보</div>
<div id="approveInfo" class="space-y-1 text-gray-700"></div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">프로젝트 배정</label>
<select id="approveProject" class="input-field w-full 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="text" id="approveWorkplace" class="input-field w-full px-3 py-2 rounded-lg text-sm">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">작업일</label>
<input type="date" id="approveStartDate" 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="date" id="approveEndDate" class="input-field w-full px-3 py-2 rounded-lg text-sm">
</div>
</div>
</div>
<div class="flex justify-end mt-4 gap-2">
<button onclick="doReject()" class="px-4 py-2 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600">
<i class="fas fa-times mr-1"></i>반려
</button>
<button onclick="doApprove()" class="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm hover:bg-emerald-700">
<i class="fas fa-check mr-1"></i>승인
</button>
</div>
</div>
</div>
<script src="/static/js/tkpurchase-core.js?v=20260313"></script>
<script src="/static/js/tkpurchase-schedule.js?v=20260313"></script>
<script>initSchedulePage();</script>

View File

@@ -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 */

View File

@@ -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 `<div class="bg-white rounded-xl shadow-sm overflow-hidden border ${isRejected ? 'border-red-100' : 'border-amber-100'}">
<div class="p-5">
<div class="flex items-center justify-between mb-2">
<h3 class="text-base font-semibold text-gray-800">${escapeHtml(r.workplace_name || '작업장 미지정')}</h3>
<span class="text-xs ${statusTextClass} font-medium px-2 py-1 rounded-full ${statusBg}">
<i class="fas ${statusIcon} mr-1"></i>${statusText}
</span>
</div>
<div class="text-sm text-gray-600 mb-2">${escapeHtml(r.work_description || '')}</div>
<div class="flex gap-4 text-xs text-gray-500">
<span><i class="fas fa-calendar mr-1"></i>${formatDate(r.start_date)}</span>
<span><i class="fas fa-users mr-1"></i>예상 ${r.expected_workers || 0}명</span>
</div>
</div>
</div>`;
}).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;

View File

@@ -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 `<tr>
return `<tr${isRequest ? ' class="bg-amber-50"' : ''}>
<td class="font-medium">${escapeHtml(s.company_name || '')}</td>
<td class="whitespace-nowrap">${formatDateRange(s.start_date, s.end_date)}</td>
<td class="text-xs text-gray-500">${escapeHtml(projectLabel)}</td>
@@ -86,6 +89,7 @@ function renderScheduleTable(list, total) {
<td class="text-center">${s.expected_workers || 0}명</td>
<td><span class="badge ${cls}">${label}</span></td>
<td class="text-right">
${isRequest ? `<button onclick="openApproveModal(${s.id})" class="text-amber-600 hover:text-amber-800 text-xs mr-1 font-medium" title="승인/반려"><i class="fas fa-check-circle mr-1"></i>처리</button>` : ''}
${(s.status === 'in_progress' || s.status === 'completed') ? `<button onclick="viewCheckins(${s.id})" class="text-emerald-600 hover:text-emerald-800 text-xs mr-1" title="체크인 현황"><i class="fas fa-clipboard-check"></i></button>` : ''}
${canEdit ? `<button onclick="openEditSchedule(${s.id})" class="text-blue-600 hover:text-blue-800 text-xs mr-1" title="수정"><i class="fas fa-edit"></i></button>
<button onclick="deleteSchedule(${s.id})" class="text-red-500 hover:text-red-700 text-xs" title="삭제"><i class="fas fa-trash"></i></button>` : ''}
@@ -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 = `
<div><strong>업체:</strong> ${escapeHtml(s.company_name || '')}</div>
<div><strong>작업일:</strong> ${formatDate(s.start_date)}</div>
<div><strong>예상 인원:</strong> ${s.expected_workers || 0}명</div>
<div><strong>작업 내용:</strong> ${escapeHtml(s.work_description || '-')}</div>
<div><strong>작업장:</strong> ${escapeHtml(s.workplace_name || '-')}</div>
`;
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;