fix(purchase): 모바일 네비 수정 + 권한 자동허용 + 장바구니 다중 품목 신청
- sideNav: TBM 패턴(hidden lg:flex + mobile-open) 적용, 회색 오버레이 버그 수정 - 권한: NAV_MENU 기반 publicPageKeys 추출, admin이 아닌 페이지는 비관리자 자동 접근 허용 - 장바구니: 검색→품목 추가→계속 검색→추가, 동일 품목 수량 합산, 품목별 메모 - bulk API: POST /purchase-requests/bulk (트랜잭션, is_new 품목 마스터 등록 포함) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -180,6 +180,87 @@ const PurchaseRequestController = {
|
||||
}
|
||||
},
|
||||
|
||||
// 일괄 신청 (장바구니, 트랜잭션)
|
||||
bulkCreate: async (req, res) => {
|
||||
const { getDb } = require('../dbPool');
|
||||
let conn;
|
||||
try {
|
||||
const { items, photo } = req.body;
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: '신청할 품목을 선택해주세요.' });
|
||||
}
|
||||
for (const item of items) {
|
||||
if (!item.item_id && !item.item_name) {
|
||||
return res.status(400).json({ success: false, message: '품목 정보가 올바르지 않습니다.' });
|
||||
}
|
||||
if (!item.quantity || item.quantity < 1) {
|
||||
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
|
||||
}
|
||||
}
|
||||
|
||||
// 사진 업로드 (트랜잭션 밖 — 파일은 롤백 불가)
|
||||
let photo_path = null;
|
||||
if (photo) {
|
||||
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
conn = await db.getConnection();
|
||||
await conn.beginTransaction();
|
||||
|
||||
const createdIds = [];
|
||||
let newItemRegistered = false;
|
||||
|
||||
for (const item of items) {
|
||||
let itemId = item.item_id || null;
|
||||
|
||||
// 신규 품목 등록 (is_new)
|
||||
if (item.is_new && item.item_name) {
|
||||
const [existing] = await conn.query(
|
||||
`SELECT item_id FROM consumable_items
|
||||
WHERE item_name = ? AND (spec = ? OR (spec IS NULL AND ? IS NULL))
|
||||
AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
|
||||
[item.item_name.trim(), item.spec || null, item.spec || null, item.maker || null, item.maker || null]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
itemId = existing[0].item_id;
|
||||
} else {
|
||||
const [ins] = await conn.query(
|
||||
`INSERT INTO consumable_items (item_name, spec, maker, category, is_active) VALUES (?, ?, ?, ?, 1)`,
|
||||
[item.item_name.trim(), item.spec || null, item.maker || null, item.category || 'consumable']
|
||||
);
|
||||
itemId = ins.insertId;
|
||||
newItemRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
// purchase_request 생성
|
||||
const [result] = await conn.query(
|
||||
`INSERT INTO purchase_requests (item_id, custom_item_name, custom_category, quantity, requester_id, request_date, notes, photo_path)
|
||||
VALUES (?, ?, ?, ?, ?, CURDATE(), ?, ?)`,
|
||||
[itemId, item.is_new && !itemId ? item.item_name : null, item.is_new && !itemId ? item.category : null,
|
||||
item.quantity, req.user.id, item.notes || null, photo_path]
|
||||
);
|
||||
createdIds.push(result.insertId);
|
||||
}
|
||||
|
||||
await conn.commit();
|
||||
if (newItemRegistered) koreanSearch.clearCache();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: { request_ids: createdIds, count: createdIds.length },
|
||||
message: `${createdIds.length}건의 소모품 신청이 등록되었습니다.`
|
||||
});
|
||||
} catch (err) {
|
||||
if (conn) await conn.rollback().catch(() => {});
|
||||
logger.error('bulkCreate error:', err);
|
||||
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
|
||||
} finally {
|
||||
if (conn) conn.release();
|
||||
}
|
||||
},
|
||||
|
||||
// 스마트 검색 (초성 + 별칭 + substring)
|
||||
search: async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,8 @@ router.get('/my-requests', ctrl.getMyRequests);
|
||||
|
||||
// 품목 등록 + 신청 동시 (트랜잭션)
|
||||
router.post('/register-and-request', ctrl.registerAndRequest);
|
||||
// 일괄 신청 (장바구니)
|
||||
router.post('/bulk', ctrl.bulkCreate);
|
||||
|
||||
// 구매신청 CRUD
|
||||
router.get('/', ctrl.getAll);
|
||||
|
||||
@@ -215,6 +215,75 @@
|
||||
}
|
||||
.pm-search-register:active { background: #fff7ed; }
|
||||
|
||||
/* 장바구니 */
|
||||
.pm-cart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pm-cart-title { font-size: 14px; font-weight: 600; color: #374151; }
|
||||
.pm-cart-count { font-size: 12px; color: #ea580c; font-weight: 600; }
|
||||
.pm-cart-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.pm-cart-item-info { flex: 1; min-width: 0; }
|
||||
.pm-cart-item-name { font-size: 13px; font-weight: 600; color: #1f2937; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pm-cart-item-meta { font-size: 11px; color: #9ca3af; margin-top: 2px; }
|
||||
.pm-cart-item-new { font-size: 10px; color: #ea580c; }
|
||||
.pm-cart-qty {
|
||||
width: 48px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pm-cart-memo {
|
||||
width: 80px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pm-cart-remove {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: #fecaca;
|
||||
color: #dc2626;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
/* 신규 품목 인라인 필드 */
|
||||
.pm-cart-new-fields {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.pm-cart-new-fields input, .pm-cart-new-fields select {
|
||||
padding: 3px 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 폼 필드 */
|
||||
.pm-field { margin-bottom: 12px; }
|
||||
.pm-label { display: block; font-size: 12px; font-weight: 600; color: #6b7280; margin-bottom: 4px; }
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<title>소모품 신청 - TK 공장관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040102">
|
||||
<link rel="stylesheet" href="/css/purchase-mobile.css?v=2026040102">
|
||||
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
|
||||
<link rel="stylesheet" href="/css/purchase-mobile.css?v=2026040103">
|
||||
<script src="https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
@@ -24,9 +24,9 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 사이드 네비 -->
|
||||
<nav id="sideNav" class="fixed inset-y-0 left-0 w-64 bg-white shadow-xl z-40 transform -translate-x-full transition-transform duration-200" style="top:48px"></nav>
|
||||
<div id="mobileOverlay" class="fixed inset-0 bg-black/40 z-30 hidden" onclick="toggleMobileMenu()"></div>
|
||||
<!-- 사이드 네비 (TBM 패턴) -->
|
||||
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
|
||||
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-12 left-0 bottom-0 overflow-y-auto"></nav>
|
||||
|
||||
<div class="pm-content">
|
||||
<!-- 상태 탭 -->
|
||||
@@ -62,61 +62,18 @@
|
||||
</div>
|
||||
<div class="pm-search-results" id="searchResults"></div>
|
||||
|
||||
<!-- 선택된 품목 표시 -->
|
||||
<div id="selectedItemWrap" class="hidden mb-3">
|
||||
<div class="flex items-center gap-2 p-3 bg-orange-50 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<div id="selectedItemName" class="font-medium text-sm text-gray-800"></div>
|
||||
<div id="selectedItemMeta" class="text-xs text-gray-500 mt-1"></div>
|
||||
</div>
|
||||
<button onclick="clearSelectedItem()" class="text-gray-400 hover:text-red-500"><i class="fas fa-times-circle"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="selectedItemId">
|
||||
<input type="hidden" id="selectedCustomName">
|
||||
|
||||
<!-- 신규 품목 등록 영역 (Phase 4에서 활성화) -->
|
||||
<div id="newItemForm" class="hidden">
|
||||
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-3">
|
||||
<div class="text-sm font-medium text-yellow-800 mb-2"><i class="fas fa-plus-circle mr-1"></i>새 품목 등록</div>
|
||||
<div class="pm-field">
|
||||
<label class="pm-label">품목명 *</label>
|
||||
<input type="text" id="newItemName" class="pm-input" readonly>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="pm-field">
|
||||
<label class="pm-label">규격</label>
|
||||
<input type="text" id="newItemSpec" class="pm-input" placeholder="예: 300mm">
|
||||
</div>
|
||||
<div class="pm-field">
|
||||
<label class="pm-label">제조사</label>
|
||||
<input type="text" id="newItemMaker" class="pm-input" placeholder="예: 3M">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pm-field">
|
||||
<label class="pm-label">분류 (선택)</label>
|
||||
<select id="newItemCategory" class="pm-select">
|
||||
<option value="">나중에 분류</option>
|
||||
<option value="consumable">소모품</option>
|
||||
<option value="safety">안전용품</option>
|
||||
<option value="repair">수선비</option>
|
||||
<option value="equipment">설비</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 장바구니 -->
|
||||
<div id="cartWrap" class="hidden mb-3">
|
||||
<div class="pm-cart-header">
|
||||
<span class="pm-cart-title"><i class="fas fa-shopping-cart mr-1"></i>신청 목록</span>
|
||||
<span class="pm-cart-count" id="cartCount">0건</span>
|
||||
</div>
|
||||
<div id="cartList"></div>
|
||||
</div>
|
||||
|
||||
<!-- 수량/메모/사진 -->
|
||||
<!-- 사진 (공통) -->
|
||||
<div class="pm-field">
|
||||
<label class="pm-label">수량 *</label>
|
||||
<input type="number" id="reqQuantity" class="pm-input" value="1" min="1" inputmode="numeric">
|
||||
</div>
|
||||
<div class="pm-field">
|
||||
<label class="pm-label">메모</label>
|
||||
<input type="text" id="reqNotes" class="pm-input" placeholder="요청 사항">
|
||||
</div>
|
||||
<div class="pm-field">
|
||||
<label class="pm-label">사진 첨부</label>
|
||||
<label class="pm-label">참고 사진</label>
|
||||
<label class="pm-photo-btn">
|
||||
<i class="fas fa-camera"></i>
|
||||
<span id="photoLabel">사진 촬영/선택</span>
|
||||
@@ -125,7 +82,7 @@
|
||||
<img id="reqPhotoPreview" class="pm-photo-preview hidden">
|
||||
</div>
|
||||
|
||||
<button id="submitBtn" class="pm-submit" onclick="submitRequest()">신청하기</button>
|
||||
<button id="submitBtn" class="pm-submit" onclick="submitRequest()" disabled>신청하기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,8 +97,8 @@
|
||||
<div class="pm-sheet-body" id="detailContent"></div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tkfb-core.js?v=2026040102"></script>
|
||||
<script src="/static/js/purchase-request-mobile.js?v=2026040102"></script>
|
||||
<script src="/static/js/shared-bottom-nav.js?v=2026040102"></script>
|
||||
<script src="/static/js/tkfb-core.js?v=2026040103"></script>
|
||||
<script src="/static/js/purchase-request-mobile.js?v=2026040103"></script>
|
||||
<script src="/static/js/shared-bottom-nav.js?v=2026040103"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -310,7 +310,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/tkfb-core.js?v=2026040102"></script>
|
||||
<script src="/static/js/purchase-request.js?v=2026040102"></script>
|
||||
<script src="/static/js/tkfb-core.js?v=2026040103"></script>
|
||||
<script src="/static/js/purchase-request.js?v=2026040103"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* ===== 소모품 신청 모바일 ===== */
|
||||
/* ===== 소모품 신청 모바일 (장바구니 방식) ===== */
|
||||
const TKUSER_BASE_URL = location.hostname.includes('technicalkorea.net')
|
||||
? 'https://tkuser.technicalkorea.net'
|
||||
: location.protocol + '//' + location.hostname + ':30180';
|
||||
@@ -15,7 +15,8 @@ let isLoadingMore = false;
|
||||
let requestsList = [];
|
||||
let photoBase64 = null;
|
||||
let searchTimer = null;
|
||||
let isRegisterMode = false;
|
||||
let lastSearchResults = [];
|
||||
let cartItems = []; // [{item_id, item_name, spec, maker, category, quantity, notes, is_new, ...}]
|
||||
|
||||
/* ===== 상태 탭 필터 ===== */
|
||||
function filterByStatus(btn) {
|
||||
@@ -35,16 +36,10 @@ async function loadRequests(append = false) {
|
||||
}
|
||||
const params = new URLSearchParams({ page: currentPage, limit: 20 });
|
||||
if (currentStatus) params.set('status', currentStatus);
|
||||
|
||||
const res = await api('/purchase-requests/my-requests?' + params.toString());
|
||||
const items = res.data || [];
|
||||
totalPages = res.pagination?.totalPages || 1;
|
||||
|
||||
if (append) {
|
||||
requestsList = requestsList.concat(items);
|
||||
} else {
|
||||
requestsList = items;
|
||||
}
|
||||
if (append) { requestsList = requestsList.concat(items); } else { requestsList = items; }
|
||||
renderCards();
|
||||
} catch (e) {
|
||||
document.getElementById('requestCards').innerHTML = `<div class="pm-empty"><i class="fas fa-exclamation-circle"></i>${escapeHtml(e.message)}</div>`;
|
||||
@@ -64,7 +59,6 @@ function renderCards() {
|
||||
const statusLabel = STATUS_LABELS[r.status] || r.status;
|
||||
const statusColor = STATUS_COLORS[r.status] || 'badge-gray';
|
||||
const isCustom = !r.item_id && r.custom_item_name;
|
||||
|
||||
return `<div class="pm-card" onclick="openDetail(${r.request_id})">
|
||||
<div class="pm-card-header">
|
||||
<div>
|
||||
@@ -100,7 +94,6 @@ window.addEventListener('scroll', () => {
|
||||
function openDetail(requestId) {
|
||||
const r = requestsList.find(x => x.request_id === requestId);
|
||||
if (!r) return;
|
||||
|
||||
const itemName = r.item_name || r.custom_item_name || '-';
|
||||
const category = r.category || r.custom_category;
|
||||
const catLabel = CAT_LABELS[category] || category || '-';
|
||||
@@ -114,38 +107,18 @@ function openDetail(requestId) {
|
||||
</div>
|
||||
<div class="pm-detail-row"><span class="pm-detail-label">분류</span><span class="pm-detail-value">${catLabel}</span></div>
|
||||
<div class="pm-detail-row"><span class="pm-detail-label">수량</span><span class="pm-detail-value">${r.quantity}</span></div>
|
||||
<div class="pm-detail-row"><span class="pm-detail-label">신청일</span><span class="pm-detail-value">${formatDate(r.request_date)}</span></div>
|
||||
`;
|
||||
if (r.notes) {
|
||||
html += `<div class="pm-detail-row"><span class="pm-detail-label">메모</span><span class="pm-detail-value">${escapeHtml(r.notes)}</span></div>`;
|
||||
}
|
||||
if (r.batch_name) {
|
||||
html += `<div class="pm-detail-row"><span class="pm-detail-label">구매 그룹</span><span class="pm-detail-value">${escapeHtml(r.batch_name)}</span></div>`;
|
||||
}
|
||||
if (r.hold_reason) {
|
||||
html += `<div class="pm-detail-row"><span class="pm-detail-label">보류 사유</span><span class="pm-detail-value text-red-600">${escapeHtml(r.hold_reason)}</span></div>`;
|
||||
}
|
||||
|
||||
// 입고 정보
|
||||
<div class="pm-detail-row"><span class="pm-detail-label">신청일</span><span class="pm-detail-value">${formatDate(r.request_date)}</span></div>`;
|
||||
if (r.notes) html += `<div class="pm-detail-row"><span class="pm-detail-label">메모</span><span class="pm-detail-value">${escapeHtml(r.notes)}</span></div>`;
|
||||
if (r.batch_name) html += `<div class="pm-detail-row"><span class="pm-detail-label">구매 그룹</span><span class="pm-detail-value">${escapeHtml(r.batch_name)}</span></div>`;
|
||||
if (r.hold_reason) html += `<div class="pm-detail-row"><span class="pm-detail-label">보류 사유</span><span class="pm-detail-value text-red-600">${escapeHtml(r.hold_reason)}</span></div>`;
|
||||
if (r.status === 'received') {
|
||||
html += `<div class="mt-4 p-3 bg-teal-50 rounded-lg">
|
||||
<div class="text-sm font-semibold text-teal-700 mb-2"><i class="fas fa-box-open mr-1"></i>입고 완료</div>`;
|
||||
if (r.received_location) {
|
||||
html += `<div class="text-sm text-gray-700"><i class="fas fa-map-marker-alt mr-1 text-teal-500"></i>보관위치: ${escapeHtml(r.received_location)}</div>`;
|
||||
}
|
||||
if (r.received_at) {
|
||||
html += `<div class="text-xs text-gray-500 mt-1">${formatDateTime(r.received_at)}${r.received_by_name ? ' · ' + escapeHtml(r.received_by_name) : ''}</div>`;
|
||||
}
|
||||
if (r.received_photo_path) {
|
||||
html += `<img src="${r.received_photo_path}" class="pm-received-photo" onerror="this.style.display='none'">`;
|
||||
}
|
||||
html += `<div class="mt-4 p-3 bg-teal-50 rounded-lg"><div class="text-sm font-semibold text-teal-700 mb-2"><i class="fas fa-box-open mr-1"></i>입고 완료</div>`;
|
||||
if (r.received_location) html += `<div class="text-sm text-gray-700"><i class="fas fa-map-marker-alt mr-1 text-teal-500"></i>보관위치: ${escapeHtml(r.received_location)}</div>`;
|
||||
if (r.received_at) html += `<div class="text-xs text-gray-500 mt-1">${formatDateTime(r.received_at)}${r.received_by_name ? ' · ' + escapeHtml(r.received_by_name) : ''}</div>`;
|
||||
if (r.received_photo_path) html += `<img src="${r.received_photo_path}" class="pm-received-photo" onerror="this.style.display='none'">`;
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// 신청 사진
|
||||
if (r.pr_photo_path) {
|
||||
html += `<div class="mt-3"><div class="text-xs text-gray-500 mb-1">첨부 사진</div><img src="${r.pr_photo_path}" class="pm-received-photo" onerror="this.style.display='none'"></div>`;
|
||||
}
|
||||
if (r.pr_photo_path) html += `<div class="mt-3"><div class="text-xs text-gray-500 mb-1">첨부 사진</div><img src="${r.pr_photo_path}" class="pm-received-photo" onerror="this.style.display='none'"></div>`;
|
||||
|
||||
document.getElementById('detailContent').innerHTML = html;
|
||||
document.getElementById('detailOverlay').classList.add('open');
|
||||
@@ -173,17 +146,13 @@ function closeRequestSheet() {
|
||||
function resetRequestForm() {
|
||||
document.getElementById('searchInput').value = '';
|
||||
document.getElementById('searchResults').classList.remove('open');
|
||||
document.getElementById('selectedItemWrap').classList.add('hidden');
|
||||
document.getElementById('selectedItemId').value = '';
|
||||
document.getElementById('selectedCustomName').value = '';
|
||||
document.getElementById('newItemForm').classList.add('hidden');
|
||||
document.getElementById('reqQuantity').value = '1';
|
||||
document.getElementById('reqNotes').value = '';
|
||||
cartItems = [];
|
||||
renderCart();
|
||||
document.getElementById('reqPhotoInput').value = '';
|
||||
document.getElementById('reqPhotoPreview').classList.add('hidden');
|
||||
document.getElementById('photoLabel').textContent = '사진 촬영/선택';
|
||||
photoBase64 = null;
|
||||
isRegisterMode = false;
|
||||
updateSubmitBtn();
|
||||
}
|
||||
|
||||
/* ===== 서버 스마트 검색 ===== */
|
||||
@@ -191,14 +160,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const input = document.getElementById('searchInput');
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
// 검색어 입력 시 이전 선택 자동 해제
|
||||
if (document.getElementById('selectedItemId').value || document.getElementById('selectedCustomName').value) {
|
||||
document.getElementById('selectedItemWrap').classList.add('hidden');
|
||||
document.getElementById('selectedItemId').value = '';
|
||||
document.getElementById('selectedCustomName').value = '';
|
||||
document.getElementById('newItemForm').classList.add('hidden');
|
||||
isRegisterMode = false;
|
||||
}
|
||||
const q = input.value.trim();
|
||||
if (q.length === 0) {
|
||||
document.getElementById('searchResults').classList.remove('open');
|
||||
@@ -210,11 +171,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
const res = await api('/purchase-requests/search?q=' + encodeURIComponent(q));
|
||||
renderSearchResults(res.data || [], q);
|
||||
} catch (e) {
|
||||
console.error('검색 오류:', e);
|
||||
} finally {
|
||||
document.getElementById('searchSpinner').classList.remove('show');
|
||||
}
|
||||
} catch (e) { console.error('검색 오류:', e); }
|
||||
finally { document.getElementById('searchSpinner').classList.remove('show'); }
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
@@ -223,13 +181,12 @@ function renderSearchResults(items, query) {
|
||||
lastSearchResults = items;
|
||||
const container = document.getElementById('searchResults');
|
||||
let html = '';
|
||||
|
||||
items.forEach(item => {
|
||||
const catLabel = CAT_LABELS[item.category] || '';
|
||||
const matchLabel = MATCH_LABELS[item._matchType] || '';
|
||||
const spec = item.spec ? ' [' + escapeHtml(item.spec) + ']' : '';
|
||||
const maker = item.maker ? ' (' + escapeHtml(item.maker) + ')' : '';
|
||||
html += `<div class="pm-search-item" onclick="selectSearchItem(${item.item_id})">
|
||||
html += `<div class="pm-search-item" onclick="addToCart(${item.item_id})">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">${escapeHtml(item.item_name)}${spec}${maker}</div>
|
||||
${catLabel ? `<div class="text-xs text-gray-400 mt-0.5">${catLabel}</div>` : ''}
|
||||
@@ -237,57 +194,134 @@ function renderSearchResults(items, query) {
|
||||
${matchLabel ? `<span class="match-type">${matchLabel}</span>` : ''}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
// 새 품목 등록 옵션
|
||||
html += `<div class="pm-search-register" onclick="selectNewItem()">
|
||||
html += `<div class="pm-search-register" onclick="addNewToCart()">
|
||||
<i class="fas fa-plus-circle"></i>
|
||||
<span>"${escapeHtml(query)}" 새 품목으로 등록 후 신청</span>
|
||||
<span>"${escapeHtml(query)}" 새 품목으로 추가</span>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = html;
|
||||
container.classList.add('open');
|
||||
}
|
||||
|
||||
/* ===== 품목 선택 ===== */
|
||||
let lastSearchResults = [];
|
||||
|
||||
function selectSearchItem(itemId) {
|
||||
/* ===== 장바구니 ===== */
|
||||
function addToCart(itemId) {
|
||||
const item = lastSearchResults.find(i => i.item_id === itemId);
|
||||
if (!item) return;
|
||||
document.getElementById('selectedItemId').value = item.item_id;
|
||||
document.getElementById('selectedCustomName').value = '';
|
||||
document.getElementById('selectedItemName').textContent = item.item_name + (item.spec ? ' [' + item.spec + ']' : '') + (item.maker ? ' (' + item.maker + ')' : '');
|
||||
document.getElementById('selectedItemMeta').textContent = (CAT_LABELS[item.category] || '') + (item.base_price ? ' · ' + Number(item.base_price).toLocaleString() + '원' : '');
|
||||
document.getElementById('selectedItemWrap').classList.remove('hidden');
|
||||
document.getElementById('searchResults').classList.remove('open');
|
||||
|
||||
// 동일 품목이면 수량 +1
|
||||
const existing = cartItems.find(c => c.item_id === item.item_id && !c.is_new);
|
||||
if (existing) {
|
||||
existing.quantity++;
|
||||
showToast(`${item.item_name} 수량이 ${existing.quantity}개로 추가되었습니다.`);
|
||||
} else {
|
||||
cartItems.push({
|
||||
item_id: item.item_id,
|
||||
item_name: item.item_name,
|
||||
spec: item.spec,
|
||||
maker: item.maker,
|
||||
category: item.category,
|
||||
quantity: 1,
|
||||
notes: '',
|
||||
is_new: false
|
||||
});
|
||||
}
|
||||
document.getElementById('searchInput').value = '';
|
||||
document.getElementById('newItemForm').classList.add('hidden');
|
||||
isRegisterMode = false;
|
||||
document.getElementById('searchResults').classList.remove('open');
|
||||
renderCart();
|
||||
updateSubmitBtn();
|
||||
}
|
||||
|
||||
function selectNewItem() {
|
||||
function addNewToCart() {
|
||||
const query = document.getElementById('searchInput').value.trim();
|
||||
if (!query) return;
|
||||
document.getElementById('selectedItemId').value = '';
|
||||
document.getElementById('selectedCustomName').value = query;
|
||||
document.getElementById('selectedItemName').textContent = query;
|
||||
document.getElementById('selectedItemMeta').textContent = '직접 입력';
|
||||
document.getElementById('selectedItemWrap').classList.remove('hidden');
|
||||
cartItems.push({
|
||||
item_id: null,
|
||||
item_name: query,
|
||||
spec: '',
|
||||
maker: '',
|
||||
category: '',
|
||||
quantity: 1,
|
||||
notes: '',
|
||||
is_new: true
|
||||
});
|
||||
document.getElementById('searchInput').value = '';
|
||||
document.getElementById('searchResults').classList.remove('open');
|
||||
|
||||
// 신규 등록 폼 표시
|
||||
document.getElementById('newItemForm').classList.remove('hidden');
|
||||
document.getElementById('newItemName').value = query;
|
||||
isRegisterMode = true;
|
||||
renderCart();
|
||||
updateSubmitBtn();
|
||||
}
|
||||
|
||||
function clearSelectedItem() {
|
||||
document.getElementById('selectedItemWrap').classList.add('hidden');
|
||||
document.getElementById('selectedItemId').value = '';
|
||||
document.getElementById('selectedCustomName').value = '';
|
||||
document.getElementById('newItemForm').classList.add('hidden');
|
||||
document.getElementById('searchInput').value = '';
|
||||
isRegisterMode = false;
|
||||
function removeFromCart(idx) {
|
||||
cartItems.splice(idx, 1);
|
||||
renderCart();
|
||||
updateSubmitBtn();
|
||||
}
|
||||
|
||||
function updateCartQty(idx, val) {
|
||||
const qty = parseInt(val) || 1;
|
||||
cartItems[idx].quantity = Math.max(1, qty);
|
||||
}
|
||||
|
||||
function updateCartNotes(idx, val) {
|
||||
cartItems[idx].notes = val;
|
||||
}
|
||||
|
||||
function updateCartNewField(idx, field, val) {
|
||||
cartItems[idx][field] = val;
|
||||
}
|
||||
|
||||
function renderCart() {
|
||||
const wrap = document.getElementById('cartWrap');
|
||||
const list = document.getElementById('cartList');
|
||||
const count = document.getElementById('cartCount');
|
||||
|
||||
if (cartItems.length === 0) {
|
||||
wrap.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
wrap.classList.remove('hidden');
|
||||
count.textContent = cartItems.length + '건';
|
||||
|
||||
list.innerHTML = cartItems.map((c, idx) => {
|
||||
const spec = c.spec ? ' [' + escapeHtml(c.spec) + ']' : '';
|
||||
const maker = c.maker ? ' (' + escapeHtml(c.maker) + ')' : '';
|
||||
const catLabel = CAT_LABELS[c.category] || '';
|
||||
|
||||
let newFields = '';
|
||||
if (c.is_new) {
|
||||
newFields = `<div class="pm-cart-new-fields">
|
||||
<input type="text" placeholder="규격" value="${escapeHtml(c.spec || '')}" oninput="updateCartNewField(${idx},'spec',this.value)">
|
||||
<input type="text" placeholder="제조사" value="${escapeHtml(c.maker || '')}" oninput="updateCartNewField(${idx},'maker',this.value)">
|
||||
<select onchange="updateCartNewField(${idx},'category',this.value)">
|
||||
<option value="">분류</option>
|
||||
<option value="consumable" ${c.category==='consumable'?'selected':''}>소모품</option>
|
||||
<option value="safety" ${c.category==='safety'?'selected':''}>안전용품</option>
|
||||
<option value="repair" ${c.category==='repair'?'selected':''}>수선비</option>
|
||||
<option value="equipment" ${c.category==='equipment'?'selected':''}>설비</option>
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div class="pm-cart-item">
|
||||
<div class="pm-cart-item-info">
|
||||
<div class="pm-cart-item-name">${escapeHtml(c.item_name)}${spec}${maker}</div>
|
||||
<div class="pm-cart-item-meta">${catLabel}${c.is_new ? ' <span class="pm-cart-item-new">(신규등록)</span>' : ''}</div>
|
||||
${newFields}
|
||||
</div>
|
||||
<input type="number" class="pm-cart-qty" value="${c.quantity}" min="1" inputmode="numeric" onchange="updateCartQty(${idx},this.value)">
|
||||
<input type="text" class="pm-cart-memo" placeholder="메모" value="${escapeHtml(c.notes || '')}" oninput="updateCartNotes(${idx},this.value)">
|
||||
<button class="pm-cart-remove" onclick="removeFromCart(${idx})">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateSubmitBtn() {
|
||||
const btn = document.getElementById('submitBtn');
|
||||
if (cartItems.length > 0) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = cartItems.length + '건 신청하기';
|
||||
} else {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '품목을 추가해주세요';
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 사진 ===== */
|
||||
@@ -295,7 +329,6 @@ async function onMobilePhotoSelected(inputEl) {
|
||||
const file = inputEl.files[0];
|
||||
if (!file) return;
|
||||
let processFile = file;
|
||||
|
||||
const isHeic = file.type === 'image/heic' || file.type === 'image/heif' ||
|
||||
file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
|
||||
if (isHeic && typeof heic2any !== 'undefined') {
|
||||
@@ -303,18 +336,13 @@ async function onMobilePhotoSelected(inputEl) {
|
||||
try {
|
||||
const blob = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.85 });
|
||||
processFile = new File([blob], file.name.replace(/\.hei[cf]$/i, '.jpg'), { type: 'image/jpeg' });
|
||||
} catch (e) {
|
||||
console.warn('HEIC 변환 실패, 원본 사용:', e);
|
||||
// 폴백: 원본 파일 그대로 사용
|
||||
}
|
||||
} catch (e) { console.warn('HEIC 변환 실패, 원본 사용:', e); }
|
||||
}
|
||||
|
||||
if (processFile.size > 10 * 1024 * 1024) {
|
||||
showToast('파일 크기는 10MB 이하만 가능합니다.', 'error');
|
||||
document.getElementById('photoLabel').textContent = '사진 촬영/선택';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
photoBase64 = e.target.result;
|
||||
@@ -325,50 +353,26 @@ async function onMobilePhotoSelected(inputEl) {
|
||||
reader.readAsDataURL(processFile);
|
||||
}
|
||||
|
||||
/* ===== 신청 제출 ===== */
|
||||
/* ===== 일괄 신청 제출 ===== */
|
||||
async function submitRequest() {
|
||||
const itemId = document.getElementById('selectedItemId').value;
|
||||
const customName = document.getElementById('selectedCustomName').value;
|
||||
const quantity = parseInt(document.getElementById('reqQuantity').value) || 0;
|
||||
|
||||
if (!itemId && !customName) { showToast('품목을 선택하거나 검색해주세요.', 'error'); return; }
|
||||
if (quantity < 1) { showToast('수량은 1 이상이어야 합니다.', 'error'); return; }
|
||||
if (cartItems.length === 0) { showToast('품목을 추가해주세요.', 'error'); return; }
|
||||
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '처리 중...';
|
||||
|
||||
try {
|
||||
if (isRegisterMode && customName) {
|
||||
// Phase 4: 인라인 등록 + 신청
|
||||
const body = {
|
||||
item_name: customName,
|
||||
spec: document.getElementById('newItemSpec').value.trim() || null,
|
||||
maker: document.getElementById('newItemMaker').value.trim() || null,
|
||||
category: document.getElementById('newItemCategory').value || null,
|
||||
quantity,
|
||||
notes: document.getElementById('reqNotes').value.trim() || null
|
||||
};
|
||||
if (photoBase64) body.photo = photoBase64;
|
||||
await api('/purchase-requests/register-and-request', {
|
||||
method: 'POST', body: JSON.stringify(body)
|
||||
});
|
||||
} else {
|
||||
// 기존 방식
|
||||
const body = { quantity, notes: document.getElementById('reqNotes').value.trim() };
|
||||
if (itemId) {
|
||||
body.item_id = parseInt(itemId);
|
||||
} else {
|
||||
body.custom_item_name = customName;
|
||||
body.custom_category = document.getElementById('newItemCategory')?.value || null;
|
||||
const items = cartItems.map(c => {
|
||||
if (c.is_new) {
|
||||
return { item_name: c.item_name, spec: c.spec || null, maker: c.maker || null, category: c.category || null, quantity: c.quantity, notes: c.notes || null, is_new: true };
|
||||
}
|
||||
if (photoBase64) body.photo = photoBase64;
|
||||
await api('/purchase-requests', {
|
||||
method: 'POST', body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
return { item_id: c.item_id, quantity: c.quantity, notes: c.notes || null };
|
||||
});
|
||||
const body = { items };
|
||||
if (photoBase64) body.photo = photoBase64;
|
||||
|
||||
showToast('소모품 신청이 등록되었습니다.');
|
||||
await api('/purchase-requests/bulk', { method: 'POST', body: JSON.stringify(body) });
|
||||
showToast(`${cartItems.length}건 소모품 신청이 등록되었습니다.`);
|
||||
closeRequestSheet();
|
||||
currentPage = 1;
|
||||
requestsList = [];
|
||||
@@ -377,7 +381,7 @@ async function submitRequest() {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '신청하기';
|
||||
updateSubmitBtn();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,10 +389,7 @@ async function submitRequest() {
|
||||
function checkViewParam() {
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const viewId = urlParams.get('view');
|
||||
if (viewId) {
|
||||
// 데이터 로드 후 상세 열기
|
||||
setTimeout(() => openDetail(parseInt(viewId)), 500);
|
||||
}
|
||||
if (viewId) setTimeout(() => openDetail(parseInt(viewId)), 500);
|
||||
}
|
||||
|
||||
/* ===== Init ===== */
|
||||
|
||||
@@ -170,7 +170,6 @@ const PAGE_KEY_ALIASES = {
|
||||
'attendance.work_status': 'inspection.work_status',
|
||||
'work.meeting_detail': 'work.meetings',
|
||||
'work.proxy_input': 'work.daily_status',
|
||||
'purchase.request_mobile': 'purchase.request',
|
||||
};
|
||||
|
||||
function _getCurrentPageKey() {
|
||||
@@ -283,10 +282,16 @@ async function initAuth() {
|
||||
let accessibleKeys = [];
|
||||
if (!isAdmin) {
|
||||
accessibleKeys = await _fetchPageAccess(currentUser.id);
|
||||
// NAV_MENU에서 admin이 아닌 페이지는 모든 인증 사용자에게 공개
|
||||
const publicPageKeys = NAV_MENU.flatMap(entry => {
|
||||
if (!entry.items) return entry.key ? [entry.key] : [];
|
||||
if (entry.admin) return [];
|
||||
return entry.items.filter(item => !item.admin).map(item => item.key);
|
||||
});
|
||||
// 현재 페이지 접근 권한 확인
|
||||
const pageKey = _getCurrentPageKey();
|
||||
if (pageKey && pageKey !== 'dashboard' && !pageKey.startsWith('profile.')) {
|
||||
if (!accessibleKeys.includes(pageKey)) {
|
||||
if (!publicPageKeys.includes(pageKey) && !accessibleKeys.includes(pageKey)) {
|
||||
alert('이 페이지에 접근할 권한이 없습니다.');
|
||||
location.href = '/pages/dashboard-new.html';
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user