fix : 모델, BOM 구성 수정

- 설계용 모델, BOM 기능 추가
This commit is contained in:
2025-09-05 17:59:34 +09:00
parent 41d0afa245
commit d9563c96cb
19 changed files with 1972 additions and 290 deletions

View File

@@ -0,0 +1,224 @@
<!-- SAM RULES: include=../inc/header.php; base=/tenant; width=1280; js=jQuery+BS5 -->
<?php
// BOM 템플릿 편집기
$CURRENT_SECTION='item';
include '../inc/header.php';
$modelId = isset($_GET['model_id']) ? (int)$_GET['model_id'] : 0;
?>
<div class="container" style="max-width:1280px; margin-top:24px;">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="h5 m-0">BOM 템플릿 편집기</h1>
<div class="text-muted small">모델 ID: <code id="modelIdView"><?php echo $modelId; ?></code> — 모델 버전 선택 후 구성 편집</div>
</div>
<div class="d-flex gap-2">
<a href="/tenant/item/model_list.php" class="btn btn-outline-secondary"><i class="bi bi-list-ul"></i> 목록</a>
<a href="/tenant/item/model_create.php?id=<?php echo $modelId; ?>" class="btn btn-outline-primary"><i class="bi bi-pencil"></i> 모델 수정</a>
<button class="btn btn-success" id="btnSave"><i class="bi bi-save"></i> 저장</button>
</div>
</div>
<div class="card mb-3">
<div class="card-body row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label">모델 버전</label>
<select id="modelVersion" class="form-select">
<!-- API 연동 전 샘플 -->
<option value="v1">v1.0 (DRAFT)</option>
<option value="v1r">v1.0 (RELEASED)</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">그룹 추가</label>
<div class="input-group">
<input id="groupName" class="form-control" placeholder="예: 본체, 절곡물" />
<button id="btnAddGroup" class="btn btn-outline-primary"><i class="bi bi-plus-lg"></i></button>
</div>
</div>
<div class="col-md-6 text-end">
<div class="form-text">※ 그룹 = BOM 템플릿 내 상위 분류 (예: 본체/절곡물/모터/부자재)</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h2 class="h6 m-0">BOM 그룹 (템플릿)</h2>
<button id="btnExpand" class="btn btn-sm btn-outline-secondary">모두 펼치기</button>
</div>
<div class="card-body" style="max-height:520px; overflow:auto;">
<ul class="list-group" id="groupList">
<!-- JS 렌더링: 그룹/하위아이템 -->
</ul>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card h-100">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h2 class="h6 m-0">아이템 상세</h2>
<div class="d-flex gap-2">
<button id="btnAddItem" class="btn btn-sm btn-outline-primary"><i class="bi bi-plus-lg"></i> 아이템 추가</button>
<button id="btnDelNode" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i> 선택 삭제</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table align-middle" id="itemTable">
<thead class="table-light">
<tr>
<th style="width:160px;">구분</th>
<th style="width:160px;">참조코드</th>
<th>명칭</th>
<th style="width:120px;">수량</th>
<th style="width:140px;">옵션/조건</th>
<th style="width:80px;" class="text-center">삭제</th>
</tr>
</thead>
<tbody>
<!-- JS 렌더링 -->
</tbody>
</table>
</div>
<div class="form-text">
※ 구분: PRODUCT(서브모델) / MATERIAL(자재) / PART(부품). 옵션/조건은 견적-선택값과 매핑(예: 가이드레일=벽면형).
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function(){
const modelId = Number($('#modelIdView').text()) || 0;
// 샘플 데이터: 그룹과 아이템
let GROUPS = [
{id:'g1', name:'본체', items:[{id:'i1', type:'MATERIAL', ref:'SILICA-SET', name:'실리카원단+내화실 세트', qty:1, cond:'-'}, {id:'i2', type:'PRODUCT', ref:'SLAT-JOINT-SET', name:'슬랫+조인트바 세트', qty:1, cond:'-'}]},
{id:'g2', name:'절곡물', items:[{id:'i3', type:'PRODUCT', ref:'GUIDE-WALL-C', name:'가이드레일(벽면형/C형)', qty:1, cond:'옵션: 벽면형'}, {id:'i4', type:'PRODUCT', ref:'GUIDE-SIDE-D', name:'가이드레일(측면형/D형)', qty:1, cond:'옵션: 측면형'}]},
{id:'g3', name:'모터', items:[{id:'i5', type:'PART', ref:'MOTOR-SET', name:'모터+브라켓', qty:1, cond:'-'}]},
{id:'g4', name:'부자재', items:[{id:'i6', type:'PART', ref:'SHAFT-2IN', name:'감기샤프트 2인치', qty:1, cond:'-'}, {id:'i7', type:'PART', ref:'BOX-PIPE', name:'각파이프', qty:2, cond:'-'}]},
];
// 그룹 렌더링
function renderGroups(){
const $ul = $('#groupList').empty();
GROUPS.forEach(g=>{
const $li = $(`<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="fw-semibold">${g.name}</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary btn-rename" data-id="${g.id}"><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-del-group" data-id="${g.id}"><i class="bi bi-trash"></i></button>
</div>
</div>
</li>`);
$li.on('click', function(){ selectGroup(g.id); });
$ul.append($li);
});
}
let currentGroupId = null;
function selectGroup(id){
currentGroupId = id;
const g = GROUPS.find(x=>x.id===id);
renderItems(g ? g.items : []);
$('#groupList .list-group-item').removeClass('active');
$('#groupList .list-group-item').filter(function(){ return $(this).text().trim().startsWith(g.name); }).addClass('active');
}
function renderItems(items){
const $tb = $('#itemTable tbody').empty();
items.forEach(it=>{
const $tr = $(`<tr data-id="${it.id}">
<td>
<select class="form-select form-select-sm type">
<option ${it.type==='PRODUCT'?'selected':''}>PRODUCT</option>
<option ${it.type==='PART'?'selected':''}>PART</option>
<option ${it.type==='MATERIAL'?'selected':''}>MATERIAL</option>
</select>
</td>
<td><input class="form-control form-control-sm ref" value="${it.ref}"></td>
<td><input class="form-control form-control-sm name" value="${it.name}"></td>
<td style="width:120px;"><input type="number" step="0.001" class="form-control form-control-sm qty" value="${it.qty}"></td>
<td><input class="form-control form-control-sm cond" placeholder="예: 옵션=벽면형" value="${it.cond||''}"></td>
<td class="text-center"><button class="btn btn-sm btn-outline-danger btn-del-item"><i class="bi bi-x-lg"></i></button></td>
</tr>`);
$tb.append($tr);
});
}
// 이벤트 바인딩
$('#btnAddGroup').on('click', function(e){
e.preventDefault();
const name = ($('#groupName').val()||'').trim();
if(!name) return alert('그룹명을 입력하세요.');
const id = 'g'+(Date.now());
GROUPS.push({id, name, items:[]});
$('#groupName').val('');
renderGroups();
});
$('#groupList').on('click', '.btn-del-group', function(e){
e.stopPropagation();
const id = $(this).data('id');
if(!confirm('그룹을 삭제할까요? 하위 아이템도 함께 삭제됩니다.')) return;
GROUPS = GROUPS.filter(g=>g.id!==id);
currentGroupId = null;
renderGroups();
renderItems([]);
});
$('#groupList').on('click', '.btn-rename', function(e){
e.stopPropagation();
const id = $(this).data('id');
const g = GROUPS.find(x=>x.id===id);
const name = prompt('새 그룹명', g?.name||'');
if(name){ g.name = name; renderGroups(); }
});
$('#btnAddItem').on('click', function(){
if(!currentGroupId) return alert('먼저 그룹을 선택하세요.');
const g = GROUPS.find(x=>x.id===currentGroupId);
g.items.push({id:'i'+Date.now(), type:'PART', ref:'', name:'', qty:1, cond:''});
renderItems(g.items);
});
$('#itemTable').on('click', '.btn-del-item', function(){
const $tr = $(this).closest('tr');
const id = $tr.data('id');
const g = GROUPS.find(x=>x.id===currentGroupId);
g.items = g.items.filter(x=>x.id!==id);
renderItems(g.items);
});
// 저장 (템플릿 -> items payload)
$('#btnSave').on('click', function(){
// collect
const payload = {
model_id: modelId,
version: $('#modelVersion').val(),
groups: GROUPS.map(g=>({
name: g.name,
items: g.items.map(it=>({ type: $('.type', `tr[data-id="${it.id}"]`).val() || it.type,
ref: $('.ref', `tr[data-id="${it.id}"]`).val() || it.ref,
name: $('.name',`tr[data-id="${it.id}"]`).val() || it.name,
qty: Number($('.qty', `tr[data-id="${it.id}"]`).val() || it.qty),
cond: $('.cond',`tr[data-id="${it.id}"]`).val() || it.cond }))
}))
};
console.log('BOM TEMPLATE SAVE', payload);
alert('샘플: 콘솔에 저장 payload 출력. API 연동 필요');
});
// 초기 렌더
renderGroups();
if(GROUPS.length) selectGroup(GROUPS[0].id);
})();
</script>
<?php include '../inc/footer.php'; ?>

