177 lines
9.3 KiB
HTML
177 lines
9.3 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>排产日历 - 智能排产系统</title>
|
|||
|
|
<link rel="stylesheet" href="../css/common.css">
|
|||
|
|
<style>
|
|||
|
|
.gantt-wrap { min-height: 320px; }
|
|||
|
|
.gantt-header { display: flex; border-bottom: 1px solid var(--border); background: #fafafa; }
|
|||
|
|
.gantt-header .gantt-label { border-right: 1px solid var(--border); font-weight: 700; }
|
|||
|
|
.gantt-header .gantt-track { display: flex; }
|
|||
|
|
.day-slot { flex: 1; border-right: 1px dashed #e5e7eb; padding: 6px; font-size: 12px; color: var(--text-muted); text-align: center; }
|
|||
|
|
.drag-tip { position: fixed; background: rgba(31,41,55,0.9); color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; pointer-events: none; z-index: 100; display: none; }
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div id="app">
|
|||
|
|
<header class="header" id="header"></header>
|
|||
|
|
<div class="body">
|
|||
|
|
<aside class="sidebar" id="sidebar"></aside>
|
|||
|
|
<main class="main">
|
|||
|
|
<h1 class="page-title">排产日历</h1>
|
|||
|
|
<div class="toolbar">
|
|||
|
|
<button class="btn" onclick="changeDate(-7)">← 上一周</button>
|
|||
|
|
<button class="btn" onclick="changeDate(7)">下一周 →</button>
|
|||
|
|
<span id="rangeLabel" class="text-muted"></span>
|
|||
|
|
<select id="viewLevel" class="btn" style="padding:7px 10px;margin-left:auto" onchange="render()">
|
|||
|
|
<option value="LINE">产线视图</option>
|
|||
|
|
<option value="WORKSTATION">工位视图</option>
|
|||
|
|
</select>
|
|||
|
|
<button class="btn btn-primary" onclick="location.href='scheduling.html'">重新排产</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card gantt-wrap" id="ganttCard">
|
|||
|
|
<div class="gantt-header">
|
|||
|
|
<div class="gantt-label">资源</div>
|
|||
|
|
<div class="gantt-track" id="dayHeader"></div>
|
|||
|
|
</div>
|
|||
|
|
<div id="ganttBody"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-header">图例</div>
|
|||
|
|
<div class="card-body flex gap-3">
|
|||
|
|
<span class="badge badge-primary">正常</span>
|
|||
|
|
<span class="badge badge-danger">冲突</span>
|
|||
|
|
<span class="badge" style="background:#9ca3af;color:#fff">已下发冻结</span>
|
|||
|
|
<span class="badge badge-success">已完成</span>
|
|||
|
|
<span class="text-muted">提示:拖拽工单条可调整时间,红色表示不可放置</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="drag-tip" id="dragTip"></div>
|
|||
|
|
<script src="../js/data.js"></script>
|
|||
|
|
<script src="../js/common.js"></script>
|
|||
|
|
<script>
|
|||
|
|
initPage('calendar');
|
|||
|
|
let viewStart = apsData.$utils.today();
|
|||
|
|
const days = 7;
|
|||
|
|
|
|||
|
|
function changeDate(offset) {
|
|||
|
|
viewStart = apsData.$utils.addMinutes(viewStart, offset * 24 * 60);
|
|||
|
|
render();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function render() {
|
|||
|
|
const level = document.getElementById('viewLevel').value;
|
|||
|
|
const end = apsData.$utils.addMinutes(viewStart, (days - 1) * 24 * 60);
|
|||
|
|
document.getElementById('rangeLabel').textContent = `${apsData.$utils.fmtDate(viewStart)} ~ ${apsData.$utils.fmtDate(end)}`;
|
|||
|
|
const header = document.getElementById('dayHeader');
|
|||
|
|
header.innerHTML = '';
|
|||
|
|
for (let i = 0; i < days; i++) {
|
|||
|
|
const d = apsData.$utils.addMinutes(viewStart, i * 24 * 60);
|
|||
|
|
const div = document.createElement('div'); div.className = 'day-slot'; div.textContent = apsData.$utils.fmtDate(d).slice(5) + ' ' + ['日','一','二','三','四','五','六'][d.getDay()];
|
|||
|
|
header.appendChild(div);
|
|||
|
|
}
|
|||
|
|
const body = document.getElementById('ganttBody');
|
|||
|
|
body.innerHTML = '';
|
|||
|
|
const resources = level === 'WORKSTATION' ? apsData.workstations : apsData.lines;
|
|||
|
|
const trackWidth = header.clientWidth || 800;
|
|||
|
|
const pxPerHour = trackWidth / (days * 24);
|
|||
|
|
const viewStartMs = viewStart.getTime();
|
|||
|
|
|
|||
|
|
resources.forEach(res => {
|
|||
|
|
const row = document.createElement('div'); row.className = 'gantt-row';
|
|||
|
|
const label = document.createElement('div'); label.className = 'gantt-label'; label.textContent = res.name;
|
|||
|
|
const track = document.createElement('div'); track.className = 'gantt-track';
|
|||
|
|
track.style.width = trackWidth + 'px';
|
|||
|
|
for (let i = 0; i < days; i++) {
|
|||
|
|
const slot = document.createElement('div'); slot.className = 'slot'; track.appendChild(slot);
|
|||
|
|
}
|
|||
|
|
const wos = apsData.workOrders.filter(wo => {
|
|||
|
|
if (level === 'WORKSTATION') return wo.workstationId == res.id;
|
|||
|
|
return wo.lineId == res.id;
|
|||
|
|
});
|
|||
|
|
wos.forEach(wo => {
|
|||
|
|
const s = apsData.$utils.parseDate(wo.plannedStartTime);
|
|||
|
|
const e = apsData.$utils.parseDate(wo.plannedEndTime);
|
|||
|
|
const left = ((s.getTime() - viewStartMs) / 3600000) * pxPerHour;
|
|||
|
|
const width = Math.max(((e - s) / 3600000) * pxPerHour, 20);
|
|||
|
|
if (left + width < 0 || left > trackWidth) return;
|
|||
|
|
const bar = document.createElement('div');
|
|||
|
|
bar.className = 'gantt-bar' + (wo.conflictCount ? ' conflict' : '') + (wo.isFrozen ? ' frozen' : '') + (wo.status === 'COMPLETED' ? ' completed' : '');
|
|||
|
|
bar.style.left = Math.max(0, left) + 'px';
|
|||
|
|
bar.style.width = Math.max(width, 20) + 'px';
|
|||
|
|
bar.textContent = `${wo.productName} #${wo.quantity}`;
|
|||
|
|
bar.title = `${wo.orderNo}\n${wo.operationName}\n${wo.plannedStartTime} ~ ${wo.plannedEndTime}`;
|
|||
|
|
bar.onclick = (ev) => { ev.stopPropagation(); openWoDetail(wo); };
|
|||
|
|
makeDraggable(bar, wo, pxPerHour, viewStartMs);
|
|||
|
|
track.appendChild(bar);
|
|||
|
|
});
|
|||
|
|
row.appendChild(label); row.appendChild(track); body.appendChild(row);
|
|||
|
|
});
|
|||
|
|
if (!resources.length) body.innerHTML = '<div class="empty-state">暂无资源数据</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function makeDraggable(bar, wo, pxPerHour, viewStartMs) {
|
|||
|
|
let startX, startLeft, dragging = false;
|
|||
|
|
bar.addEventListener('mousedown', e => {
|
|||
|
|
if (wo.isFrozen || wo.status === 'COMPLETED') return;
|
|||
|
|
dragging = true; startX = e.clientX; startLeft = parseFloat(bar.style.left) || 0;
|
|||
|
|
bar.style.zIndex = 10; bar.style.opacity = 0.8;
|
|||
|
|
document.body.style.userSelect = 'none';
|
|||
|
|
});
|
|||
|
|
window.addEventListener('mousemove', e => {
|
|||
|
|
if (!dragging) return;
|
|||
|
|
const dx = e.clientX - startX;
|
|||
|
|
const newLeft = startLeft + dx;
|
|||
|
|
bar.style.left = Math.max(0, newLeft) + 'px';
|
|||
|
|
const newStart = new Date(viewStartMs + (newLeft / pxPerHour) * 3600000);
|
|||
|
|
const check = checkMoveConflict(wo, apsData.$utils.fmtDateTime(newStart));
|
|||
|
|
const tip = document.getElementById('dragTip');
|
|||
|
|
tip.style.display = 'block';
|
|||
|
|
tip.style.left = (e.clientX + 12) + 'px';
|
|||
|
|
tip.style.top = (e.clientY - 30) + 'px';
|
|||
|
|
tip.style.background = check.canApply ? '#16a34a' : '#dc2626';
|
|||
|
|
tip.textContent = check.canApply ? '可放置' : check.conflicts.map(c => c.message).join('; ');
|
|||
|
|
});
|
|||
|
|
window.addEventListener('mouseup', e => {
|
|||
|
|
if (!dragging) return;
|
|||
|
|
dragging = false; bar.style.zIndex = ''; bar.style.opacity = 1; document.body.style.userSelect = '';
|
|||
|
|
document.getElementById('dragTip').style.display = 'none';
|
|||
|
|
const newLeft = parseFloat(bar.style.left) || 0;
|
|||
|
|
const newStart = new Date(viewStartMs + (newLeft / pxPerHour) * 3600000);
|
|||
|
|
const check = checkMoveConflict(wo, apsData.$utils.fmtDateTime(newStart));
|
|||
|
|
if (check.canApply) {
|
|||
|
|
const dur = apsData.$utils.parseDate(wo.plannedEndTime) - apsData.$utils.parseDate(wo.plannedStartTime);
|
|||
|
|
wo.plannedStartTime = apsData.$utils.fmtDateTime(newStart);
|
|||
|
|
wo.plannedEndTime = apsData.$utils.fmtDateTime(new Date(newStart.getTime() + dur));
|
|||
|
|
apsData.$utils.save(apsData);
|
|||
|
|
showToast('调整成功', `工单 ${wo.orderNo} 已更新`);
|
|||
|
|
render();
|
|||
|
|
} else {
|
|||
|
|
showToast('无法放置', check.conflicts.map(c => c.message).join('; '), 'error');
|
|||
|
|
render();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openWoDetail(wo) {
|
|||
|
|
const po = findById(apsData.productionOrders, wo.productionOrderId);
|
|||
|
|
openModal('工单详情', `
|
|||
|
|
<div class="form-row"><div class="form-group"><label>工单号</label><input value="${wo.orderNo}" readonly></div><div class="form-group"><label>工序</label><input value="${wo.operationName}" readonly></div></div>
|
|||
|
|
<div class="form-row"><div class="form-group"><label>产品</label><input value="${wo.productName} × ${wo.quantity}" readonly></div><div class="form-group"><label>产线/工位</label><input value="${wo.lineName} / ${wo.workstationName}" readonly></div></div>
|
|||
|
|
<div class="form-row"><div class="form-group"><label>计划开始</label><input value="${wo.plannedStartTime}" readonly></div><div class="form-group"><label>计划结束</label><input value="${wo.plannedEndTime}" readonly></div></div>
|
|||
|
|
<div class="form-row"><div class="form-group"><label>状态</label><input value="${STATUS_LABELS[wo.status] || wo.status}" readonly></div><div class="form-group"><label>齐套</label><input value="${STATUS_LABELS[wo.kitStatus] || wo.kitStatus}" readonly></div></div>
|
|||
|
|
<p><strong>所属生产订单:</strong>${po ? po.orderNo : '-'}</p>
|
|||
|
|
`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.addEventListener('resize', render);
|
|||
|
|
setTimeout(render, 50);
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|