feat: 위치 편집 기능 추가
API: - GET /api/locations - 모든 위치 목록 (461개) - PUT /api/drugs/<code>/location - 위치 업데이트/삭제 UI: - 위치 있음: 노란색 뱃지 (클릭 가능) - 위치 없음: '미지정' 회색 점선 뱃지 - 클릭 시 위치 설정 모달 열림 - 드롭다운 선택 또는 직접 입력 - person-lookup-web-local 참고하여 구현
This commit is contained in:
parent
96a3df8470
commit
27bb0b7b86
@ -3542,6 +3542,79 @@ def api_products():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 위치 정보 API ====================
|
||||
|
||||
@app.route('/api/locations')
|
||||
def api_get_all_locations():
|
||||
"""모든 위치명 목록 조회"""
|
||||
try:
|
||||
drug_session = db_manager.get_session('PM_DRUG')
|
||||
result = drug_session.execute(text("""
|
||||
SELECT DISTINCT CD_NM_sale
|
||||
FROM CD_item_position
|
||||
WHERE CD_NM_sale IS NOT NULL AND CD_NM_sale != ''
|
||||
ORDER BY CD_NM_sale
|
||||
"""))
|
||||
locations = [row[0] for row in result.fetchall()]
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'locations': locations,
|
||||
'total': len(locations)
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"위치 목록 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/drugs/<drug_code>/location', methods=['PUT'])
|
||||
def api_update_drug_location(drug_code):
|
||||
"""약품 위치 업데이트"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
location_name = data.get('location_name', '').strip() if data else ''
|
||||
|
||||
# 위치명 길이 검증 (최대 20자)
|
||||
if location_name and len(location_name) > 20:
|
||||
return jsonify({'success': False, 'error': '위치명은 20자를 초과할 수 없습니다'}), 400
|
||||
|
||||
drug_session = db_manager.get_session('PM_DRUG')
|
||||
|
||||
# 기존 레코드 확인
|
||||
existing = drug_session.execute(text("""
|
||||
SELECT DrugCode FROM CD_item_position WHERE DrugCode = :drug_code
|
||||
"""), {'drug_code': drug_code}).fetchone()
|
||||
|
||||
if existing:
|
||||
# UPDATE
|
||||
if location_name:
|
||||
drug_session.execute(text("""
|
||||
UPDATE CD_item_position SET CD_NM_sale = :location WHERE DrugCode = :drug_code
|
||||
"""), {'location': location_name, 'drug_code': drug_code})
|
||||
else:
|
||||
# 빈 값이면 삭제
|
||||
drug_session.execute(text("""
|
||||
DELETE FROM CD_item_position WHERE DrugCode = :drug_code
|
||||
"""), {'drug_code': drug_code})
|
||||
else:
|
||||
# INSERT (위치가 있을 때만)
|
||||
if location_name:
|
||||
drug_session.execute(text("""
|
||||
INSERT INTO CD_item_position (DrugCode, CD_NM_sale) VALUES (:drug_code, :location)
|
||||
"""), {'drug_code': drug_code, 'location': location_name})
|
||||
|
||||
drug_session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '위치 정보가 업데이트되었습니다',
|
||||
'location': location_name
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"위치 업데이트 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/admin/sales')
|
||||
def admin_sales_pos():
|
||||
"""판매 내역 페이지 (POS 스타일, 거래별 그룹핑)"""
|
||||
|
||||
@ -405,7 +405,148 @@
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.location-badge:hover {
|
||||
background: #fcd34d;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.location-badge.unset {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
border: 1px dashed #cbd5e1;
|
||||
}
|
||||
.location-badge.unset:hover {
|
||||
background: #e2e8f0;
|
||||
border-color: #8b5cf6;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
/* 위치 모달 */
|
||||
.location-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 2000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.location-modal.show { display: flex; }
|
||||
.location-modal-content {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
animation: modalSlideIn 0.2s ease;
|
||||
}
|
||||
@keyframes modalSlideIn {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.location-modal-content h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #92400e;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.location-product-info {
|
||||
background: #fffbeb;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #fef3c7;
|
||||
}
|
||||
.location-product-info .name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.location-product-info .code {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.location-select-wrapper {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.location-select-wrapper label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.location-select {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.location-select:focus {
|
||||
outline: none;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
.location-input-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.location-input-wrapper label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.location-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.location-input:focus {
|
||||
outline: none;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
.location-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.location-modal-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.location-modal-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.location-modal-btn.secondary { background: #f1f5f9; color: #64748b; }
|
||||
.location-modal-btn.secondary:hover { background: #e2e8f0; }
|
||||
.location-modal-btn.danger { background: #fef2f2; color: #dc2626; }
|
||||
.location-modal-btn.danger:hover { background: #fee2e2; }
|
||||
.location-modal-btn.primary { background: #f59e0b; color: #fff; }
|
||||
.location-modal-btn.primary:hover { background: #d97706; }
|
||||
|
||||
/* ── 가격 ── */
|
||||
.price {
|
||||
@ -905,7 +1046,9 @@
|
||||
<td>${item.barcode
|
||||
? `<span class="code code-barcode">${item.barcode}</span>`
|
||||
: `<span class="code code-na">없음</span>`}</td>
|
||||
<td>${item.location ? `<span class="location-badge">${escapeHtml(item.location)}</span>` : ''}</td>
|
||||
<td>${item.location
|
||||
? `<span class="location-badge" onclick="openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '${escapeHtml(item.location)}')">${escapeHtml(item.location)}</span>`
|
||||
: `<span class="location-badge unset" onclick="openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '')">미지정</span>`}</td>
|
||||
<td class="stock ${(item.stock || 0) > 0 ? 'in-stock' : 'out-stock'}">${item.stock || 0}${wsStock}</td>
|
||||
<td class="price">${formatPrice(item.sale_price)}</td>
|
||||
<td>
|
||||
@ -1402,5 +1545,124 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 위치 편집 모달 -->
|
||||
<div class="location-modal" id="locationModal">
|
||||
<div class="location-modal-content">
|
||||
<h3>📍 위치 설정</h3>
|
||||
<div class="location-product-info">
|
||||
<div class="name" id="locModalProductName">제품명</div>
|
||||
<div class="code" id="locModalDrugCode">상품코드</div>
|
||||
</div>
|
||||
|
||||
<div class="location-select-wrapper">
|
||||
<label>기존 위치에서 선택</label>
|
||||
<select class="location-select" id="locationSelect" onchange="onLocationSelectChange()">
|
||||
<option value="">-- 선택하세요 --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="location-input-wrapper">
|
||||
<label>또는 직접 입력</label>
|
||||
<input type="text" class="location-input" id="locationInput"
|
||||
placeholder="예: 진열대1-3, 냉장고, 창고A" maxlength="20">
|
||||
<div class="location-hint">최대 20자 / 새 위치를 입력하면 목록에 추가됩니다</div>
|
||||
</div>
|
||||
|
||||
<div class="location-modal-btns">
|
||||
<button class="location-modal-btn danger" onclick="clearLocation()" id="locClearBtn" style="display:none;">삭제</button>
|
||||
<div style="flex:1;"></div>
|
||||
<button class="location-modal-btn secondary" onclick="closeLocationModal()">취소</button>
|
||||
<button class="location-modal-btn primary" onclick="saveLocation()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── 위치 모달 ──
|
||||
let locModalDrugCode = null;
|
||||
let locModalCurrentLocation = null;
|
||||
let allLocations = [];
|
||||
|
||||
async function openLocationModal(drugCode, productName, currentLocation) {
|
||||
locModalDrugCode = drugCode;
|
||||
locModalCurrentLocation = currentLocation;
|
||||
|
||||
document.getElementById('locModalProductName').textContent = productName;
|
||||
document.getElementById('locModalDrugCode').textContent = drugCode;
|
||||
document.getElementById('locationInput').value = currentLocation || '';
|
||||
|
||||
// 삭제 버튼 표시 (현재 위치가 있을 때만)
|
||||
document.getElementById('locClearBtn').style.display = currentLocation ? 'block' : 'none';
|
||||
|
||||
// 위치 목록 로드
|
||||
try {
|
||||
const res = await fetch('/api/locations');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
allLocations = data.locations || [];
|
||||
const select = document.getElementById('locationSelect');
|
||||
select.innerHTML = '<option value="">-- 선택하세요 --</option>' +
|
||||
allLocations.map(loc =>
|
||||
`<option value="${escapeHtml(loc)}" ${loc === currentLocation ? 'selected' : ''}>${escapeHtml(loc)}</option>`
|
||||
).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('위치 목록 로드 실패:', e);
|
||||
}
|
||||
|
||||
document.getElementById('locationModal').classList.add('show');
|
||||
document.getElementById('locationInput').focus();
|
||||
}
|
||||
|
||||
function closeLocationModal() {
|
||||
document.getElementById('locationModal').classList.remove('show');
|
||||
locModalDrugCode = null;
|
||||
locModalCurrentLocation = null;
|
||||
}
|
||||
|
||||
function onLocationSelectChange() {
|
||||
const selected = document.getElementById('locationSelect').value;
|
||||
if (selected) {
|
||||
document.getElementById('locationInput').value = selected;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLocation() {
|
||||
const newLocation = document.getElementById('locationInput').value.trim();
|
||||
|
||||
if (!locModalDrugCode) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/drugs/${locModalDrugCode}/location`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ location_name: newLocation })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast(newLocation ? `✅ 위치가 "${newLocation}"(으)로 설정되었습니다` : '✅ 위치가 삭제되었습니다', 'success');
|
||||
closeLocationModal();
|
||||
searchProducts(); // 테이블 새로고침
|
||||
} else {
|
||||
showToast(data.error || '저장 실패', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('오류: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearLocation() {
|
||||
if (!confirm('위치 정보를 삭제하시겠습니까?')) return;
|
||||
document.getElementById('locationInput').value = '';
|
||||
await saveLocation();
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
document.getElementById('locationModal')?.addEventListener('click', e => {
|
||||
if (e.target.id === 'locationModal') closeLocationModal();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user