View File

@@ -0,0 +1,321 @@
<?php
// 모델관리
$CURRENT_SECTION='item';
include '../inc/header.php';
?>
<!--
SAM RULES (summary)
1) Prototype uses Bootstrap 5 + jQuery; no server changes.
2) Layout max-width 1280px; brand blue #2c4a85.
3) Keep scope to *Model metadata only* (NO BOM, NO attributes).
4) All paths placeholder; wire Ajax later.
5) Comment banner for consistency across files.
-->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>모델 등록 | SAM Prototype (Model-only)</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" />
<style>
:root{ --brand:#2c4a85; }
body{ background:#f7f9fc; }
.container{ max-width:1280px; }
.card{ border-radius:1rem; box-shadow:0 6px 20px rgba(0,0,0,.05); }
.page-title{ display:flex; gap:.75rem; align-items:center; }
.page-title .badge{ background:var(--brand); }
.required::after{ content:"*"; color:#dc3545; margin-left:4px; }
.tag-input{ min-height:38px; border:1px solid #ced4da; border-radius:.5rem; padding:.25rem .5rem; background:#fff; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; }
.tag-chip{ display:inline-flex; align-items:center; gap:.25rem; padding:.25rem .5rem; border-radius:20px; background:#edf2ff; }
.tag-chip .remove{ cursor:pointer; }
.dropzone{ border:2px dashed #cdd7e1; border-radius:.75rem; padding:16px; text-align:center; background:#fff; cursor:pointer; }
.sticky-actions{ position:sticky; bottom:0; background:rgba(255,255,255,.95); backdrop-filter:saturate(120%) blur(4px); border-top:1px solid #e5e7eb; }
</style>
</head>
<body>
<header class="py-3 mb-3 border-bottom bg-white">
<div class="container d-flex justify-content-between align-items-center">
<div class="page-title">
<a class="btn btn-outline-secondary" href="#" onclick="history.back();return false;"><i class="bi bi-arrow-left"></i></a>
<div>
<h1 class="h4 m-0">모델 등록</h1>
<div class="text-muted">모델(설계 뼈대) 메타데이터만 입력합니다. ※ BOM/속성은 별도 화면</div>
</div>
<span class="badge text-bg-primary ms-2">MODEL-ONLY</span>
</div>
<div>
<a class="btn btn-outline-primary" href="#"><i class="bi bi-list-ul me-1"></i>목록</a>
</div>
</div>
</header>
<main class="container pb-5">
<form id="modelForm" class="needs-validation" novalidate>
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-header bg-white">
<div class="d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">기본정보</h2>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="isActive" checked>
<label class="form-check-label" for="isActive">활성화</label>
</div>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label required" for="code">모델 코드</label>
<input type="text" class="form-control" id="code" placeholder="예: KSS01" required>
<div class="invalid-feedback">모델 코드를 입력하세요.</div>
</div>
<div class="col-md-8">
<label class="form-label required" for="name">모델명</label>
<input type="text" class="form-control" id="name" placeholder="예: 방화셔터 KSS01" required>
<div class="invalid-feedback">모델명을 입력하세요.</div>
</div>
<div class="col-md-6">
<label class="form-label required" for="unit">기본 단위</label>
<select id="unit" class="form-select" required>
<option value="">선택...</option>
<option>EA</option>
<option>SET</option>
<option>M</option>
<option>KG</option>
</select>
<div class="invalid-feedback">기본 단위를 선택하세요.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="tagsInput">태그</label>
<div id="tagsInput" class="tag-input" role="group" aria-label="tags">
<input id="tagText" type="text" class="form-control border-0 p-0" placeholder="Enter로 태그 추가 (예: 본체, 절곡물, BLDC)" />
</div>
</div>
<div class="col-12">
<label class="form-label required" for="categoryName">카테고리</label>
<div class="input-group">
<input type="text" id="categoryName" class="form-control" placeholder="카테고리를 선택하세요" readonly required>
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#categoryModal"><i class="bi bi-diagram-3"></i> 선택</button>
</div>
<input type="hidden" id="categoryId" />
<div class="form-text">※ 모델이 속하는 분류(예: 제품>방화셔터>KSS) — 구성(BOM)과는 별개</div>
<div class="invalid-feedback">카테고리를 선택하세요.</div>
</div>
<div class="col-12">
<label class="form-label" for="description">설명</label>
<textarea id="description" rows="3" class="form-control" placeholder="모델 개요/용도 등을 입력"></textarea>
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header bg-white d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">이미지</h2>
<div class="text-muted small">대표 이미지 1장</div>
</div>
<div class="card-body">
<div class="row g-3 align-items-center">
<div class="col-md-6">
<div id="imageDrop" class="dropzone">여기로 드래그하거나 클릭하여 선택</div>
<input type="file" id="imageFile" accept="image/*" class="form-control mt-2" />
</div>
<div class="col-md-6">
<div id="previewImg" class="bg-light border rounded d-flex align-items-center justify-content-center" style="width:120px;height:120px;">
<i class="bi bi-image fs-3 text-muted"></i>
</div>
</div>
</div>
</div>
</div>
<div class="sticky-actions mt-4 p-3 d-flex gap-2 justify-content-end bg-white">
<button type="button" class="btn btn-outline-secondary" id="btnReset"><i class="bi bi-arrow-counterclockwise"></i> 초기화</button>
<button type="button" class="btn btn-outline-primary" id="btnDraft"><i class="bi bi-cloud-arrow-up"></i> 임시저장</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-check2-circle"></i> 등록</button>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header bg-white">
<h2 class="h6 m-0">미리보기</h2>
</div>
<div class="card-body">
<div class="d-flex align-items-center gap-3 mb-3">
<div id="pvImg" class="bg-light border rounded" style="width:72px;height:72px; display:flex; align-items:center; justify-content:center;">
<i class="bi bi-image fs-3 text-muted"></i>
</div>
<div>
<div class="text-muted small" id="pvCode">CODE</div>
<div class="fw-bold" id="pvName">모델명 미입력</div>
<div class="small text-muted" id="pvCategory">카테고리 미선택</div>
</div>
</div>
<div id="pvTags" class="d-flex flex-wrap gap-1"></div>
<hr>
<div class="small text-muted">※ 이 화면은 모델 메타데이터만 다룹니다. BOM 구성은 별도의 “BOM 템플릿 편집기”에서 작성하세요.</div>
</div>
</div>
</div>
</div>
</form>
</main>
<!-- Category Modal (sample tree) -->
<div class="modal fade" id="categoryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-diagram-3 me-2"></i>카테고리 선택</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-5">
<input id="catSearch" type="search" class="form-control" placeholder="카테고리 검색" />
<div class="border rounded p-2 mt-2" style="max-height:320px; overflow:auto;">
<ul class="list-unstyled" id="catTree">
<li>
<label class="d-block"><input type="radio" name="cat" value="100" data-name="제품/방화셔터/KSS" class="form-check-input me-2"> 제품</label>
<ul class="ms-4 mt-1">
<li>
<label class="d-block"><input type="radio" name="cat" value="110" data-name="제품/방화셔터" class="form-check-input me-2"> 방화셔터</label>
<ul class="ms-4 mt-1">
<li><label class="d-block"><input type="radio" name="cat" value="111" data-name="제품/방화셔터/KSS" class="form-check-input me-2"> KSS</label></li>
</ul>
</li>
</ul>
</li>
</ul>
</div>
</div>
<div class="col-md-7">
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i> 최종(리프) 카테고리를 선택하세요. 이 선택은 모델의 *분류*만 의미합니다.
</div>
<div>
<div class="text-muted small">선택된 카테고리</div>
<div id="catPicked" class="fw-semibold">-</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" id="btnPickCategory"><i class="bi bi-check2"></i> 선택</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:1080;">
<div id="saveToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<i class="bi bi-check2-circle me-2 text-success"></i>
<strong class="me-auto">저장 완료</strong>
<small>지금</small>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">모델 메타데이터가 임시로 저장되었습니다. (콘솔 로그 확인)</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const toast = new bootstrap.Toast(document.getElementById('saveToast'));
function collectFormData(){
const tags = [];
$('#pvTags .tag-chip').each(function(){ tags.push($(this).data('tag')); });
return {
code: $('#code').val().trim(),
name: $('#name').val().trim(),
unit: $('#unit').val(),
is_active: $('#isActive').is(':checked') ? 1 : 0,
category_id: $('#categoryId').val() || null,
category_path: $('#categoryName').val() || null,
description: $('#description').val().trim(),
tags
};
}
function updatePreview(){
$('#pvCode').text($('#code').val() || 'CODE');
$('#pvName').text($('#name').val() || '모델명 미입력');
$('#pvCategory').text($('#categoryName').val() || '카테고리 미선택');
}
function addTag(text){
const tag = text.trim();
if(!tag) return;
const dup = $('#pvTags .tag-chip').filter(function(){return $(this).data('tag')===tag;}).length>0;
if(dup) return;
const chip = $(`<span class="tag-chip" data-tag="${tag}"><i class="bi bi-hash"></i>${tag}<i class="bi bi-x-lg remove"></i></span>`);
$('#pvTags').append(chip);
$('#tagText').val('');
}
$(function(){
// live preview
$('#code, #name, #categoryName').on('input change', updatePreview);
$('#code').on('input', function(){ this.value = this.value.toUpperCase(); });
// tags
$('#tagText').on('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); addTag(this.value); } });
$('#pvTags').on('click', '.remove', function(){ $(this).closest('.tag-chip').remove(); });
// category search
$('#catSearch').on('input', function(){
const q = this.value.trim().toLowerCase();
$('#catTree li').each(function(){ $(this).toggle($(this).text().toLowerCase().includes(q)); });
});
// category pick
$('input[name="cat"]').on('change', function(){ $('#catPicked').text($(this).data('name')); });
$('#btnPickCategory').on('click', function(){
const $sel = $('input[name="cat"]:checked');
if($sel.length===0){ alert('카테고리를 선택하세요.'); return; }
$('#categoryId').val($sel.val());
$('#categoryName').val($sel.data('name'));
updatePreview();
bootstrap.Modal.getInstance(document.getElementById('categoryModal')).hide();
});
// image preview + drop
$('#imageDrop').on('click', ()=> $('#imageFile').trigger('click'));
$('#imageDrop').on('dragover', e=>{ e.preventDefault(); $(e.currentTarget).addClass('border-primary'); });
$('#imageDrop').on('dragleave drop', e=>{ e.preventDefault(); $(e.currentTarget).removeClass('border-primary'); if(e.type==='drop'){ const f=e.originalEvent.dataTransfer.files; if(f && f[0]){ $('#imageFile')[0].files=f; $('#imageFile').trigger('change'); } } });
$('#imageFile').on('change', function(){
const file = this.files && this.files[0]; if(!file) return;
const reader = new FileReader();
reader.onload = e => { $('#previewImg, #pvImg').html(`<img src="${e.target.result}" alt="img" class="img-fluid rounded" style="max-width:100%; max-height:100%;">`); };
reader.readAsDataURL(file);
});
// submit
const form = document.getElementById('modelForm');
form.addEventListener('submit', function(ev){
ev.preventDefault(); ev.stopPropagation();
if(!form.checkValidity()) { form.classList.add('was-validated'); return; }
const payload = collectFormData();
console.log('MODEL:CREATE payload', payload);
toast.show();
});
// draft + reset
$('#btnDraft').on('click', function(){ console.log('MODEL:DRAFT payload', collectFormData()); toast.show(); });
$('#btnReset').on('click', function(){ form.reset(); $('#pvTags').empty(); updatePreview(); $('#previewImg, #pvImg').html('<i class="bi bi-image fs-3 text-muted"></i>'); });
updatePreview();
});
</script>
</body>
</html>
<?php include '../inc/footer.php'; ?>

View File

@@ -4,307 +4,179 @@
include '../inc/header.php';
?>
<div class="container" style="max-width:1280px; margin-top:24px;">
<!-- 탭 -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link" href="/tenant/material/list.php">자재 관리</a></li>
<li class="nav-item"><a class="nav-link active" href="/tenant/product/model_list.php">모델 관리</a></li>
<li class="nav-item"><a class="nav-link" href="/tenant/product/bom_editor.php">BOM 관리</a></li>
</ul>
<!-- 툴바 -->
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<div class="d-flex align-items-center gap-2">
<label class="text-muted">분류</label>
<select class="form-select form-select-sm" id="filterCategory" style="width:160px;">
<option value="">전체</option>
<option value="스크린">스크린</option>
<option value="철재">철재</option>
<option value="유리">유리</option>
</select>
</div>
<div class="input-group" style="max-width:340px;">
<input type="text" class="form-control form-control-sm" id="keyword" placeholder="모델명/로트코드 검색">
<button class="btn btn-outline-secondary btn-sm" id="btnSearch">Search</button>
</div>
<div class="d-flex align-items-center gap-2">
<label class="text-muted">표시</label>
<select id="pageSize" class="form-select form-select-sm" style="width:80px;">
<option>10</option><option selected>20</option><option>30</option><option>50</option>
</select>
</div>
<div class="ms-auto small text-muted" id="totalInfo">총 0건</div>
<button class="btn btn-primary btn-sm" id="btnOpenCreate">신규등록</button>
</div>
<!-- 리스트 -->
<div class="table-responsive border rounded">
<table class="table table-hover table-striped align-middle m-0">
<thead class="table-light">
<tr>
<th style="width:70px;">NO</th>
<th style="width:140px;">분류</th>
<th>모델명</th>
<th style="width:140px;">로트 코드</th>
<th style="width:140px;">등록일</th>
<th style="width:160px;" class="text-center">수정/삭제</th>
</tr>
</thead>
<tbody id="listBody"></tbody>
</table>
</div>
<!-- 페이징 -->
<div class="d-flex justify-content-center mt-3">
<nav id="pagerNav" aria-label="Page navigation"></nav>
</div>
</div>
<!-- 모달 (부트스트랩 + 폴백) -->
<div class="modal fade" id="modelModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<form class="modal-content needs-validation" id="modelForm" novalidate>
<div class="modal-header">
<h5 class="modal-title" id="modelModalTitle">모델 등록</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="닫기" id="btnCloseX"></button>
<form id="modelForm" class="needs-validation" novalidate>
<div class="row g-4">
<!-- LEFT: form fields -->
<div class="col-lg-8">
<div class="card">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h2 class="h6 m-0">모델 기본정보</h2>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="isActive" checked>
<label class="form-check-label" for="isActive">활성화</label>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label required" for="code">모델 코드</label>
<input type="text" class="form-control" id="code" placeholder="예: MDL-2025-001" required>
<div class="invalid-feedback">모델 코드를 입력하세요.</div>
</div>
<div class="col-md-8">
<label class="form-label required" for="name">모델명</label>
<input type="text" class="form-control" id="name" placeholder="예: 컨베이어 모듈 A" required>
<div class="invalid-feedback">모델명을 입력하세요.</div>
</div>
<div class="col-md-6">
<label class="form-label required" for="unit">기본 단위</label>
<select id="unit" class="form-select" required>
<option value="">선택...</option>
<option>EA</option>
<option>SET</option>
<option>M</option>
<option>KG</option>
<option>BOX</option>
</select>
<div class="invalid-feedback">기본 단위를 선택하세요.</div>
</div>
<div class="col-md-6">
<label class="form-label required" for="categoryName">카테고리</label>
<div class="input-group">
<input type="text" id="categoryName" class="form-control" placeholder="카테고리를 선택하세요" readonly required>
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#categoryModal"><i class="bi bi-diagram-3"></i> 선택</button>
</div>
<input type="hidden" id="categoryId" />
<div class="invalid-feedback">카테고리를 선택하세요.</div>
</div>
<div class="col-12">
<label class="form-label" for="description">설명</label>
<textarea id="description" rows="3" class="form-control" placeholder="모델 설명을 입력"></textarea>
</div>
<div class="col-12">
<label class="form-label" for="tagsInput">태그</label>
<div id="tagsInput" class="tag-input">
<input id="tagText" type="text" class="form-control border-0 p-0" placeholder="Enter로 태그 추가" />
</div>
</div>
<div class="col-12">
<label class="form-label">대표 이미지</label>
<div id="imageDrop" class="dropzone">클릭 또는 드래그하여 선택</div>
<input type="file" id="imageFile" accept="image/*" class="form-control mt-2" />
</div>
</div>
</div>
</div>
<div class="mt-4 d-flex justify-content-end gap-2">
<button type="reset" class="btn btn-outline-secondary"><i class="bi bi-arrow-counterclockwise"></i> 초기화</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-check2-circle"></i> 등록</button>
</div>
</div>
<div class="modal-body">
<input type="hidden" id="mdlId">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">분류 <span class="text-danger">*</span></label>
<select class="form-select" id="mdlCategory" required>
<option value="">선택</option>
<option>스크린</option>
<option>철재</option>
<option>유리</option>
</select>
<div class="invalid-feedback">분류를 선택하세요.</div>
<!-- RIGHT: preview -->
<div class="col-lg-4">
<div class="card preview-card">
<div class="card-header bg-white">
<h2 class="h6 m-0">미리보기</h2>
</div>
<div class="col-md-5">
<label class="form-label">모델명 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="mdlName" required>
<div class="invalid-feedback">모델명을 입력하세요.</div>
</div>
<div class="col-md-4">
<label class="form-label">로트 코드</label>
<input type="text" class="form-control" id="mdlLot" placeholder="예: SA, WE">
</div>
<div class="col-12">
<label class="form-label">내용</label>
<input type="text" class="form-control" id="mdlDesc" placeholder="간단 설명">
<div class="card-body">
<div class="d-flex align-items-center gap-3 mb-3">
<div id="previewImg" class="bg-light border rounded" style="width:72px;height:72px; display:flex; align-items:center; justify-content:center;">
<i class="bi bi-image fs-3 text-muted"></i>
</div>
<div>
<div class="text-muted small" id="pvCode">CODE</div>
<div class="fw-bold" id="pvName">모델명 미입력</div>
<div class="small text-muted" id="pvCategory">카테고리 미선택</div>
</div>
</div>
<div id="pvTags" class="d-flex flex-wrap gap-1 mb-3"></div>
</div>
</div>
</div>
<div class="modal-footer">
<div class="me-auto small text-muted" id="modalHint"></div>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="btnClose">닫기</button>
<button type="submit" class="btn btn-primary" id="btnSubmit">등록</button>
</div>
</form>
</div>
</div>
</form>
</div>
<!-- 토스트 -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:1080">
<div id="snack" class="toast align-items-center text-bg-primary border-0" role="alert">
<div class="d-flex">
<div class="toast-body" id="snackMsg">Saved</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
<!-- Category Modal -->
<div class="modal fade" id="categoryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-diagram-3 me-2"></i>카테고리 선택</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="list-unstyled" id="catTree">
<li><label><input type="radio" name="cat" value="101" data-name="제품/모터" class="form-check-input me-2"> 제품 > 모터</label></li>
<li><label><input type="radio" name="cat" value="201" data-name="제품/컨베이어" class="form-check-input me-2"> 제품 > 컨베이어</label></li>
<li><label><input type="radio" name="cat" value="301" data-name="반제품/프레임" class="form-check-input me-2"> 반제품 > 프레임</label></li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" id="btnPickCategory">선택</button>
</div>
</div>
</div>
</div>
<style>
.table > :not(caption) > * > * { vertical-align: middle; }
/* 부트스트랩 JS 없는 경우의 간단 폴백 */
.modal.fallback-show { display:block; background:rgba(0,0,0,.45); }
.modal.fallback-show .modal-dialog {
top: 50%;
transform: translateY(-50%);
margin: 0 auto;
position: relative;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function(){
// ===== 샘플 데이터 =====
function buildSample(count=64){
const cats=['스크린','철재','유리'], lots=['SS','SA','SE','WE','TS','TE','DS'];
const arr=[]; for(let i=1;i<=count;i++){
arr.push({
id:i,
category: cats[i%cats.length],
name: (i%7===0)? `${cats[i%cats.length]} 비인정` : `${cats[i%cats.length]} K${String(i).padStart(2,'0')}`,
lot: lots[i%lots.length],
created_at: new Date(Date.now()-86400000*i).toISOString().slice(0,10),
desc:''
});
} return arr;
$(function(){
function updatePreview(){
$('#pvCode').text($('#code').val() || 'CODE');
$('#pvName').text($('#name').val() || '모델명 미입력');
$('#pvCategory').text($('#categoryName').val() || '카테고리 미선택');
}
let MODELS = buildSample(97);
$('#code,#name,#categoryName').on('input change', updatePreview);
// ===== 상태/유틸 =====
let PAGE=1, SIZE=20;
const $=s=>document.querySelector(s), $$=s=>Array.from(document.querySelectorAll(s));
const hasBS = ()=> !!window.bootstrap;
const snack = ()=> hasBS() ? new bootstrap.Toast('#snack') : { show(){ /* no-op */ } };
function getFiltered(){
const cat=$('#filterCategory').value.trim();
const kw=$('#keyword').value.trim().toLowerCase();
return MODELS.filter(m=>{
const okCat=!cat || m.category===cat;
const hay=(m.name+' '+(m.lot||'')).toLowerCase();
const okKw=!kw || hay.includes(kw);
return okCat && okKw;
});
}
// ===== 렌더 =====
function renderList(){
const data=getFiltered();
const total=data.length;
const pages=Math.max(1, Math.ceil(total/SIZE));
if(PAGE>pages) PAGE=pages;
const start=(PAGE-1)*SIZE, end=start+SIZE;
$('#listBody').innerHTML = data.slice(start,end).map((m,i)=>`
<tr data-id="${m.id}">
<td>${start+i+1}</td>
<td>${m.category}</td>
<td>${m.name}</td>
<td>${m.lot||''}</td>
<td>${m.created_at}</td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary me-1 btnEdit">수정</button>
<button class="btn btn-sm btn-outline-danger btnDel">삭제</button>
</td>
</tr>
`).join('') || `<tr><td colspan="6" class="text-center text-muted py-4">데이터가 없습니다.</td></tr>`;
$('#totalInfo').textContent=`총 ${total.toLocaleString()}건 · ${total?(start+1):0}-${Math.min(end,total)} 표시`;
const win=7; let sp=Math.max(1,PAGE-Math.floor(win/2)), ep=Math.min(pages, sp+win-1); sp=Math.max(1,ep-win+1);
let html=`<ul class="pagination pagination-sm m-0">`;
html+=`<li class="page-item ${PAGE===1?'disabled':''}"><a class="page-link" href="#" data-go="first">«</a></li>`;
html+=`<li class="page-item ${PAGE===1?'disabled':''}"><a class="page-link" href="#" data-go="${PAGE-1}"></a></li>`;
for(let p=sp;p<=ep;p++){ html+=`<li class="page-item ${p===PAGE?'active':''}"><a class="page-link" href="#" data-go="${p}">${p}</a></li>`; }
html+=`<li class="page-item ${PAGE===pages?'disabled':''}"><a class="page-link" href="#" data-go="${PAGE+1}"></a></li>`;
html+=`<li class="page-item ${PAGE===pages?'disabled':''}"><a class="page-link" href="#" data-go="last">»</a></li>`;
html+=`</ul>`; $('#pagerNav').innerHTML=html;
}
// ===== 모달(부트스트랩/폴백) =====
let mdlModal = null;
function openModal(){
try{
if (hasBS()){
mdlModal = mdlModal || new bootstrap.Modal('#modelModal');
mdlModal.show(); return;
$('#tagText').on('keydown', function(e){
if(e.key==='Enter'){
e.preventDefault();
const tag=this.value.trim();
if(tag){
const chip=$(`<span class="tag-chip" data-tag="${tag}"><i class="bi bi-hash"></i>${tag}<i class="bi bi-x-lg remove"></i></span>`);
$('#pvTags').append(chip);
this.value='';
}
}catch(_){}
// 폴백
const m = $('#modelModal');
m.classList.add('fallback-show','show');
}
function closeModal(){
if (hasBS() && mdlModal){ mdlModal.hide(); return; }
const m = $('#modelModal');
m.classList.remove('fallback-show','show');
}
$('#btnClose').addEventListener('click', closeModal);
$('#btnCloseX').addEventListener('click', closeModal);
}
});
$('#pvTags').on('click','.remove',function(){ $(this).parent().remove(); });
// ===== 이벤트 =====
// 신규등록
$('#btnOpenCreate').addEventListener('click', ()=>{
$('#modelModalTitle').textContent='모델 등록';
$('#btnSubmit').textContent='등록';
$('#modelForm').reset();
$('#mdlId').value='';
$('#modalHint').textContent='※ 필수 입력: 분류, 모델명';
openModal();
$('input[name="cat"]').on('change', function(){
$('#categoryId').val(this.value);
$('#categoryName').val($(this).data('name')).trigger('change');
});
// 저장(등록/수정)
$('#modelForm').addEventListener('submit', e=>{
e.preventDefault(); e.stopPropagation();
e.currentTarget.classList.add('was-validated');
if(!e.currentTarget.checkValidity()) return;
$('#imageFile').on('change', function(){
const file=this.files[0];
if(file){
const reader=new FileReader();
reader.onload=e=>$('#previewImg').html(`<img src="${e.target.result}" class="img-fluid rounded" style="max-width:72px; max-height:72px;">`);
reader.readAsDataURL(file);
}
});
const dto={
id: $('#mdlId').value ? parseInt($('#mdlId').value,10) : null,
category: $('#mdlCategory').value,
name: $('#mdlName').value.trim(),
lot: $('#mdlLot').value.trim(),
desc: $('#mdlDesc').value.trim(),
created_at: new Date().toISOString().slice(0,10)
$('#imageDrop').on('click', ()=> $('#imageFile').trigger('click'));
$('#modelForm').on('submit', function(e){
e.preventDefault();
if(!this.checkValidity()){ this.classList.add('was-validated'); return; }
const payload={
code:$('#code').val(),
name:$('#name').val(),
unit:$('#unit').val(),
is_active:$('#isActive').is(':checked')?1:0,
category_id:$('#categoryId').val(),
description:$('#description').val(),
tags:$('#pvTags .tag-chip').map((i,e)=>$(e).data('tag')).get()
};
if(dto.id){
const idx=MODELS.findIndex(x=>x.id===dto.id);
if(idx>=0) MODELS[idx]={...MODELS[idx], ...dto};
$('#snackMsg').textContent='수정되었습니다.'; snack().show();
}else{
dto.id=(MODELS.reduce((mx,m)=>Math.max(mx,m.id),0)||0)+1;
MODELS.unshift(dto);
$('#snackMsg').textContent='등록되었습니다.'; snack().show();
}
closeModal();
PAGE=1; renderList();
console.log('SAVE',payload);
alert('모델 등록 데이터 콘솔 확인');
});
// 수정/삭제/페이징
document.addEventListener('click', e=>{
const a=e.target.closest('#pagerNav a');
if (a){ e.preventDefault();
const pages=Math.max(1, Math.ceil(getFiltered().length/SIZE));
const go=a.dataset.go;
if(go==='first') PAGE=1;
else if(go==='last') PAGE=pages;
else PAGE=Math.min(Math.max(parseInt(go,10)||1,1), pages);
renderList();
return;
}
if (e.target.classList.contains('btnEdit')){
const id=parseInt(e.target.closest('tr').dataset.id,10);
const m=MODELS.find(x=>x.id===id); if(!m) return;
$('#modelModalTitle').textContent='모델 수정';
$('#btnSubmit').textContent='수정';
$('#mdlId').value=m.id; $('#mdlCategory').value=m.category; $('#mdlName').value=m.name;
$('#mdlLot').value=m.lot||''; $('#mdlDesc').value=m.desc||'';
$('#modalHint').textContent='필드 수정 후 [수정] 클릭';
openModal();
}
if (e.target.classList.contains('btnDel')){
const id=parseInt(e.target.closest('tr').dataset.id,10);
if(!confirm('삭제하시겠습니까?')) return;
MODELS = MODELS.filter(x=>x.id!==id);
$('#snackMsg').textContent='삭제되었습니다.'; snack().show();
renderList();
}
});
// 검색/필터/표시개수
$('#btnSearch').addEventListener('click', ()=>{ PAGE=1; renderList(); });
$('#keyword').addEventListener('keydown', e=>{ if(e.key==='Enter'){ PAGE=1; renderList(); }});
$('#filterCategory').addEventListener('change', ()=>{ PAGE=1; renderList(); });
$('#pageSize').addEventListener('change', function(){ SIZE=parseInt(this.value,10)||20; PAGE=1; renderList(); });
// 최초
document.addEventListener('DOMContentLoaded', ()=>{
const ps=$('#pageSize'); if(ps) SIZE=parseInt(ps.value,10)||20;
renderList();
});
})();
});
</script>
<!-- Bootstrap JS (있으면 사용, 없어도 폴백으로 동작) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<?php include '../inc/footer.php'; ?>