초기화

This commit is contained in:
2026-07-15 18:37:19 +09:00
commit 94abc5461d
1268 changed files with 380198 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
// ChemiFactory MES - Productivity Cost Simulator Frontend Logic
const API_INVENTORY = "/inventory";
let allInventories = [];
document.addEventListener("DOMContentLoaded", () => {
fetchInventoryDropdowns();
document.getElementById("simulator-form").addEventListener("submit", runSimulation);
document.getElementById("adjustment-form").addEventListener("submit", runAdjustmentCalculator);
document.getElementById("dryer-form").addEventListener("submit", runDryingCalculator);
});
async function fetchInventoryDropdowns() {
try {
const res = await fetch(`${API_INVENTORY}/`);
if (!res.ok) throw new Error("Failed to fetch inventory dropdowns");
allInventories = await res.json();
const yarns = allInventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'yarn' || i.material_type === '원사') && i.is_depleted !== 1);
const bonds = allInventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'bond' || i.material_type === '본드') && i.is_depleted !== 1);
const yarnSelect = document.getElementById("sim-yarn");
yarnSelect.innerHTML = '<option value="">-- 원재료 원사 선택 --</option>';
yarns.forEach(y => {
yarnSelect.innerHTML += `<option value="${y.id}">${y.item_name || y.material_name} (재고: ${y.stock}kg)</option>`;
});
const bondSelect = document.getElementById("sim-bond");
bondSelect.innerHTML = '<option value="">-- 원재료 본드 선택 (선택사항) --</option>';
bonds.forEach(b => {
bondSelect.innerHTML += `<option value="${b.id}">${b.item_name || b.material_name} (재고: ${b.stock}kg)</option>`;
});
} catch (err) {
console.error(err);
}
}
async function runSimulation(e) {
e.preventDefault();
const yarnId = document.getElementById("sim-yarn").value;
const qty = document.getElementById("sim-qty").value;
if (!yarnId || !qty) {
alert("원사 자재와 목표 수량을 올바르게 지정하십시오.");
return;
}
const payload = {
inputs: {
yarn_inventory_id: parseInt(yarnId),
bond_inventory_id: parseInt(document.getElementById("sim-bond").value) || null,
yarn_diameter_micron: parseFloat(document.getElementById("sim-dia").value),
yarn_k: parseInt(document.getElementById("sim-k").value),
ports: parseInt(document.getElementById("sim-ports").value),
cycle_time_hz: parseFloat(document.getElementById("sim-hz").value),
cut_length_mm: parseFloat(document.getElementById("sim-cut").value),
work_hours_day: parseInt(document.getElementById("sim-hours").value),
work_days_month: 20.0,
num_machines: parseFloat(document.getElementById("sim-machines").value),
bond_percentage: parseFloat(document.getElementById("sim-bond-pct").value) || 3.0
},
targetProductionQuantity: parseFloat(qty)
};
try {
const res = await fetch(`${API_INVENTORY}/analysis/calculate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.detail || "시뮬레이션 연산 실패");
}
const data = await res.json();
// Bind 결과 데이터
document.getElementById("res-days").innerText = `${data.required_days_target.toFixed(1)}`;
document.getElementById("res-fee").innerText = `${Math.round(data.plan_processing_fee).toLocaleString()}`;
document.getElementById("res-sales").innerText = `${Math.round(data.plan_estimated_sales).toLocaleString()}`;
document.getElementById("res-margin").innerText = `${Math.round(data.plan_company_margin).toLocaleString()}`;
document.getElementById("res-yarn-cost").innerText = `${Math.round(data.plan_yarn_cost).toLocaleString()}`;
document.getElementById("res-bond-cost").innerText = `${Math.round(data.plan_bond_cost).toLocaleString()}`;
} catch (err) {
console.error(err);
alert(`시뮬레이션 가동 중 오류 발생: ${err.message}`);
}
}
function runAdjustmentCalculator(e) {
e.preventDefault();
const resultBox = document.getElementById("adj-result-box");
const resultsContainer = document.getElementById("adj-results");
resultBox.style.display = "none";
resultsContainer.innerHTML = "";
const cRaw = parseFloat(document.getElementById("adj-raw-conc").value);
const vCurr = parseFloat(document.getElementById("adj-curr-vol").value);
const cCurr = parseFloat(document.getElementById("adj-curr-conc").value);
const vTarget = parseFloat(document.getElementById("adj-tgt-vol").value);
const cTarget = parseFloat(document.getElementById("adj-tgt-conc").value);
if ([cRaw, vCurr, cCurr, vTarget, cTarget].some(val => isNaN(val))) {
alert("모든 필드에 유효한 숫자를 입력해 주십시오.");
return;
}
if (vTarget < vCurr) {
alert("목표 부피는 현재 부피보다 작을 수 없습니다.");
return;
}
if (cRaw <= 0 || cTarget < 0 || cCurr < 0 || cRaw > 100 || cTarget > 100 || cCurr > 100) {
alert("농도 값은 0%에서 100% 사이여야 합니다.");
return;
}
if (cTarget > cRaw) {
alert("목표 농도는 원자재 본드 농도보다 높을 수 없습니다.");
return;
}
const cRawDec = cRaw / 100;
const cCurrDec = cCurr / 100;
const cTargetDec = cTarget / 100;
const currentBond = vCurr * cCurrDec;
const targetBond = vTarget * cTargetDec;
if (targetBond < currentBond - 0.001) {
alert("계산 불가: 목표 본드량이 현재 본드량보다 적습니다.");
return;
}
let rawMaterialToAdd = 0;
if (cRawDec > 0) {
rawMaterialToAdd = (targetBond - currentBond) / cRawDec;
}
if (rawMaterialToAdd < 0) rawMaterialToAdd = 0;
const waterToAdd = vTarget - vCurr - rawMaterialToAdd;
if (waterToAdd < -0.001) {
if (cRaw - cTarget > 0) {
const suggestedMinVolume = vCurr * (cRaw - cCurr) / (cRaw - cTarget);
alert(`목표 부피(${vTarget.toFixed(1)}L)로는 목표 농도(${cTarget}%)를 맞출 수 없습니다.\n해당 농도를 맞추기 위한 최소 목표 부피는 ${suggestedMinVolume.toFixed(2)}L 입니다.`);
} else {
alert("계산 불가: 목표 농도가 원자재 농도와 같거나 높아 도달할 수 없습니다.");
}
return;
}
const finalPureBond = targetBond;
const finalTotalWater = vTarget - finalPureBond;
resultsContainer.innerHTML = `
<p style="color:var(--text-secondary);">• 추가할 물: <strong style="color:#ffffff;">${waterToAdd.toFixed(2)} L</strong></p>
<p style="color:var(--text-secondary);">• 추가할 원자재: <strong style="color:#ffffff;">${rawMaterialToAdd.toFixed(2)} L</strong></p>
<hr style="border-color:var(--border-color); margin: 6px 0;">
<p style="color:var(--text-secondary);">• 최종 용액 총 부피: <strong style="color:var(--accent-green);">${vTarget.toFixed(1)} L</strong></p>
<p style="color:var(--text-muted); font-size:12px; margin-left: 10px;">- 순수 본드 성분: ${finalPureBond.toFixed(2)} L</p>
<p style="color:var(--text-muted); font-size:12px; margin-left: 10px;">- 포함된 총 물: ${finalTotalWater.toFixed(2)} L</p>
`;
resultBox.style.display = "block";
}
function runDryingCalculator(e) {
e.preventDefault();
const resultBox = document.getElementById("dry-result-box");
const resultsContainer = document.getElementById("dry-results");
resultBox.style.display = "none";
resultsContainer.innerHTML = "";
const speed = parseFloat(document.getElementById("dry-speed").value);
const concentration = parseFloat(document.getElementById("dry-conc").value);
const usage = parseFloat(document.getElementById("dry-usage").value);
const loss = parseFloat(document.getElementById("dry-loss").value);
if ([speed, concentration, usage, loss].some(val => isNaN(val))) {
alert("모든 필드에 유효한 숫자를 입력해 주십시오.");
return;
}
const usageMlPerSec = (usage * 1000) / 86400; // L/day -> ml/s
const waterFraction = 1 - (concentration / 100);
const waterRateGps = usageMlPerSec * waterFraction;
const latentHeat = 2260; // J/g
const powerW = waterRateGps * latentHeat;
const recommendedPowerW = powerW * (1 + (loss / 100));
resultsContainer.innerHTML = `
<p style="color:var(--text-secondary);">• 이론상 최소 필요 전력: <strong style="color:#ffffff;">${powerW.toFixed(2)} W</strong></p>
<p style="color:var(--text-secondary);">• 권장 히터 동작 전력 (Loss 포함): <strong style="color:var(--accent-orange);">${recommendedPowerW.toFixed(2)} W</strong></p>
<p style="color:var(--text-muted); font-size: 11px; margin-top: 6px;">* 물의 잠열 ${latentHeat} J/g 및 입력 건조 손실률 ${loss}%를 대입한 추산치입니다.</p>
`;
resultBox.style.display = "block";
}
+73
View File
@@ -0,0 +1,73 @@
// Common Navigation and UI Utilities for ChemiFactory MES/HMI
document.addEventListener("DOMContentLoaded", () => {
renderNavigation();
highlightActiveLink();
});
// Navigation configuration
const navItems = [
{ name: "대시보드", icon: "📊", path: "index.html" },
{ name: "생산 계획", icon: "📅", path: "plan.html" },
{ name: "생산 현황", icon: "⚙️", path: "equipment.html" },
{ name: "고객사 관리", icon: "🤝", path: "customer.html" },
{ name: "공급사 관리", icon: "🏭", path: "supplier.html" },
{ name: "기기 관리", icon: "🛠️", path: "device.html" },
{ name: "재고 관리", icon: "📦", path: "inventory.html" },
{ name: "계산/분석", icon: "📐", path: "analysis.html" },
{ name: "설정", icon: "⚙️", path: "setting.html" }
];
function renderNavigation() {
const sidebar = document.getElementById("sidebar");
if (!sidebar) return;
let html = `
<div class="sidebar-brand" style="display: flex; align-items: center; gap: 10px; padding: 20px 24px;">
<img src="source/logo.png" alt="Logo" class="brand-icon" style="width: 32px; height: 32px; object-fit: contain; font-size: 0;">
<img src="source/company_name.png" alt="ChemiFactory" class="brand-name" style="height: 24px; object-fit: contain; max-width: 150px;">
</div>
<ul class="nav-links">
`;
navItems.forEach(item => {
html += `
<li>
<a href="${item.path}" class="nav-item-link" data-path="${item.path}">
<span class="nav-icon">${item.icon}</span>
<span class="nav-text">${item.name}</span>
</a>
</li>
`;
});
html += `
</ul>
<div class="sidebar-footer">
<div class="user-profile">
<span class="user-avatar">👤</span>
<div class="user-info">
<span class="user-name">관리자</span>
<span class="user-role">SYSTEM ADMIN</span>
</div>
</div>
</div>
`;
sidebar.innerHTML = html;
}
function highlightActiveLink() {
// Current page filename detection
const path = window.location.pathname;
const page = path.split("/").pop() || "index.html";
const links = document.querySelectorAll(".nav-item-link");
links.forEach(link => {
const itemPath = link.getAttribute("data-path");
if (page === itemPath || (page === "" && itemPath === "index.html")) {
link.classList.add("active");
} else {
link.classList.remove("active");
}
});
}
+409
View File
@@ -0,0 +1,409 @@
// ChemiFactory MES - Customer Management Frontend Logic
const API_BASE = "/customers";
let allCustomers = [];
let selectedCustomerId = null;
document.addEventListener("DOMContentLoaded", () => {
initEvents();
fetchCustomers();
});
// APIs Integration
async function fetchCustomers() {
try {
const res = await fetch(`${API_BASE}/`);
if (!res.ok) throw new Error("Failed to fetch customers");
allCustomers = await res.json();
renderCustomerList(allCustomers);
} catch (err) {
console.error(err);
showListError();
}
}
async function showCustomerDetail(id) {
try {
selectedCustomerId = id;
const res = await fetch(`${API_BASE}/${id}`);
if (!res.ok) throw new Error("Failed to fetch customer details");
const data = await res.json();
renderCustomerDetail(data);
} catch (err) {
console.error(err);
alert("고객사 정보를 상세히 불러오지 못했습니다.");
}
}
// Render Lists
function renderCustomerList(list) {
const tbody = document.getElementById("customer-list-body");
tbody.innerHTML = "";
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="3" style="text-align:center; color:var(--text-muted); padding:30px 0;">등록된 고객사가 없습니다.</td></tr>`;
return;
}
list.forEach(cust => {
const tr = document.createElement("tr");
if (selectedCustomerId === cust.id) tr.className = "active-row";
tr.innerHTML = `
<td><strong>${cust.name_kr}</strong></td>
<td>${cust.contact_number || "-"}</td>
<td>${cust.business_registration_number || "-"}</td>
`;
tr.addEventListener("click", () => {
document.querySelectorAll("#customer-list-body tr").forEach(el => el.classList.remove("active-row"));
tr.classList.add("active-row");
showCustomerDetail(cust.id);
});
tbody.appendChild(tr);
});
}
function showListError() {
const tbody = document.getElementById("customer-list-body");
tbody.innerHTML = `<tr><td colspan="3" style="text-align:center; color:var(--accent-red); padding:30px 0;">데이터 통신에 실패했습니다. DB 작동을 확인하십시오.</td></tr>`;
}
function renderCustomerDetail(data) {
// Show details card, hide placeholder
document.getElementById("customer-placeholder-card").style.display = "none";
document.getElementById("customer-detail-card").style.display = "block";
const cust = data.customer;
document.getElementById("detail-name-kr").innerText = cust.name_kr;
document.getElementById("detail-name-en").innerText = cust.name_en || "";
document.getElementById("detail-brn").innerText = cust.business_registration_number || "-";
document.getElementById("detail-contact").innerText = cust.contact_number || "-";
document.getElementById("detail-address").innerText = cust.address || "-";
// Bind Contacts
renderContactsList(data.contacts || []);
// Bind Sales Activities
renderActivitiesList(data.salesActivities || []);
}
function renderContactsList(contacts) {
const tbody = document.getElementById("contact-list-body");
tbody.innerHTML = "";
if (contacts.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:20px 0;">등록된 담당자가 없습니다.</td></tr>`;
return;
}
contacts.forEach(c => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><strong>${c.name}</strong></td>
<td>${c.position || "-"} ${c.work_location ? `(${c.work_location})` : ""}</td>
<td>${c.phone_number || "-"}</td>
<td>${c.email || "-"}</td>
<td>
<button class="btn btn-secondary" style="padding: 2px 6px; font-size:11px;" onclick="openEditContactModal(${JSON.stringify(c).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding: 2px 6px; font-size:11px;" onclick="deleteContact(${c.id})">삭제</button>
</td>
`;
tbody.appendChild(tr);
});
}
function renderActivitiesList(activities) {
const tbody = document.getElementById("activity-list-body");
tbody.innerHTML = "";
if (activities.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color:var(--text-muted); padding:20px 0;">영업 활동 내역이 없습니다.</td></tr>`;
return;
}
// Sort by date descending
activities.sort((a, b) => new Date(b.activity_date) - new Date(a.activity_date));
activities.forEach(act => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${act.activity_date}</td>
<td><span class="badge badge-online">${act.activity_type}</span></td>
<td style="max-width: 250px; white-wrap: wrap;">${act.memo}</td>
<td>
<button class="btn btn-secondary" style="padding: 2px 6px; font-size:11px;" onclick="openEditActivityModal(${JSON.stringify(act).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding: 2px 6px; font-size:11px;" onclick="deleteActivity(${act.id})">삭제</button>
</td>
`;
tbody.appendChild(tr);
});
}
// Event Bindings
function initEvents() {
// Search Filter
document.getElementById("search-customer").addEventListener("input", (e) => {
const query = e.target.value.toLowerCase();
const filtered = allCustomers.filter(c =>
c.name_kr.toLowerCase().includes(query) ||
(c.name_en && c.name_en.toLowerCase().includes(query)) ||
(c.business_registration_number && c.business_registration_number.includes(query))
);
renderCustomerList(filtered);
});
// Tab Toggle
document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab-content").forEach(tc => tc.classList.remove("active"));
btn.classList.add("active");
document.getElementById(btn.dataset.tab).classList.add("active");
});
});
// Modals Control
const modals = ["customer-modal", "contact-modal", "activity-modal"];
modals.forEach(mId => {
const modal = document.getElementById(mId);
modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => {
btn.addEventListener("click", () => modal.style.display = "none");
});
});
// Add Customer
document.getElementById("btn-add-customer").addEventListener("click", () => {
document.getElementById("customer-form").reset();
document.getElementById("customer-id").value = "";
document.getElementById("customer-modal-title").innerText = "고객사 등록";
document.getElementById("customer-modal").style.display = "flex";
});
document.getElementById("customer-form").addEventListener("submit", handleCustomerSubmit);
// Edit & Delete Customer
document.getElementById("btn-edit-customer").addEventListener("click", () => {
const activeCust = allCustomers.find(c => c.id === selectedCustomerId);
if (!activeCust) return;
document.getElementById("customer-id").value = activeCust.id;
document.getElementById("cust-name-kr").value = activeCust.name_kr;
document.getElementById("cust-name-en").value = activeCust.name_en || "";
document.getElementById("cust-brn").value = activeCust.business_registration_number || "";
document.getElementById("cust-contact").value = activeCust.contact_number || "";
document.getElementById("cust-address").value = activeCust.address || "";
document.getElementById("customer-modal-title").innerText = "고객사 정보 수정";
document.getElementById("customer-modal").style.display = "flex";
});
document.getElementById("btn-delete-customer").addEventListener("click", handleDeleteCustomer);
// Add Contact
document.getElementById("btn-add-contact").addEventListener("click", () => {
document.getElementById("contact-form").reset();
document.getElementById("contact-id").value = "";
document.getElementById("contact-modal-title").innerText = "담당자 등록";
document.getElementById("contact-modal").style.display = "flex";
});
document.getElementById("contact-form").addEventListener("submit", handleContactSubmit);
// Add Activity
document.getElementById("btn-add-activity").addEventListener("click", () => {
document.getElementById("activity-form").reset();
document.getElementById("activity-id").value = "";
document.getElementById("act-date").value = new Date().toISOString().substring(0, 10);
document.getElementById("activity-modal-title").innerText = "활동 일지 등록";
document.getElementById("activity-modal").style.display = "flex";
});
document.getElementById("activity-form").addEventListener("submit", handleActivitySubmit);
}
// Form Handlers
async function handleCustomerSubmit(e) {
e.preventDefault();
const id = document.getElementById("customer-id").value;
const payload = {
name_kr: document.getElementById("cust-name-kr").value,
name_en: document.getElementById("cust-name-en").value || null,
business_registration_number: document.getElementById("cust-brn").value || null,
contact_number: document.getElementById("cust-contact").value || null,
address: document.getElementById("cust-address").value || null
};
try {
let res;
if (id) {
// Update
res = await fetch(`${API_BASE}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
// Create
res = await fetch(`${API_BASE}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("API error occurred");
const result = await res.json();
document.getElementById("customer-modal").style.display = "none";
await fetchCustomers();
if (id) {
showCustomerDetail(parseInt(id));
} else {
showCustomerDetail(result.id);
}
} catch (err) {
console.error(err);
alert("고객사 정보를 저장하는 중 오류가 발생했습니다.");
}
}
async function handleDeleteCustomer() {
if (!selectedCustomerId) return;
if (!confirm("이 고객사를 정말 삭제하시겠습니까?\n해당 거래처의 연락처 및 활동 일지 정보가 영구 삭제됩니다.")) return;
try {
const res = await fetch(`${API_BASE}/${selectedCustomerId}`, {
method: "DELETE"
});
if (!res.ok) throw new Error("Failed to delete customer");
selectedCustomerId = null;
document.getElementById("customer-detail-card").style.display = "none";
document.getElementById("customer-placeholder-card").style.display = "flex";
await fetchCustomers();
} catch (err) {
console.error(err);
alert("고객사 삭제에 실패했습니다.");
}
}
// Contact Handlers
window.openEditContactModal = function(contact) {
document.getElementById("contact-id").value = contact.id;
document.getElementById("cont-name").value = contact.name;
document.getElementById("cont-position").value = contact.position || "";
document.getElementById("cont-phone").value = contact.phone_number || "";
document.getElementById("cont-email").value = contact.email || "";
document.getElementById("cont-location").value = contact.work_location || "";
document.getElementById("contact-modal-title").innerText = "담당자 정보 수정";
document.getElementById("contact-modal").style.display = "flex";
};
async function handleContactSubmit(e) {
e.preventDefault();
const id = document.getElementById("contact-id").value;
const payload = {
customer_id: selectedCustomerId,
name: document.getElementById("cont-name").value,
position: document.getElementById("cont-position").value || null,
phone_number: document.getElementById("cont-phone").value || null,
email: document.getElementById("cont-email").value || null,
work_location: document.getElementById("cont-location").value || null
};
try {
let res;
if (id) {
res = await fetch(`${API_BASE}/contacts/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_BASE}/contacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("Contact save API error");
document.getElementById("contact-modal").style.display = "none";
showCustomerDetail(selectedCustomerId);
} catch (err) {
console.error(err);
alert("담당자 저장에 실패했습니다.");
}
}
window.deleteContact = async function(contactId) {
if (!confirm("이 담당자 정보를 정말 삭제하시겠습니까?")) return;
try {
const res = await fetch(`${API_BASE}/contacts/${contactId}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete contact");
showCustomerDetail(selectedCustomerId);
} catch (err) {
console.error(err);
alert("담당자 삭제에 실패했습니다.");
}
};
// Activity Handlers
window.openEditActivityModal = function(act) {
document.getElementById("activity-id").value = act.id;
document.getElementById("act-date").value = act.activity_date;
document.getElementById("act-type").value = act.activity_type;
document.getElementById("act-memo").value = act.memo || "";
document.getElementById("activity-modal-title").innerText = "활동 일지 수정";
document.getElementById("activity-modal").style.display = "flex";
};
async function handleActivitySubmit(e) {
e.preventDefault();
const id = document.getElementById("activity-id").value;
const payload = {
customer_id: selectedCustomerId,
contact_id: null, // Optional in back-end
activity_date: document.getElementById("act-date").value,
activity_type: document.getElementById("act-type").value,
memo: document.getElementById("act-memo").value
};
try {
let res;
if (id) {
res = await fetch(`${API_BASE}/activities/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_BASE}/activities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("Activity save API error");
document.getElementById("activity-modal").style.display = "none";
showCustomerDetail(selectedCustomerId);
} catch (err) {
console.error(err);
alert("일지 저장에 실패했습니다.");
}
}
window.deleteActivity = async function(actId) {
if (!confirm("이 활동 일지를 삭제하시겠습니까?")) return;
try {
const res = await fetch(`${API_BASE}/activities/${actId}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete activity");
showCustomerDetail(selectedCustomerId);
} catch (err) {
console.error(err);
alert("일지 삭제에 실패했습니다.");
}
};
+136
View File
@@ -0,0 +1,136 @@
// Dashboard Business logic and data visualization for ChemiFactory MES
document.addEventListener("DOMContentLoaded", () => {
fetchAPIStatus();
loadDashboardData();
});
const BACKEND_URL = `${window.location.protocol}//${window.location.host}`;
// API 연결 상태 확인
async function fetchAPIStatus() {
const badge = document.getElementById("api-status-badge");
try {
const response = await fetch(`${BACKEND_URL}/api/status`);
if (response.ok) {
badge.textContent = "시스템 정상";
badge.className = "badge badge-online";
} else {
throw new Error("HTTP Status Error");
}
} catch (e) {
badge.textContent = "연결 실패";
badge.className = "badge badge-offline";
}
}
// 대시보드 데이터 바인딩
async function loadDashboardData() {
try {
// 1. 고객사 개수 로드 (API: /customers/)
const custRes = await fetch(`${BACKEND_URL}/customers/`);
if (custRes.ok) {
const list = await custRes.json();
document.getElementById("customer-count").textContent = `${list.length} 개사`;
}
// 2. 재고 정보 로드 (API: /inventory/)
const invRes = await fetch(`${BACKEND_URL}/inventory/`);
if (invRes.ok) {
const list = await invRes.json();
// 품절되거나 소진 임계에 도달한 재고 경보 카운트
const lowStockCount = list.filter(item => item.is_depleted === 1 || item.stock <= 10).length;
document.getElementById("low-inventory-count").textContent = `${lowStockCount}`;
}
// 3. 설비 리스트 및 작동 요약 로드 (⚠️ 제외 대상에 따른 대시보드 미니 가동기기 출력 유지)
const equipRes = await fetch(`${BACKEND_URL}/equipment/`);
let totalEquip = 0;
let activeEquip = 0;
if (equipRes.ok) {
const list = await equipRes.json();
totalEquip = list.length;
// 미니 HMI 대시보드 리스트 생성
const hmiContainer = document.getElementById("equipment-mini-list");
if (list.length === 0) {
hmiContainer.innerHTML = `<div style="text-align: center; color: var(--text-muted); padding: 30px 0;">등록된 설비가 없습니다.</div>`;
} else {
hmiContainer.innerHTML = ""; // 초기화
list.forEach(eq => {
const isOnline = eq.status === "available" || eq.is_online; // 활성화 체크 필드
if (isOnline) activeEquip++;
const dotClass = isOnline ? "dot-green" : "dot-red";
const statusText = isOnline ? "가동준비" : "점검필요";
const miniItem = document.createElement("div");
miniItem.className = "hmi-mini-card";
miniItem.innerHTML = `
<div>
<strong style="display:block; font-size:15px;">${eq.name || eq.id}</strong>
<span style="font-size:12px; color:var(--text-secondary);">${eq.status || "상태미필"}</span>
</div>
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:13px; color:var(--text-secondary);">${statusText}</span>
<span class="indicator-dot ${dotClass}"></span>
</div>
`;
hmiContainer.appendChild(miniItem);
});
}
document.getElementById("active-equip-count").textContent = `${activeEquip} / ${totalEquip}`;
}
// 4. 생산 지시 리스트 및 실적 요약 (API: /plans/)
const planRes = await fetch(`${BACKEND_URL}/plans/`);
if (planRes.ok) {
const list = await planRes.json();
const planTable = document.getElementById("plan-list");
// 실적 합계 계산
let totalActualQty = 0;
let totalTargetQty = 0;
if (list.length === 0) {
planTable.innerHTML = `<tr><td colspan="5" style="text-align: center; color: var(--text-muted); padding: 30px 0;">등록된 생산 지시가 없습니다.</td></tr>`;
} else {
planTable.innerHTML = ""; // 초기화
// 최신 계획 5개만 대시보드에 표기
const recentPlans = list.slice(0, 5);
recentPlans.forEach(plan => {
// actual_quantity 속성이 없는 경우 baseline 0kg으로 방어
const actual = plan.actual_quantity || 0;
const target = plan.requested_quantity_kg || 0;
totalActualQty += actual;
totalTargetQty += target;
const row = document.createElement("tr");
row.innerHTML = `
<td><strong>Plan #${plan.plan_id}</strong></td>
<td>${plan.plan_name || "-"}</td>
<td>${target.toLocaleString()} kg</td>
<td>${actual.toLocaleString()} kg</td>
<td>
<span class="badge ${plan.status === 'Completed' ? 'badge-online' : 'badge-offline'}">
${plan.status === 'Completed' ? '완료' : '예정'}
</span>
</td>
`;
planTable.appendChild(row);
});
}
// 오늘의 생산량 카드 바인딩
document.getElementById("today-prod").textContent = `${totalActualQty.toLocaleString()} kg`;
const progress = totalTargetQty > 0 ? Math.round((totalActualQty / totalTargetQty) * 100) : 0;
const statCard = document.querySelector(".stat-card:nth-child(2) .stat-desc");
if (statCard) {
statCard.textContent = `전체 목표 대비 진행도 ${progress}%`;
}
}
} catch (e) {
console.error("Failed to load dashboard statistics:", e);
}
}
+216
View File
@@ -0,0 +1,216 @@
// ChemiFactory MES - Equipment Device Master Management Logic
const API_EQUIPMENT = "/api/equipment";
let allDevices = [];
document.addEventListener("DOMContentLoaded", () => {
initEvents();
loadDevices();
});
async function loadDevices() {
try {
const res = await fetch(API_EQUIPMENT);
if (!res.ok) throw new Error("Failed to fetch equipment devices");
allDevices = await res.json();
renderDevices(allDevices);
} catch (err) {
console.error(err);
document.getElementById("device-grid-container").innerHTML = `
<div style="grid-column: 1 / -1; padding: 40px; text-align: center; color: var(--accent-red);">
서버로부터 설비 정보를 조회하지 못했습니다. (DB 또는 네트워크 점검 필요)
</div>
`;
}
}
function renderDevices(devices) {
const container = document.getElementById("device-grid-container");
container.innerHTML = "";
if (devices.length === 0) {
container.innerHTML = `
<div style="grid-column: 1 / -1; padding: 40px; text-align: center; color: var(--text-muted);">
등록된 설비 기기가 없습니다.
</div>
`;
return;
}
devices.forEach(d => {
const card = document.createElement("div");
card.className = "device-card";
card.addEventListener("click", () => openEditModal(d));
// 상태 한글 변환
let statusText = "가동 가능";
let statusBadgeClass = "device-badge";
if (d.status === "maintenance") {
statusText = "점검 중";
} else if (d.status === "broken") {
statusText = "고장/정지";
}
card.innerHTML = `
<div>
<div class="device-card-header">
<span class="device-name">${d.name}</span>
<span class="${statusBadgeClass}">${statusText}</span>
</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top:-8px; margin-bottom: 12px;">
IP: ${d.ip_address} | Prefix: ${d.line_prefix}
</div>
</div>
<div class="device-details">
<div class="detail-item">
<span class="detail-label">보빈 수</span>
<span class="detail-value">${d.yarn_bobbin_count}개</span>
</div>
<div class="detail-item">
<span class="detail-label">건조기 타입</span>
<span class="detail-value">${d.dryer_type}</span>
</div>
<div class="detail-item">
<span class="detail-label">소비 전력</span>
<span class="detail-value">${d.power_consumption} kW</span>
</div>
<div class="detail-item">
<span class="detail-label">최대 속도</span>
<span class="detail-value">Cut: ${d.max_cutting_speed} / Feed: ${d.max_feed_speed}</span>
</div>
</div>
`;
container.appendChild(card);
});
}
function initEvents() {
const modal = document.getElementById("device-modal");
const closeBtns = modal.querySelectorAll(".close-btn, .close-modal-btn");
const addBtn = document.getElementById("btn-add-device");
const form = document.getElementById("device-form");
const deleteBtn = document.getElementById("btn-delete-device");
const searchInput = document.getElementById("search-input");
// 모달 닫기
closeBtns.forEach(btn => {
btn.addEventListener("click", () => {
modal.style.display = "none";
});
});
// 신규 추가 오픈
addBtn.addEventListener("click", () => {
form.reset();
document.getElementById("device-id").value = "";
document.getElementById("modal-title").innerText = "신규 기기 등록";
deleteBtn.style.display = "none";
document.getElementById("delete-spacer").style.display = "none";
modal.style.display = "flex";
});
// 검색 필터링
searchInput.addEventListener("input", (e) => {
const query = e.target.value.toLowerCase().trim();
const filtered = allDevices.filter(d =>
d.name.toLowerCase().includes(query) ||
(d.dryer_type && d.dryer_type.toLowerCase().includes(query)) ||
(d.ip_address && d.ip_address.includes(query))
);
renderDevices(filtered);
});
// 폼 저장 제출
form.addEventListener("submit", saveDevice);
// 삭제 실행
deleteBtn.addEventListener("click", deleteDevice);
}
function openEditModal(device) {
const modal = document.getElementById("device-modal");
const deleteBtn = document.getElementById("btn-delete-device");
document.getElementById("device-id").value = device.id;
document.getElementById("device-name").value = device.name;
document.getElementById("device-bobbins").value = device.yarn_bobbin_count;
document.getElementById("device-dryer").value = device.dryer_type;
document.getElementById("device-power").value = device.power_consumption;
document.getElementById("device-max-cut").value = device.max_cutting_speed;
document.getElementById("device-max-feed").value = device.max_feed_speed;
document.getElementById("device-ip").value = device.ip_address;
document.getElementById("device-prefix").value = device.line_prefix;
document.getElementById("device-status").value = device.status;
document.getElementById("modal-title").innerText = "설비 정보 편집";
// 삭제 버튼 노출
deleteBtn.style.display = "block";
document.getElementById("delete-spacer").style.display = "block";
modal.style.display = "flex";
}
async function saveDevice(e) {
e.preventDefault();
const id = document.getElementById("device-id").value;
const payload = {
name: document.getElementById("device-name").value,
yarn_bobbin_count: parseInt(document.getElementById("device-bobbins").value),
dryer_type: document.getElementById("device-dryer").value,
power_consumption: parseFloat(document.getElementById("device-power").value),
max_cutting_speed: parseFloat(document.getElementById("device-max-cut").value),
max_feed_speed: parseFloat(document.getElementById("device-max-feed").value),
ip_address: document.getElementById("device-ip").value,
line_prefix: document.getElementById("device-prefix").value,
status: document.getElementById("device-status").value
};
try {
let res;
if (id) {
// 수정
res = await fetch(`${API_EQUIPMENT}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
// 추가
res = await fetch(API_EQUIPMENT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("Failed to save device data");
document.getElementById("device-modal").style.display = "none";
loadDevices();
} catch (err) {
console.error(err);
alert("기기 저장 중 요류가 발생했습니다.");
}
}
async function deleteDevice() {
const id = document.getElementById("device-id").value;
if (!id) return;
if (!confirm("정말로 해당 설비를 삭제하시겠습니까? 관련 데이터가 소실될 수 있습니다.")) {
return;
}
try {
const res = await fetch(`${API_EQUIPMENT}/${id}`, {
method: "DELETE"
});
if (!res.ok) throw new Error("Failed to delete device");
document.getElementById("device-modal").style.display = "none";
loadDevices();
} catch (err) {
console.error(err);
alert("기기 삭제에 실패했습니다.");
}
}
+684
View File
@@ -0,0 +1,684 @@
// Real-time HMI Websocket Client & REST API Manager
let socket = null;
let currentSelectedEquipment = null;
let currentSelectedPrefix = "line1"; // 기본 프리픽스 설정
let currentActiveGroup = "A"; // 현재 선택된 제어 그룹 ('A' 또는 'B')
let lastReceivedStatusData = null; // 가장 최근 수신한 실시간 상태 정보 캐시
const pendingControlCommands = new Map();
let equipmentControlsLocked = true;
const BACKEND_URL = `${window.location.protocol}//${window.location.host}`;
const WS_PROTOCOL = window.location.protocol === "https:" ? "wss:" : "ws:";
const WS_URL = `${WS_PROTOCOL}//${window.location.host}/ws/equipment`;
document.addEventListener("DOMContentLoaded", () => {
loadEquipmentCards();
connectWebsocket();
initHangerPopoverOutsideClick();
});
// Websocket 연결 및 자동 재접속 로직
function connectWebsocket() {
const wsStatusBadge = document.getElementById("hmi-websocket-status");
socket = new WebSocket(WS_URL);
socket.onopen = () => {
if (wsStatusBadge) {
wsStatusBadge.textContent = "실시간 연결";
wsStatusBadge.className = "badge badge-online";
}
};
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "equipment_update" && currentSelectedEquipment) {
// 선택한 설비의 업데이트 정보인 경우 HMI 갱신 수행
if (String(msg.equipment_id) === String(currentSelectedEquipment.id)) {
lastReceivedStatusData = msg.data;
updateHMILiveDisplay(msg.data);
}
} else if (msg.type === "command_ack") {
handleCommandAck(msg);
}
} catch (err) {
console.error("Websocket parsing error:", err);
}
};
socket.onclose = () => {
if (wsStatusBadge) {
wsStatusBadge.textContent = "연결 안됨 (재접속중...)";
wsStatusBadge.className = "badge badge-offline";
}
setTimeout(connectWebsocket, 3000); // 3초 후 자동 재연결 시도
};
socket.onerror = (err) => {
console.error("Websocket error:", err);
};
}
function handleCommandAck(message) {
if (!message.command_id || !["PLC_APPLIED", "FAILED", "TIMEOUT", "BUSY"].includes(message.status)) return;
const label = pendingControlCommands.get(message.command_id) || "PLC 제어 명령";
pendingControlCommands.delete(message.command_id);
if (message.status === "PLC_APPLIED") {
// alert(`${label}이(가) PLC에 정상 적용되었습니다.`);
} else if (message.status === "TIMEOUT") {
// alert(`${label} 적용 확인 시간이 초과되었습니다.`);
} else if (message.status === "BUSY") {
// alert(`${label} 요청 실패: 게이트웨이가 이전 제어 명령을 처리 중입니다 (바쁨).`);
} else {
// alert(`${label} 적용에 실패했습니다.`);
}
}
function setEquipmentControlsLocked(locked) {
equipmentControlsLocked = locked;
const overlay = document.getElementById("initialization-overlay");
if (!overlay) return;
overlay.classList.toggle("active", locked);
overlay.setAttribute("aria-hidden", String(!locked));
}
let cachedEquipments = [];
// 설비 카드 목록 로드
async function loadEquipmentCards() {
const container = document.getElementById("equipment-cards-container");
try {
const response = await fetch(`${BACKEND_URL}/api/equipment`);
if (response.ok) {
const list = await response.json();
cachedEquipments = list;
container.innerHTML = ""; // 초기화
if (currentSelectedEquipment) {
// 상세페이지 뷰 모드
container.style.display = "none";
document.getElementById("hmi-detail-panel").style.display = "block";
// 페이지 헤더 타이틀 업데이트
document.getElementById("main-page-title").innerText = "실시간 HMI 제어";
document.getElementById("main-page-desc").style.display = "none";
return;
}
// 목록 뷰 모드
container.style.display = "grid";
document.getElementById("hmi-detail-panel").style.display = "none";
// 페이지 헤더 타이틀 업데이트
document.getElementById("main-page-title").innerText = "설비 리스트";
document.getElementById("main-page-desc").innerText = "등록된 설비 목록 및 상세 사양 정보를 확인할 수 있습니다.";
document.getElementById("main-page-desc").style.display = "block";
if (list.length === 0) {
container.innerHTML = `<div style="grid-column: 1/-1; text-align:center; padding: 40px; color: var(--text-muted);">등록된 설비가 없습니다.</div>`;
return;
}
list.forEach(eq => {
const isOnline = eq.status === "running" || eq.is_online;
const card = document.createElement("div");
card.className = `card equip-card`;
card.onclick = () => selectEquipment(eq);
card.innerHTML = `
<div class="equip-card-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
<div>
<span class="equip-title" style="font-size:16px; font-weight:700;">${eq.name || eq.equipment_id}</span>
<div class="equip-type" style="font-size:11px; color:var(--text-muted); margin-top:2px;">타입: ${eq.type || "N/A"}</div>
</div>
<span class="badge ${isOnline ? 'badge-online' : 'badge-offline'}">${isOnline ? 'ONLINE' : 'OFFLINE'}</span>
</div>
<div style="font-size:11px; color:var(--text-muted); margin-bottom: 12px;">IP Address: ${eq.ip_address || "0.0.0.0"}</div>
<div class="equip-specs" style="display:grid; grid-template-columns: 1fr 1fr; gap:6px 12px; font-size:12px; color:var(--text-secondary); border-top:1px solid var(--border-color); padding-top:12px;">
<div><span style="color:var(--text-muted);">보빈 수:</span> ${eq.yarn_bobbin_count || 0}개</div>
<div><span style="color:var(--text-muted);">건조기 타입:</span> ${eq.dryer_type || "N/A"}</div>
<div><span style="color:var(--text-muted);">소비 전력:</span> ${eq.power_consumption || 0} kW</div>
<div><span style="color:var(--text-muted);">PLC 연결상태:</span> <span style="font-weight:600; color:${eq.device_state === 'ONLINE' ? 'var(--accent-green)' : 'var(--text-muted)'};">${eq.device_state || 'OFFLINE'}</span></div>
<div style="grid-column:1/-1;"><span style="color:var(--text-muted);">최대 속도:</span> Cut: ${eq.max_cutting_speed || 0} / Feed: ${eq.max_feed_speed || 0}</div>
</div>
`;
container.appendChild(card);
});
}
} catch (e) {
console.error("Failed to load equipment list:", e);
}
}
// 전체 목록으로 돌아가기
function goBackToEquipmentList() {
currentSelectedEquipment = null;
document.getElementById("hmi-detail-panel").style.display = "none";
document.getElementById("equipment-cards-container").style.display = "grid";
loadEquipmentCards();
}
// 설비 선택 및 HMI 렌더링
async function selectEquipment(eq) {
currentSelectedEquipment = eq;
currentSelectedPrefix = eq.line_prefix || "line1";
// 목록 화면 숨김 & HMI 디테일 표출
document.getElementById("equipment-cards-container").style.display = "none";
document.getElementById("hmi-detail-panel").style.display = "block";
// 페이지 헤더 타이틀 업데이트
document.getElementById("main-page-title").innerText = "실시간 HMI 제어";
document.getElementById("main-page-desc").style.display = "none";
// 초기 그룹은 'A'로 설정 및 탭 활성화
setHmiActiveGroup('A');
// DB에서 실시간 상세 상태 데이터 가져와 UI 동기화
try {
const response = await fetch(`${BACKEND_URL}/api/equipment/${eq.id}/status`);
if (response.ok) {
const statusData = await response.json();
lastReceivedStatusData = statusData;
updateHMILiveDisplay(statusData);
}
} catch (e) {
console.error("실시간 설비 상태 로드 실패:", e);
}
}
// A그룹 / B그룹 제어 전환 설정
function setHmiActiveGroup(groupChar) {
currentActiveGroup = groupChar;
// 각 그룹 카드의 제목 span 태그를 찾아 뒤에 A 또는 B 추가
document.querySelectorAll('.hmi-group-title > span:first-child').forEach(span => {
if (!span.dataset.baseTitle) {
span.dataset.baseTitle = span.textContent.trim();
}
span.textContent = `${span.dataset.baseTitle} ${groupChar}`;
});
// 건조부 히터 번호 변경 (A그룹: H1~H5, B그룹: H6~H10)
for (let h = 1; h <= 5; h++) {
const btn = document.getElementById(`btn-dryer-h${h}`);
if (btn) {
const heaterNum = groupChar === 'A' ? h : (h + 5);
const lampSpan = btn.querySelector('.hmi-lamp');
btn.innerHTML = `H${heaterNum} `;
if (lampSpan) {
btn.appendChild(lampSpan);
}
}
}
// 탭 UI 교체
const tabA = document.getElementById("btn-tab-a");
const tabB = document.getElementById("btn-tab-b");
if (groupChar === 'A') {
tabA.classList.add("active");
tabB.classList.remove("active");
} else {
tabB.classList.add("active");
tabA.classList.remove("active");
}
// 행거 포트 그리드 재렌더링
renderHangerPorts();
// 팝오버 닫기
hideHangerPopover();
// 기존 캐싱 데이터가 존재한다면 즉시 새로운 오프셋 기준으로 리렌더링
if (lastReceivedStatusData) {
updateHMILiveDisplay(lastReceivedStatusData);
}
}
// 6그룹: 행거부 20개 포트 콤팩트 그리드 렌더링
function renderHangerPorts() {
const container = document.getElementById("hanger-ports-container");
container.innerHTML = "";
const startPortNum = currentActiveGroup === 'A' ? 1 : 21;
const endPortNum = currentActiveGroup === 'A' ? 20 : 40;
for (let p = startPortNum; p <= endPortNum; p++) {
const cell = document.createElement("div");
const paddedNum = String(p).padStart(2, '0');
cell.className = "port-cell port-idle";
cell.id = `port-cell-${p}`;
cell.innerText = `P${paddedNum}`;
cell.onclick = (e) => {
e.stopPropagation();
showHangerPopover(p, cell);
};
container.appendChild(cell);
}
}
// 행거 포트 클릭시 공용 제어 팝오버 표시
function showHangerPopover(portNum, cellElement) {
const popover = document.getElementById("hanger-popover");
const title = document.getElementById("popover-title");
const paddedNum = String(portNum).padStart(2, '0');
title.innerText = `P${paddedNum} 제어`;
// 팝오버 위치를 클릭된 셀 뒤로 가져다 붙임
popover.style.display = "flex";
// 1행인지 2행 이하인지 구분하여 위/아래 배치 결정 (그리드가 5열이므로 index 5(6번째 포트)부터 2행)
const startPortNum = currentActiveGroup === 'A' ? 1 : 21;
const relativeIndex = portNum - startPortNum;
if (relativeIndex >= 5) {
// 2행 이하: 셀 위에 배치 (위로 열림)
popover.style.top = `${cellElement.offsetTop - popover.offsetHeight - 6}px`;
} else {
// 1행: 셀 아래에 배치 (아래로 열림)
popover.style.top = `${cellElement.offsetTop + cellElement.offsetHeight + 6}px`;
}
popover.style.left = `${cellElement.offsetLeft + (cellElement.offsetWidth / 2) - 65}px`;
// 제어 버튼 바인딩 (PLC 쓰기 비트 인덱스 계산)
const btnAutoDown = document.getElementById("btn-popover-auto-down");
const btnManualDown = document.getElementById("btn-popover-manual-down");
const btnManualUp = document.getElementById("btn-popover-manual-up");
let autoDownBitId, manualDownBitId, manualUpBitId;
if (currentActiveGroup === 'A') {
autoDownBitId = 68 + (portNum - 1); // 68 ~ 87
manualDownBitId = 108 + (portNum - 1); // 108 ~ 127
manualUpBitId = 148 + (portNum - 1); // 148 ~ 167
} else {
autoDownBitId = 88 + (portNum - 21); // 88 ~ 107
manualDownBitId = 128 + (portNum - 21); // 128 ~ 147
manualUpBitId = 168 + (portNum - 21); // 168 ~ 187
}
btnAutoDown.onclick = () => { sendHmiBitDirect(autoDownBitId); hideHangerPopover(); };
btnManualDown.onclick = () => { sendHmiBitDirect(manualDownBitId); hideHangerPopover(); };
btnManualUp.onclick = () => { sendHmiBitDirect(manualUpBitId); hideHangerPopover(); };
}
function hideHangerPopover() {
const popover = document.getElementById("hanger-popover");
if (popover) popover.style.display = "none";
}
function initHangerPopoverOutsideClick() {
document.addEventListener("click", () => {
hideHangerPopover();
});
const popover = document.getElementById("hanger-popover");
if (popover) {
popover.addEventListener("click", (e) => {
e.stopPropagation(); // 팝오버 내부 클릭은 닫히지 않음
});
}
}
// 실시간 수신 데이터의 A/B 오프셋을 판단하여 UI 상태 갱신
function updateHMILiveDisplay(data) {
if (!data) return;
setEquipmentControlsLocked(data.sync_state === "SYNCING" || data.device_state !== "ONLINE");
const mRead = typeof data.m_read_raw === 'string' ? JSON.parse(data.m_read_raw || '{}') : (data.m_read_raw || {});
// A/B 그룹에 따른 읽기 비트 오프셋 설정
const isGroupB = (currentActiveGroup === 'B');
const mOffset = isGroupB ? 19 : 0;
const dOffset = isGroupB ? 5 : 0;
// ----------------------------------------------------
// 1그룹: 활성화 및 리셋 비트 상태 동기화 (m_read_raw)
// ----------------------------------------------------
// 안전 인터락 상태 (A-1) - 정상=녹색, 이상=적색
setInterlockLamp("lamp-estop", mRead[String(1 + mOffset)], 1);
setInterlockLamp("lamp-door-left", mRead[String(2 + mOffset)], 1);
setInterlockLamp("lamp-door-right", mRead[String(3 + mOffset)], 1);
setInterlockLamp("lamp-water-level", mRead[String(4 + mOffset)], 0);
setInterlockLamp("lamp-heater-interlock", mRead[String(5 + mOffset)], 1);
setLampStatus("lamp-group-active-on", mRead[String(6 + mOffset)]); // 활성화 ON
setLampStatus("lamp-group-active-off", mRead[String(7 + mOffset)], true); // 활성화 OFF (반전/빨간색)
// 함침&행거 ON/OFF (2그룹 & 6그룹 동시 업데이트)
setLampStatus("lamp-impreg-hanger-on-g2", mRead[String(11 + mOffset)]);
setLampStatus("lamp-impreg-hanger-off-g2", mRead[String(12 + mOffset)], true);
setLampStatus("lamp-impreg-hanger-on-g6", mRead[String(11 + mOffset)]);
setLampStatus("lamp-impreg-hanger-off-g6", mRead[String(12 + mOffset)], true);
setLampStatus("lamp-dryer-on", mRead[String(13 + mOffset)]); // 건조 ON
setLampStatus("lamp-dryer-off", mRead[String(14 + mOffset)], true);
setLampStatus("lamp-cutter-on", mRead[String(15 + mOffset)]); // 커팅 ON
setLampStatus("lamp-cutter-off", mRead[String(16 + mOffset)], true);
setLampStatus("lamp-transfer-on", mRead[String(17 + mOffset)]); // 이송 ON
setLampStatus("lamp-transfer-off", mRead[String(18 + mOffset)], true);
setLampStatus("lamp-group-stop", mRead[String(8 + mOffset)], true); // 전체정지
setLampStatus("lamp-group-pause", mRead[String(9 + mOffset)], true); // 일시정지
setLampStatus("lamp-group-restart", mRead[String(10 + mOffset)]); // 재시작
setLampStatus("lamp-interlock-reset", mRead[String(0 + mOffset)]); // 인터락 리셋
// ----------------------------------------------------
// 2그룹: 함침부 작동 상태 및 수치 동기화
// ----------------------------------------------------
const impregAutoOffset = isGroupB ? 42 : 38;
const impregManualOffset = isGroupB ? 43 : 39;
const pumpCircOffset = isGroupB ? 44 : 40;
const pumpDrainOffset = isGroupB ? 45 : 41;
setLampStatus("lamp-impreg-auto", mRead[String(impregAutoOffset)]);
setLampStatus("lamp-impreg-manual", mRead[String(impregManualOffset)]);
setLampStatus("lamp-pump-circ", mRead[String(pumpCircOffset)]);
setLampStatus("lamp-pump-drain", mRead[String(pumpDrainOffset)]);
// 순환펌프 재작동 시간 (D0 / D5)
const circTimeVal = data[`d_read_g${0 + dOffset}`] !== undefined ? data[`d_read_g${0 + dOffset}`] : 0;
document.getElementById("val-pump-circ-time").innerHTML = `${circTimeVal} <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">초</span>`;
// ----------------------------------------------------
// 3그룹: 건조기 작동 상태 동기화
// ----------------------------------------------------
const dryerAutoOffset = isGroupB ? 56 : 46;
const dryerManualOffset = isGroupB ? 57 : 47;
const fanTripOffset = isGroupB ? 58 : 48;
const heaterReqOffset = isGroupB ? 59 : 49;
const fanActiveOffset = isGroupB ? 60 : 50;
const heaterStartOffset = isGroupB ? 61 : 51;
setLampStatus("lamp-dryer-auto", mRead[String(dryerAutoOffset)]);
setLampStatus("lamp-dryer-manual", mRead[String(dryerManualOffset)]);
setLampStatus("lamp-dryer-fan", mRead[String(fanActiveOffset)]);
// 히터 1~5 (A그룹: 51~55, B그룹: 61~65)
for (let h = 1; h <= 5; h++) {
setLampStatus(`lamp-dryer-h${h}`, mRead[String(heaterStartOffset + (h - 1))]);
}
setLampStatus("lamp-fan-trip", mRead[String(fanTripOffset)], true); // 송풍기 트립 (알람이므로 빨간색)
setLampStatus("lamp-heater-req", mRead[String(heaterReqOffset)]);
// ----------------------------------------------------
// 4그룹: 커팅부 작동 상태 및 수치 동기화
// ----------------------------------------------------
const cutterActiveOffset = isGroupB ? 68 : 66;
const cutterStartOffset = isGroupB ? 69 : 67;
setLampStatus("lamp-cutter-motor-active", mRead[String(cutterActiveOffset)]);
setLampStatus("lamp-cutter-motor-start", mRead[String(cutterStartOffset)]);
// 커팅속도 (D1 / D6) - 소수점 1째자리 포맷팅 및 Hz 단위 적용
const rawCutterSpeed = data[`d_read_g${1 + dOffset}`] !== undefined ? data[`d_read_g${1 + dOffset}`] : 0;
const cutterSpeedVal = Number(rawCutterSpeed).toFixed(1);
document.getElementById("val-cutter-speed").innerHTML = `${cutterSpeedVal} <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">Hz</span>`;
// ----------------------------------------------------
// 5그룹: 이송 작동 상태 및 수치 동기화
// ----------------------------------------------------
const transAutoOffset = isGroupB ? 79 : 70;
const transManualOffset = isGroupB ? 80 : 71;
const transAutoStartOffset = isGroupB ? 81 : 72;
const transAutoStopOffset = isGroupB ? 82 : 73;
const transFwdOffset = isGroupB ? 83 : 74;
const transRevOffset = isGroupB ? 84 : 75;
const transStopOffset = isGroupB ? 85 : 76;
const transZeroOffset = isGroupB ? 86 : 77;
const transErrOffset = isGroupB ? 87 : 78;
setLampStatus("lamp-transfer-auto", mRead[String(transAutoOffset)]);
setLampStatus("lamp-transfer-manual", mRead[String(transManualOffset)]);
setLampStatus("lamp-transfer-auto-start", mRead[String(transAutoStartOffset)]);
setLampStatus("lamp-transfer-auto-stop", mRead[String(transAutoStopOffset)], true);
setLampStatus("lamp-transfer-fwd", mRead[String(transFwdOffset)]);
setLampStatus("lamp-transfer-rev", mRead[String(transRevOffset)]);
setLampStatus("lamp-transfer-stop", mRead[String(transStopOffset)], true);
setLampStatus("lamp-transfer-zero", mRead[String(transZeroOffset)]);
setLampStatus("lamp-transfer-err-reset", mRead[String(transErrOffset)]);
// 이송 데이터 (D2, D3, D4 / D7, D8, D9)
const transTargetPos = data[`d_read_g${2 + dOffset}`] !== undefined ? data[`d_read_g${2 + dOffset}`] : 0;
const transCurPos = data[`d_read_g${3 + dOffset}`] !== undefined ? data[`d_read_g${3 + dOffset}`] : 0;
const transTargetSpeed = data[`d_read_g${4 + dOffset}`] !== undefined ? data[`d_read_g${4 + dOffset}`] : 0;
// 자동 목표 이동량(그룹 2/7)은 REAL 소수 1자리, 수동 목표 속도(그룹 4/9)는 REAL 정수 표기
document.getElementById("val-transfer-target-pos").innerText = Number(transTargetPos).toFixed(1);
document.getElementById("val-transfer-cur-pos").innerHTML = `${Number(transCurPos).toFixed(1)} <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">mm/s</span>`;
document.getElementById("val-transfer-target-speed").innerText = Number(transTargetSpeed).toFixed(0);
// ----------------------------------------------------
// 6그룹: 행거부 작동 (20개 포트 상태 셀 리프레시)
// ----------------------------------------------------
const startPortNum = isGroupB ? 21 : 1;
const endPortNum = isGroupB ? 40 : 20;
for (let p = startPortNum; p <= endPortNum; p++) {
const cell = document.getElementById(`port-cell-${p}`);
if (!cell) continue;
let autoDownBitId, manualDownBitId, manualUpBitId;
if (!isGroupB) {
// A그룹 포트 읽기 비트 매핑
autoDownBitId = 88 + (p - 1); // 88 ~ 107
manualDownBitId = 128 + (p - 1); // 128 ~ 147
manualUpBitId = 168 + (p - 1); // 168 ~ 187 (168:자동상승, 169~187:수동상승)
} else {
// B그룹 포트 읽기 비트 매핑
autoDownBitId = 108 + (p - 21); // 108 ~ 127
manualDownBitId = 148 + (p - 21); // 148 ~ 167
manualUpBitId = 188 + (p - 21); // 188 ~ 207
}
// CSS 스타일 초기화 후 상태에 따라 네온 효과 부여
cell.className = "port-cell";
if (mRead[String(autoDownBitId)] === 1) {
cell.classList.add("port-active-auto-down");
} else if (mRead[String(manualDownBitId)] === 1) {
cell.classList.add("port-active-manual-down");
} else if (mRead[String(manualUpBitId)] === 1) {
cell.classList.add("port-active-manual-up");
} else {
cell.classList.add("port-idle");
}
}
}
// 램프 인디케이터 상태 변경 유틸리티 함수
function setLampStatus(elementId, value, isRed = false) {
const el = document.getElementById(elementId);
if (!el) return;
const activeClass = isRed ? "active-red" : "active";
// 버튼 바인딩용 클래스 처리 (상위 부모가 btn-hmi인 경우 버튼 테두리 색상 처리 포함)
const btnParent = el.closest(".btn-hmi");
if (value === 1 || value === true || String(value).toLowerCase() === "true" || String(value) === "1") {
el.classList.add("active");
if (btnParent) btnParent.classList.add(activeClass);
} else {
el.classList.remove("active");
if (btnParent) {
btnParent.classList.remove("active");
btnParent.classList.remove("active-red");
}
}
}
// 안전 인터락 전용 상태 갱신 함수 (정상 = 녹색(active), 이상 = 적색(active-red))
function setInterlockLamp(elementId, value, normalValue) {
const el = document.getElementById(elementId);
if (!el) return;
// value와 normalValue 값을 비교 (타입 무관하게 스트링 및 boolean 유연하게 비교)
const isNormal = (String(value) === String(normalValue) ||
(value === true && normalValue === 1) ||
(value === false && normalValue === 0));
if (isNormal) {
el.classList.add("active");
el.classList.remove("active-red");
} else {
el.classList.add("active-red");
el.classList.remove("active");
}
}
// HMI 비트 제어 패킷 전송 (A/B 오프셋 적용하여 자동 제어)
async function sendHmiBit(baseBitId, actionName) {
if (!currentSelectedEquipment) return;
// A/B 그룹별 PLC 쓰기 오프셋 계산
const isGroupB = (currentActiveGroup === 'B');
let actualBitId = baseBitId;
// 그룹별 매핑 규정 오프셋 적용
if (isGroupB) {
if (baseBitId >= 0 && baseBitId <= 11) {
actualBitId = baseBitId + 12; // 1그룹: Offset 12 (12~23)
} else if (baseBitId >= 24 && baseBitId <= 27) {
actualBitId = baseBitId + 4; // 2그룹: Offset 4 (28~31)
} else if (baseBitId >= 32 && baseBitId <= 39) {
actualBitId = baseBitId + 8; // 3그룹: Offset 8 (40~47)
} else if (baseBitId === 48) {
actualBitId = baseBitId + 1; // 4그룹: Offset 1 (49)
} else if (baseBitId >= 50 && baseBitId <= 58) {
actualBitId = baseBitId + 9; // 5그룹: Offset 9 (59~67)
}
}
await sendHmiBitDirect(actualBitId);
}
// 실제 HTTP 통신으로 비트 명령 전송
async function sendHmiBitDirect(bitId) {
if (!currentSelectedEquipment || equipmentControlsLocked) return;
try {
const payload = [[bitId, 1]]; // 제어 패킷 [[ID, 1]] (모멘터리 비트)
const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/control/m`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
console.error(`Control failed for bit ${bitId}`);
} else {
const result = await response.json();
pendingControlCommands.set(result.command_id, `M${bitId} 비트 제어`);
}
} catch (e) {
console.error("비트 전송 실패:", e);
}
}
// HMI 설정 레지스터 전송 (D영역 설정)
async function sendHmiRegister(baseRegId, inputElementId) {
if (!currentSelectedEquipment || equipmentControlsLocked) return;
const inputElement = document.getElementById(inputElementId);
if (!inputElement) return;
const rawVal = parseFloat(inputElement.value);
if (!Number.isFinite(rawVal)) return;
// A/B 그룹에 따른 쓰기 레지스터 ID 오프셋 계산 (A: D0, D1 / B: D2, D3)
const isGroupB = (currentActiveGroup === 'B');
let actualRegId = baseRegId;
if (isGroupB) {
actualRegId = baseRegId + 2; // Offset = 2
}
try {
const payload = [[actualRegId, rawVal]];
const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/control/d`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
pendingControlCommands.set(result.command_id, `D${actualRegId} 설정`);
inputElement.value = ""; // 입력창 초기화 (접수 팝업은 조작 편의를 위해 표시하지 않음)
} else {
alert("전송 실패");
}
} catch (e) {
console.error("수치 전송 실패:", e);
}
}
// 모달 다이얼로그 제어
function openAddModal() {
document.getElementById("add-equipment-modal").style.display = "flex";
}
function closeAddModal() {
document.getElementById("add-equipment-modal").style.display = "none";
document.getElementById("add-equipment-form").reset();
}
// 새 설비 저장
async function saveEquipment(event) {
event.preventDefault();
const eqId = document.getElementById("eq-id").value;
const name = document.getElementById("eq-name").value;
const type = document.getElementById("eq-type").value;
const ip = document.getElementById("eq-ip").value;
const payload = {
equipment_id: eqId,
name: name,
type: type,
ip_address: ip,
line_prefix: "line1" // 기본 기본라인 할당
};
try {
const response = await fetch(`${BACKEND_URL}/api/equipment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
closeAddModal();
loadEquipmentCards();
} else {
alert("중복된 ID이거나 등록 정보가 올바르지 않습니다.");
}
} catch (e) {
console.error("Save equipment error:", e);
}
}
// 수동 Full-Sync 초기화 요청 전송
async function triggerManualSync() {
if (!currentSelectedEquipment) return;
if (!confirm("게이트웨이의 내부 캐시를 초기화하고 PLC로부터 전체 데이터를 강제 수신(Full-Sync)하시겠습니까?")) return;
try {
const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/sync`, {
method: 'POST'
});
if (response.ok) {
setEquipmentControlsLocked(true);
alert("Full-Sync 초기화 명령이 전송되었습니다. 게이트웨이가 즉시 전체 상태를 갱신합니다.");
} else {
alert("초기화 명령 전송 실패");
}
} catch (e) {
console.error("Manual sync request failed:", e);
alert("서버 통신 오류가 발생했습니다.");
}
}
+327
View File
@@ -0,0 +1,327 @@
// ChemiFactory MES - Inventory and Material Property Management Logic
const API_INVENTORY = "/inventory";
const API_SUPPLIERS = "/suppliers";
let allInventories = [];
let allMaterials = [];
let allSuppliers = [];
document.addEventListener("DOMContentLoaded", () => {
initEvents();
fetchInventory();
fetchMaterials();
fetchSuppliers();
});
// Fetch API Data
async function fetchInventory() {
try {
const res = await fetch(`${API_INVENTORY}/`);
if (!res.ok) throw new Error("Failed to fetch inventory");
allInventories = await res.json();
renderInventoryList(allInventories);
} catch (err) {
console.error(err);
document.getElementById("inventory-list-body").innerHTML = `<tr><td colspan="10" style="text-align:center; color:var(--accent-red); padding:20px 0;">재고 데이터를 불러오지 못했습니다.</td></tr>`;
}
}
async function fetchMaterials() {
try {
const res = await fetch(`${API_INVENTORY}/materials`);
if (!res.ok) throw new Error("Failed to fetch materials");
allMaterials = await res.json();
renderMaterialList(allMaterials);
bindMaterialDropdown(allMaterials);
} catch (err) {
console.error(err);
document.getElementById("material-list-body").innerHTML = `<tr><td colspan="6" style="text-align:center; color:var(--accent-red); padding:20px 0;">자재 데이터를 불러오지 못했습니다.</td></tr>`;
}
}
async function fetchSuppliers() {
try {
const res = await fetch(`${API_SUPPLIERS}/`);
if (!res.ok) throw new Error("Failed to fetch suppliers");
allSuppliers = await res.json();
bindSupplierDropdown(allSuppliers);
} catch (err) {
console.error(err);
}
}
// Renderers
function renderInventoryList(list) {
const tbody = document.getElementById("inventory-list-body");
tbody.innerHTML = "";
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center; color:var(--text-muted); padding:30px 0;">재고 입고 원장이 비어있습니다.</td></tr>`;
return;
}
list.forEach(inv => {
const tr = document.createElement("tr");
const statusBadge = inv.is_depleted
? `<span class="depleted-badge">소진 완료</span>`
: `<span class="active-badge">재고 있음</span>`;
tr.innerHTML = `
<td>${inv.receipt_date}</td>
<td><strong>${inv.item_name || "-"}</strong><br><small style="color:var(--text-muted);">${inv.item_number || "-"}</small></td>
<td>${inv.material_name || "-"} (${inv.material_code || "-"})</td>
<td>${inv.receipt_quantity.toLocaleString()}</td>
<td>${(inv.usage_quantity || 0).toLocaleString()}</td>
<td style="font-weight:700; color:${inv.stock <= 0 ? 'var(--accent-red)' : 'var(--text-primary)'}">${(inv.stock || 0).toLocaleString()}</td>
<td>${inv.unit_cost.toLocaleString()} 원</td>
<td>${inv.supplier || "-"}</td>
<td>${statusBadge}</td>
<td>
<button class="btn btn-secondary" style="padding:4px 8px; font-size:11px;" onclick="openEditInventoryModal(${JSON.stringify(inv).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" onclick="depleteInventory(${inv.id})">소진</button>
</td>
`;
tbody.appendChild(tr);
});
}
function renderMaterialList(list) {
const tbody = document.getElementById("material-list-body");
tbody.innerHTML = "";
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; color:var(--text-muted); padding:30px 0;">등록된 자재 기본 물성이 없습니다.</td></tr>`;
return;
}
list.forEach(mat => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code>${mat.material_code}</code></td>
<td><strong>${mat.material_name}</strong></td>
<td>${mat.material_type === 'Yarn' ? '원사 (Yarn)' : mat.material_type === 'Bond' ? '본드 (Bond)' : '기타'}</td>
<td>${mat.density} g/cm³</td>
<td>${mat.remarks || "-"}</td>
<td>
<button class="btn btn-secondary" style="padding:4px 8px; font-size:11px;" onclick="openEditMaterialModal(${JSON.stringify(mat).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" onclick="deleteMaterial(${mat.id})">삭제</button>
</td>
`;
tbody.appendChild(tr);
});
}
function bindMaterialDropdown(materials) {
const select = document.getElementById("inv-material");
select.innerHTML = '<option value="">-- 자재 규격 선택 --</option>';
materials.forEach(m => {
select.innerHTML += `<option value="${m.id}">${m.material_name} (${m.material_code})</option>`;
});
}
function bindSupplierDropdown(suppliers) {
const select = document.getElementById("inv-supplier");
select.innerHTML = '<option value="">-- 공급사 선택 --</option>';
suppliers.forEach(s => {
select.innerHTML += `<option value="${s.id}">${s.name_kr}</option>`;
});
}
// Events
function initEvents() {
// Tab switching
document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab-content").forEach(tc => tc.classList.remove("active"));
btn.classList.add("active");
document.getElementById(btn.dataset.tab).classList.add("active");
});
});
// Modal windows cancel
const modalIds = ["material-modal", "inventory-modal"];
modalIds.forEach(id => {
const modal = document.getElementById(id);
modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => {
btn.addEventListener("click", () => modal.style.display = "none");
});
});
// Material logic trigger
document.getElementById("btn-add-material").addEventListener("click", () => {
document.getElementById("material-form").reset();
document.getElementById("material-id").value = "";
document.getElementById("material-modal-title").innerText = "자재 물성 등록";
document.getElementById("material-modal").style.display = "flex";
});
document.getElementById("material-form").addEventListener("submit", handleMaterialSubmit);
// Inventory logic trigger
document.getElementById("btn-add-inventory").addEventListener("click", () => {
document.getElementById("inventory-form").reset();
document.getElementById("inventory-id").value = "";
document.getElementById("usage-group").style.display = "none";
document.getElementById("inv-date").value = new Date().toISOString().substring(0, 10);
document.getElementById("inventory-modal-title").innerText = "자재 신규 입고 등록";
document.getElementById("inventory-modal").style.display = "flex";
});
document.getElementById("inventory-form").addEventListener("submit", handleInventorySubmit);
// Search filters
document.getElementById("search-inventory").addEventListener("input", (e) => {
const query = e.target.value.toLowerCase();
const filtered = allInventories.filter(i =>
(i.item_name && i.item_name.toLowerCase().includes(query)) ||
(i.item_number && i.item_number.toLowerCase().includes(query)) ||
(i.material_name && i.material_name.toLowerCase().includes(query)) ||
(i.material_code && i.material_code.toLowerCase().includes(query))
);
renderInventoryList(filtered);
});
document.getElementById("search-material").addEventListener("input", (e) => {
const query = e.target.value.toLowerCase();
const filtered = allMaterials.filter(m =>
m.material_name.toLowerCase().includes(query) ||
m.material_code.toLowerCase().includes(query)
);
renderMaterialList(filtered);
});
}
// Submits Handlers
async function handleMaterialSubmit(e) {
e.preventDefault();
const id = document.getElementById("material-id").value;
const payload = {
material_code: document.getElementById("mat-code").value,
material_name: document.getElementById("mat-name").value,
material_type: document.getElementById("mat-type").value,
density: parseFloat(document.getElementById("mat-density").value),
remarks: document.getElementById("mat-remarks").value || null
};
try {
let res;
if (id) {
res = await fetch(`${API_INVENTORY}/materials/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_INVENTORY}/materials`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("API failed");
document.getElementById("material-modal").style.display = "none";
fetchMaterials();
} catch (err) {
console.error(err);
alert("자재 규격 저장에 실패했습니다.");
}
}
window.openEditMaterialModal = function(mat) {
document.getElementById("material-id").value = mat.id;
document.getElementById("mat-code").value = mat.material_code;
document.getElementById("mat-name").value = mat.material_name;
document.getElementById("mat-type").value = mat.material_type;
document.getElementById("mat-density").value = mat.density;
document.getElementById("mat-remarks").value = mat.remarks || "";
document.getElementById("material-modal-title").innerText = "자재 물성 수정";
document.getElementById("material-modal").style.display = "flex";
};
window.deleteMaterial = async function(id) {
if (!confirm("이 자재 물성 규격을 정말 삭제하시겠습니까?\n이미 입고된 재고 이력이 존재할 경우 삭제가 거부될 수 있습니다.")) return;
try {
const res = await fetch(`${API_INVENTORY}/materials/${id}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || "Check references");
}
fetchMaterials();
} catch (err) {
console.error(err);
alert(`자재 삭제 실패: ${err.message}`);
}
};
// Inventory Submits Handlers
async function handleInventorySubmit(e) {
e.preventDefault();
const id = document.getElementById("inventory-id").value;
const payload = {
material_id: parseInt(document.getElementById("inv-material").value),
supplier_id: parseInt(document.getElementById("inv-supplier").value),
receipt_date: document.getElementById("inv-date").value,
item_name: document.getElementById("inv-item-name").value || null,
item_number: document.getElementById("inv-item-num").value || null,
receipt_quantity: parseFloat(document.getElementById("inv-qty").value),
usage_quantity: id ? parseFloat(document.getElementById("inv-usage").value || 0) : 0.0,
unit_cost: parseFloat(document.getElementById("inv-cost").value),
remarks: document.getElementById("inv-remarks").value || null
};
try {
let res;
if (id) {
res = await fetch(`${API_INVENTORY}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_INVENTORY}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("API failed");
document.getElementById("inventory-modal").style.display = "none";
fetchInventory();
} catch (err) {
console.error(err);
alert("재고 정보를 저장하는 데 실패했습니다.");
}
}
window.openEditInventoryModal = function(inv) {
document.getElementById("inventory-id").value = inv.id;
document.getElementById("inv-material").value = inv.material_id;
document.getElementById("inv-supplier").value = inv.supplier_id;
document.getElementById("inv-date").value = inv.receipt_date;
document.getElementById("inv-item-name").value = inv.item_name || "";
document.getElementById("inv-item-num").value = inv.item_number || "";
document.getElementById("inv-qty").value = inv.receipt_quantity;
document.getElementById("inv-cost").value = inv.unit_cost;
document.getElementById("inv-remarks").value = inv.remarks || "";
// Show usage input when modifying
document.getElementById("usage-group").style.display = "block";
document.getElementById("inv-usage").value = inv.usage_quantity || 0;
document.getElementById("inventory-modal-title").innerText = "재고 정보 수정";
document.getElementById("inventory-modal").style.display = "flex";
};
window.depleteInventory = async function(id) {
if (!confirm("이 입고 건의 잔량 재고를 모두 소진 처리하시겠습니까?")) return;
try {
const res = await fetch(`${API_INVENTORY}/${id}/deplete`, { method: "PUT" });
if (!res.ok) throw new Error("Failed to deplete");
fetchInventory();
} catch (err) {
console.error(err);
alert("재고 소진 처리에 실패했습니다.");
}
};
+138
View File
@@ -0,0 +1,138 @@
// ChemiFactory MES - Material Properties Specifications Management Logic
const API_BASE = "/inventory/materials";
let allMaterials = [];
document.addEventListener("DOMContentLoaded", () => {
initEvents();
fetchMaterials();
});
async function fetchMaterials() {
try {
const res = await fetch(API_BASE);
if (!res.ok) throw new Error("Failed to fetch material specifications");
allMaterials = await res.json();
renderMaterialList(allMaterials);
} catch (err) {
console.error(err);
document.getElementById("material-list-body").innerHTML = `<tr><td colspan="6" style="text-align:center; color:var(--accent-red); padding:20px 0;">자재 규격 데이터를 로드하지 못했습니다.</td></tr>`;
}
}
function renderMaterialList(list) {
const tbody = document.getElementById("material-list-body");
tbody.innerHTML = "";
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; color:var(--text-muted); padding:30px 0;">등록된 자재 물성 규격이 존재하지 않습니다.</td></tr>`;
return;
}
list.forEach(m => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code>${m.material_code}</code></td>
<td><strong>${m.material_name}</strong></td>
<td>${m.material_type === 'Yarn' ? '원사 (Yarn)' : m.material_type === 'Bond' ? '본드 (Bond)' : '기타'}</td>
<td>${m.density} g/cm³</td>
<td>${m.remarks || "-"}</td>
<td>
<button class="btn btn-secondary" style="padding: 4px 8px; font-size:11px;" onclick="openEditMaterialModal(${JSON.stringify(m).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding: 4px 8px; font-size:11px;" onclick="deleteMaterial(${m.id})">삭제</button>
</td>
`;
tbody.appendChild(tr);
});
}
function initEvents() {
// Search filter
document.getElementById("search-material").addEventListener("input", (e) => {
const query = e.target.value.toLowerCase();
const filtered = allMaterials.filter(m =>
m.material_name.toLowerCase().includes(query) ||
m.material_code.toLowerCase().includes(query)
);
renderMaterialList(filtered);
});
// Close Modals
const modal = document.getElementById("material-modal");
modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => {
btn.addEventListener("click", () => modal.style.display = "none");
});
// Trigger Form
document.getElementById("btn-add-material").addEventListener("click", () => {
document.getElementById("material-form").reset();
document.getElementById("material-id").value = "";
document.getElementById("material-modal-title").innerText = "자재 규격 정보 등록";
modal.style.display = "flex";
});
document.getElementById("material-form").addEventListener("submit", handleMaterialSubmit);
}
async function handleMaterialSubmit(e) {
e.preventDefault();
const id = document.getElementById("material-id").value;
const payload = {
material_code: document.getElementById("mat-code").value,
material_name: document.getElementById("mat-name").value,
material_type: document.getElementById("mat-type").value,
density: parseFloat(document.getElementById("mat-density").value),
remarks: document.getElementById("mat-remarks").value || null
};
try {
let res;
if (id) {
res = await fetch(`${API_BASE}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(API_BASE, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("API operation failed");
modal.style.display = "none";
fetchMaterials();
} catch (err) {
console.error(err);
alert("자재 규격 데이터를 저장하는 데 실패했습니다.");
}
}
window.openEditMaterialModal = function(m) {
document.getElementById("material-id").value = m.id;
document.getElementById("mat-code").value = m.material_code;
document.getElementById("mat-name").value = m.material_name;
document.getElementById("mat-type").value = m.material_type;
document.getElementById("mat-density").value = m.density;
document.getElementById("mat-remarks").value = m.remarks || "";
document.getElementById("material-modal-title").innerText = "자재 규격 정보 수정";
document.getElementById("material-modal").style.display = "flex";
};
window.deleteMaterial = async function(id) {
if (!confirm("이 자재 물성 규격을 삭제하시겠습니까?\n이미 해당 규격으로 입고된 재고 내역이 존재하면 삭제가 불가합니다.")) return;
try {
const res = await fetch(`${API_BASE}/${id}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || "오류 발생");
}
fetchMaterials();
} catch (err) {
console.error(err);
alert(`물성 데이터 삭제 실패: ${err.message}`);
}
};
+534
View File
@@ -0,0 +1,534 @@
// ChemiFactory MES - Production Plan Gantt Chart & Simulator Logic
const API_PLANS = "/plans";
const API_CUSTOMERS = "/customers";
const API_INVENTORY = "/inventory";
let allPlans = [];
let allCustomers = [];
let allYarns = [];
let allBonds = [];
// Gantt configuration
const cellWidth = 40; // px
let ganttStartDate = null;
let ganttEndDate = null;
let datesArray = [];
let selectedEquipmentIds = [];
let simResultData = null;
document.addEventListener("DOMContentLoaded", () => {
initEvents();
initGanttDates();
loadAllData();
initScrollSync();
});
function initGanttDates() {
const today = new Date();
// 2 weeks before today
const start = new Date(today);
start.setDate(today.getDate() - 14);
ganttStartDate = start;
// 5 weeks after today
const end = new Date(today);
end.setDate(today.getDate() + 35);
ganttEndDate = end;
datesArray = [];
let curr = new Date(start);
while (curr <= end) {
datesArray.push(new Date(curr));
curr.setDate(curr.getDate() + 1);
}
}
async function loadAllData() {
await fetchCustomers();
await fetchInventoryDropdowns();
await fetchPlans();
}
async function fetchPlans() {
try {
const res = await fetch(`${API_PLANS}/`);
if (!res.ok) throw new Error("Failed to fetch plans");
allPlans = await res.json();
renderGantt();
} catch (err) {
console.error("DB 연결 불가로 인해 간트차트 레이아웃 확인용 데모 데이터를 렌더링합니다:", err);
const todayStr = new Date().toISOString().substring(0, 10);
// 데모용 생산계획 데이터 2건 추가
allPlans = [
{
plan_id: 1,
plan_name: "샘플고객사A - 원사 12K (데모)",
customer_id: 1,
customer_name: "샘플고객사A",
yarn_inventory_id: 1,
yarn_name: "원사 12K",
bond_inventory_id: null,
requested_quantity_kg: 850.5,
start_date: todayStr,
production_days: 14.5,
assigned_equipment: [{equipment_id: 8}],
works_on_saturday: true,
works_on_sunday: false,
yarn_diameter: 7.0,
yarn_k: 12,
ports: 40,
cycle_time: 8.0,
cut_length: 6.0,
work_hours_day: 16,
plan_estimated_sales: 12500000,
plan_company_margin: 4200000,
plan_total_material_cost: 8300000,
plan_net_processing_cost: 3000000,
plan_processing_fee: 7200000,
plan_yarn_cost: 7500000,
plan_bond_cost: 80000
},
{
plan_id: 2,
plan_name: "샘플고객사B - 원사 24K (데모)",
customer_id: 2,
customer_name: "샘플고객사B",
yarn_inventory_id: 2,
yarn_name: "원사 24K",
bond_inventory_id: null,
requested_quantity_kg: 1200.0,
start_date: new Date(Date.now() + 86400000 * 5).toISOString().substring(0, 10), // 5일 뒤 시작
production_days: 20.0,
assigned_equipment: [{equipment_id: 8}],
works_on_saturday: true,
works_on_sunday: true,
yarn_diameter: 7.5,
yarn_k: 24,
ports: 40,
cycle_time: 8.5,
cut_length: 6.5,
work_hours_day: 16,
plan_estimated_sales: 18000000,
plan_company_margin: 5500000,
plan_total_material_cost: 12500000,
plan_net_processing_cost: 4500000,
plan_processing_fee: 10000000,
plan_yarn_cost: 11000000,
plan_bond_cost: 150000
}
];
renderGantt();
}
}
async function fetchCustomers() {
try {
const res = await fetch(`${API_CUSTOMERS}/`);
if (!res.ok) throw new Error("Failed to fetch customers");
allCustomers = await res.json();
const select = document.getElementById("plan-customer");
select.innerHTML = '<option value="">-- 거래처 선택 --</option>';
allCustomers.forEach(c => {
select.innerHTML += `<option value="${c.id}">${c.name_kr}</option>`;
});
} catch (err) {
console.error(err);
}
}
async function fetchInventoryDropdowns() {
try {
const res = await fetch(`${API_INVENTORY}/`);
if (!res.ok) throw new Error("Failed to fetch inventory dropdowns");
const inventories = await res.json();
// 대소문자 및 한글 구분 필터링 및 소진되지 않은(is_depleted !== 1) 재고 추출
allYarns = inventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'yarn' || i.material_type === '원사') && i.is_depleted !== 1);
allBonds = inventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'bond' || i.material_type === '본드') && i.is_depleted !== 1);
const yarnSelect = document.getElementById("plan-yarn");
yarnSelect.innerHTML = '<option value="">-- 입고 원사 선택 --</option>';
allYarns.forEach(y => {
const labelName = y.item_name || y.material_name || "미지정 원사";
yarnSelect.innerHTML += `<option value="${y.id}">${labelName} (재고: ${y.stock || 0}kg)</option>`;
});
const bondSelect = document.getElementById("plan-bond");
bondSelect.innerHTML = '<option value="">-- 입고 본드 선택 (선택사항) --</option>';
allBonds.forEach(b => {
const labelName = b.item_name || b.material_name || "미지정 본드";
bondSelect.innerHTML += `<option value="${b.id}">${labelName} (재고: ${b.stock || 0}kg)</option>`;
});
} catch (err) {
console.error("Dropdown load error: ", err);
}
}
// Gantt Rendering Logic
function renderGantt() {
renderGanttHeaders();
const leftBody = document.getElementById("gantt-left-body");
const rightBody = document.getElementById("gantt-right-body");
leftBody.innerHTML = "";
rightBody.innerHTML = "";
if (allPlans.length === 0) {
leftBody.innerHTML = `<div style="padding: 20px; font-size:12px; color:var(--text-muted);">수립된 계획이 없습니다.</div>`;
return;
}
allPlans.forEach(plan => {
// Left Column Panel Row
const leftRow = document.createElement("div");
leftRow.className = "gantt-row";
leftRow.innerHTML = `
<div class="gantt-left-item" onclick="openEditPlanModal(${JSON.stringify(plan).replace(/"/g, '&quot;')})">
<strong>${plan.plan_name}</strong>
<small>${plan.customer_name} | ${plan.yarn_name}</small>
</div>
`;
leftBody.appendChild(leftRow);
// Right Timeline Row
const rightRow = document.createElement("div");
rightRow.className = "gantt-row";
rightRow.style.width = `${datesArray.length * cellWidth}px`;
let gridHtml = `<div class="gantt-right-grid" style="width: ${datesArray.length * cellWidth}px;">`;
datesArray.forEach(() => {
gridHtml += `<div class="gantt-grid-cell"></div>`;
});
gridHtml += '</div>';
rightRow.innerHTML = gridHtml;
// Render schedule bar overlay
const planStart = new Date(plan.start_date);
const planEnd = new Date(plan.end_date);
// Calculate offsets
const startDiff = Math.ceil((planStart - ganttStartDate) / (1000 * 60 * 60 * 24));
const duration = Math.ceil((planEnd - planStart) / (1000 * 60 * 60 * 24)) + 1;
// Draw only if it overlaps with our Gantt window
if (planEnd >= ganttStartDate && planStart <= ganttEndDate) {
const visibleStart = Math.max(0, startDiff);
const visibleDuration = duration - (visibleStart - startDiff);
const barContainer = document.createElement("div");
barContainer.className = "gantt-bar-container";
barContainer.style.left = `${visibleStart * cellWidth}px`;
barContainer.style.width = `${visibleDuration * cellWidth}px`;
const bar = document.createElement("div");
bar.className = "gantt-bar-item";
bar.innerText = `[${plan.requested_quantity_kg}kg] ${plan.plan_name}`;
bar.addEventListener("click", () => openEditPlanModal(plan));
barContainer.appendChild(bar);
rightRow.appendChild(barContainer);
}
rightBody.appendChild(rightRow);
});
}
function renderGanttHeaders() {
const headerRow = document.getElementById("gantt-date-headers");
headerRow.style.width = `${datesArray.length * cellWidth}px`;
headerRow.innerHTML = "";
const todayStr = new Date().toISOString().substring(0, 10);
datesArray.forEach(d => {
const day = d.getDate();
const dow = d.getDay();
const dateStr = d.toISOString().substring(0, 10);
let cellClass = "gantt-day-header-cell";
if (dow === 6) cellClass += " day-sat";
else if (dow === 0) cellClass += " day-sun";
if (dateStr === todayStr) cellClass += " day-today";
headerRow.innerHTML += `
<div class="${cellClass}">
<span style="font-size:10px; color:var(--text-secondary);">${d.getMonth() + 1}/${day}</span>
<span style="font-weight:700;">${['일', '월', '화', '수', '목', '금', '토'][dow]}</span>
</div>
`;
});
}
// Events Integration
function initEvents() {
const modal = document.getElementById("plan-modal");
modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => {
btn.addEventListener("click", () => modal.style.display = "none");
});
document.getElementById("btn-add-plan").addEventListener("click", () => {
document.getElementById("plan-form").reset();
document.getElementById("plan-id").value = "";
document.getElementById("plan-modal-title").innerText = "생산 계획 수립";
document.getElementById("plan-start-date").value = new Date().toISOString().substring(0, 10);
document.getElementById("equip-assignment-box").style.display = "none";
document.getElementById("btn-save-plan").disabled = true;
simResultData = null;
selectedEquipmentIds = [];
document.getElementById("sim-days").innerText = "-";
document.getElementById("sim-sales").innerText = "-";
document.getElementById("sim-margin").innerText = "-";
modal.style.display = "flex";
});
document.getElementById("btn-simulate").addEventListener("click", runSimulation);
document.getElementById("plan-form").addEventListener("submit", savePlan);
}
// Simulation & Optimization
async function runSimulation() {
const yarnInvId = document.getElementById("plan-yarn").value;
const qty = document.getElementById("plan-qty").value;
if (!yarnInvId || !qty) {
alert("원사 자재와 목표 생산량을 먼저 입력해 주십시오.");
return;
}
const payload = {
inputs: {
yarn_inventory_id: parseInt(yarnInvId),
bond_inventory_id: parseInt(document.getElementById("plan-bond").value) || null,
yarn_diameter_micron: parseFloat(document.getElementById("plan-yarn-dia").value),
yarn_k: parseInt(document.getElementById("plan-yarn-k").value),
ports: parseInt(document.getElementById("plan-ports").value),
cycle_time_hz: parseFloat(document.getElementById("plan-hz").value),
cut_length_mm: parseFloat(document.getElementById("plan-cut").value),
work_hours_day: parseInt(document.getElementById("plan-hours").value),
work_days_month: 20.0, // Default constraint
num_machines: 2.0, // Baseline machines
bond_percentage: 3.0
},
targetProductionQuantity: parseFloat(qty)
};
try {
const res = await fetch("/inventory/analysis/calculate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Calculation failure");
const data = await res.json();
simResultData = data;
// Render results
document.getElementById("sim-days").innerText = data.required_days_target.toFixed(1);
document.getElementById("sim-sales").innerText = Math.round(data.plan_estimated_sales).toLocaleString();
document.getElementById("sim-margin").innerText = Math.round(data.plan_company_margin).toLocaleString();
// Query Available equipment
const startDate = document.getElementById("plan-start-date").value;
if (startDate) {
await queryAvailableEquipment(startDate, data.required_days_target);
}
} catch (err) {
console.error(err);
alert("시뮬레이션 가동 계산 중 오류가 발생했습니다.");
}
}
async function queryAvailableEquipment(startDateStr, requiredDays) {
// ⚠️ 장비 배정 부분 연동 (간트 시각화 및 배정을 위해 가용 장비 1~4호기를 체크할 수 있도록 칩 형태로 조회)
const startDate = new Date(startDateStr);
const endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + Math.ceil(requiredDays) - 1);
const sat = document.getElementById("plan-sat").checked;
const sun = document.getElementById("plan-sun").checked;
try {
const url = `${API_PLANS}/find-available-equipment?start_date=${startDateStr}&end_date=${endDate.toISOString().substring(0, 10)}&works_on_saturday=${sat}&works_on_sunday=${sun}`;
const res = await fetch(url);
if (!res.ok) throw new Error("Equipment search failed");
const machines = await res.json();
const container = document.getElementById("available-machines-container");
container.innerHTML = "";
if (machines.length === 0) {
container.innerHTML = "<span style='color:var(--accent-red); font-size:12px;'>해당 일정에 가용한 설비가 없습니다.</span>";
document.getElementById("btn-save-plan").disabled = true;
} else {
machines.forEach(m => {
const chip = document.createElement("div");
chip.className = "machine-chip";
if (selectedEquipmentIds.includes(m.id)) chip.classList.add("selected");
chip.innerText = m.name;
chip.addEventListener("click", () => {
if (selectedEquipmentIds.includes(m.id)) {
selectedEquipmentIds = selectedEquipmentIds.filter(id => id !== m.id);
chip.classList.remove("selected");
} else {
selectedEquipmentIds.push(m.id);
chip.classList.add("selected");
}
document.getElementById("btn-save-plan").disabled = selectedEquipmentIds.length === 0;
});
container.appendChild(chip);
});
}
document.getElementById("equip-assignment-box").style.display = "block";
} catch (err) {
console.error(err);
}
}
async function savePlan(e) {
e.preventDefault();
if (!simResultData || selectedEquipmentIds.length === 0) return;
const id = document.getElementById("plan-id").value;
const custSelect = document.getElementById("plan-customer");
const yarnSelect = document.getElementById("plan-yarn");
const bondSelect = document.getElementById("plan-bond");
const payload = {
plan_name: `${custSelect.options[custSelect.selectedIndex].text} - ${yarnSelect.options[yarnSelect.selectedIndex].text.split(' ')[0]}`,
customer_id: parseInt(custSelect.value),
customer_name: custSelect.options[custSelect.selectedIndex].text,
yarn_inventory_id: parseInt(yarnSelect.value),
yarn_name: yarnSelect.options[yarnSelect.selectedIndex].text.split(' ')[0],
bond_inventory_id: parseInt(bondSelect.value) || null,
bond_name: bondSelect.value ? bondSelect.options[bondSelect.selectedIndex].text.split(' ')[0] : "",
requested_quantity_kg: parseFloat(document.getElementById("plan-qty").value),
start_date: document.getElementById("plan-start-date").value,
production_days: simResultData.required_days_target,
// Sim data
hourly_net_cost_per_machine: simResultData.hourly_net_cost_per_machine,
hourly_profit_per_machine: simResultData.hourly_profit_per_machine,
plan_total_material_cost: simResultData.plan_total_material_cost,
yarn_diameter: parseFloat(document.getElementById("plan-yarn-dia").value),
yarn_k: parseFloat(document.getElementById("plan-yarn-k").value),
ports: parseFloat(document.getElementById("plan-ports").value),
cycle_time: parseFloat(document.getElementById("plan-hz").value),
cut_length: parseFloat(document.getElementById("plan-cut").value),
work_hours_day: parseFloat(document.getElementById("plan-hours").value),
work_days_month: 20.0,
num_machines: parseFloat(selectedEquipmentIds.length),
plan_net_processing_cost: simResultData.plan_net_processing_cost,
plan_company_margin: simResultData.plan_company_margin,
plan_processing_fee: simResultData.plan_processing_fee,
plan_yarn_cost: simResultData.plan_yarn_cost,
plan_bond_cost: simResultData.plan_bond_cost,
plan_estimated_sales: simResultData.plan_estimated_sales,
// Allocations
equipment_ids: selectedEquipmentIds,
works_on_saturday: document.getElementById("plan-sat").checked,
works_on_sunday: document.getElementById("plan-sun").checked
};
try {
let res;
if (id) {
res = await fetch(`${API_PLANS}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_PLANS}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("Plan saving failed");
document.getElementById("plan-modal").style.display = "none";
fetchPlans();
} catch (err) {
console.error(err);
alert("계획 등록에 실패했습니다.");
}
}
window.openEditPlanModal = function(plan) {
document.getElementById("plan-id").value = plan.plan_id;
document.getElementById("plan-customer").value = plan.customer_id;
document.getElementById("plan-yarn").value = plan.yarn_inventory_id;
document.getElementById("plan-bond").value = plan.bond_inventory_id || "";
document.getElementById("plan-qty").value = plan.requested_quantity_kg;
document.getElementById("plan-start-date").value = plan.start_date;
document.getElementById("plan-sat").checked = plan.works_on_saturday || false;
document.getElementById("plan-sun").checked = plan.works_on_sunday || false;
document.getElementById("plan-yarn-dia").value = plan.yarn_diameter;
document.getElementById("plan-yarn-k").value = plan.yarn_k;
document.getElementById("plan-ports").value = plan.ports;
document.getElementById("plan-hz").value = plan.cycle_time;
document.getElementById("plan-cut").value = plan.cut_length;
document.getElementById("plan-hours").value = plan.work_hours_day;
selectedEquipmentIds = plan.assigned_equipment ? plan.assigned_equipment.map(ae => ae.equipment_id) : [];
simResultData = {
required_days_target: plan.production_days,
plan_estimated_sales: plan.plan_estimated_sales,
plan_company_margin: plan.plan_company_margin,
hourly_net_cost_per_machine: plan.hourly_net_cost_per_machine,
hourly_profit_per_machine: plan.hourly_profit_per_machine,
plan_total_material_cost: plan.plan_total_material_cost,
plan_net_processing_cost: plan.plan_net_processing_cost,
plan_processing_fee: plan.plan_processing_fee,
plan_yarn_cost: plan.plan_yarn_cost,
plan_bond_cost: plan.plan_bond_cost
};
document.getElementById("sim-days").innerText = plan.production_days.toFixed(1);
document.getElementById("sim-sales").innerText = Math.round(plan.plan_estimated_sales).toLocaleString();
document.getElementById("sim-margin").innerText = Math.round(plan.plan_company_margin).toLocaleString();
queryAvailableEquipment(plan.start_date, plan.production_days);
document.getElementById("plan-modal-title").innerText = "생산 계획 정보 조회 및 수정";
document.getElementById("btn-save-plan").disabled = false;
document.getElementById("plan-modal").style.display = "flex";
};
function initScrollSync() {
const leftBody = document.getElementById("gantt-left-body");
const rightBody = document.getElementById("gantt-right-body");
if (!leftBody || !rightBody) return;
let isLeftScrolling = false;
let isRightScrolling = false;
leftBody.addEventListener("scroll", () => {
if (isRightScrolling) {
isRightScrolling = false;
return;
}
isLeftScrolling = true;
rightBody.scrollTop = leftBody.scrollTop;
});
rightBody.addEventListener("scroll", () => {
if (isLeftScrolling) {
isLeftScrolling = false;
return;
}
isRightScrolling = true;
leftBody.scrollTop = rightBody.scrollTop;
});
}
+165
View File
@@ -0,0 +1,165 @@
// ChemiFactory MES - System Settings Frontend Logic
const API_SETTINGS = "/settings";
let allSettings = [];
// DOM 준비 상태와 무관하게 theme.js 로딩 완료 대기
function waitForThemeAndInit() {
// 최대 2초 동안 AppTheme 대기
let attempts = 0;
const maxAttempts = 20; // 100ms * 20 = 2초
const checkReady = () => {
attempts++;
if (window.AppTheme) {
// AppTheme 준비 완료, 테마 선택기 초기화
initThemeSelector();
} else if (attempts < maxAttempts) {
// 아직 준비 안 됨, 100ms 후 재시도
setTimeout(checkReady, 100);
} else {
console.warn("[Setting] AppTheme not loaded after 2 seconds");
}
};
checkReady();
}
document.addEventListener("DOMContentLoaded", () => {
fetchSettings();
document.getElementById("settings-form").addEventListener("submit", saveSettings);
waitForThemeAndInit();
});
// --- 화면 테마 선택기 (theme.js의 전역 AppTheme 사용, 서버 DB와 무관) ---
function initThemeSelector() {
const cards = document.querySelectorAll(".theme-option-card");
if (!cards.length) {
console.warn("[Theme Selector] No theme option cards found");
return;
}
if (!window.AppTheme) {
console.error("[Theme Selector] AppTheme is not available");
return;
}
// 현재 저장된 테마에 active 표시
const applyActiveState = (theme) => {
cards.forEach(card => {
card.classList.toggle("active", card.dataset.themeValue === theme);
});
};
// 초기 테마 상태 적용
const currentTheme = window.AppTheme.get();
applyActiveState(currentTheme);
console.log("[Theme Selector] Initialized with theme:", currentTheme);
cards.forEach(card => {
const selectTheme = () => {
const theme = card.dataset.themeValue;
console.log("[Theme Selector] User selected theme:", theme);
window.AppTheme.set(theme); // localStorage 저장 + 즉시 화면 적용
applyActiveState(theme);
};
card.addEventListener("click", selectTheme);
// 키보드 접근성 (Enter/Space)
card.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectTheme();
}
});
});
}
async function fetchSettings() {
try {
const res = await fetch(`${API_SETTINGS}/`);
if (!res.ok) throw new Error("Failed to fetch settings");
allSettings = await res.json();
renderSettings(allSettings);
} catch (err) {
console.error(err);
document.getElementById("settings-container").innerHTML = `<div style="color:var(--accent-red); text-align:center; padding:20px 0;">설정 정보를 불러오는 도중 오류가 발생했습니다.</div>`;
}
}
function renderSettings(settings) {
const container = document.getElementById("settings-container");
container.innerHTML = "";
// Group settings by category
const grouped = {};
settings.forEach(s => {
if (!grouped[s.setting_group]) {
grouped[s.setting_group] = [];
}
grouped[s.setting_group].push(s);
});
for (const groupName in grouped) {
// Category Header
const categoryDiv = document.createElement("div");
categoryDiv.className = "settings-category";
categoryDiv.innerText = translateGroup(groupName);
container.appendChild(categoryDiv);
// Inputs mapping
grouped[groupName].forEach(s => {
const group = document.createElement("div");
group.className = "form-group";
let inputType = "text";
if (s.setting_type === "integer" || s.setting_type === "float") {
inputType = "number";
}
group.innerHTML = `
<label for="setting-${s.setting_key}">${s.setting_key} (${s.description || "상세 비고 없음"})</label>
<input type="${inputType}" id="setting-${s.setting_key}" data-key="${s.setting_key}" class="form-control" value="${s.setting_value}">
<div class="form-desc">타입: ${s.setting_type}</div>
`;
container.appendChild(group);
});
}
}
function translateGroup(group) {
const dict = {
"mqtt": "MQTT 통신 파라미터 설정",
"gateway": "ESP32 게이트웨이 기기 연동 설정",
"database": "데이터베이스 연동 세부 설정",
"system": "시스템 전역 상용 매개변수"
};
return dict[group] || group;
}
async function saveSettings(e) {
e.preventDefault();
const payload = [];
const inputs = document.querySelectorAll("#settings-container input");
inputs.forEach(input => {
payload.push({
setting_key: input.dataset.key,
setting_value: input.value
});
});
try {
const res = await fetch(`${API_SETTINGS}/`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Saving settings failed");
alert("시스템 설정 정보가 성공적으로 업데이트되었습니다!");
fetchSettings();
} catch (err) {
console.error(err);
alert("설정 적용 도중 오류가 발생했습니다.");
}
}
+409
View File
@@ -0,0 +1,409 @@
// ChemiFactory MES - Supplier Management Frontend Logic
const API_BASE = "/suppliers";
let allSuppliers = [];
let selectedSupplierId = null;
document.addEventListener("DOMContentLoaded", () => {
initEvents();
fetchSuppliers();
});
// APIs Integration
async function fetchSuppliers() {
try {
const res = await fetch(`${API_BASE}/`);
if (!res.ok) throw new Error("Failed to fetch suppliers");
allSuppliers = await res.json();
renderSupplierList(allSuppliers);
} catch (err) {
console.error(err);
showListError();
}
}
async function showSupplierDetail(id) {
try {
selectedSupplierId = id;
const res = await fetch(`${API_BASE}/${id}`);
if (!res.ok) throw new Error("Failed to fetch supplier details");
const data = await res.json();
renderSupplierDetail(data);
} catch (err) {
console.error(err);
alert("공급사 정보를 상세히 불러오지 못했습니다.");
}
}
// Render Lists
function renderSupplierList(list) {
const tbody = document.getElementById("supplier-list-body");
tbody.innerHTML = "";
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="3" style="text-align:center; color:var(--text-muted); padding:30px 0;">등록된 공급사가 없습니다.</td></tr>`;
return;
}
list.forEach(supp => {
const tr = document.createElement("tr");
if (selectedSupplierId === supp.id) tr.className = "active-row";
tr.innerHTML = `
<td><strong>${supp.name_kr}</strong></td>
<td>${supp.contact_number || "-"}</td>
<td>${supp.business_registration_number || "-"}</td>
`;
tr.addEventListener("click", () => {
document.querySelectorAll("#supplier-list-body tr").forEach(el => el.classList.remove("active-row"));
tr.classList.add("active-row");
showSupplierDetail(supp.id);
});
tbody.appendChild(tr);
});
}
function showListError() {
const tbody = document.getElementById("supplier-list-body");
tbody.innerHTML = `<tr><td colspan="3" style="text-align:center; color:var(--accent-red); padding:30px 0;">데이터 통신에 실패했습니다. DB 작동을 확인하십시오.</td></tr>`;
}
function renderSupplierDetail(data) {
// Show details card, hide placeholder
document.getElementById("supplier-placeholder-card").style.display = "none";
document.getElementById("supplier-detail-card").style.display = "block";
const supp = data.supplier;
document.getElementById("detail-name-kr").innerText = supp.name_kr;
document.getElementById("detail-name-en").innerText = supp.name_en || "";
document.getElementById("detail-brn").innerText = supp.business_registration_number || "-";
document.getElementById("detail-contact").innerText = supp.contact_number || "-";
document.getElementById("detail-address").innerText = supp.address || "-";
// Bind Contacts
renderContactsList(data.contacts || []);
// Bind Purchase Activities
renderActivitiesList(data.purchaseActivities || []);
}
function renderContactsList(contacts) {
const tbody = document.getElementById("contact-list-body");
tbody.innerHTML = "";
if (contacts.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:20px 0;">등록된 담당자가 없습니다.</td></tr>`;
return;
}
contacts.forEach(c => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><strong>${c.name}</strong></td>
<td>${c.position || "-"} ${c.work_location ? `(${c.work_location})` : ""}</td>
<td>${c.phone_number || "-"}</td>
<td>${c.email || "-"}</td>
<td>
<button class="btn btn-secondary" style="padding: 2px 6px; font-size:11px;" onclick="openEditContactModal(${JSON.stringify(c).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding: 2px 6px; font-size:11px;" onclick="deleteContact(${c.id})">삭제</button>
</td>
`;
tbody.appendChild(tr);
});
}
function renderActivitiesList(activities) {
const tbody = document.getElementById("activity-list-body");
tbody.innerHTML = "";
if (activities.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color:var(--text-muted); padding:20px 0;">매입 활동 내역이 없습니다.</td></tr>`;
return;
}
// Sort by date descending
activities.sort((a, b) => new Date(b.activity_date) - new Date(a.activity_date));
activities.forEach(act => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${act.activity_date}</td>
<td><span class="badge badge-online">${act.activity_type}</span></td>
<td style="max-width: 250px; white-wrap: wrap;">${act.memo}</td>
<td>
<button class="btn btn-secondary" style="padding: 2px 6px; font-size:11px;" onclick="openEditActivityModal(${JSON.stringify(act).replace(/"/g, '&quot;')})">수정</button>
<button class="btn btn-danger" style="padding: 2px 6px; font-size:11px;" onclick="deleteActivity(${act.id})">삭제</button>
</td>
`;
tbody.appendChild(tr);
});
}
// Event Bindings
function initEvents() {
// Search Filter
document.getElementById("search-supplier").addEventListener("input", (e) => {
const query = e.target.value.toLowerCase();
const filtered = allSuppliers.filter(c =>
c.name_kr.toLowerCase().includes(query) ||
(c.name_en && c.name_en.toLowerCase().includes(query)) ||
(c.business_registration_number && c.business_registration_number.includes(query))
);
renderSupplierList(filtered);
});
// Tab Toggle
document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab-content").forEach(tc => tc.classList.remove("active"));
btn.classList.add("active");
document.getElementById(btn.dataset.tab).classList.add("active");
});
});
// Modals Control
const modals = ["supplier-modal", "contact-modal", "activity-modal"];
modals.forEach(mId => {
const modal = document.getElementById(mId);
modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => {
btn.addEventListener("click", () => modal.style.display = "none");
});
});
// Add Supplier
document.getElementById("btn-add-supplier").addEventListener("click", () => {
document.getElementById("supplier-form").reset();
document.getElementById("supplier-id").value = "";
document.getElementById("supplier-modal-title").innerText = "공급사 등록";
document.getElementById("supplier-modal").style.display = "flex";
});
document.getElementById("supplier-form").addEventListener("submit", handleSupplierSubmit);
// Edit & Delete Supplier
document.getElementById("btn-edit-supplier").addEventListener("click", () => {
const activeSupp = allSuppliers.find(c => c.id === selectedSupplierId);
if (!activeSupp) return;
document.getElementById("supplier-id").value = activeSupp.id;
document.getElementById("supp-name-kr").value = activeSupp.name_kr;
document.getElementById("supp-name-en").value = activeSupp.name_en || "";
document.getElementById("supp-brn").value = activeSupp.business_registration_number || "";
document.getElementById("supp-contact").value = activeSupp.contact_number || "";
document.getElementById("supp-address").value = activeSupp.address || "";
document.getElementById("supplier-modal-title").innerText = "공급사 정보 수정";
document.getElementById("supplier-modal").style.display = "flex";
});
document.getElementById("btn-delete-supplier").addEventListener("click", handleDeleteSupplier);
// Add Contact
document.getElementById("btn-add-contact").addEventListener("click", () => {
document.getElementById("contact-form").reset();
document.getElementById("contact-id").value = "";
document.getElementById("contact-modal-title").innerText = "담당자 등록";
document.getElementById("contact-modal").style.display = "flex";
});
document.getElementById("contact-form").addEventListener("submit", handleContactSubmit);
// Add Activity
document.getElementById("btn-add-activity").addEventListener("click", () => {
document.getElementById("activity-form").reset();
document.getElementById("activity-id").value = "";
document.getElementById("act-date").value = new Date().toISOString().substring(0, 10);
document.getElementById("activity-modal-title").innerText = "매입 일지 등록";
document.getElementById("activity-modal").style.display = "flex";
});
document.getElementById("activity-form").addEventListener("submit", handleActivitySubmit);
}
// Form Handlers
async function handleSupplierSubmit(e) {
e.preventDefault();
const id = document.getElementById("supplier-id").value;
const payload = {
name_kr: document.getElementById("supp-name-kr").value,
name_en: document.getElementById("supp-name-en").value || null,
business_registration_number: document.getElementById("supp-brn").value || null,
contact_number: document.getElementById("supp-contact").value || null,
address: document.getElementById("supp-address").value || null
};
try {
let res;
if (id) {
// Update
res = await fetch(`${API_BASE}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
// Create
res = await fetch(`${API_BASE}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("API error occurred");
const result = await res.json();
document.getElementById("supplier-modal").style.display = "none";
await fetchSuppliers();
if (id) {
showSupplierDetail(parseInt(id));
} else {
showSupplierDetail(result.id);
}
} catch (err) {
console.error(err);
alert("공급사 정보를 저장하는 중 오류가 발생했습니다.");
}
}
async function handleDeleteSupplier() {
if (!selectedSupplierId) return;
if (!confirm("이 공급사를 정말 삭제하시겠습니까?\n해당 공급사의 연락처 및 매입 일지 정보가 영구 삭제됩니다.")) return;
try {
const res = await fetch(`${API_BASE}/${selectedSupplierId}`, {
method: "DELETE"
});
if (!res.ok) throw new Error("Failed to delete supplier");
selectedSupplierId = null;
document.getElementById("supplier-detail-card").style.display = "none";
document.getElementById("supplier-placeholder-card").style.display = "flex";
await fetchSuppliers();
} catch (err) {
console.error(err);
alert("공급사 삭제에 실패했습니다.");
}
}
// Contact Handlers
window.openEditContactModal = function(contact) {
document.getElementById("contact-id").value = contact.id;
document.getElementById("cont-name").value = contact.name;
document.getElementById("cont-position").value = contact.position || "";
document.getElementById("cont-phone").value = contact.phone_number || "";
document.getElementById("cont-email").value = contact.email || "";
document.getElementById("cont-location").value = contact.work_location || "";
document.getElementById("contact-modal-title").innerText = "담당자 정보 수정";
document.getElementById("contact-modal").style.display = "flex";
};
async function handleContactSubmit(e) {
e.preventDefault();
const id = document.getElementById("contact-id").value;
const payload = {
supplier_id: selectedSupplierId,
name: document.getElementById("cont-name").value,
position: document.getElementById("cont-position").value || null,
phone_number: document.getElementById("cont-phone").value || null,
email: document.getElementById("cont-email").value || null,
work_location: document.getElementById("cont-location").value || null
};
try {
let res;
if (id) {
res = await fetch(`${API_BASE}/contacts/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_BASE}/contacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("Contact save API error");
document.getElementById("contact-modal").style.display = "none";
showSupplierDetail(selectedSupplierId);
} catch (err) {
console.error(err);
alert("담당자 저장에 실패했습니다.");
}
}
window.deleteContact = async function(contactId) {
if (!confirm("이 담당자 정보를 정말 삭제하시겠습니까?")) return;
try {
const res = await fetch(`${API_BASE}/contacts/${contactId}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete contact");
showSupplierDetail(selectedSupplierId);
} catch (err) {
console.error(err);
alert("담당자 삭제에 실패했습니다.");
}
};
// Activity Handlers
window.openEditActivityModal = function(act) {
document.getElementById("activity-id").value = act.id;
document.getElementById("act-date").value = act.activity_date;
document.getElementById("act-type").value = act.activity_type;
document.getElementById("act-memo").value = act.memo || "";
document.getElementById("activity-modal-title").innerText = "매입 일지 수정";
document.getElementById("activity-modal").style.display = "flex";
};
async function handleActivitySubmit(e) {
e.preventDefault();
const id = document.getElementById("activity-id").value;
const payload = {
supplier_id: selectedSupplierId,
contact_id: null,
activity_date: document.getElementById("act-date").value,
activity_type: document.getElementById("act-type").value,
memo: document.getElementById("act-memo").value
};
try {
let res;
if (id) {
res = await fetch(`${API_BASE}/activities/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} else {
res = await fetch(`${API_BASE}/activities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
if (!res.ok) throw new Error("Activity save API error");
document.getElementById("activity-modal").style.display = "none";
showSupplierDetail(selectedSupplierId);
} catch (err) {
console.error(err);
alert("일지 저장에 실패했습니다.");
}
}
window.deleteActivity = async function(actId) {
if (!confirm("이 매입 일지를 삭제하시겠습니까?")) return;
try {
const res = await fetch(`${API_BASE}/activities/${actId}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete activity");
showSupplierDetail(selectedSupplierId);
} catch (err) {
console.error(err);
alert("일지 삭제에 실패했습니다.");
}
};
+59
View File
@@ -0,0 +1,59 @@
/* --- 테마 통합 관리 모듈 ---
* 라이트/다크 테마를 localStorage 기반으로 관리한다. (DB 저장 없음)
* 이 스크립트는 각 페이지 <head> 최상단(base.css 링크보다 먼저)에서 로드되어야
* FOWT(Flash Of Wrong Theme, 잘못된 테마가 순간적으로 보이는 현상)를 방지한다.
*/
(function () {
"use strict";
var THEME_KEY = "app-theme";
var DEFAULT_THEME = "light"; // 기본값: 화이트 계열
var VALID_THEMES = ["light", "dark"];
/** localStorage에서 저장된 테마를 읽는다. 없거나 잘못된 값이면 기본값 반환. */
function getStoredTheme() {
var stored;
try {
stored = window.localStorage.getItem(THEME_KEY);
} catch (e) {
stored = null; // localStorage 접근 불가(사생활 보호 모드 등) 시 안전하게 기본값
}
return VALID_THEMES.indexOf(stored) !== -1 ? stored : DEFAULT_THEME;
}
/** <html> 요소에 data-theme 속성을 적용한다. (실제 화면 반영) */
function applyTheme(theme) {
if (VALID_THEMES.indexOf(theme) === -1) {
theme = DEFAULT_THEME;
}
document.documentElement.setAttribute("data-theme", theme);
}
/** 테마를 저장하고 즉시 화면에 적용한다. */
function setTheme(theme) {
if (VALID_THEMES.indexOf(theme) === -1) {
theme = DEFAULT_THEME;
}
try {
window.localStorage.setItem(THEME_KEY, theme);
} catch (e) {
/* 저장 실패해도 현재 세션 적용은 계속 진행 */
}
applyTheme(theme);
}
// 로드 즉시 동기 실행 → FOWT 방지 (DOMContentLoaded를 기다리지 않음)
applyTheme(getStoredTheme());
// 다른 스크립트(setting.js 등)에서 사용할 수 있도록 전역 노출
try {
window.AppTheme = {
get: getStoredTheme,
set: setTheme,
apply: applyTheme,
THEMES: VALID_THEMES.slice()
};
} catch (e) {
console.warn("[Theme] Failed to expose AppTheme to window:", e);
}
})();