Files
sam-api/public/tenant/material/list.php

508 lines
26 KiB
PHP
Raw Normal View History

2025-08-10 02:36:50 +09:00
<?php
// 자재관리 > 리스트
$CURRENT_SECTION = 'item';
2025-08-10 02:36:50 +09:00
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 active" href="/tenant/material/list.php">자재 관리</a></li>
<li class="nav-item"><a class="nav-link" 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:120px;">분류</th>
<th>품목명</th>
<th style="width:90px;">단위</th>
<th style="width:140px;">제조사</th>
<th style="width:120px;">이미지</th>
<th style="width:140px;" 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="materialModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<form class="modal-content needs-validation" id="materialForm" novalidate>
<div class="modal-header">
<h5 class="modal-title" id="materialModalTitle">자재 등록</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="닫기"></button>
</div>
<!-- === 모달 본문 (동적 규격/기타 + 품목명 자동생성) === -->
<div class="modal-body">
<input type="hidden" id="matId">
<!-- 기본정보 -->
<details open class="mb-3">
<summary class="fw-semibold">기본정보 입력</summary>
<div class="row g-3 mt-2">
<div class="col-md-3">
<label class="form-label">분류 <span class="text-danger">*</span></label>
<select class="form-select" id="matCategory" required>
<option value="">선택</option>
<option>자재</option>
<option>부품</option>
<option>소재</option>
</select>
<div class="invalid-feedback">분류를 선택해 주세요.</div>
</div>
<div class="col-md-4">
<label class="form-label">자재명(베이스) <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="matBaseName" placeholder="예: 절곡판" required>
<div class="invalid-feedback">자재명을 입력해 주세요.</div>
</div>
<div class="col-md-3">
<label class="form-label">단위</label>
<input type="text" class="form-control" id="matUnit" placeholder="EA, mm, kg …">
</div>
<div class="col-12">
<label class="form-label">품목명(자동생성)</label>
<input type="text" class="form-control" id="matName" placeholder="자재명 + 규격값/단위" readonly>
<div class="form-text">규격을 추가하면 자동으로 갱신됩니다. () 절곡판 1.5t 20mm</div>
</div>
</div>
</details>
<!-- 규격정보 -->
<details open class="mb-3">
<summary class="fw-semibold d-flex align-items-center">
규격정보 입력
<button type="button" class="btn btn-sm btn-outline-primary ms-2" id="btnAddSpec"> 추가</button>
</summary>
<div id="specRows" class="mt-2"></div>
</details>
<!-- 기타정보 -->
<details class="mb-3">
<summary class="fw-semibold d-flex align-items-center">
기타정보 입력
<button type="button" class="btn btn-sm btn-outline-primary ms-2" id="btnAddExtra"> 추가</button>
</summary>
<div id="extraRows" class="mt-2"></div>
</details>
<!-- 부가 입력 -->
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">제조사</label>
<input type="text" class="form-control" id="matMaker">
</div>
<div class="col-md-4">
<label class="form-label">품목코드</label>
<input type="text" class="form-control" id="matCode">
</div>
<div class="col-md-4">
<label class="form-label">안전재고</label>
<input type="number" class="form-control" id="matSafety" min="0" step="1" value="0">
</div>
<div class="col-12">
<label class="form-label">이미지 업로드</label>
<input class="form-control" type="file" id="matImages" accept="image/*" multiple>
<div class="form-text">여러 업로드 가능 (프로토타입: 미리보기만)</div>
</div>
<div class="col-12">
<label class="form-label">비고</label>
<textarea class="form-control" id="matMemo" rows="2"></textarea>
</div>
<div class="col-12">
<div class="d-flex gap-2 flex-wrap" id="previewWrap"></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">취소</button>
<button type="submit" class="btn btn-primary" id="btnSubmit">저장</button>
</div>
</form>
</div>
</div>
<!-- 이미지 라이트박스 -->
<div class="modal fade" id="imgModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content bg-dark">
<div class="modal-body p-0">
<img id="imgModalImg" src="" alt="preview" class="w-100" style="display:block;">
</div>
</div>
</div>
</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>
</div>
</div>
</div>
<style>
.thumb{width:56px;height:40px;object-fit:cover;border-radius:4px;cursor:zoom-in;border:1px solid rgba(0,0,0,.06)}
.table > :not(caption) > * > * { vertical-align: middle; }
details > summary { cursor: pointer; }
</style>
<!-- ===== 페이지 스크립트 (jQuery 불필요) ===== -->
<script>
(function(){
// ---------- 샘플 데이터 ----------
function buildSampleData(count=173){
const cats=['자재','부품','소재'], makers=['KG스틸','대한','경동기업','한빛','에이스','대림'], units=['EA','M','KG','매','BOX'];
return Array.from({length:count}, (_,i)=>({
id: i+1,
category: cats[(i+1)%cats.length],
name: `${cats[(i+1)%cats.length]} 샘플 품목 #${i+1} (${['A','B','C','D'][i%4]})`,
base_name: '',
unit: units[i%units.length],
maker: makers[i%makers.length],
code: `MAT-${String(i+1).padStart(5,'0')}`,
safety: (i%7===0)?50:0,
images: (i%5===0)?[`https://picsum.photos/seed/m${i+1}/120/80`]:[],
specs:[], extras:[]
}));
}
let MATERIALS = buildSampleData();
// ---------- 상태 ----------
let PAGE = 1, SIZE = 20;
// ---------- 헬퍼 ----------
const $ = sel => document.querySelector(sel);
const $$ = sel => Array.from(document.querySelectorAll(sel));
function getFiltered(){
const cat = ($('#filterCategory')?.value || '').trim();
const kw = ($('#keyword')?.value || '').trim().toLowerCase();
return MATERIALS.filter(m=>{
const okCat = !cat || m.category===cat;
const hay = (m.name+' '+(m.maker||'')+' '+(m.code||'')).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;
const pageData = data.slice(start,end);
const body = $('#listBody');
body.innerHTML = pageData.map((m,i)=>`
<tr data-id="${m.id}">
<td>${start+i+1}</td>
<td>${m.category||''}</td>
<td>${m.name||''}</td>
<td>${m.unit||''}</td>
<td>${m.maker||''}</td>
<td>${
(m.images||[]).slice(0,2).map(src=>`<img src="${src}" class="thumb me-1" data-src="${src}">`).join('') +
((m.images&&m.images.length>2)? `<span class="text-muted small">+${m.images.length-2}</span>`:'')
}</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="7" class="text-center text-muted py-4">데이터가 없습니다.</td></tr>`;
// 총건수
$('#totalInfo').textContent = `총 ${total.toLocaleString()}건 · ${total? (start+1):0}-${Math.min(end,total)} 표시`;
renderPager(pages);
}
function renderPager(pages){
const nav = $('#pagerNav');
const win=7;
let sp=Math.max(1, PAGE-Math.floor(win/2));
let 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" aria-label="First">«</a></li>`;
html += `<li class="page-item ${PAGE===1?'disabled':''}">
<a class="page-link" href="#" data-go="${PAGE-1}" aria-label="Prev"></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}" aria-label="Next"></a></li>`;
html += `<li class="page-item ${PAGE===pages?'disabled':''}">
<a class="page-link" href="#" data-go="last" aria-label="Last">»</a></li>`;
html += `</ul>`;
nav.innerHTML = html;
}
// ---------- 규격/기타 동적행 + 품목명 자동생성 ----------
const UNIT_OPTIONS = ['EA','mm','cm','m','kg','t','L'];
function specRowTpl(data={label:'규격', value:'', unit:''}) {
const uid='spec_'+Math.random().toString(36).slice(2,8);
const opts=UNIT_OPTIONS.map(u=>`<option ${data.unit===u?'selected':''}>${u}</option>`).join('');
return `
<div class="row g-2 align-items-center border rounded p-2 mb-2" data-type="spec" id="${uid}">
<div class="col-auto"><button type="button" class="btn btn-sm btn-outline-secondary btnDelRow"></button></div>
<div class="col-md-2"><label class="form-label m-0 small">규격</label>
<input type="text" class="form-control form-control-sm spec-label" value="${data.label||''}" placeholder="예: 두께"></div>
<div class="col-md-4"><label class="form-label m-0 small"></label>
<input type="text" class="form-control form-control-sm spec-value" value="${data.value||''}" placeholder="예: 1.5"></div>
<div class="col-md-2"><label class="form-label m-0 small">단위</label>
<select class="form-select form-select-sm spec-unit"><option value="">선택</option>${opts}</select></div>
</div>`;
}
function extraRowTpl(data={key:'', value:''}) {
const uid='extra_'+Math.random().toString(36).slice(2,8);
return `
<div class="row g-2 align-items-center border rounded p-2 mb-2" data-type="extra" id="${uid}">
<div class="col-auto"><button type="button" class="btn btn-sm btn-outline-secondary btnDelRow"></button></div>
<div class="col-md-3"><label class="form-label m-0 small">항목</label>
<input type="text" class="form-control form-control-sm extra-key" value="${data.key||''}" placeholder="예: 색상"></div>
<div class="col-md-6"><label class="form-label m-0 small"></label>
<input type="text" class="form-control form-control-sm extra-value" value="${data.value||''}" placeholder="예: 블루"></div>
</div>`;
}
function addSpecRow(data){ $('#specRows').insertAdjacentHTML('beforeend', specRowTpl(data)); wireSpecEvents(); composeName(); }
function addExtraRow(data){ $('#extraRows').insertAdjacentHTML('beforeend', extraRowTpl(data)); }
function wireSpecEvents(){
$$('#specRows .spec-value, #specRows .spec-unit, #matBaseName').forEach(el=>{
el.oninput = composeName; el.onchange = composeName;
});
}
function composeName(){
const base = ($('#matBaseName')?.value||'').trim();
const parts=[];
$$('#specRows [data-type="spec"]').forEach(row=>{
const v=row.querySelector('.spec-value')?.value.trim();
const u=row.querySelector('.spec-unit')?.value.trim();
if (v) parts.push(u?`${v}${u}`:v);
});
$('#matName').value = [base, ...parts].filter(Boolean).join(' ');
}
document.addEventListener('click', e=>{
if (e.target.id==='btnAddSpec') addSpecRow({});
if (e.target.id==='btnAddExtra') addExtraRow({});
if (e.target.classList.contains('btnDelRow')){
e.preventDefault();
const row=e.target.closest('.row'); row?.parentNode?.removeChild(row);
composeName();
}
});
function resetDynamicRows(specs=[], extras=[]){
$('#specRows').innerHTML=''; $('#extraRows').innerHTML='';
if (!specs.length) specs=[{}];
specs.forEach(s=> addSpecRow(s));
extras.forEach(x=> addExtraRow(x));
composeName();
}
// ---------- 모달/토스트 ----------
let materialModal, imgModal, snackToast;
function ensureBootstrapInstances(){
// 부트스트랩 JS가 있다면 인스턴스 생성
if (window.bootstrap){
materialModal = materialModal || new bootstrap.Modal('#materialModal');
imgModal = imgModal || new bootstrap.Modal('#imgModal');
snackToast = snackToast || new bootstrap.Toast('#snack');
}
}
// ---------- 이벤트 바인딩 ----------
// 신규등록
document.getElementById('btnOpenCreate').addEventListener('click', ()=>{
ensureBootstrapInstances();
$('#materialModalTitle').textContent='자재 등록';
$('#btnSubmit').textContent='저장';
$('#materialForm').reset();
$('#matId').value='';
$('#previewWrap').innerHTML='';
$('#modalHint').textContent='※ 필수 입력: 분류, 자재명(베이스)';
resetDynamicRows();
materialModal ? materialModal.show() : document.getElementById('materialModal').classList.add('show');
});
// 이미지 미리보기
document.getElementById('matImages').addEventListener('change', function(){
const wrap=$('#previewWrap'); wrap.innerHTML='';
Array.from(this.files).forEach(f=>{
const url=URL.createObjectURL(f);
wrap.insertAdjacentHTML('beforeend', `<img src="${url}" class="thumb">`);
});
});
// 저장(등록/수정)
document.getElementById('materialForm').addEventListener('submit', function(e){
e.preventDefault(); e.stopPropagation();
this.classList.add('was-validated');
if (!this.checkValidity()) return;
const specs = $$('#specRows [data-type="spec"]').map(row=>({
label: row.querySelector('.spec-label')?.value.trim() || '규격',
value: row.querySelector('.spec-value')?.value.trim() || '',
unit : row.querySelector('.spec-unit')?.value.trim() || ''
}));
const extras = $$('#extraRows [data-type="extra"]').map(row=>({
key: row.querySelector('.extra-key')?.value.trim() || '',
value: row.querySelector('.extra-value')?.value.trim() || ''
}));
const dto = {
id: $('#matId').value ? parseInt($('#matId').value,10) : null,
category: $('#matCategory').value,
base_name: $('#matBaseName').value.trim(),
name: $('#matName').value.trim(),
unit: $('#matUnit').value.trim(),
maker: $('#matMaker').value.trim(),
code: $('#matCode').value.trim(),
safety: parseInt($('#matSafety').value,10) || 0,
memo: $('#matMemo').value.trim(),
images: [],
specs, extras
};
const files = $('#matImages').files; for (const f of files) dto.images.push(URL.createObjectURL(f));
if (dto.id){
const idx = MATERIALS.findIndex(m=>m.id===dto.id);
if (idx>=0) MATERIALS[idx] = {...MATERIALS[idx], ...dto};
if (snackToast){ $('#snackMsg').textContent='수정되었습니다.'; snackToast.show(); }
}else{
dto.id = (MATERIALS.reduce((mx,m)=>Math.max(mx,m.id),0)||0)+1;
MATERIALS.unshift(dto);
if (snackToast){ $('#snackMsg').textContent='등록되었습니다.'; snackToast.show(); }
}
materialModal?.hide();
PAGE=1; renderList();
});
// 수정/삭제
document.addEventListener('click', e=>{
if (e.target.classList.contains('btnEdit')){
ensureBootstrapInstances();
const id = parseInt(e.target.closest('tr').dataset.id,10);
const m = MATERIALS.find(x=>x.id===id); if(!m) return;
$('#materialModalTitle').textContent='자재 수정';
$('#btnSubmit').textContent='수정';
$('#matId').value=m.id;
$('#matCategory').value=m.category||'';
$('#matBaseName').value=m.base_name || (m.name||'').split(' ')[0] || '';
$('#matUnit').value=m.unit||'';
$('#matName').value=m.name||'';
$('#matMaker').value=m.maker||'';
$('#matCode').value=m.code||'';
$('#matSafety').value=m.safety||0;
$('#matMemo').value=m.memo||'';
$('#matImages').value='';
const wrap=$('#previewWrap'); wrap.innerHTML='';
(m.images||[]).forEach(src=> wrap.insertAdjacentHTML('beforeend', `<img src="${src}" class="thumb">`));
resetDynamicRows(m.specs||[], m.extras||[]);
materialModal ? materialModal.show() : document.getElementById('materialModal').classList.add('show');
}
if (e.target.classList.contains('btnDel')){
const id = parseInt(e.target.closest('tr').dataset.id,10);
if (!confirm('삭제하시겠습니까?')) return;
MATERIALS = MATERIALS.filter(m=>m.id!==id);
if (snackToast){ $('#snackMsg').textContent='삭제되었습니다.'; snackToast.show(); }
renderList();
}
if (e.target.classList.contains('thumb')){
ensureBootstrapInstances();
const src = e.target.dataset.src || e.target.getAttribute('src');
$('#imgModalImg').setAttribute('src', src);
imgModal ? imgModal.show() : document.getElementById('imgModal').classList.add('show');
}
});
// 검색/필터/페이지크기
document.getElementById('btnSearch').addEventListener('click', ()=>{ PAGE=1; renderList(); });
document.getElementById('keyword').addEventListener('keydown', e=>{ if(e.key==='Enter'){ PAGE=1; renderList(); }});
document.getElementById('filterCategory').addEventListener('change', ()=>{ PAGE=1; renderList(); });
document.getElementById('pageSize').addEventListener('change', function(){ SIZE=parseInt(this.value,10)||20; PAGE=1; renderList(); });
// 페이징 클릭
document.addEventListener('click', (e)=>{
const a = e.target.closest('#pagerNav a'); if (!a) return;
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();
});
// 최초 렌더
document.addEventListener('DOMContentLoaded', ()=>{
try{ ensureBootstrapInstances(); }catch(_){}
const ps=document.getElementById('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'; ?>