초기화
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChemiFactory 생산성 및 비용 시뮬레이터 (계산기)</title>
|
||||
<!-- Google Fonts Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
|
||||
<script src="js/theme.js"></script>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<style>
|
||||
.analysis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
.analysis-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-control {
|
||||
background-color: var(--overlay-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
|
||||
/* 결과 박스 레이아웃 */
|
||||
.result-section {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.result-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.result-card {
|
||||
background-color: var(--overlay-subtle);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.result-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.result-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.result-value.profit {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.result-value.cost {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 공통 사이드바 -->
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<!-- 메인 콘텐츠 영역 -->
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<div class="page-title">
|
||||
<h1>생산성 및 비용 시뮬레이터 (계산/분석)</h1>
|
||||
<p>기기 가동 물성 변수들을 조정하여 최종 생산 소요 기간 및 기대 회사 마진을 미리 측정합니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="analysis-grid">
|
||||
<!-- 가동 물성 입력 카드 -->
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-top:0;">물성 변수 입력</h2>
|
||||
<form id="simulator-form">
|
||||
<div class="form-group">
|
||||
<label for="sim-yarn">사용 예정 원사 자재 (재고) *</label>
|
||||
<select id="sim-yarn" class="form-control" required></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sim-bond">사용 예정 본드 자재 (선택)</label>
|
||||
<select id="sim-bond" class="form-control"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sim-qty">목표 생산량 (kg) *</label>
|
||||
<input type="number" id="sim-qty" value="1000" class="form-control" required>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
|
||||
<div class="form-group">
|
||||
<label for="sim-dia">원사 선경 (㎛) *</label>
|
||||
<input type="number" id="sim-dia" value="7.0" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sim-k">K수 (K 번수) *</label>
|
||||
<input type="number" id="sim-k" value="12" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
|
||||
<div class="form-group">
|
||||
<label for="sim-ports">포트 수 (Ports) *</label>
|
||||
<input type="number" id="sim-ports" value="40" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sim-hz">사이클 타임 (Hz) *</label>
|
||||
<input type="number" id="sim-hz" value="8.0" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
|
||||
<div class="form-group">
|
||||
<label for="sim-cut">커팅 길이 (mm) *</label>
|
||||
<input type="number" id="sim-cut" value="6.0" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sim-hours">일 작업 시간 *</label>
|
||||
<input type="number" id="sim-hours" value="16" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
|
||||
<div class="form-group">
|
||||
<label for="sim-machines">투입 예정 설비 대수 *</label>
|
||||
<input type="number" id="sim-machines" value="2" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sim-bond-pct">본드 혼합비 (%)</label>
|
||||
<input type="number" id="sim-bond-pct" value="3.0" step="0.1" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width:100%; padding: 12px 0; font-weight:700;">시뮬레이션 가동 진단</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 시뮬레이션 산출 보고서 카드 -->
|
||||
<div class="card" style="min-height:500px; display:flex; flex-direction:column; justify-content:space-between;">
|
||||
<div>
|
||||
<h2 class="section-title" style="margin-top:0; color:var(--accent-blue);">가동 연산 결과 보고서</h2>
|
||||
<div class="result-card-grid">
|
||||
<div class="result-card">
|
||||
<span class="result-label">소요 작업일 (필요일수)</span>
|
||||
<span class="result-value" id="res-days">- 일</span>
|
||||
</div>
|
||||
<div class="result-card">
|
||||
<span class="result-label">총 가공비 (Processing Fee)</span>
|
||||
<span class="result-value" id="res-fee">- 원</span>
|
||||
</div>
|
||||
<div class="result-card">
|
||||
<span class="result-label">예상 총매출 (Estimated Sales)</span>
|
||||
<span class="result-value" id="res-sales">- 원</span>
|
||||
</div>
|
||||
<div class="result-card">
|
||||
<span class="result-label">회사 영업 이익 (Margin)</span>
|
||||
<span class="result-value profit" id="res-margin">- 원</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section">
|
||||
<h3 style="font-size: 14px; margin-bottom: 12px; color:var(--text-secondary);">원자재 소요 비용</h3>
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:16px;">
|
||||
<div class="result-card">
|
||||
<span class="result-label">원사 구매 소요 비용</span>
|
||||
<span class="result-value cost" id="res-yarn-cost">- 원</span>
|
||||
</div>
|
||||
<div class="result-card">
|
||||
<span class="result-label">본드 구매 소요 비용</span>
|
||||
<span class="result-value cost" id="res-bond-cost">- 원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px; font-size:12px; color:var(--text-muted); border-top:1px solid var(--border-color); padding-top:16px;">
|
||||
※ 본 연산 시뮬레이터 결과치는 등록된 자재 매입 원장(Inventory)의 단가와 밀도 기준에 의해 자동으로 산출되며, 실제 생산 여건에 따라 오차가 있을 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 만능 조절 & 건조 전력 계산기 그리드 -->
|
||||
<div class="analysis-grid" style="margin-top: 30px;">
|
||||
<!-- 만능 조절 계산기 -->
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-top:0; color:var(--accent-green);">만능 조절 계산기</h2>
|
||||
<form id="adjustment-form">
|
||||
<div class="form-group">
|
||||
<label for="adj-raw-conc">원자재 본드 농도 (%) *</label>
|
||||
<input type="number" id="adj-raw-conc" value="40" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
|
||||
<div class="form-group">
|
||||
<label for="adj-curr-vol">현재 수조 부피 (L) *</label>
|
||||
<input type="number" id="adj-curr-vol" value="100" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adj-curr-conc">현재 수조 농도 (%) *</label>
|
||||
<input type="number" id="adj-curr-conc" value="15" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
|
||||
<div class="form-group">
|
||||
<label for="adj-tgt-vol">목표 부피 (L) *</label>
|
||||
<input type="number" id="adj-tgt-vol" value="150" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adj-tgt-conc">목표 농도 (%) *</label>
|
||||
<input type="number" id="adj-tgt-conc" value="20" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%; padding: 10px 0; font-weight:700; background-color:var(--accent-green); border:none;">조절 수량 연산</button>
|
||||
</form>
|
||||
|
||||
<div class="result-section" id="adj-result-box" style="display:none;">
|
||||
<h3 style="font-size: 13px; margin-bottom: 12px; color:var(--accent-green);">조절 연산 결과</h3>
|
||||
<div style="display:flex; flex-direction:column; gap:6px; font-size:13px;" id="adj-results">
|
||||
<!-- JS 결과 바인딩 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전기 건조 필요 전력 계산기 -->
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-top:0; color:var(--accent-orange);">전기 건조 필요 전력 계산기</h2>
|
||||
<form id="dryer-form">
|
||||
<div class="form-group">
|
||||
<label for="dry-speed">원사 이송 속도 (mm/s) *</label>
|
||||
<input type="number" id="dry-speed" value="60" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dry-conc">용액의 농도 (%) *</label>
|
||||
<input type="number" id="dry-conc" value="20" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap:12px;">
|
||||
<div class="form-group">
|
||||
<label for="dry-usage">일간 용액 사용량 (L/day) *</label>
|
||||
<input type="number" id="dry-usage" value="15" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dry-loss">Loss Factor (%) *</label>
|
||||
<input type="number" id="dry-loss" value="10" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%; padding: 10px 0; font-weight:700; background-color:var(--accent-orange); border:none;">필요 전력 연산</button>
|
||||
</form>
|
||||
|
||||
<div class="result-section" id="dry-result-box" style="display:none;">
|
||||
<h3 style="font-size: 13px; margin-bottom: 12px; color:var(--accent-orange);">소요 전력 연산 결과</h3>
|
||||
<div style="display:flex; flex-direction:column; gap:6px; font-size:13px;" id="dry-results">
|
||||
<!-- JS 결과 바인딩 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 연결 -->
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/analysis.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,390 @@
|
||||
/* --- Sleek Dark & Glassmorphism Design System --- */
|
||||
/*
|
||||
* 테마 색상은 CSS 변수로 통합 관리한다.
|
||||
* :root = 다크 테마(기본 상수), [data-theme="light"] = 라이트 테마 오버라이드.
|
||||
* data-theme 속성은 js/theme.js가 <html> 요소에 설정한다.
|
||||
* 색상 하드코딩 금지 — 반드시 아래 시맨틱 변수를 사용할 것.
|
||||
*/
|
||||
:root {
|
||||
/* 배경 / 표면 */
|
||||
--bg-dark: #0b0f19;
|
||||
--bg-card: #151c2c;
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--border-focus: #3b82f6;
|
||||
|
||||
/* 텍스트 */
|
||||
--text-primary: #f3f4f6;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
|
||||
/* 강조색 (accent) */
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-blue-hover: #2563eb;
|
||||
--accent-blue-glow: rgba(59, 130, 246, 0.35);
|
||||
--accent-green: #10b981;
|
||||
--accent-green-glow: rgba(16, 185, 129, 0.35);
|
||||
--accent-red: #ef4444;
|
||||
--accent-red-glow: rgba(239, 68, 68, 0.35);
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-blue-soft: #60a5fa; /* 밝은 블루 텍스트 (plan.html 등) */
|
||||
--accent-red-soft: #f87171; /* 밝은 레드 텍스트 (plan.html 등) */
|
||||
|
||||
/* 시맨틱 오버레이 / 상태 (테마별로 명암 반전) */
|
||||
--overlay-subtle: rgba(255, 255, 255, 0.02); /* 아주 옅은 표면 틴트 */
|
||||
--overlay-hover: rgba(255, 255, 255, 0.05); /* hover / 입력 배경 */
|
||||
--overlay-strong: rgba(255, 255, 255, 0.08); /* 강한 hover / focus 배경 */
|
||||
--overlay-border: rgba(255, 255, 255, 0.15); /* 강조 테두리 */
|
||||
--nav-active-bg: rgba(59, 130, 246, 0.15); /* 활성 메뉴 배경 */
|
||||
--card-shadow: rgba(0, 0, 0, 0.2); /* 카드 hover 그림자 */
|
||||
--modal-backdrop: rgba(8, 15, 30, 0.82); /* 모달 뒷배경 */
|
||||
--modal-bg: #111827; /* 모달 본체 배경 */
|
||||
--text-on-accent: #ffffff; /* accent 위 텍스트(버튼 등) */
|
||||
--toggle-off-bg: #374151; /* 토글 OFF 배경 */
|
||||
--danger-solid: #dc2626; /* 삭제 버튼 등 솔리드 위험색 */
|
||||
--badge-green-bg: rgba(16, 185, 129, 0.1);
|
||||
--badge-green-border: rgba(16, 185, 129, 0.2);
|
||||
--badge-red-bg: rgba(239, 68, 68, 0.1);
|
||||
--badge-red-border: rgba(239, 68, 68, 0.2);
|
||||
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-collapsed-width: 70px;
|
||||
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* --- Light Theme (기본값) --- */
|
||||
/*
|
||||
* 완전한 순백이 아니라 은은한 블루-그레이 톤을 사용해
|
||||
* 다크 테마(남색 계열)와 통일감을 준다.
|
||||
*/
|
||||
[data-theme="light"] {
|
||||
--bg-dark: #eef1f6; /* 페이지 배경: 옅은 블루-그레이 */
|
||||
--bg-card: #ffffff; /* 카드/사이드바: 흰색 표면 */
|
||||
--border-color: rgba(15, 23, 42, 0.10);
|
||||
--border-focus: #3b82f6;
|
||||
|
||||
--text-primary: #1e293b; /* 진한 슬레이트 */
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-blue-hover: #2563eb;
|
||||
--accent-blue-glow: rgba(59, 130, 246, 0.25);
|
||||
--accent-green: #059669;
|
||||
--accent-green-glow: rgba(16, 185, 129, 0.25);
|
||||
--accent-red: #dc2626;
|
||||
--accent-red-glow: rgba(239, 68, 68, 0.25);
|
||||
--accent-orange: #d97706;
|
||||
--accent-blue-soft: #2563eb;
|
||||
--accent-red-soft: #dc2626;
|
||||
|
||||
/* 오버레이는 어두운 색 기반으로 반전 (밝은 배경 위에서 보이도록) */
|
||||
--overlay-subtle: rgba(15, 23, 42, 0.02);
|
||||
--overlay-hover: rgba(15, 23, 42, 0.04);
|
||||
--overlay-strong: rgba(15, 23, 42, 0.07);
|
||||
--overlay-border: rgba(15, 23, 42, 0.14);
|
||||
--nav-active-bg: rgba(59, 130, 246, 0.12);
|
||||
--card-shadow: rgba(15, 23, 42, 0.10);
|
||||
--modal-backdrop: rgba(15, 23, 42, 0.45);
|
||||
--modal-bg: #ffffff;
|
||||
--text-on-accent: #ffffff;
|
||||
--toggle-off-bg: #cbd5e1;
|
||||
--danger-solid: #dc2626;
|
||||
--badge-green-bg: rgba(5, 150, 105, 0.12);
|
||||
--badge-green-border: rgba(5, 150, 105, 0.28);
|
||||
--badge-red-bg: rgba(220, 38, 38, 0.10);
|
||||
--badge-red-border: rgba(220, 38, 38, 0.24);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-family);
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* --- Layout --- */
|
||||
.app-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- Sidebar --- */
|
||||
aside.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background-color: var(--bg-card);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--accent-blue-soft), var(--accent-blue));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
padding: 20px 12px;
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-links li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.nav-item-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-item-link:hover {
|
||||
background-color: var(--overlay-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item-link.active {
|
||||
background-color: var(--nav-active-bg);
|
||||
color: var(--accent-blue);
|
||||
font-weight: 600;
|
||||
border-left: 3px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
font-size: 24px;
|
||||
background: var(--overlay-hover);
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- Main Content Area --- */
|
||||
main.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
width: calc(100% - var(--sidebar-width));
|
||||
flex-grow: 1;
|
||||
padding: 40px;
|
||||
min-height: 100vh;
|
||||
transition: margin-left 0.3s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
header.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.page-title p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* --- Responsive Layout (Grid) --- */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px var(--card-shadow);
|
||||
border-color: var(--overlay-border);
|
||||
}
|
||||
|
||||
/* --- Status Badges --- */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-online {
|
||||
background-color: var(--badge-green-bg);
|
||||
color: var(--accent-green);
|
||||
border: 1px solid var(--badge-green-border);
|
||||
}
|
||||
|
||||
.badge-offline {
|
||||
background-color: var(--badge-red-bg);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid var(--badge-red-border);
|
||||
}
|
||||
|
||||
/* --- CSS Forms & UI Kit --- */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent-blue);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-blue-hover);
|
||||
box-shadow: 0 0 12px var(--accent-blue-glow);
|
||||
}
|
||||
|
||||
/* --- Common Form Inputs & Selects --- */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-control {
|
||||
background-color: var(--overlay-hover) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
color: var(--text-primary) !important;
|
||||
padding: 10px 14px !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 14px !important;
|
||||
width: 100% !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none !important;
|
||||
border-color: var(--border-focus) !important;
|
||||
background-color: var(--overlay-strong) !important;
|
||||
}
|
||||
select.form-control {
|
||||
appearance: none !important;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E") !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: right 14px center !important;
|
||||
background-size: 16px !important;
|
||||
padding-right: 40px !important;
|
||||
}
|
||||
select.form-control option {
|
||||
background-color: var(--bg-card) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
/* 라이트 테마에서 select 화살표 색을 밝은 배경에 맞게 어둡게 */
|
||||
[data-theme="light"] select.form-control {
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E") !important;
|
||||
}
|
||||
|
||||
/* --- Responsive Media Queries --- */
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
aside.sidebar {
|
||||
width: var(--sidebar-collapsed-width);
|
||||
}
|
||||
.brand-name, .nav-text, .user-info {
|
||||
display: none;
|
||||
}
|
||||
main.main-content {
|
||||
margin-left: var(--sidebar-collapsed-width);
|
||||
padding: 20px;
|
||||
}
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChemiFactory 고객사 관리</title>
|
||||
<!-- Google Fonts Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
|
||||
<script src="js/theme.js"></script>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<style>
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.search-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.form-control {
|
||||
background-color: var(--overlay-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
.grid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
.grid-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
.list-table th, .list-table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
}
|
||||
.list-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.list-table tr {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.list-table tr:hover {
|
||||
background-color: var(--overlay-subtle);
|
||||
}
|
||||
.list-table tr.active-row {
|
||||
background-color: var(--nav-active-bg);
|
||||
border-left: 3px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
/* 탭 구조 */
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: var(--accent-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: var(--accent-blue);
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--modal-backdrop);
|
||||
backdrop-filter: blur(4px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 10px 30px var(--card-shadow);
|
||||
animation: modalFadeIn 0.3s ease;
|
||||
}
|
||||
@keyframes modalFadeIn {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.close-btn {
|
||||
font-size: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: var(--overlay-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: var(--accent-red);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: var(--danger-solid);
|
||||
box-shadow: 0 0 12px var(--accent-red-glow);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 공통 사이드바 -->
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<!-- 메인 콘텐츠 영역 -->
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<div class="page-title">
|
||||
<h1>고객사 관리</h1>
|
||||
<p>고객사 등록 정보, 담당자 연락망 및 거래 영업 활동 일지를 관리합니다.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-add-customer">+ 고객사 추가</button>
|
||||
</header>
|
||||
|
||||
<div class="grid-layout">
|
||||
<!-- 왼쪽: 고객사 목록 -->
|
||||
<div class="card">
|
||||
<div class="header-row">
|
||||
<h2 class="section-title" style="margin:0;">고객사 목록</h2>
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-customer" class="form-control" placeholder="고객사명 검색...">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>고객사명(한글)</th>
|
||||
<th>대표 번호</th>
|
||||
<th>등록번호</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="customer-list-body">
|
||||
<tr>
|
||||
<td colspan="3" style="text-align: center; color: var(--text-muted); padding: 30px 0;">데이터를 불러오는 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 선택된 고객사 상세정보 -->
|
||||
<div class="card" id="customer-detail-card" style="display: none;">
|
||||
<div class="header-row" style="border-bottom: 1px solid var(--border-color); padding-bottom: 12px; margin-bottom: 16px;">
|
||||
<div>
|
||||
<h2 id="detail-name-kr" style="margin: 0; font-size: 20px; font-weight: 700;">고객사명</h2>
|
||||
<p id="detail-name-en" style="color: var(--text-secondary); font-size: 13px; margin: 4px 0 0 0;">English Name</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-secondary" id="btn-edit-customer" style="padding: 6px 12px; font-size: 13px;">수정</button>
|
||||
<button class="btn btn-danger" id="btn-delete-customer" style="padding: 6px 12px; font-size: 13px;">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 24px; font-size: 14px;">
|
||||
<p style="margin-bottom: 8px;"><strong style="color: var(--text-secondary);">사업자등록번호:</strong> <span id="detail-brn"></span></p>
|
||||
<p style="margin-bottom: 8px;"><strong style="color: var(--text-secondary);">대표 연락처:</strong> <span id="detail-contact"></span></p>
|
||||
<p style="margin-bottom: 8px;"><strong style="color: var(--text-secondary);">주소:</strong> <span id="detail-address"></span></p>
|
||||
</div>
|
||||
|
||||
<!-- 탭메뉴 -->
|
||||
<div class="tabs-header">
|
||||
<button class="tab-btn active" data-tab="tab-contacts">담당자 정보</button>
|
||||
<button class="tab-btn" data-tab="tab-activities">영업/거래 일지</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 1: 담당자 정보 -->
|
||||
<div id="tab-contacts" class="tab-content active">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h3 style="font-size: 15px; font-weight: 600;">담당자 연락처 목록</h3>
|
||||
<button class="btn btn-primary" id="btn-add-contact" style="padding: 6px 12px; font-size: 12px;">+ 담당자 추가</button>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="list-table" style="font-size: 13px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>직급/소속</th>
|
||||
<th>연락처</th>
|
||||
<th>이메일</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="contact-list-body">
|
||||
<!-- 담당자 정보 리스트 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 2: 영업 활동 기록 -->
|
||||
<div id="tab-activities" class="tab-content">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h3 style="font-size: 15px; font-weight: 600;">활동 내역 이력</h3>
|
||||
<button class="btn btn-primary" id="btn-add-activity" style="padding: 6px 12px; font-size: 12px;">+ 일지 추가</button>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="list-table" style="font-size: 13px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>활동 구분</th>
|
||||
<th>상세 내용 및 메모</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="activity-list-body">
|
||||
<!-- 영업 활동 이력 리스트 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 대기 안내 카드 -->
|
||||
<div class="card" id="customer-placeholder-card" style="display: flex; align-items: center; justify-content: center; height: 300px; color: var(--text-secondary);">
|
||||
<div>왼쪽 목록에서 고객사를 선택하시면 상세 내역을 확인할 수 있습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 고객사 추가/수정 모달 -->
|
||||
<div id="customer-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="customer-modal-title">고객사 등록</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="customer-form">
|
||||
<input type="hidden" id="customer-id">
|
||||
<div class="form-group">
|
||||
<label for="cust-name-kr">고객사 한글명 *</label>
|
||||
<input type="text" id="cust-name-kr" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cust-name-en">고객사 영문명</label>
|
||||
<input type="text" id="cust-name-en" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cust-brn">사업자등록번호</label>
|
||||
<input type="text" id="cust-brn" class="form-control" placeholder="123-45-67890">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cust-contact">대표 연락처</label>
|
||||
<input type="text" id="cust-contact" class="form-control" placeholder="02-1234-5678">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cust-address">회사 주소</label>
|
||||
<input type="text" id="cust-address" class="form-control">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 담당자 추가/수정 모달 -->
|
||||
<div id="contact-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="contact-modal-title">담당자 등록</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="contact-form">
|
||||
<input type="hidden" id="contact-id">
|
||||
<div class="form-group">
|
||||
<label for="cont-name">담당자 이름 *</label>
|
||||
<input type="text" id="cont-name" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cont-position">직급/부서</label>
|
||||
<input type="text" id="cont-position" class="form-control" placeholder="예: 구매팀 과장">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cont-phone">연락처</label>
|
||||
<input type="text" id="cont-phone" class="form-control" placeholder="010-1234-5678">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cont-email">이메일 주소</label>
|
||||
<input type="email" id="cont-email" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cont-location">근무처/사무실 위치</label>
|
||||
<input type="text" id="cont-location" class="form-control" placeholder="예: 본사 2층">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 영업 활동 추가/수정 모달 -->
|
||||
<div id="activity-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="activity-modal-title">활동 일지 등록</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="activity-form">
|
||||
<input type="hidden" id="activity-id">
|
||||
<div class="form-group">
|
||||
<label for="act-date">활동 날짜 *</label>
|
||||
<input type="date" id="act-date" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="act-type">활동 구분 *</label>
|
||||
<select id="act-type" class="form-control" required>
|
||||
<option value="미팅">미팅 / 방문</option>
|
||||
<option value="전화상담">전화 상담</option>
|
||||
<option value="메일전송">메일 / 문서 전송</option>
|
||||
<option value="계약 체결">계약 체결</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="act-memo">메모 / 상담 내용 *</label>
|
||||
<textarea id="act-memo" class="form-control" style="height: 100px; resize: none;" required></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 연결 -->
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/customer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,333 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChemiFactory 설비 기기 관리</title>
|
||||
<!-- Google Fonts Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
|
||||
<script src="js/theme.js"></script>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<style>
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--overlay-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0 14px;
|
||||
width: 300px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.search-container:focus-within {
|
||||
border-color: var(--border-focus);
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
.search-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
padding: 10px 0;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.search-icon {
|
||||
margin-right: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Device Cards Grid */
|
||||
.device-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.device-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 220px;
|
||||
}
|
||||
.device-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px var(--card-shadow);
|
||||
border-color: var(--overlay-border);
|
||||
}
|
||||
.device-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.device-name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.device-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background-color: var(--nav-active-bg);
|
||||
color: var(--accent-blue);
|
||||
border: 1px solid var(--accent-blue-glow);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.device-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.detail-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.detail-value {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--modal-backdrop);
|
||||
backdrop-filter: blur(4px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 95%;
|
||||
max-width: 550px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 30px var(--card-shadow);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.close-btn {
|
||||
font-size: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-control {
|
||||
background-color: var(--overlay-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.action-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: var(--overlay-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: var(--badge-red-bg);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid var(--badge-red-border);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: var(--badge-red-bg);
|
||||
box-shadow: 0 0 10px var(--accent-red-glow);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 공통 사이드바 -->
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<!-- 메인 콘텐츠 영역 -->
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<div class="page-title">
|
||||
<h1>설비 기기 마스터 관리</h1>
|
||||
<p>공장에 등록된 카본 절단 설비의 기본 사양 및 전장 파라미터를 등록하고 편집합니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="header-row">
|
||||
<div class="search-container">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input type="text" id="search-input" class="search-input" placeholder="기기명 또는 건조타입 검색...">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-add-device">+ 기기 추가</button>
|
||||
</div>
|
||||
|
||||
<!-- 설비 목록 그리드 -->
|
||||
<div class="device-grid" id="device-grid-container">
|
||||
<!-- 설비 카드 동적 바인딩 -->
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 기기 등록/수정 다이얼로그 모달 -->
|
||||
<div id="device-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="modal-title">신규 기기 등록</h3>
|
||||
<button class="close-btn" id="close-modal-x">×</button>
|
||||
</div>
|
||||
<form id="device-form">
|
||||
<input type="hidden" id="device-id">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width">
|
||||
<label for="device-name">설비명 *</label>
|
||||
<input type="text" id="device-name" class="form-control" placeholder="예: 양산 3호기" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-bobbins">원사 보빈 수 *</label>
|
||||
<input type="number" id="device-bobbins" class="form-control" value="10" min="1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-dryer">건조기 타입</label>
|
||||
<input type="text" id="device-dryer" class="form-control" value="전기" placeholder="예: 전기, 열풍">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-power">사용 전력 (kW)</label>
|
||||
<input type="number" id="device-power" class="form-control" value="5.0" step="0.1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-max-cut">최대 절단 속도 (rpm)</label>
|
||||
<input type="number" id="device-max-cut" class="form-control" value="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-max-feed">최대 피드 속도 (mm/s)</label>
|
||||
<input type="number" id="device-max-feed" class="form-control" value="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-ip">IP 주소 *</label>
|
||||
<input type="text" id="device-ip" class="form-control" value="192.168.1.50" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-prefix">MQTT 라인 Prefix *</label>
|
||||
<input type="text" id="device-prefix" class="form-control" value="line1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="device-status">동작 상태</label>
|
||||
<select id="device-status" class="form-control">
|
||||
<option value="available">가동 가능</option>
|
||||
<option value="maintenance">점검 중</option>
|
||||
<option value="broken">고장/정지</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-danger" id="btn-delete-device" style="display:none;">삭제</button>
|
||||
<span id="delete-spacer" style="display:none; flex-grow:1;"></span>
|
||||
<div class="action-right">
|
||||
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공통 및 디바이스 관리 JS 연결 -->
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/device.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,728 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>설비 관리 및 실시간 HMI - ChemiFactory</title>
|
||||
<!-- Google Fonts Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
|
||||
<script src="js/theme.js"></script>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<style>
|
||||
/* 설비 카드 그리드 전용 스타일 */
|
||||
.equip-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.equip-card {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 180px;
|
||||
}
|
||||
.equip-card.active-card {
|
||||
border-color: var(--accent-blue);
|
||||
box-shadow: 0 0 15px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.equip-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.equip-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.equip-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.equip-status-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* HMI 디테일 분할 레이아웃 */
|
||||
.hmi-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
gap: 24px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.hmi-section {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
.hmi-panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 실시간 램프 그리드 */
|
||||
.lamp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.lamp-card {
|
||||
background: var(--overlay-subtle);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.lamp-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.lamp-indicator {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--toggle-off-bg); /* OFF 상태 */
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.lamp-indicator.active {
|
||||
background-color: var(--accent-green);
|
||||
box-shadow: 0 0 10px var(--accent-green-glow);
|
||||
}
|
||||
|
||||
/* 제어 버튼 폼 */
|
||||
.control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#hmi-detail-panel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.initialization-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--modal-backdrop);
|
||||
backdrop-filter: blur(2px);
|
||||
border-radius: 10px;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.initialization-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.initialization-overlay-message {
|
||||
padding: 22px 30px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.65);
|
||||
border-radius: 10px;
|
||||
background: var(--modal-bg);
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
line-height: 1.7;
|
||||
box-shadow: 0 12px 30px var(--card-shadow);
|
||||
}
|
||||
.btn-control {
|
||||
padding: 14px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--overlay-subtle);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.btn-control:hover {
|
||||
background: var(--overlay-strong);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
.btn-control:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.btn-control.momentary-active {
|
||||
background: rgba(59, 130, 246, 0.2) !important;
|
||||
border-color: var(--accent-blue) !important;
|
||||
box-shadow: 0 0 10px var(--accent-blue-glow);
|
||||
}
|
||||
|
||||
/* D영역 수치 입출력 */
|
||||
.register-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.register-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--overlay-subtle);
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.register-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lamp-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.input-inline {
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.input-inline:focus {
|
||||
border-color: var(--accent-blue);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--modal-backdrop);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 90%;
|
||||
max-width: 450px;
|
||||
box-shadow: 0 10px 30px var(--card-shadow);
|
||||
}
|
||||
.modal-header {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
background-color: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: var(--accent-blue);
|
||||
outline: none;
|
||||
}
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* HMI Functional Groups Styling */
|
||||
.group-tab-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
background: var(--overlay-subtle);
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
width: fit-content;
|
||||
}
|
||||
.btn-tab {
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-tab.active {
|
||||
background: var(--accent-blue);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: 0 0 10px var(--accent-blue-glow);
|
||||
}
|
||||
|
||||
.hmi-groups-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.hmi-group-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hmi-group-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* HMI Button with Integrated Status Glow */
|
||||
.btn-hmi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: var(--overlay-subtle);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-hmi:hover {
|
||||
background: var(--overlay-strong);
|
||||
}
|
||||
.btn-hmi:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
/* HMI Lamp Base (Global) */
|
||||
.hmi-lamp {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--toggle-off-bg);
|
||||
box-shadow: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.hmi-lamp.active {
|
||||
background-color: var(--accent-green);
|
||||
box-shadow: 0 0 8px var(--accent-green-glow);
|
||||
}
|
||||
.hmi-lamp.active-red {
|
||||
background-color: var(--accent-red);
|
||||
box-shadow: 0 0 8px var(--accent-red-glow);
|
||||
}
|
||||
|
||||
/* HMI Button with Integrated Status Glow */
|
||||
.btn-hmi.active {
|
||||
border-color: var(--accent-green);
|
||||
}
|
||||
.btn-hmi.active .hmi-lamp {
|
||||
background-color: var(--accent-green);
|
||||
box-shadow: 0 0 8px var(--accent-green-glow);
|
||||
}
|
||||
.btn-hmi.active-red {
|
||||
border-color: var(--accent-red);
|
||||
}
|
||||
.btn-hmi.active-red .hmi-lamp {
|
||||
background-color: var(--accent-red);
|
||||
box-shadow: 0 0 8px var(--accent-red-glow);
|
||||
}
|
||||
|
||||
/* Compact Port Hanger Grid */
|
||||
.port-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.port-cell {
|
||||
padding: 12px 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
background: var(--overlay-subtle);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
.port-cell:hover {
|
||||
background: var(--overlay-strong);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
.port-cell.port-active-auto-down {
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--accent-blue);
|
||||
box-shadow: inset 0 0 8px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.port-cell.port-active-manual-down {
|
||||
border-color: var(--accent-orange);
|
||||
color: var(--accent-orange);
|
||||
box-shadow: inset 0 0 8px rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
.port-cell.port-active-manual-up {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
box-shadow: inset 0 0 8px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
/* Popover Overlay */
|
||||
.hmi-popover {
|
||||
position: absolute;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 10px 25px var(--card-shadow);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
z-index: 500;
|
||||
width: 130px;
|
||||
}
|
||||
.hmi-popover button {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.hmi-groups-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 공통 사이드바 -->
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<div class="page-title">
|
||||
<h1 id="main-page-title">설비 및 실시간 HMI 제어</h1>
|
||||
<p id="main-page-desc">등록된 설비 리스트 확인 및 개별 HMI 원격 제어 패널</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 설비 목록 그리드 -->
|
||||
<div class="equip-grid" id="equipment-cards-container">
|
||||
<!-- 동적 로드 -->
|
||||
</div>
|
||||
|
||||
<!-- HMI 상세 원격 제어 판넬 (설비 선택 시 노출) -->
|
||||
<div id="hmi-detail-panel" style="display: none; margin-top: 15px;">
|
||||
<div id="initialization-overlay" class="initialization-overlay" aria-hidden="true">
|
||||
<div class="initialization-overlay-message">설비 초기화 중<br><span style="font-size:13px; font-weight:400; color:var(--text-secondary);">PLC 상태 동기화가 완료될 때까지 조작할 수 없습니다.</span></div>
|
||||
</div>
|
||||
<!-- A/B 그룹 제어 토글 탭, 목록 복귀 & 수동 초기화 버튼 -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; flex-wrap: wrap; gap: 12px;">
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
<button class="btn" style="background: var(--overlay-hover); border: 1px solid var(--border-color); color: var(--text-primary); padding: 8px 16px; font-weight: 600; font-size: 14px;" onclick="goBackToEquipmentList()">📋 목록</button>
|
||||
<div class="group-tab-container" style="margin-bottom: 0;">
|
||||
<button class="btn-tab active" id="btn-tab-a" onclick="setHmiActiveGroup('A')">A그룹</button>
|
||||
<button class="btn-tab" id="btn-tab-b" onclick="setHmiActiveGroup('B')">B그룹</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" style="background: var(--nav-active-bg); border: 1px solid var(--accent-blue); color: var(--accent-blue); padding: 8px 16px; font-weight: 600; font-size: 14px;" onclick="triggerManualSync()">🔄 Sync 초기화</button>
|
||||
</div>
|
||||
|
||||
<!-- 6대 기능 그룹 그리드 -->
|
||||
<div class="hmi-groups-container">
|
||||
<!-- 1그룹: 그룹 활성화 및 리셋 -->
|
||||
<div class="hmi-group-card">
|
||||
<div class="hmi-group-title">
|
||||
<span>메인 제어</span>
|
||||
<span class="badge badge-online" id="hmi-websocket-status">실시간 연결</span>
|
||||
</div>
|
||||
<!-- 안전 인터락 상태 모니터링 영역 (A-1) -->
|
||||
<div class="interlock-status-container" style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; padding: 6px; background: var(--overlay-subtle); border-radius: 6px; margin-bottom: 12px; border: 1px solid var(--border-color); text-align: center;">
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 10px; color: var(--text-secondary);">
|
||||
<span>비상정지</span>
|
||||
<span class="hmi-lamp" id="lamp-estop"></span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 10px; color: var(--text-secondary);">
|
||||
<span>도어(좌)</span>
|
||||
<span class="hmi-lamp" id="lamp-door-left"></span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 10px; color: var(--text-secondary);">
|
||||
<span>도어(우)</span>
|
||||
<span class="hmi-lamp" id="lamp-door-right"></span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 10px; color: var(--text-secondary);">
|
||||
<span>수위장치</span>
|
||||
<span class="hmi-lamp" id="lamp-water-level"></span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 10px; color: var(--text-secondary);">
|
||||
<span>히터장치</span>
|
||||
<span class="hmi-lamp" id="lamp-heater-interlock"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-grid" style="grid-template-columns: 1fr 1fr; margin-bottom: 0;">
|
||||
<button class="btn-hmi" id="btn-group-active-on" onclick="sendHmiBit(1, 'group_active_on')">전체 활성화 ON <span class="hmi-lamp" id="lamp-group-active-on"></span></button>
|
||||
<button class="btn-hmi" id="btn-group-active-off" onclick="sendHmiBit(2, 'group_active_off')">전체 활성화 OFF <span class="hmi-lamp" id="lamp-group-active-off"></span></button>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:10px; margin-top:8px;">
|
||||
<button class="btn-hmi btn-danger" style="background: rgba(239,68,68,0.15);" id="btn-group-stop" onclick="sendHmiBit(3, 'group_stop')">전체정지 <span class="hmi-lamp" id="lamp-group-stop"></span></button>
|
||||
<button class="btn-hmi" style="justify-content:center; cursor:default;" id="btn-group-pause">일시정지 <span class="hmi-lamp" style="margin-left:8px;" id="lamp-group-pause"></span></button>
|
||||
<button class="btn-hmi" style="justify-content:center; cursor:default;" id="btn-group-restart">재시작 <span class="hmi-lamp" style="margin-left:8px;" id="lamp-group-restart"></span></button>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-hmi" style="justify-content:center; gap:8px;" id="btn-interlock-reset" onclick="sendHmiBit(0, 'interlock_reset')">⚡ 인터락 리셋 <span class="hmi-lamp" id="lamp-interlock-reset"></span></button>
|
||||
</div>
|
||||
|
||||
<!-- 2그룹: 함침부 작동 -->
|
||||
<div class="hmi-group-card">
|
||||
<div class="hmi-group-title">
|
||||
<span>🧪 함침부</span>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="btn-hmi" id="btn-impreg-hanger-on-g2" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(4, 'impreg_hanger_on')">ON <span class="hmi-lamp" id="lamp-impreg-hanger-on-g2"></span></button>
|
||||
<button class="btn-hmi" id="btn-impreg-hanger-off-g2" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(5, 'impreg_hanger_off')">OFF <span class="hmi-lamp" id="lamp-impreg-hanger-off-g2"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 기존 조작 버튼들 -->
|
||||
<div style="display:flex; justify-content:space-between; gap:10px;">
|
||||
<button class="btn-hmi" style="flex:1;" id="btn-impreg-auto" onclick="sendHmiBit(24, 'impreg_auto')">자동 모드 <span class="hmi-lamp" id="lamp-impreg-auto"></span></button>
|
||||
<button class="btn-hmi" style="flex:1;" id="btn-impreg-manual" onclick="sendHmiBit(25, 'impreg_manual')">수동 모드 <span class="hmi-lamp" id="lamp-impreg-manual"></span></button>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; gap:10px;">
|
||||
<button class="btn-hmi" style="flex:1;" id="btn-pump-circ" onclick="sendHmiBit(26, 'pump_circ')">순환 펌프 <span class="hmi-lamp" id="lamp-pump-circ"></span></button>
|
||||
<button class="btn-hmi" style="flex:1;" id="btn-pump-drain" onclick="sendHmiBit(27, 'pump_drain')">배출 펌프 <span class="hmi-lamp" id="lamp-pump-drain"></span></button>
|
||||
</div>
|
||||
<div class="register-row">
|
||||
<span class="lamp-label">순환펌프 재작동 시간</span>
|
||||
<span class="register-value" id="val-pump-circ-time">0 <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">초</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3그룹: 건조기 작동 -->
|
||||
<div class="hmi-group-card">
|
||||
<div class="hmi-group-title">
|
||||
<span>♨️ 건조부</span>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="btn-hmi" id="btn-dryer-group-on" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(6, 'dryer_on')">ON <span class="hmi-lamp" id="lamp-dryer-on"></span></button>
|
||||
<button class="btn-hmi" id="btn-dryer-group-off" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(7, 'dryer_off')">OFF <span class="hmi-lamp" id="lamp-dryer-off"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 기존 조작 버튼들 -->
|
||||
<div style="display:flex; justify-content:space-between; gap:10px;">
|
||||
<button class="btn-hmi" style="flex:1;" id="btn-dryer-auto" onclick="sendHmiBit(32, 'dryer_auto')">자동 모드 <span class="hmi-lamp" id="lamp-dryer-auto"></span></button>
|
||||
<button class="btn-hmi" style="flex:1;" id="btn-dryer-manual" onclick="sendHmiBit(33, 'dryer_manual')">수동 모드 <span class="hmi-lamp" id="lamp-dryer-manual"></span></button>
|
||||
</div>
|
||||
<button class="btn-hmi" id="btn-dryer-fan" onclick="sendHmiBit(34, 'dryer_fan')">송풍기 작동 <span class="hmi-lamp" id="lamp-dryer-fan"></span></button>
|
||||
|
||||
<div style="display:grid; grid-template-columns: repeat(5, 1fr); gap:6px;">
|
||||
<button class="btn-hmi" style="flex-direction:column; padding:8px 4px; gap:4px; font-size:11px;" id="btn-dryer-h1" onclick="sendHmiBit(35, 'dryer_h1')">H1 <span class="hmi-lamp" id="lamp-dryer-h1"></span></button>
|
||||
<button class="btn-hmi" style="flex-direction:column; padding:8px 4px; gap:4px; font-size:11px;" id="btn-dryer-h2" onclick="sendHmiBit(36, 'dryer_h2')">H2 <span class="hmi-lamp" id="lamp-dryer-h2"></span></button>
|
||||
<button class="btn-hmi" style="flex-direction:column; padding:8px 4px; gap:4px; font-size:11px;" id="btn-dryer-h3" onclick="sendHmiBit(37, 'dryer_h3')">H3 <span class="hmi-lamp" id="lamp-dryer-h3"></span></button>
|
||||
<button class="btn-hmi" style="flex-direction:column; padding:8px 4px; gap:4px; font-size:11px;" id="btn-dryer-h4" onclick="sendHmiBit(38, 'dryer_h4')">H4 <span class="hmi-lamp" id="lamp-dryer-h4"></span></button>
|
||||
<button class="btn-hmi" style="flex-direction:column; padding:8px 4px; gap:4px; font-size:11px;" id="btn-dryer-h5" onclick="sendHmiBit(39, 'dryer_h5')">H5 <span class="hmi-lamp" id="lamp-dryer-h5"></span></button>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; gap:10px;">
|
||||
<div class="btn-hmi" style="flex:1; cursor:default; background:var(--overlay-subtle);" id="lamp-card-fan-trip">송풍기 트립 <span class="hmi-lamp" id="lamp-fan-trip"></span></div>
|
||||
<div class="btn-hmi" style="flex:1; cursor:default; background:var(--overlay-subtle);" id="lamp-card-heater-req">히터 제어 <span class="hmi-lamp" id="lamp-heater-req"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4그룹: 커팅부 작동 -->
|
||||
<div class="hmi-group-card">
|
||||
<div class="hmi-group-title">
|
||||
<span>✂️ 커팅부</span>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="btn-hmi" id="btn-cutter-group-on" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(8, 'cutter_on')">ON <span class="hmi-lamp" id="lamp-cutter-on"></span></button>
|
||||
<button class="btn-hmi" id="btn-cutter-group-off" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(9, 'cutter_off')">OFF <span class="hmi-lamp" id="lamp-cutter-off"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 기존 조작 버튼들 -->
|
||||
<button class="btn-hmi" id="btn-cutter-motor" onclick="sendHmiBit(48, 'cutter_motor')">커팅모터 작동 기동 <span class="hmi-lamp" id="lamp-cutter-motor-start"></span></button>
|
||||
<div style="display:flex; justify-content:space-between; gap:10px;">
|
||||
<div class="btn-hmi" style="flex:1; cursor:default; background:var(--overlay-subtle);">모터 활성 피드백 <span class="hmi-lamp" id="lamp-cutter-motor-active"></span></div>
|
||||
</div>
|
||||
<div class="register-row">
|
||||
<span class="lamp-label">실시간 커팅모터 속도</span>
|
||||
<span class="register-value" id="val-cutter-speed">0.0 <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">Hz</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5그룹: 이송 작동 -->
|
||||
<div class="hmi-group-card">
|
||||
<div class="hmi-group-title">
|
||||
<span>🔄 이송부</span>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="btn-hmi" id="btn-transfer-group-on" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(10, 'transfer_on')">ON <span class="hmi-lamp" id="lamp-transfer-on"></span></button>
|
||||
<button class="btn-hmi" id="btn-transfer-group-off" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(11, 'transfer_off')">OFF <span class="hmi-lamp" id="lamp-transfer-off"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 기존 조작 버튼들 -->
|
||||
<div style="display:flex; justify-content:space-between; gap:10px;">
|
||||
<button class="btn-hmi" style="flex:1;" id="btn-transfer-auto" onclick="sendHmiBit(50, 'transfer_auto')">자동 모드 <span class="hmi-lamp" id="lamp-transfer-auto"></span></button>
|
||||
<button class="btn-hmi" style="flex:1;" id="btn-transfer-manual" onclick="sendHmiBit(51, 'transfer_manual')">수동 모드 <span class="hmi-lamp" id="lamp-transfer-manual"></span></button>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; gap:10px;">
|
||||
<button class="btn-hmi" style="flex:1;" id="btn-transfer-auto-start" onclick="sendHmiBit(52, 'transfer_auto_start')">자동 시작 <span class="hmi-lamp" id="lamp-transfer-auto-start"></span></button>
|
||||
<button class="btn-hmi" style="flex:1;" id="btn-transfer-auto-stop" onclick="sendHmiBit(53, 'transfer_auto_stop')">자동 정지 <span class="hmi-lamp" id="lamp-transfer-auto-stop"></span></button>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:8px;">
|
||||
<button class="btn-hmi" style="padding:10px 4px; font-size:12px;" id="btn-transfer-fwd" onclick="sendHmiBit(54, 'transfer_fwd')">수동(정) <span class="hmi-lamp" id="lamp-transfer-fwd"></span></button>
|
||||
<button class="btn-hmi" style="padding:10px 4px; font-size:12px;" id="btn-transfer-rev" onclick="sendHmiBit(55, 'transfer_rev')">수동(역) <span class="hmi-lamp" id="lamp-transfer-rev"></span></button>
|
||||
<button class="btn-hmi btn-danger" style="padding:10px 4px; font-size:12px; background: rgba(239,68,68,0.15);" id="btn-transfer-stop" onclick="sendHmiBit(56, 'transfer_stop')">수동 정지 <span class="hmi-lamp" id="lamp-transfer-stop"></span></button>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; gap:10px;">
|
||||
<button class="btn-hmi" style="flex:1;" id="btn-transfer-zero" onclick="sendHmiBit(57, 'transfer_zero')">제로셋 <span class="hmi-lamp" id="lamp-transfer-zero"></span></button>
|
||||
<button class="btn-hmi" style="flex:1;" id="btn-transfer-err-reset" onclick="sendHmiBit(58, 'transfer_err_reset')">에러리셋 <span class="hmi-lamp" id="lamp-transfer-err-reset"></span></button>
|
||||
</div>
|
||||
|
||||
<div class="register-row">
|
||||
<span class="lamp-label">현재 이동량 (1초 기준)</span>
|
||||
<span class="register-value" id="val-transfer-cur-pos">0.0 <span style="font-size:11px; font-weight:normal; color:var(--text-secondary);">mm/s</span></span>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 10px;">
|
||||
<!-- 자동 목표 이동량 설정 -->
|
||||
<div style="display: flex; flex-direction: column; gap: 6px; background: var(--overlay-subtle); border: 1px solid var(--border-color); padding: 8px 12px; border-radius: 8px;">
|
||||
<span class="lamp-label" style="font-size: 13px; color: var(--text-primary); font-weight: 600;">자동 이동량 (mm/rev)</span>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; align-items: center; width: 100%;">
|
||||
<span style="font-size: 13px; color: var(--text-primary); white-space: nowrap; text-align: left;">현: <span id="val-transfer-target-pos" style="color: var(--text-primary); font-weight: 600;">0</span></span>
|
||||
<input type="number" class="input-inline" style="width: 100%; box-sizing: border-box; padding: 4px 6px; font-size: 12px; text-align: center;" id="input-transfer-target-pos" value="0">
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width: 100%; padding: 6px; font-size: 11px; white-space: nowrap; margin-top: 2px;" onclick="sendHmiRegister(0, 'input-transfer-target-pos')">전송</button>
|
||||
</div>
|
||||
<!-- 수동 목표 속도 설정 -->
|
||||
<div style="display: flex; flex-direction: column; gap: 6px; background: var(--overlay-subtle); border: 1px solid var(--border-color); padding: 8px 12px; border-radius: 8px;">
|
||||
<span class="lamp-label" style="font-size: 13px; color: var(--text-primary); font-weight: 600;">수동 속도 (mm/s)</span>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; align-items: center; width: 100%;">
|
||||
<span style="font-size: 13px; color: var(--text-primary); white-space: nowrap; text-align: left;">현: <span id="val-transfer-target-speed" style="color: var(--text-primary); font-weight: 600;">0</span></span>
|
||||
<input type="number" class="input-inline" style="width: 100%; box-sizing: border-box; padding: 4px 6px; font-size: 12px; text-align: center;" id="input-transfer-target-speed" value="0">
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width: 100%; padding: 6px; font-size: 11px; white-space: nowrap; margin-top: 2px;" onclick="sendHmiRegister(1, 'input-transfer-target-speed')">전송</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6그룹: 행거부 작동 -->
|
||||
<div class="hmi-group-card" style="position:relative;">
|
||||
<div class="hmi-group-title">
|
||||
<span>🪝 행거부</span>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="btn-hmi" id="btn-impreg-hanger-on-g6" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(4, 'impreg_hanger_on')">ON <span class="hmi-lamp" id="lamp-impreg-hanger-on-g6"></span></button>
|
||||
<button class="btn-hmi" id="btn-impreg-hanger-off-g6" style="padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; gap: 6px;" onclick="sendHmiBit(5, 'impreg_hanger_off')">OFF <span class="hmi-lamp" id="lamp-impreg-hanger-off-g6"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 기존 조작 영역 -->
|
||||
<div class="port-grid" id="hanger-ports-container">
|
||||
<!-- 20개 컴팩트 셀 JS로 동적 렌더링 -->
|
||||
</div>
|
||||
|
||||
<!-- 공용 팝오버 오버레이 -->
|
||||
<div class="hmi-popover" id="hanger-popover">
|
||||
<h4 style="margin:0 0 6px 0; font-size:11px; color:var(--text-primary); text-align:center;" id="popover-title">P01 제어</h4>
|
||||
<button class="btn btn-primary" id="btn-popover-auto-down">자동하강</button>
|
||||
<button class="btn" style="background:rgba(245,158,11,0.2); border-color:var(--accent-orange); color:var(--accent-orange);" id="btn-popover-manual-down">수동하강</button>
|
||||
<button class="btn" style="background:rgba(16,185,129,0.2); border-color:var(--accent-green); color:var(--accent-green);" id="btn-popover-manual-up">수동상승</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 신규 설비 등록 모달 -->
|
||||
<div class="modal" id="add-equipment-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">신규 설비 등록</div>
|
||||
<form id="add-equipment-form" onsubmit="saveEquipment(event)">
|
||||
<div class="form-group">
|
||||
<label for="eq-id">설비 코드 (Unique ID)</label>
|
||||
<input type="text" id="eq-id" class="form-control" placeholder="예: CC_MC_TYPE_A" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="eq-name">설비명</label>
|
||||
<input type="text" id="eq-name" class="form-control" placeholder="예: 카본절단기 1호기" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="eq-type">설비 타입</label>
|
||||
<input type="text" id="eq-type" class="form-control" placeholder="예: cutter" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="eq-ip">IP 주소</label>
|
||||
<input type="text" id="eq-ip" class="form-control" placeholder="예: 192.168.1.100" required>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" onclick="closeAddModal()" style="background:transparent; color:var(--text-secondary);">취소</button>
|
||||
<button type="submit" class="btn btn-primary">등록하기</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 파일 연결 -->
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/equipment.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,195 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChemiFactory 통합 회사 관리 시스템</title>
|
||||
<!-- Google Fonts Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
|
||||
<script src="js/theme.js"></script>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<style>
|
||||
/* 추가 대시보드 전용 스타일 */
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.stat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 12px 0 6px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.stat-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.section-title {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.grid-row-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
.list-table th {
|
||||
padding: 14px 16px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.list-table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.list-table tr:hover {
|
||||
background-color: var(--overlay-subtle);
|
||||
}
|
||||
|
||||
/* HMI 위젯 스타일 */
|
||||
.hmi-mini-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--overlay-subtle);
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.indicator-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.dot-green {
|
||||
background-color: var(--accent-green);
|
||||
box-shadow: 0 0 8px var(--accent-green-glow);
|
||||
}
|
||||
.dot-red {
|
||||
background-color: var(--accent-red);
|
||||
box-shadow: 0 0 8px var(--accent-red-glow);
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.grid-row-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 공통 사이드바 (common.js 가 동적으로 렌더링) -->
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<!-- 메인 콘텐츠 영역 -->
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<div class="page-title">
|
||||
<h1>통합 대시보드</h1>
|
||||
<p>ChemiFactory 사업장 현황 및 설비 모니터링 개요</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<span id="api-status-badge" class="badge badge-offline">연결 중...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 주요 현황 요약 카드 -->
|
||||
<div class="dashboard-grid">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-header">
|
||||
<span>설비 가동 상태</span>
|
||||
<span>⚙️</span>
|
||||
</div>
|
||||
<div class="stat-value" id="active-equip-count">0 / 0</div>
|
||||
<div class="stat-desc">동작중인 설비 / 전체 설비</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-header">
|
||||
<span>오늘의 생산량</span>
|
||||
<span>📈</span>
|
||||
</div>
|
||||
<div class="stat-value" id="today-prod">0 kg</div>
|
||||
<div class="stat-desc">금일 목표 대비 진행도 0%</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-header">
|
||||
<span>재고 경보</span>
|
||||
<span>📦</span>
|
||||
</div>
|
||||
<div class="stat-value" id="low-inventory-count" style="color: var(--accent-orange);">0 건</div>
|
||||
<div class="stat-desc">안전재고 미달 품목</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-header">
|
||||
<span>등록 고객사</span>
|
||||
<span>🤝</span>
|
||||
</div>
|
||||
<div class="stat-value" id="customer-count">0 개사</div>
|
||||
<div class="stat-desc">비즈니스 파트너사 목록</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 하단 2단 레이아웃 -->
|
||||
<div class="grid-row-2">
|
||||
<!-- 생산 계획 현황 -->
|
||||
<div class="card">
|
||||
<h2 class="section-title">오늘의 생산 지시 및 실적</h2>
|
||||
<table class="list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>계획 ID</th>
|
||||
<th>설비명</th>
|
||||
<th>목표 수량</th>
|
||||
<th>현재 생산 실적</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="plan-list">
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 30px 0;">등록된 생산 지시가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 설비 미니 상태 HMI 모니터 -->
|
||||
<div class="card">
|
||||
<h2 class="section-title">실시간 설비 모니터링 (HMI)</h2>
|
||||
<div id="equipment-mini-list">
|
||||
<div style="text-align: center; color: var(--text-muted); padding: 30px 0;">설비 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 연결 -->
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,399 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChemiFactory 재고 및 자재 관리</title>
|
||||
<!-- Google Fonts Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
|
||||
<script src="js/theme.js"></script>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<style>
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.search-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.form-control {
|
||||
background-color: var(--overlay-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
.list-table th, .list-table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
}
|
||||
.list-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.list-table tr:hover {
|
||||
background-color: var(--overlay-subtle);
|
||||
}
|
||||
|
||||
/* 탭 구조 */
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 12px 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: var(--accent-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: var(--accent-blue);
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 경고 뱃지 */
|
||||
.depleted-badge {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.active-badge {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--accent-green);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--modal-backdrop);
|
||||
backdrop-filter: blur(4px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 10px 30px var(--card-shadow);
|
||||
animation: modalFadeIn 0.3s ease;
|
||||
}
|
||||
@keyframes modalFadeIn {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.close-btn {
|
||||
font-size: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: var(--overlay-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 공통 사이드바 -->
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<!-- 메인 콘텐츠 영역 -->
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<div class="page-title">
|
||||
<h1>재고 및 물성 관리</h1>
|
||||
<p>자재 물성 데이터 및 원자재 실재고 입출고 상태를 실시간 통합 관리합니다.</p>
|
||||
</div>
|
||||
<div class="header-actions" style="display: flex; gap: 12px;">
|
||||
<button class="btn btn-secondary" id="btn-add-material">+ 자재 규격 추가</button>
|
||||
<button class="btn btn-primary" id="btn-add-inventory">+ 자재 신규 입고</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 탭 헤더 -->
|
||||
<div class="tabs-header">
|
||||
<button class="tab-btn active" data-tab="tab-inventory">실재고 현황 이력</button>
|
||||
<button class="tab-btn" data-tab="tab-materials">자재 기본 규격 (물성)</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 1: 실재고 입고 및 잔량 현황 -->
|
||||
<div id="tab-inventory" class="tab-content active card">
|
||||
<div class="header-row">
|
||||
<h2 class="section-title" style="margin:0;">재고 원장 목록</h2>
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-inventory" class="form-control" placeholder="자재코드, 명칭, 품번 검색...">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>입고일자</th>
|
||||
<th>품명/품번</th>
|
||||
<th>자재 구분</th>
|
||||
<th>입고량 (kg)</th>
|
||||
<th>사용량 (kg)</th>
|
||||
<th>현재고 (kg)</th>
|
||||
<th>매입 단가 (원)</th>
|
||||
<th>공급처</th>
|
||||
<th>상태</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="inventory-list-body">
|
||||
<tr>
|
||||
<td colspan="10" style="text-align: center; color: var(--text-muted); padding: 30px 0;">재고 데이터를 로드하고 있습니다...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 2: 자재 기본 물성 규격 정보 -->
|
||||
<div id="tab-materials" class="tab-content card">
|
||||
<div class="header-row">
|
||||
<h2 class="section-title" style="margin:0;">자재 기준 정보 관리</h2>
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-material" class="form-control" placeholder="자재명, 자재코드 검색...">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>자재 코드</th>
|
||||
<th>자재 명칭</th>
|
||||
<th>자재 구분</th>
|
||||
<th>밀도 (g/cm³)</th>
|
||||
<th>상세 비고</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="material-list-body">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; color: var(--text-muted); padding: 30px 0;">자재 물성 정보를 로드하고 있습니다...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 자재 물성 규격 추가/수정 모달 -->
|
||||
<div id="material-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="material-modal-title">자재 규격 등록</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="material-form">
|
||||
<input type="hidden" id="material-id">
|
||||
<div class="form-group">
|
||||
<label for="mat-code">자재 코드 *</label>
|
||||
<input type="text" id="mat-code" class="form-control" placeholder="예: YRN-CF-12K" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mat-name">자재 명칭 *</label>
|
||||
<input type="text" id="mat-name" class="form-control" placeholder="예: 카본원사 12K" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mat-type">자재 구분 *</label>
|
||||
<select id="mat-type" class="form-control" required>
|
||||
<option value="Yarn">원사 (Yarn)</option>
|
||||
<option value="Bond">본드 (Bond)</option>
|
||||
<option value="Etc">기타 자재</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mat-density">밀도 (Density) *</label>
|
||||
<input type="number" id="mat-density" step="0.001" class="form-control" placeholder="g/cm³ 단위 수치" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mat-remarks">비고 설명</label>
|
||||
<input type="text" id="mat-remarks" class="form-control">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 재고 입고 추가/수정 모달 -->
|
||||
<div id="inventory-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="inventory-modal-title">자재 신규 입고 등록</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="inventory-form">
|
||||
<input type="hidden" id="inventory-id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inv-material">대상 자재 규격 *</label>
|
||||
<select id="inv-material" class="form-control" required>
|
||||
<!-- 자재 규격 리스트 바인딩 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inv-supplier">원료 공급처 *</label>
|
||||
<select id="inv-supplier" class="form-control" required>
|
||||
<!-- 공급사 리스트 바인딩 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inv-date">입고 일자 *</label>
|
||||
<input type="date" id="inv-date" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inv-item-name">품명 (입고 세부 명칭)</label>
|
||||
<input type="text" id="inv-item-name" class="form-control" placeholder="예: 카본 원사 24K A급">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inv-item-num">품번 (Lot 번호 / 품격)</label>
|
||||
<input type="text" id="inv-item-num" class="form-control" placeholder="예: LOT-2026-001">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inv-qty">입고 수량 (kg) *</label>
|
||||
<input type="number" id="inv-qty" step="0.1" class="form-control" min="0.1" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="usage-group" style="display: none;">
|
||||
<label for="inv-usage">사용 수량 (kg)</label>
|
||||
<input type="number" id="inv-usage" step="0.1" class="form-control" min="0">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inv-cost">매입 단가 (원/kg) *</label>
|
||||
<input type="number" id="inv-cost" class="form-control" min="1" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inv-remarks">입고 특이사항 비고</label>
|
||||
<input type="text" id="inv-remarks" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
|
||||
<button type="submit" class="btn btn-primary">입고 저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 연결 -->
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/inventory.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,228 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChemiFactory 자재 물성 관리</title>
|
||||
<!-- Google Fonts Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
|
||||
<script src="js/theme.js"></script>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<style>
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.search-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.form-control {
|
||||
background-color: var(--overlay-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
.list-table th, .list-table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
}
|
||||
.list-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.list-table tr:hover {
|
||||
background-color: var(--overlay-subtle);
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--modal-backdrop);
|
||||
backdrop-filter: blur(4px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 10px 30px var(--card-shadow);
|
||||
animation: modalFadeIn 0.3s ease;
|
||||
}
|
||||
@keyframes modalFadeIn {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.close-btn {
|
||||
font-size: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: var(--overlay-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 공통 사이드바 -->
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<!-- 메인 콘텐츠 영역 -->
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<div class="page-title">
|
||||
<h1>자재 물성 규격 관리</h1>
|
||||
<p>생산 단가 및 소요 기간 시뮬레이션 연산의 기준이 되는 원자재 사양 규격을 정의합니다.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-add-material">+ 신규 규격 추가</button>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<div class="header-row">
|
||||
<h2 class="section-title" style="margin:0;">원자재 규격 관리 리스트</h2>
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-material" class="form-control" placeholder="자재 코드, 명칭 검색...">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>자재 코드</th>
|
||||
<th>자재 명칭</th>
|
||||
<th>자재 구분</th>
|
||||
<th>밀도 (Density, g/cm³)</th>
|
||||
<th>상세 비고</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="material-list-body">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; color: var(--text-muted); padding: 30px 0;">자재 데이터를 조회하는 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 물성 추가/수정 모달 -->
|
||||
<div id="material-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="material-modal-title">자재 규격 정보 등록</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="material-form">
|
||||
<input type="hidden" id="material-id">
|
||||
<div class="form-group">
|
||||
<label for="mat-code">자재 코드 *</label>
|
||||
<input type="text" id="mat-code" class="form-control" placeholder="예: YRN-CF-3K" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mat-name">자재 명칭 *</label>
|
||||
<input type="text" id="mat-name" class="form-control" placeholder="예: 국산 카본원사 3K" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mat-type">자재 구분 *</label>
|
||||
<select id="mat-type" class="form-control" required>
|
||||
<option value="Yarn">원사 (Yarn)</option>
|
||||
<option value="Bond">본드 (Bond)</option>
|
||||
<option value="Etc">기타 자재</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mat-density">밀도 (Density, g/cm³) *</label>
|
||||
<input type="number" id="mat-density" step="0.001" class="form-control" placeholder="수치 입력" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mat-remarks">상세 비고</label>
|
||||
<input type="text" id="mat-remarks" class="form-control">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 연결 -->
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/material.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,439 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChemiFactory 생산 계획 관리 (Gantt)</title>
|
||||
<!-- Google Fonts Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
|
||||
<script src="js/theme.js"></script>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<style>
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.form-control {
|
||||
background-color: var(--overlay-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
|
||||
/* Gantt Layout */
|
||||
.gantt-outer-container {
|
||||
display: flex;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background-color: var(--bg-card);
|
||||
overflow: hidden;
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 500px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.gantt-left-panel {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.gantt-right-timeline {
|
||||
flex-grow: 1;
|
||||
overflow-x: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: calc(100% - 250px);
|
||||
min-width: 0;
|
||||
}
|
||||
.gantt-right-timeline::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
.gantt-right-timeline::-webkit-scrollbar-track {
|
||||
background: var(--overlay-subtle);
|
||||
}
|
||||
.gantt-right-timeline::-webkit-scrollbar-thumb {
|
||||
background: var(--overlay-border);
|
||||
border-radius: 5px;
|
||||
}
|
||||
.gantt-right-timeline::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--overlay-border);
|
||||
}
|
||||
.gantt-header-row {
|
||||
height: 70px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--overlay-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gantt-left-header {
|
||||
padding: 0 16px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Gantt Grid Cells */
|
||||
.gantt-day-header-cell {
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.day-sat {
|
||||
color: var(--accent-blue-soft);
|
||||
background-color: rgba(96, 165, 250, 0.05);
|
||||
}
|
||||
.day-sun {
|
||||
color: var(--accent-red-soft);
|
||||
background-color: rgba(248, 113, 113, 0.05);
|
||||
}
|
||||
.day-today {
|
||||
border: 2px solid var(--accent-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gantt-body {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
#gantt-right-body {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
.gantt-row {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
}
|
||||
.gantt-row:hover {
|
||||
background-color: var(--overlay-subtle);
|
||||
}
|
||||
.gantt-left-item {
|
||||
width: 250px;
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
border-right: 1px solid var(--border-color);
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gantt-left-item small {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.gantt-right-grid {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.gantt-grid-cell {
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Timeline bar */
|
||||
.gantt-bar-container {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none; /* 그리드 클릭이 방해되지 않도록 */
|
||||
}
|
||||
.gantt-bar-item {
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.65), rgba(37, 99, 235, 0.65));
|
||||
border: 1px solid var(--accent-blue);
|
||||
color: var(--text-on-accent);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
box-shadow: 0 4px 10px var(--card-shadow);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: auto; /* 클릭 가능 */
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.gantt-bar-item:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 10px var(--accent-blue-glow);
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--modal-backdrop);
|
||||
backdrop-filter: blur(4px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 95%;
|
||||
max-width: 750px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 30px var(--card-shadow);
|
||||
}
|
||||
.close-btn {
|
||||
font-size: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.modal-body-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.modal-body-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: var(--overlay-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
|
||||
.machine-checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.machine-chip {
|
||||
background-color: var(--overlay-subtle);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.machine-chip.selected {
|
||||
background-color: var(--nav-active-bg);
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--accent-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 공통 사이드바 -->
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<!-- 메인 콘텐츠 영역 -->
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<div class="page-title">
|
||||
<h1>생산 계획 관리 (간트차트)</h1>
|
||||
<p>자재 시뮬레이션을 실행하여 필요 작업 기간을 연산하고, 기기 배정 일정을 수립합니다.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-add-plan">+ 생산 계획 수립</button>
|
||||
</header>
|
||||
|
||||
<!-- Gantt 차트 메인 판넬 -->
|
||||
<div class="gantt-outer-container">
|
||||
<!-- 좌측 정적 리스트 -->
|
||||
<div class="gantt-left-panel">
|
||||
<div class="gantt-header-row gantt-left-header">
|
||||
생산 계획 / 배정 기기
|
||||
</div>
|
||||
<div class="gantt-body" id="gantt-left-body">
|
||||
<!-- Left rows -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 우측 타임라인 스크롤 그리드 -->
|
||||
<div class="gantt-right-timeline" id="gantt-timeline-container">
|
||||
<div class="gantt-header-row" id="gantt-date-headers">
|
||||
<!-- Date headers -->
|
||||
</div>
|
||||
<div class="gantt-body" id="gantt-right-body">
|
||||
<!-- Grid rows & bars -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 생산 계획 수립/수정 다이얼로그 모달 -->
|
||||
<div id="plan-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="plan-modal-title">생산 계획 및 시뮬레이션 수립</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="plan-form">
|
||||
<input type="hidden" id="plan-id">
|
||||
<div class="modal-body-grid">
|
||||
<!-- 왼쪽: 거래처 및 소요 자재 입력 정보 -->
|
||||
<div>
|
||||
<h4 style="font-size: 14px; color: var(--accent-blue); margin-bottom: 12px;">1. 파트너사 및 자재 매핑</h4>
|
||||
<div class="form-group">
|
||||
<label for="plan-customer">거래처(고객사) 선택 *</label>
|
||||
<select id="plan-customer" class="form-control" required></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="plan-yarn">입고 원사 자재 *</label>
|
||||
<select id="plan-yarn" class="form-control" required></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="plan-bond">소요 본드 자재</label>
|
||||
<select id="plan-bond" class="form-control"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="plan-qty">목표 생산량 (kg) *</label>
|
||||
<input type="number" id="plan-qty" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="plan-start-date">생산 가동 예정일 *</label>
|
||||
<input type="date" id="plan-start-date" class="form-control" required>
|
||||
</div>
|
||||
<div style="display: flex; gap: 20px; margin-top: 10px;">
|
||||
<label style="display:flex; align-items:center; font-size:13px; cursor:pointer;">
|
||||
<input type="checkbox" id="plan-sat" style="margin-right:6px;"> 토요일 가동
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; font-size:13px; cursor:pointer;">
|
||||
<input type="checkbox" id="plan-sun" style="margin-right:6px;"> 일요일 가동
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 기계 가동 물성 및 시뮬레이션 계산 결과 -->
|
||||
<div>
|
||||
<h4 style="font-size: 14px; color: var(--accent-blue); margin-bottom: 12px;">2. 가동 변수 및 시뮬레이션 연산</h4>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||||
<div class="form-group">
|
||||
<label for="plan-yarn-dia">선경 (Diameter, ㎛) *</label>
|
||||
<input type="number" id="plan-yarn-dia" value="7.0" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="plan-yarn-k">K수 (K 번수) *</label>
|
||||
<input type="number" id="plan-yarn-k" value="12" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||||
<div class="form-group">
|
||||
<label for="plan-ports">포트 수 *</label>
|
||||
<input type="number" id="plan-ports" value="40" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="plan-hz">사이클 타임 (Hz) *</label>
|
||||
<input type="number" id="plan-hz" value="8.0" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||||
<div class="form-group">
|
||||
<label for="plan-cut">커팅 길이 (mm) *</label>
|
||||
<input type="number" id="plan-cut" value="6.0" step="0.1" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="plan-hours">일일 작업시간 (시간) *</label>
|
||||
<input type="number" id="plan-hours" value="16" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin: 14px 0; padding: 12px; background: var(--overlay-subtle); border: 1px dashed var(--border-color); border-radius: 8px;">
|
||||
<button type="button" class="btn btn-secondary" id="btn-simulate" style="width: 100%; padding: 8px 0; font-size:12px; font-weight:700;">시뮬레이션 가동 계산 실행</button>
|
||||
<div style="margin-top: 10px; font-size: 12px; display:flex; flex-direction:column; gap:4px;" id="sim-result-box">
|
||||
<p style="color:var(--text-secondary);">소요 기간: <span id="sim-days" style="color:var(--text-primary); font-weight:700;">-</span> 일</p>
|
||||
<p style="color:var(--text-secondary);">예상 매출: <span id="sim-sales" style="color:var(--text-primary); font-weight:700;">-</span> 원</p>
|
||||
<p style="color:var(--text-secondary);">마진 (공장 이익): <span id="sim-margin" style="color:var(--accent-green); font-weight:700;">-</span> 원</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="equip-assignment-box" style="display: none;">
|
||||
<label>3. 가용 배정 설비 선택 *</label>
|
||||
<div class="machine-checkbox-group" id="available-machines-container">
|
||||
<!-- 가용 설비 칩 바인딩 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
|
||||
<button type="submit" class="btn btn-primary" id="btn-save-plan" disabled>계획 최종 저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 연결 -->
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/plan.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,202 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChemiFactory 시스템 설정</title>
|
||||
<!-- Google Fonts Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
|
||||
<script src="js/theme.js"></script>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<style>
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
max-width: 600px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-control {
|
||||
background-color: var(--overlay-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
.form-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.settings-category {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-blue);
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.actions-row {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
max-width: 600px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* --- 화면 테마 선택 카드 --- */
|
||||
.theme-option-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.theme-option-card {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
background-color: var(--overlay-subtle);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.theme-option-card:hover {
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
.theme-option-card.active {
|
||||
border-color: var(--accent-blue);
|
||||
box-shadow: 0 0 0 3px var(--accent-blue-glow);
|
||||
}
|
||||
/* 미니 미리보기: 배경/사이드바/카드 3색 블록 */
|
||||
.theme-preview {
|
||||
display: flex;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.theme-preview .prev-sidebar {
|
||||
width: 30%;
|
||||
}
|
||||
.theme-preview .prev-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.theme-preview .prev-card {
|
||||
width: 60%;
|
||||
height: 40%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* 라이트 미리보기 색상(테마와 무관하게 고정) */
|
||||
.preview-light { background-color: #eef1f6; }
|
||||
.preview-light .prev-sidebar { background-color: #ffffff; border-right: 1px solid rgba(15,23,42,0.1); }
|
||||
.preview-light .prev-card { background-color: #ffffff; border: 1px solid rgba(15,23,42,0.12); }
|
||||
/* 다크 미리보기 색상(테마와 무관하게 고정) */
|
||||
.preview-dark { background-color: #0b0f19; }
|
||||
.preview-dark .prev-sidebar { background-color: #151c2c; border-right: 1px solid rgba(255,255,255,0.08); }
|
||||
.preview-dark .prev-card { background-color: #1e2740; border: 1px solid rgba(255,255,255,0.1); }
|
||||
.theme-option-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.theme-option-check {
|
||||
color: var(--accent-blue);
|
||||
font-weight: 700;
|
||||
visibility: hidden;
|
||||
}
|
||||
.theme-option-card.active .theme-option-check {
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 공통 사이드바 -->
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<!-- 메인 콘텐츠 영역 -->
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<div class="page-title">
|
||||
<h1>시스템 전역 설정</h1>
|
||||
<p>FastAPI 서버의 전역 파라미터 및 MQTT 게이트웨이 연동 상수를 설정합니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 화면 테마 설정 (localStorage 저장, 서버 DB와 무관) -->
|
||||
<div class="card" style="margin-bottom: 24px;">
|
||||
<div class="settings-category">화면 테마</div>
|
||||
<p class="form-desc" style="margin-bottom: 16px;">밝은 화면(라이트) 또는 어두운 화면(다크) 테마를 선택합니다. 이 설정은 사용 중인 브라우저에만 저장됩니다.</p>
|
||||
<div class="theme-option-group">
|
||||
<div class="theme-option-card" data-theme-value="light" role="button" tabindex="0">
|
||||
<div class="theme-preview preview-light">
|
||||
<div class="prev-sidebar"></div>
|
||||
<div class="prev-body"><div class="prev-card"></div></div>
|
||||
</div>
|
||||
<div class="theme-option-label">
|
||||
<span>☀️ 라이트</span>
|
||||
<span class="theme-option-check">✔ 사용 중</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-option-card" data-theme-value="dark" role="button" tabindex="0">
|
||||
<div class="theme-preview preview-dark">
|
||||
<div class="prev-sidebar"></div>
|
||||
<div class="prev-body"><div class="prev-card"></div></div>
|
||||
</div>
|
||||
<div class="theme-option-label">
|
||||
<span>🌙 다크</span>
|
||||
<span class="theme-option-check">✔ 사용 중</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form id="settings-form">
|
||||
<div class="form-grid" id="settings-container">
|
||||
<div style="text-align: center; color: var(--text-muted); padding: 30px 0;">설정 정보를 조회하는 중...</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-row">
|
||||
<button type="submit" class="btn btn-primary" style="padding: 12px 24px;">설정 사항 일괄 적용</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 연결 -->
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/setting.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -0,0 +1,442 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChemiFactory 공급사 관리</title>
|
||||
<!-- Google Fonts Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- 테마 적용 (base.css보다 먼저 로드해 FOWT 방지) -->
|
||||
<script src="js/theme.js"></script>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<style>
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.search-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.form-control {
|
||||
background-color: var(--overlay-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
.grid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
.grid-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
.list-table th, .list-table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
}
|
||||
.list-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.list-table tr {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.list-table tr:hover {
|
||||
background-color: var(--overlay-subtle);
|
||||
}
|
||||
.list-table tr.active-row {
|
||||
background-color: var(--nav-active-bg);
|
||||
border-left: 3px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
/* 탭 구조 */
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: var(--accent-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: var(--accent-blue);
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--modal-backdrop);
|
||||
backdrop-filter: blur(4px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 10px 30px var(--card-shadow);
|
||||
animation: modalFadeIn 0.3s ease;
|
||||
}
|
||||
@keyframes modalFadeIn {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.close-btn {
|
||||
font-size: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: var(--overlay-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--overlay-strong);
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: var(--accent-red);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: var(--danger-solid);
|
||||
box-shadow: 0 0 12px var(--accent-red-glow);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 공통 사이드바 -->
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<!-- 메인 콘텐츠 영역 -->
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<div class="page-title">
|
||||
<h1>공급사 관리</h1>
|
||||
<p>원자재 공급처 정보, 공급사 담당자 연락처망 및 자재 매입 활동 내역을 관리합니다.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-add-supplier">+ 공급사 추가</button>
|
||||
</header>
|
||||
|
||||
<div class="grid-layout">
|
||||
<!-- 왼쪽: 공급사 목록 -->
|
||||
<div class="card">
|
||||
<div class="header-row">
|
||||
<h2 class="section-title" style="margin:0;">공급사 목록</h2>
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-supplier" class="form-control" placeholder="공급사명 검색...">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>공급사명(한글)</th>
|
||||
<th>대표 번호</th>
|
||||
<th>등록번호</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="supplier-list-body">
|
||||
<tr>
|
||||
<td colspan="3" style="text-align: center; color: var(--text-muted); padding: 30px 0;">데이터를 불러오는 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 선택된 공급사 상세정보 -->
|
||||
<div class="card" id="supplier-detail-card" style="display: none;">
|
||||
<div class="header-row" style="border-bottom: 1px solid var(--border-color); padding-bottom: 12px; margin-bottom: 16px;">
|
||||
<div>
|
||||
<h2 id="detail-name-kr" style="margin: 0; font-size: 20px; font-weight: 700;">공급사명</h2>
|
||||
<p id="detail-name-en" style="color: var(--text-secondary); font-size: 13px; margin: 4px 0 0 0;">English Name</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-secondary" id="btn-edit-supplier" style="padding: 6px 12px; font-size: 13px;">수정</button>
|
||||
<button class="btn btn-danger" id="btn-delete-supplier" style="padding: 6px 12px; font-size: 13px;">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 24px; font-size: 14px;">
|
||||
<p style="margin-bottom: 8px;"><strong style="color: var(--text-secondary);">사업자등록번호:</strong> <span id="detail-brn"></span></p>
|
||||
<p style="margin-bottom: 8px;"><strong style="color: var(--text-secondary);">대표 연락처:</strong> <span id="detail-contact"></span></p>
|
||||
<p style="margin-bottom: 8px;"><strong style="color: var(--text-secondary);">주소:</strong> <span id="detail-address"></span></p>
|
||||
</div>
|
||||
|
||||
<!-- 탭메뉴 -->
|
||||
<div class="tabs-header">
|
||||
<button class="tab-btn active" data-tab="tab-contacts">담당자 정보</button>
|
||||
<button class="tab-btn" data-tab="tab-activities">원재료 매입 일지</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 1: 공급사 담당자 정보 -->
|
||||
<div id="tab-contacts" class="tab-content active">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h3 style="font-size: 15px; font-weight: 600;">공급처 담당자 목록</h3>
|
||||
<button class="btn btn-primary" id="btn-add-contact" style="padding: 6px 12px; font-size: 12px;">+ 담당자 추가</button>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="list-table" style="font-size: 13px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>직급/소속</th>
|
||||
<th>연락처</th>
|
||||
<th>이메일</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="contact-list-body">
|
||||
<!-- 담당자 정보 리스트 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 2: 매입 활동 기록 -->
|
||||
<div id="tab-activities" class="tab-content">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h3 style="font-size: 15px; font-weight: 600;">매입 및 거래 내역</h3>
|
||||
<button class="btn btn-primary" id="btn-add-activity" style="padding: 6px 12px; font-size: 12px;">+ 일지 추가</button>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="list-table" style="font-size: 13px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>활동 구분</th>
|
||||
<th>상세 내용 및 메모</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="activity-list-body">
|
||||
<!-- 매입 거래 이력 리스트 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 대기 안내 카드 -->
|
||||
<div class="card" id="supplier-placeholder-card" style="display: flex; align-items: center; justify-content: center; height: 300px; color: var(--text-secondary);">
|
||||
<div>왼쪽 목록에서 공급사를 선택하시면 상세 내역을 확인할 수 있습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 공급사 추가/수정 모달 -->
|
||||
<div id="supplier-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="supplier-modal-title">공급사 등록</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="supplier-form">
|
||||
<input type="hidden" id="supplier-id">
|
||||
<div class="form-group">
|
||||
<label for="supp-name-kr">공급사 한글명 *</label>
|
||||
<input type="text" id="supp-name-kr" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="supp-name-en">공급사 영문명</label>
|
||||
<input type="text" id="supp-name-en" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="supp-brn">사업자등록번호</label>
|
||||
<input type="text" id="supp-brn" class="form-control" placeholder="123-45-67890">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="supp-contact">대표 연락처</label>
|
||||
<input type="text" id="supp-contact" class="form-control" placeholder="02-1234-5678">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="supp-address">회사 주소</label>
|
||||
<input type="text" id="supp-address" class="form-control">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 담당자 추가/수정 모달 -->
|
||||
<div id="contact-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="contact-modal-title">담당자 등록</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="contact-form">
|
||||
<input type="hidden" id="contact-id">
|
||||
<div class="form-group">
|
||||
<label for="cont-name">담당자 이름 *</label>
|
||||
<input type="text" id="cont-name" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cont-position">직급/부서</label>
|
||||
<input type="text" id="cont-position" class="form-control" placeholder="예: 영업부 과장">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cont-phone">연락처</label>
|
||||
<input type="text" id="cont-phone" class="form-control" placeholder="010-1234-5678">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cont-email">이메일 주소</label>
|
||||
<input type="email" id="cont-email" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cont-location">근무처/사무실 위치</label>
|
||||
<input type="text" id="cont-location" class="form-control" placeholder="예: 공장 1F">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 매입 일지 추가/수정 모달 -->
|
||||
<div id="activity-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="activity-modal-title">매입 일지 등록</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="activity-form">
|
||||
<input type="hidden" id="activity-id">
|
||||
<div class="form-group">
|
||||
<label for="act-date">매입 날짜 *</label>
|
||||
<input type="date" id="act-date" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="act-type">활동 구분 *</label>
|
||||
<select id="act-type" class="form-control" required>
|
||||
<option value="원재료 입고">원재료 입고</option>
|
||||
<option value="자재 미팅">자재 상담/미팅</option>
|
||||
<option value="매입 견적 요청">매입 견적 요청</option>
|
||||
<option value="발주 협의">발주 및 계약 협의</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="act-memo">메모 / 매입 상세 내역 *</label>
|
||||
<textarea id="act-memo" class="form-control" style="height: 100px; resize: none;" required></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary close-modal-btn">취소</button>
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 연결 -->
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/supplier.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user