feat: 위치 편집 기능 추가

API:
- GET /api/locations - 모든 위치 목록 (461개)
- PUT /api/drugs/<code>/location - 위치 업데이트/삭제

UI:
- 위치 있음: 노란색 뱃지 (클릭 가능)
- 위치 없음: '미지정' 회색 점선 뱃지
- 클릭 시 위치 설정 모달 열림
- 드롭다운 선택 또는 직접 입력
- person-lookup-web-local 참고하여 구현
This commit is contained in:
thug0bin 2026-03-04 14:42:47 +09:00
parent 96a3df8470
commit 27bb0b7b86
2 changed files with 336 additions and 1 deletions

View File

@ -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 스타일, 거래별 그룹핑)"""

View File

@ -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>