초기화
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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, '"')})">수정</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, '"')})">수정</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("일지 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("기기 삭제에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
@@ -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("서버 통신 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
@@ -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, '"')})">수정</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, '"')})">수정</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("재고 소진 처리에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
@@ -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, '"')})">수정</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}`);
|
||||
}
|
||||
};
|
||||
@@ -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, '"')})">
|
||||
<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;
|
||||
});
|
||||
}
|
||||
@@ -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("설정 적용 도중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
@@ -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, '"')})">수정</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, '"')})">수정</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("일지 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user