feat(admin): 건조시럽 환산계수 관리 페이지 추가

- /admin/drysyrup 페이지 구현
- GET /api/drug-info/drysyrup 전체 목록 API
- DELETE /api/drug-info/drysyrup/<sung_code> 삭제 API
- 검색, CRUD, 통계 카드 기능
This commit is contained in:
thug0bin 2026-03-12 17:15:28 +09:00
parent 58408c9f5c
commit 2cc9ec6bb1
2 changed files with 1107 additions and 0 deletions

View File

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

View 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()">&times;</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()">&times;</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>