Files
sam-api/public/tenant/material/list.php
hskwon cc206fdbed style: Laravel Pint 코드 포맷팅 적용
- PSR-12 스타일 가이드 준수
- 302개 파일 스타일 이슈 자동 수정
- 코드 로직 변경 없음 (포맷팅만)
2025-11-06 17:45:49 +09:00

508 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// 자재관리 > 리스트
$CURRENT_SECTION = 'item';
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'; ?>