fix(proxy-input): worker_id→user_id 수정 + 공통 입력 UI로 변경
백엔드: - proxyInputModel 전체 worker_id→user_id 전환 (작업보고서/휴가 매핑 실패 → 전부 미입력으로 표시되던 문제) 프론트: - 개별 입력 → 공통 입력 1개로 전환 프로젝트/공종/시간/부적합 한번 입력 → 선택된 전원에 적용 - 부서별 그룹핑 표시 - 적용 대상 칩 표시 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ const ProxyInputModel = {
|
||||
SELECT ta.user_id, w.worker_name, ta.session_id
|
||||
FROM tbm_team_assignments ta
|
||||
JOIN tbm_sessions s ON ta.session_id = s.session_id
|
||||
JOIN workers w ON ta.user_id = w.worker_id
|
||||
JOIN workers w ON ta.user_id = w.user_id
|
||||
WHERE s.session_date = ? AND ta.user_id IN (${placeholders}) AND s.status != 'cancelled'
|
||||
`, [sessionDate, ...userIds]);
|
||||
return rows;
|
||||
@@ -27,9 +27,9 @@ const ProxyInputModel = {
|
||||
if (!userIds.length) return [];
|
||||
const placeholders = userIds.map(() => '?').join(',');
|
||||
const [rows] = await conn.query(`
|
||||
SELECT worker_id FROM workers WHERE worker_id IN (${placeholders}) AND status = 'active'
|
||||
SELECT user_id FROM workers WHERE user_id IN (${placeholders}) AND status = 'active'
|
||||
`, [...userIds]);
|
||||
return rows.map(r => r.worker_id);
|
||||
return rows.map(r => r.user_id);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -73,11 +73,11 @@ const ProxyInputModel = {
|
||||
|
||||
// 1. 활성 작업자
|
||||
const [workers] = await db.query(`
|
||||
SELECT w.worker_id AS user_id, w.worker_name, w.job_type,
|
||||
SELECT w.user_id, w.worker_name, w.job_type,
|
||||
COALESCE(d.department_name, '미배정') AS department_name
|
||||
FROM workers w
|
||||
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||
WHERE w.status = 'active'
|
||||
WHERE w.status = 'active' AND w.user_id IS NOT NULL
|
||||
ORDER BY w.worker_name
|
||||
`);
|
||||
|
||||
@@ -87,7 +87,7 @@ const ProxyInputModel = {
|
||||
lu.worker_name AS leader_name, s.is_proxy_input
|
||||
FROM tbm_team_assignments ta
|
||||
JOIN tbm_sessions s ON ta.session_id = s.session_id
|
||||
LEFT JOIN workers lu ON s.leader_user_id = lu.worker_id
|
||||
LEFT JOIN workers lu ON s.leader_user_id = lu.user_id
|
||||
WHERE s.session_date = ? AND s.status != 'cancelled'
|
||||
`, [date]);
|
||||
|
||||
@@ -191,11 +191,11 @@ const ProxyInputModel = {
|
||||
|
||||
// 작업자 정보
|
||||
const [workerRows] = await db.query(`
|
||||
SELECT w.worker_id AS user_id, w.worker_name, w.job_type,
|
||||
SELECT w.user_id, w.worker_name, w.job_type,
|
||||
COALESCE(d.department_name, '미배정') AS department_name
|
||||
FROM workers w
|
||||
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||
WHERE w.worker_id = ?
|
||||
WHERE w.user_id = ?
|
||||
`, [userId]);
|
||||
|
||||
// TBM 세션
|
||||
@@ -206,7 +206,7 @@ const ProxyInputModel = {
|
||||
p.project_name, wt.work_type_name, ta.work_hours
|
||||
FROM tbm_team_assignments ta
|
||||
JOIN tbm_sessions s ON ta.session_id = s.session_id
|
||||
LEFT JOIN workers lu ON s.leader_user_id = lu.worker_id
|
||||
LEFT JOIN workers lu ON s.leader_user_id = lu.user_id
|
||||
LEFT JOIN sso_users pu ON s.proxy_input_by = pu.user_id
|
||||
LEFT JOIN projects p ON ta.project_id = p.project_id
|
||||
LEFT JOIN work_types wt ON ta.work_type_id = wt.work_type_id
|
||||
|
||||
@@ -60,6 +60,24 @@
|
||||
.pi-skeleton { height: 52px; border-radius: 10px; background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); background-size: 200% 100%; animation: pi-shimmer 1.5s infinite; }
|
||||
@keyframes pi-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
|
||||
/* Department Label */
|
||||
.pi-dept-label { font-size: 11px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 2px 4px; }
|
||||
|
||||
/* Bulk Form */
|
||||
.pi-bulk-form { background: white; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); display: flex; flex-direction: column; gap: 8px; }
|
||||
.pi-edit-row { display: flex; gap: 8px; }
|
||||
.pi-select { flex: 1; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; background: white; }
|
||||
.pi-field { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.pi-field span { font-size: 11px; color: #6b7280; font-weight: 600; }
|
||||
.pi-input { padding: 8px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 15px; text-align: center; font-weight: 600; }
|
||||
.pi-note-input { width: 100%; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 13px; }
|
||||
|
||||
/* Target Section */
|
||||
.pi-target-section { background: white; border-radius: 12px; padding: 12px; margin-bottom: 80px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
|
||||
.pi-target-label { font-size: 12px; font-weight: 700; color: #6b7280; margin-bottom: 8px; }
|
||||
.pi-target-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.pi-target-chip { font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 20px; background: #dbeafe; color: #1e40af; }
|
||||
|
||||
/* Empty */
|
||||
.pi-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 14px; }
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* proxy-input.js — 대리입력 리뉴얼
|
||||
* Step 1: 날짜 선택 → 작업자 목록 (체크박스)
|
||||
* Step 2: 선택 작업자 일괄 편집 → 저장 (TBM 자동 생성)
|
||||
* Step 2: 공통 입력 1개 → 선택된 전원 일괄 적용
|
||||
*/
|
||||
|
||||
let currentDate = '';
|
||||
@@ -9,14 +9,11 @@ let allWorkers = [];
|
||||
let selectedIds = new Set();
|
||||
let projects = [];
|
||||
let workTypes = [];
|
||||
let editData = {}; // { userId: { project_id, work_type_id, work_hours, defect_hours, note } }
|
||||
|
||||
// ===== Init =====
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const now = new Date();
|
||||
currentDate = now.toISOString().substring(0, 10);
|
||||
currentDate = new Date().toISOString().substring(0, 10);
|
||||
document.getElementById('dateInput').value = currentDate;
|
||||
|
||||
setTimeout(async () => {
|
||||
await loadDropdownData();
|
||||
await loadWorkers();
|
||||
@@ -50,7 +47,7 @@ async function loadWorkers() {
|
||||
|
||||
allWorkers = res.data.workers || [];
|
||||
const s = res.data.summary || {};
|
||||
document.getElementById('totalNum').textContent = s.total_active_workers || 0;
|
||||
document.getElementById('totalNum').textContent = s.total_active_workers || allWorkers.length;
|
||||
document.getElementById('doneNum').textContent = s.report_completed || 0;
|
||||
document.getElementById('missingNum').textContent = s.report_missing || 0;
|
||||
document.getElementById('vacNum').textContent = allWorkers.filter(w => w.vacation_type_code === 'ANNUAL_FULL').length;
|
||||
@@ -68,27 +65,38 @@ function renderWorkerList() {
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = allWorkers.map(w => {
|
||||
const isFullVac = w.vacation_type_code === 'ANNUAL_FULL';
|
||||
const hasVac = !!w.vacation_type_code;
|
||||
const vacBadge = isFullVac ? '<span class="pi-badge vac">연차</span>'
|
||||
: hasVac ? `<span class="pi-badge vac-half">${esc(w.vacation_type_name)}</span>` : '';
|
||||
const doneBadge = w.has_report ? `<span class="pi-badge done">${w.total_report_hours}h</span>` : '<span class="pi-badge missing">미입력</span>';
|
||||
// 부서별 그룹핑
|
||||
const byDept = {};
|
||||
allWorkers.forEach(w => {
|
||||
const dept = w.department_name || '미배정';
|
||||
if (!byDept[dept]) byDept[dept] = [];
|
||||
byDept[dept].push(w);
|
||||
});
|
||||
|
||||
return `
|
||||
<label class="pi-worker ${isFullVac ? 'disabled' : ''}" data-uid="${w.user_id}">
|
||||
<input type="checkbox" class="pi-check" value="${w.user_id}"
|
||||
${isFullVac ? 'disabled' : ''}
|
||||
onchange="onWorkerCheck(${w.user_id}, this.checked)">
|
||||
<div class="pi-worker-info">
|
||||
<span class="pi-worker-name">${esc(w.worker_name)}</span>
|
||||
<span class="pi-worker-job">${esc(w.job_type || '')}</span>
|
||||
</div>
|
||||
<div class="pi-worker-badges">
|
||||
${vacBadge}${doneBadge}
|
||||
</div>
|
||||
</label>`;
|
||||
}).join('');
|
||||
let html = '';
|
||||
Object.keys(byDept).sort().forEach(dept => {
|
||||
html += `<div class="pi-dept-label">${esc(dept)}</div>`;
|
||||
byDept[dept].forEach(w => {
|
||||
const isFullVac = w.vacation_type_code === 'ANNUAL_FULL';
|
||||
const hasVac = !!w.vacation_type_code;
|
||||
const vacBadge = isFullVac ? '<span class="pi-badge vac">연차</span>'
|
||||
: hasVac ? `<span class="pi-badge vac-half">${esc(w.vacation_type_name)}</span>` : '';
|
||||
const doneBadge = w.has_report ? `<span class="pi-badge done">${w.total_report_hours}h</span>` : '<span class="pi-badge missing">미입력</span>';
|
||||
|
||||
html += `
|
||||
<label class="pi-worker ${isFullVac ? 'disabled' : ''}">
|
||||
<input type="checkbox" class="pi-check" value="${w.user_id}"
|
||||
${isFullVac ? 'disabled' : ''}
|
||||
onchange="onWorkerCheck(${w.user_id}, this.checked)">
|
||||
<div class="pi-worker-info">
|
||||
<span class="pi-worker-name">${esc(w.worker_name)}</span>
|
||||
<span class="pi-worker-job">${esc(w.job_type || '')}</span>
|
||||
</div>
|
||||
<div class="pi-worker-badges">${vacBadge}${doneBadge}</div>
|
||||
</label>`;
|
||||
});
|
||||
});
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
function onWorkerCheck(userId, checked) {
|
||||
@@ -112,61 +120,29 @@ function updateEditButton() {
|
||||
document.getElementById('editBtnText').textContent = n > 0 ? `선택 작업자 편집 (${n}명)` : '작업자를 선택하세요';
|
||||
}
|
||||
|
||||
// ===== Step 2: Edit Mode =====
|
||||
// ===== Step 2: Bulk Edit (공통 입력 1개) =====
|
||||
function openEditMode() {
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
const selected = allWorkers.filter(w => selectedIds.has(w.user_id));
|
||||
editData = {};
|
||||
|
||||
// 기본값 설정
|
||||
selected.forEach(w => {
|
||||
const isHalfVac = w.vacation_type_code === 'ANNUAL_HALF';
|
||||
const isQuarterVac = w.vacation_type_code === 'ANNUAL_QUARTER';
|
||||
editData[w.user_id] = {
|
||||
project_id: '',
|
||||
work_type_id: '',
|
||||
work_hours: isHalfVac ? 4 : isQuarterVac ? 6 : 8,
|
||||
defect_hours: 0,
|
||||
note: '',
|
||||
start_time: '08:00',
|
||||
end_time: isHalfVac ? '12:00' : isQuarterVac ? '14:00' : '17:00',
|
||||
work_status_id: 1
|
||||
};
|
||||
});
|
||||
|
||||
document.getElementById('editTitle').textContent = `일괄 편집 (${selected.length}명)`;
|
||||
|
||||
const projOpts = projects.map(p => `<option value="${p.project_id}">${esc(p.project_name)}</option>`).join('');
|
||||
const typeOpts = workTypes.map(t => `<option value="${t.work_type_id}">${esc(t.work_type_name)}</option>`).join('');
|
||||
// 프로젝트/공종 드롭다운 채우기
|
||||
const projSel = document.getElementById('bulkProject');
|
||||
projSel.innerHTML = '<option value="">프로젝트 선택 *</option>' + projects.map(p => `<option value="${p.project_id}">${esc(p.project_name)}</option>`).join('');
|
||||
|
||||
document.getElementById('editList').innerHTML = selected.map(w => {
|
||||
const d = editData[w.user_id];
|
||||
const vacLabel = w.vacation_type_name ? ` <span class="pi-badge vac-half">${esc(w.vacation_type_name)}</span>` : '';
|
||||
return `
|
||||
<div class="pi-edit-card" data-uid="${w.user_id}">
|
||||
<div class="pi-edit-header">
|
||||
<strong>${esc(w.worker_name)}</strong>
|
||||
<span class="pi-edit-job">${esc(w.job_type || '')}</span>
|
||||
${vacLabel}
|
||||
</div>
|
||||
<div class="pi-edit-fields">
|
||||
<div class="pi-edit-row">
|
||||
<select id="proj_${w.user_id}" class="pi-select" required>
|
||||
<option value="">프로젝트 *</option>${projOpts}
|
||||
</select>
|
||||
<select id="wtype_${w.user_id}" class="pi-select" required>
|
||||
<option value="">공종 *</option>${typeOpts}
|
||||
</select>
|
||||
</div>
|
||||
<div class="pi-edit-row">
|
||||
<label class="pi-field"><span>시간</span><input type="number" id="hours_${w.user_id}" value="${d.work_hours}" step="0.5" min="0" max="24" class="pi-input"></label>
|
||||
<label class="pi-field"><span>부적합</span><input type="number" id="defect_${w.user_id}" value="0" step="0.5" min="0" max="24" class="pi-input"></label>
|
||||
</div>
|
||||
<input type="text" id="note_${w.user_id}" placeholder="비고" class="pi-note-input" value="">
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const typeSel = document.getElementById('bulkWorkType');
|
||||
typeSel.innerHTML = '<option value="">공종 선택 *</option>' + workTypes.map(t => `<option value="${t.work_type_id}">${esc(t.work_type_name)}</option>`).join('');
|
||||
|
||||
// 적용 대상 목록
|
||||
document.getElementById('targetWorkers').innerHTML = selected.map(w =>
|
||||
`<span class="pi-target-chip">${esc(w.worker_name)}</span>`
|
||||
).join('');
|
||||
|
||||
// 기본값
|
||||
document.getElementById('bulkHours').value = '8';
|
||||
document.getElementById('bulkDefect').value = '0';
|
||||
document.getElementById('bulkNote').value = '';
|
||||
|
||||
document.getElementById('step1').classList.add('hidden');
|
||||
document.getElementById('step2').classList.remove('hidden');
|
||||
@@ -179,54 +155,40 @@ function closeEditMode() {
|
||||
|
||||
// ===== Save =====
|
||||
async function saveAll() {
|
||||
const projId = document.getElementById('bulkProject').value;
|
||||
const wtypeId = document.getElementById('bulkWorkType').value;
|
||||
const hours = parseFloat(document.getElementById('bulkHours').value) || 0;
|
||||
const defect = parseFloat(document.getElementById('bulkDefect').value) || 0;
|
||||
const note = document.getElementById('bulkNote').value.trim();
|
||||
|
||||
if (!projId || !wtypeId) {
|
||||
showToast('프로젝트와 공종을 선택하세요', 'error');
|
||||
return;
|
||||
}
|
||||
if (hours <= 0) {
|
||||
showToast('근무시간을 입력하세요', 'error');
|
||||
return;
|
||||
}
|
||||
if (defect > hours) {
|
||||
showToast('부적합 시간이 근무시간을 초과합니다', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('saveBtn');
|
||||
btn.disabled = true;
|
||||
document.getElementById('saveBtnText').textContent = '저장 중...';
|
||||
|
||||
const entries = [];
|
||||
let hasError = false;
|
||||
|
||||
for (const uid of selectedIds) {
|
||||
const projEl = document.getElementById('proj_' + uid);
|
||||
const wtypeEl = document.getElementById('wtype_' + uid);
|
||||
const hoursEl = document.getElementById('hours_' + uid);
|
||||
const defectEl = document.getElementById('defect_' + uid);
|
||||
const noteEl = document.getElementById('note_' + uid);
|
||||
|
||||
if (!projEl?.value || !wtypeEl?.value) {
|
||||
showToast(`프로젝트/공종을 선택하세요`, 'error');
|
||||
projEl?.focus();
|
||||
hasError = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const workHours = parseFloat(hoursEl?.value) || 0;
|
||||
const defectHours = parseFloat(defectEl?.value) || 0;
|
||||
|
||||
if (defectHours > workHours) {
|
||||
showToast('부적합 시간이 근무시간을 초과합니다', 'error');
|
||||
hasError = true;
|
||||
break;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
user_id: uid,
|
||||
project_id: parseInt(projEl.value),
|
||||
work_type_id: parseInt(wtypeEl.value),
|
||||
work_hours: workHours,
|
||||
defect_hours: defectHours,
|
||||
note: noteEl?.value || '',
|
||||
start_time: '08:00',
|
||||
end_time: '17:00',
|
||||
work_status_id: defectHours > 0 ? 2 : 1
|
||||
});
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
btn.disabled = false;
|
||||
document.getElementById('saveBtnText').textContent = '전체 저장';
|
||||
return;
|
||||
}
|
||||
const entries = Array.from(selectedIds).map(uid => ({
|
||||
user_id: uid,
|
||||
project_id: parseInt(projId),
|
||||
work_type_id: parseInt(wtypeId),
|
||||
work_hours: hours,
|
||||
defect_hours: defect,
|
||||
note: note,
|
||||
start_time: '08:00',
|
||||
end_time: '17:00',
|
||||
work_status_id: defect > 0 ? 2 : 1
|
||||
}));
|
||||
|
||||
try {
|
||||
const res = await window.apiCall('/proxy-input', 'POST', {
|
||||
|
||||
@@ -73,7 +73,22 @@
|
||||
<h2 class="pi-title" id="editTitle">일괄 편집</h2>
|
||||
</div>
|
||||
|
||||
<div class="pi-edit-list" id="editList"></div>
|
||||
<div class="pi-bulk-form">
|
||||
<div class="pi-edit-row">
|
||||
<select id="bulkProject" class="pi-select" required></select>
|
||||
<select id="bulkWorkType" class="pi-select" required></select>
|
||||
</div>
|
||||
<div class="pi-edit-row">
|
||||
<label class="pi-field"><span>시간</span><input type="number" id="bulkHours" value="8" step="0.5" min="0" max="24" class="pi-input"></label>
|
||||
<label class="pi-field"><span>부적합</span><input type="number" id="bulkDefect" value="0" step="0.5" min="0" max="24" class="pi-input"></label>
|
||||
</div>
|
||||
<input type="text" id="bulkNote" placeholder="비고 (선택)" class="pi-note-input">
|
||||
</div>
|
||||
|
||||
<div class="pi-target-section">
|
||||
<div class="pi-target-label">적용 대상</div>
|
||||
<div class="pi-target-list" id="targetWorkers"></div>
|
||||
</div>
|
||||
|
||||
<div class="pi-bottom-bar">
|
||||
<button class="pi-save-btn" id="saveBtn" onclick="saveAll()">
|
||||
|
||||
Reference in New Issue
Block a user