feat(admin): 건조시럽 환산계수 관리 페이지 추가
- /admin/drysyrup 페이지 구현 - GET /api/drug-info/drysyrup 전체 목록 API - DELETE /api/drug-info/drysyrup/<sung_code> 삭제 API - 검색, CRUD, 통계 카드 기능
This commit is contained in:
parent
58408c9f5c
commit
2cc9ec6bb1
127
backend/app.py
127
backend/app.py
@ -4145,6 +4145,133 @@ def api_drysyrup_update(sung_code):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/drug-info/drysyrup', methods=['GET'])
|
||||
def api_drysyrup_list():
|
||||
"""
|
||||
건조시럽 전체 목록 조회 API
|
||||
|
||||
Query params:
|
||||
q: 검색어 (성분명/제품명 검색)
|
||||
|
||||
Returns:
|
||||
{ "success": true, "data": [...] }
|
||||
"""
|
||||
try:
|
||||
session = db_manager.get_postgres_session()
|
||||
if session is None:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'PostgreSQL 연결 실패',
|
||||
'data': []
|
||||
})
|
||||
|
||||
search_query = request.args.get('q', '').strip()
|
||||
|
||||
if search_query:
|
||||
query = text("""
|
||||
SELECT
|
||||
idx,
|
||||
ingredient_code,
|
||||
ingredient_name,
|
||||
product_name,
|
||||
conversion_factor,
|
||||
post_prep_amount,
|
||||
main_ingredient_amt,
|
||||
storage_conditions,
|
||||
expiration_date
|
||||
FROM drysyrup
|
||||
WHERE
|
||||
ingredient_name ILIKE :search
|
||||
OR product_name ILIKE :search
|
||||
OR ingredient_code ILIKE :search
|
||||
ORDER BY ingredient_name, product_name
|
||||
""")
|
||||
rows = session.execute(query, {'search': f'%{search_query}%'}).fetchall()
|
||||
else:
|
||||
query = text("""
|
||||
SELECT
|
||||
idx,
|
||||
ingredient_code,
|
||||
ingredient_name,
|
||||
product_name,
|
||||
conversion_factor,
|
||||
post_prep_amount,
|
||||
main_ingredient_amt,
|
||||
storage_conditions,
|
||||
expiration_date
|
||||
FROM drysyrup
|
||||
ORDER BY ingredient_name, product_name
|
||||
""")
|
||||
rows = session.execute(query).fetchall()
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
data.append({
|
||||
'idx': row[0],
|
||||
'sung_code': row[1],
|
||||
'ingredient_name': row[2],
|
||||
'product_name': row[3],
|
||||
'conversion_factor': float(row[4]) if row[4] is not None else None,
|
||||
'post_prep_amount': row[5],
|
||||
'main_ingredient_amt': row[6],
|
||||
'storage_conditions': row[7],
|
||||
'expiration_date': row[8]
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': data,
|
||||
'count': len(data)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"건조시럽 목록 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'data': []
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/drug-info/drysyrup/<sung_code>', methods=['DELETE'])
|
||||
def api_drysyrup_delete(sung_code):
|
||||
"""
|
||||
건조시럽 삭제 API
|
||||
"""
|
||||
try:
|
||||
session = db_manager.get_postgres_session()
|
||||
if session is None:
|
||||
return jsonify({'success': False, 'error': 'PostgreSQL 연결 실패'}), 500
|
||||
|
||||
# 존재 여부 확인
|
||||
check_query = text("SELECT 1 FROM drysyrup WHERE ingredient_code = :sung_code")
|
||||
existing = session.execute(check_query, {'sung_code': sung_code}).fetchone()
|
||||
|
||||
if not existing:
|
||||
return jsonify({'success': False, 'error': '존재하지 않는 성분코드'}), 404
|
||||
|
||||
# 삭제
|
||||
delete_query = text("DELETE FROM drysyrup WHERE ingredient_code = :sung_code")
|
||||
session.execute(delete_query, {'sung_code': sung_code})
|
||||
session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': '삭제 완료'})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"건조시럽 삭제 오류 (SUNG_CODE={sung_code}): {e}")
|
||||
try:
|
||||
session.rollback()
|
||||
except:
|
||||
pass
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/admin/drysyrup')
|
||||
def admin_drysyrup():
|
||||
"""건조시럽 환산계수 관리 페이지"""
|
||||
return render_template('admin_drysyrup.html')
|
||||
|
||||
|
||||
# ==================== 입고이력 API ====================
|
||||
|
||||
@app.route('/api/drugs/<drug_code>/purchase-history')
|
||||
|
||||
980
backend/templates/admin_drysyrup.html
Normal file
980
backend/templates/admin_drysyrup.html
Normal file
@ -0,0 +1,980 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>건조시럽 환산계수 관리 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-card: #1e293b;
|
||||
--bg-card-hover: #334155;
|
||||
--border: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent-teal: #14b8a6;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-purple: #a855f7;
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-emerald: #10b981;
|
||||
--accent-rose: #f43f5e;
|
||||
--accent-orange: #f97316;
|
||||
--accent-cyan: #06b6d4;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ══════════════════ 헤더 ══════════════════ */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #a855f7 0%, #8b5cf6 50%, #7c3aed 100%);
|
||||
padding: 20px 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header-left h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-left p {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.header-nav a:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ══════════════════ 컨텐츠 ══════════════════ */
|
||||
.content {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 검색 & 액션 바 ══════════════════ */
|
||||
.action-bar {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
}
|
||||
.search-group input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-purple);
|
||||
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.2);
|
||||
}
|
||||
.search-group input::placeholder { color: var(--text-muted); }
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent-purple), #7c3aed);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(168, 85, 247, 0.4);
|
||||
}
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--accent-emerald), var(--accent-teal));
|
||||
color: #fff;
|
||||
}
|
||||
.btn-success:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--accent-rose), #dc2626);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(244, 63, 94, 0.4);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* ══════════════════ 통계 ══════════════════ */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
}
|
||||
.stat-card.purple::before { background: var(--accent-purple); }
|
||||
.stat-card.cyan::before { background: var(--accent-cyan); }
|
||||
.stat-card.amber::before { background: var(--accent-amber); }
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-card.purple .stat-value { color: var(--accent-purple); }
|
||||
.stat-card.cyan .stat-value { color: var(--accent-cyan); }
|
||||
.stat-card.amber .stat-value { color: var(--accent-amber); }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 테이블 ══════════════════ */
|
||||
.table-container {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.table-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.table-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.badge {
|
||||
background: linear-gradient(135deg, var(--accent-purple), #7c3aed);
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 14px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tr:hover td {
|
||||
background: rgba(168, 85, 247, 0.05);
|
||||
}
|
||||
|
||||
.code-cell {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
.factor-cell {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
.storage-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.storage-badge.cold {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
.storage-badge.room {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--accent-emerald);
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.action-btns button {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-edit {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
.btn-edit:hover {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.btn-delete {
|
||||
background: rgba(244, 63, 94, 0.2);
|
||||
color: var(--accent-rose);
|
||||
}
|
||||
.btn-delete:hover {
|
||||
background: rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
|
||||
/* ══════════════════ 빈 상태 ══════════════════ */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.empty-state .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.empty-state h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.empty-state p {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 에러 메시지 ══════════════════ */
|
||||
.error-banner {
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--accent-rose);
|
||||
}
|
||||
.error-banner.hidden { display: none; }
|
||||
.error-banner .icon { font-size: 20px; }
|
||||
|
||||
/* ══════════════════ 모달 ══════════════════ */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.modal-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 20px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.modal-overlay.active .modal {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
.modal-header {
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal-close:hover { color: var(--text-primary); }
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-purple);
|
||||
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.2);
|
||||
}
|
||||
.form-group input:disabled {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 20px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 토스트 ══════════════════ */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 2000;
|
||||
}
|
||||
.toast {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
margin-top: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
.toast.success { border-left: 4px solid var(--accent-emerald); }
|
||||
.toast.error { border-left: 4px solid var(--accent-rose); }
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ══════════════════ 로딩 ══════════════════ */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent-purple);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ══════════════════ 반응형 ══════════════════ */
|
||||
@media (max-width: 768px) {
|
||||
.action-bar { flex-direction: column; }
|
||||
.search-group { max-width: 100%; width: 100%; }
|
||||
.stats-row { flex-direction: column; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
th, td { padding: 10px 12px; }
|
||||
.header-nav { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 헤더 -->
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<div class="header-left">
|
||||
<h1>💧 건조시럽 환산계수 관리</h1>
|
||||
<p>건조시럽 mL → g 환산계수 데이터 관리</p>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<a href="/admin">관리자 홈</a>
|
||||
<a href="/pmr">PMR</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 컨텐츠 -->
|
||||
<div class="content">
|
||||
<!-- 에러 배너 -->
|
||||
<div id="errorBanner" class="error-banner hidden">
|
||||
<span class="icon">⚠️</span>
|
||||
<span id="errorMessage">PostgreSQL 연결에 실패했습니다.</span>
|
||||
</div>
|
||||
|
||||
<!-- 액션 바 -->
|
||||
<div class="action-bar">
|
||||
<div class="search-group">
|
||||
<input type="text" id="searchInput" placeholder="성분명, 제품명, 성분코드로 검색..." autocomplete="off">
|
||||
<button class="btn btn-primary" onclick="loadData()">🔍 검색</button>
|
||||
</div>
|
||||
<button class="btn btn-success" onclick="openCreateModal()">➕ 신규 등록</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card purple">
|
||||
<div class="stat-value" id="statTotal">-</div>
|
||||
<div class="stat-label">전체 등록</div>
|
||||
</div>
|
||||
<div class="stat-card cyan">
|
||||
<div class="stat-value" id="statCold">-</div>
|
||||
<div class="stat-label">냉장보관</div>
|
||||
</div>
|
||||
<div class="stat-card amber">
|
||||
<div class="stat-value" id="statRoom">-</div>
|
||||
<div class="stat-label">실온보관</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="table-container">
|
||||
<div class="table-header">
|
||||
<div class="table-title">
|
||||
<span>환산계수 목록</span>
|
||||
<span class="badge" id="countBadge">0건</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tableWrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 120px;">성분코드</th>
|
||||
<th>성분명</th>
|
||||
<th>제품명</th>
|
||||
<th style="width: 100px;">환산계수</th>
|
||||
<th style="width: 100px;">보관조건</th>
|
||||
<th style="width: 100px;">유효기간</th>
|
||||
<th style="width: 130px;">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dataBody">
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="empty-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p style="margin-top: 12px;">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 등록/수정 모달 -->
|
||||
<div class="modal-overlay" id="editModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">건조시럽 등록</h2>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="editMode" value="create">
|
||||
|
||||
<div class="form-group">
|
||||
<label>성분코드 (SUNG_CODE) *</label>
|
||||
<input type="text" id="formSungCode" placeholder="예: 535000ASY">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>성분명</label>
|
||||
<input type="text" id="formIngredientName" placeholder="예: 아목시실린수화물·클라불란산칼륨">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>제품명</label>
|
||||
<input type="text" id="formProductName" placeholder="예: 일성오구멘틴듀오시럽">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>환산계수 (g/mL)</label>
|
||||
<input type="number" id="formConversionFactor" step="0.001" placeholder="예: 0.11">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>조제 후 유효기간</label>
|
||||
<input type="text" id="formExpiration" placeholder="예: 7일">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>조제 후 함량</label>
|
||||
<input type="text" id="formPostPrepAmount" placeholder="예: 228mg/5ml">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>분말 중 주성분량</label>
|
||||
<input type="text" id="formMainIngredientAmt" placeholder="예: 200mg/g">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>보관조건</label>
|
||||
<select id="formStorageConditions">
|
||||
<option value="실온">실온</option>
|
||||
<option value="냉장">냉장</option>
|
||||
<option value="냉동">냉동</option>
|
||||
<option value="차광">차광</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">취소</button>
|
||||
<button class="btn btn-success" onclick="saveData()">💾 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 삭제 확인 모달 -->
|
||||
<div class="modal-overlay" id="deleteModal">
|
||||
<div class="modal" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h2>삭제 확인</h2>
|
||||
<button class="modal-close" onclick="closeDeleteModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="text-align: center;">
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">🗑️</div>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 8px;">
|
||||
<strong id="deleteTarget" style="color: var(--accent-rose);"></strong>
|
||||
</p>
|
||||
<p style="color: var(--text-muted); font-size: 13px;">
|
||||
이 항목을 정말 삭제하시겠습니까?<br>삭제 후 복구할 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer" style="justify-content: center;">
|
||||
<button class="btn btn-secondary" onclick="closeDeleteModal()">취소</button>
|
||||
<button class="btn btn-danger" onclick="confirmDelete()">🗑️ 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 컨테이너 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<script>
|
||||
// 전역 변수
|
||||
let allData = [];
|
||||
let deleteSungCode = null;
|
||||
|
||||
// 페이지 로드 시
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadData();
|
||||
|
||||
// 엔터키로 검색
|
||||
document.getElementById('searchInput').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') loadData();
|
||||
});
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
async function loadData() {
|
||||
const searchQuery = document.getElementById('searchInput').value.trim();
|
||||
const url = searchQuery
|
||||
? `/api/drug-info/drysyrup?q=${encodeURIComponent(searchQuery)}`
|
||||
: '/api/drug-info/drysyrup';
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
showError(result.error || 'PostgreSQL 연결에 실패했습니다.');
|
||||
renderEmptyState('데이터베이스 연결 오류');
|
||||
return;
|
||||
}
|
||||
|
||||
hideError();
|
||||
allData = result.data || [];
|
||||
renderTable(allData);
|
||||
updateStats(allData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
showError('서버 연결에 실패했습니다.');
|
||||
renderEmptyState('서버 연결 오류');
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 렌더링
|
||||
function renderTable(data) {
|
||||
const tbody = document.getElementById('dataBody');
|
||||
document.getElementById('countBadge').textContent = `${data.length}건`;
|
||||
|
||||
if (data.length === 0) {
|
||||
renderEmptyState('등록된 환산계수가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.map(item => `
|
||||
<tr>
|
||||
<td class="code-cell">${escapeHtml(item.sung_code || '')}</td>
|
||||
<td>${escapeHtml(item.ingredient_name || '-')}</td>
|
||||
<td>${escapeHtml(item.product_name || '-')}</td>
|
||||
<td class="factor-cell">${item.conversion_factor !== null ? item.conversion_factor.toFixed(3) : '-'}</td>
|
||||
<td>
|
||||
<span class="storage-badge ${getStorageClass(item.storage_conditions)}">
|
||||
${escapeHtml(item.storage_conditions || '실온')}
|
||||
</span>
|
||||
</td>
|
||||
<td>${escapeHtml(item.expiration_date || '-')}</td>
|
||||
<td>
|
||||
<div class="action-btns">
|
||||
<button class="btn-edit" onclick="openEditModal('${escapeHtml(item.sung_code)}')">✏️ 수정</button>
|
||||
<button class="btn-delete" onclick="openDeleteModal('${escapeHtml(item.sung_code)}')">🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 빈 상태 렌더링
|
||||
function renderEmptyState(message) {
|
||||
document.getElementById('dataBody').innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="empty-state">
|
||||
<div class="icon">📭</div>
|
||||
<h3>${escapeHtml(message)}</h3>
|
||||
<p>신규 등록 버튼을 눌러 환산계수를 추가하세요.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
document.getElementById('countBadge').textContent = '0건';
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
function updateStats(data) {
|
||||
const total = data.length;
|
||||
const cold = data.filter(d => (d.storage_conditions || '').includes('냉')).length;
|
||||
const room = total - cold;
|
||||
|
||||
document.getElementById('statTotal').textContent = total.toLocaleString();
|
||||
document.getElementById('statCold').textContent = cold.toLocaleString();
|
||||
document.getElementById('statRoom').textContent = room.toLocaleString();
|
||||
}
|
||||
|
||||
// 보관조건 클래스
|
||||
function getStorageClass(storage) {
|
||||
if (!storage) return 'room';
|
||||
return storage.includes('냉') ? 'cold' : 'room';
|
||||
}
|
||||
|
||||
// 신규 등록 모달 열기
|
||||
function openCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = '건조시럽 신규 등록';
|
||||
document.getElementById('editMode').value = 'create';
|
||||
document.getElementById('formSungCode').value = '';
|
||||
document.getElementById('formSungCode').disabled = false;
|
||||
document.getElementById('formIngredientName').value = '';
|
||||
document.getElementById('formProductName').value = '';
|
||||
document.getElementById('formConversionFactor').value = '';
|
||||
document.getElementById('formExpiration').value = '';
|
||||
document.getElementById('formPostPrepAmount').value = '';
|
||||
document.getElementById('formMainIngredientAmt').value = '';
|
||||
document.getElementById('formStorageConditions').value = '실온';
|
||||
|
||||
document.getElementById('editModal').classList.add('active');
|
||||
}
|
||||
|
||||
// 수정 모달 열기
|
||||
async function openEditModal(sungCode) {
|
||||
try {
|
||||
const response = await fetch(`/api/drug-info/drysyrup/${encodeURIComponent(sungCode)}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.exists) {
|
||||
showToast('데이터를 불러올 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('modalTitle').textContent = '건조시럽 수정';
|
||||
document.getElementById('editMode').value = 'edit';
|
||||
document.getElementById('formSungCode').value = result.sung_code;
|
||||
document.getElementById('formSungCode').disabled = true;
|
||||
document.getElementById('formIngredientName').value = result.ingredient_name || '';
|
||||
document.getElementById('formProductName').value = result.product_name || '';
|
||||
document.getElementById('formConversionFactor').value = result.conversion_factor || '';
|
||||
document.getElementById('formExpiration').value = result.expiration_date || '';
|
||||
document.getElementById('formPostPrepAmount').value = result.post_prep_amount || '';
|
||||
document.getElementById('formMainIngredientAmt').value = result.main_ingredient_amt || '';
|
||||
document.getElementById('formStorageConditions').value = result.storage_conditions || '실온';
|
||||
|
||||
document.getElementById('editModal').classList.add('active');
|
||||
|
||||
} catch (error) {
|
||||
console.error('수정 모달 오류:', error);
|
||||
showToast('데이터 로드 실패', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closeModal() {
|
||||
document.getElementById('editModal').classList.remove('active');
|
||||
}
|
||||
|
||||
// 데이터 저장
|
||||
async function saveData() {
|
||||
const mode = document.getElementById('editMode').value;
|
||||
const sungCode = document.getElementById('formSungCode').value.trim();
|
||||
|
||||
if (!sungCode) {
|
||||
showToast('성분코드는 필수입니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
sung_code: sungCode,
|
||||
ingredient_name: document.getElementById('formIngredientName').value.trim(),
|
||||
product_name: document.getElementById('formProductName').value.trim(),
|
||||
conversion_factor: parseFloat(document.getElementById('formConversionFactor').value) || null,
|
||||
expiration_date: document.getElementById('formExpiration').value.trim(),
|
||||
post_prep_amount: document.getElementById('formPostPrepAmount').value.trim(),
|
||||
main_ingredient_amt: document.getElementById('formMainIngredientAmt').value.trim(),
|
||||
storage_conditions: document.getElementById('formStorageConditions').value
|
||||
};
|
||||
|
||||
try {
|
||||
const url = mode === 'create'
|
||||
? '/api/drug-info/drysyrup'
|
||||
: `/api/drug-info/drysyrup/${encodeURIComponent(sungCode)}`;
|
||||
const method = mode === 'create' ? 'POST' : 'PUT';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(mode === 'create' ? '등록 완료' : '수정 완료', 'success');
|
||||
closeModal();
|
||||
loadData();
|
||||
} else {
|
||||
showToast(result.error || '저장 실패', 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
showToast('서버 오류', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 삭제 모달 열기
|
||||
function openDeleteModal(sungCode) {
|
||||
deleteSungCode = sungCode;
|
||||
document.getElementById('deleteTarget').textContent = sungCode;
|
||||
document.getElementById('deleteModal').classList.add('active');
|
||||
}
|
||||
|
||||
// 삭제 모달 닫기
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').classList.remove('active');
|
||||
deleteSungCode = null;
|
||||
}
|
||||
|
||||
// 삭제 확인
|
||||
async function confirmDelete() {
|
||||
if (!deleteSungCode) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/drug-info/drysyrup/${encodeURIComponent(deleteSungCode)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('삭제 완료', 'success');
|
||||
closeDeleteModal();
|
||||
loadData();
|
||||
} else {
|
||||
showToast(result.error || '삭제 실패', 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
showToast('서버 오류', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 표시/숨김
|
||||
function showError(message) {
|
||||
document.getElementById('errorMessage').textContent = message;
|
||||
document.getElementById('errorBanner').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
document.getElementById('errorBanner').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 토스트 메시지
|
||||
function showToast(message, type = 'success') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `
|
||||
<span>${type === 'success' ? '✅' : '❌'}</span>
|
||||
<span>${escapeHtml(message)}</span>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// HTML 이스케이프
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user