✨ 주요 기능 - 환자 관리: 환자 등록 및 조회 (이름, 전화번호, 주민번호, 성별) - 입고 관리: Excel 파일 업로드로 대량 입고 처리 - 처방 관리: 약속 처방 템플릿 등록 및 관리 - 조제 관리: 처방 기반 조제 및 약재 가감 기능 - 재고 관리: 실시간 재고 현황 및 로트별 관리 🛠️ 기술 스택 - Backend: Flask (Python 웹 프레임워크) - Database: SQLite (경량 관계형 데이터베이스) - Frontend: Bootstrap + jQuery - Excel 처리: pandas + openpyxl 🔧 핵심 개념 - 1제 = 20첩 = 30파우치 (기본값) - FIFO 방식 재고 차감 - 로트별 원산지/단가 관리 - 정확한 조제 원가 계산 📁 프로젝트 구조 - app.py: Flask 백엔드 서버 - database/: 데이터베이스 스키마 및 파일 - templates/: HTML 템플릿 - static/: JavaScript 및 CSS - sample/: 샘플 Excel 파일 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
596 lines
21 KiB
JavaScript
596 lines
21 KiB
JavaScript
// 한약 재고관리 시스템 - Frontend JavaScript
|
|
|
|
$(document).ready(function() {
|
|
// 페이지 네비게이션
|
|
$('.sidebar .nav-link').on('click', function(e) {
|
|
e.preventDefault();
|
|
const page = $(this).data('page');
|
|
|
|
// Active 상태 변경
|
|
$('.sidebar .nav-link').removeClass('active');
|
|
$(this).addClass('active');
|
|
|
|
// 페이지 전환
|
|
$('.main-content').removeClass('active');
|
|
$(`#${page}`).addClass('active');
|
|
|
|
// 페이지별 데이터 로드
|
|
loadPageData(page);
|
|
});
|
|
|
|
// 초기 데이터 로드
|
|
loadPageData('dashboard');
|
|
|
|
// 페이지별 데이터 로드 함수
|
|
function loadPageData(page) {
|
|
switch(page) {
|
|
case 'dashboard':
|
|
loadDashboard();
|
|
break;
|
|
case 'patients':
|
|
loadPatients();
|
|
break;
|
|
case 'formulas':
|
|
loadFormulas();
|
|
break;
|
|
case 'compound':
|
|
loadCompounds();
|
|
loadPatientsForSelect();
|
|
loadFormulasForSelect();
|
|
break;
|
|
case 'inventory':
|
|
loadInventory();
|
|
break;
|
|
case 'herbs':
|
|
loadHerbs();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 대시보드 데이터 로드
|
|
function loadDashboard() {
|
|
// 환자 수
|
|
$.get('/api/patients', function(response) {
|
|
if (response.success) {
|
|
$('#totalPatients').text(response.data.length);
|
|
}
|
|
});
|
|
|
|
// 재고 현황
|
|
$.get('/api/inventory/summary', function(response) {
|
|
if (response.success) {
|
|
$('#totalHerbs').text(response.data.length);
|
|
$('#inventoryValue').text(formatCurrency(response.summary.total_value));
|
|
}
|
|
});
|
|
|
|
// TODO: 오늘 조제 수, 최근 조제 내역
|
|
}
|
|
|
|
// 환자 목록 로드
|
|
function loadPatients() {
|
|
$.get('/api/patients', function(response) {
|
|
if (response.success) {
|
|
const tbody = $('#patientsList');
|
|
tbody.empty();
|
|
|
|
response.data.forEach(patient => {
|
|
tbody.append(`
|
|
<tr>
|
|
<td>${patient.name}</td>
|
|
<td>${patient.phone}</td>
|
|
<td>${patient.gender === 'M' ? '남' : patient.gender === 'F' ? '여' : '-'}</td>
|
|
<td>${patient.birth_date || '-'}</td>
|
|
<td>${patient.notes || '-'}</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-primary">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// 환자 등록
|
|
$('#savePatientBtn').on('click', function() {
|
|
const patientData = {
|
|
name: $('#patientName').val(),
|
|
phone: $('#patientPhone').val(),
|
|
jumin_no: $('#patientJumin').val(),
|
|
gender: $('#patientGender').val(),
|
|
birth_date: $('#patientBirth').val(),
|
|
address: $('#patientAddress').val(),
|
|
notes: $('#patientNotes').val()
|
|
};
|
|
|
|
$.ajax({
|
|
url: '/api/patients',
|
|
method: 'POST',
|
|
contentType: 'application/json',
|
|
data: JSON.stringify(patientData),
|
|
success: function(response) {
|
|
if (response.success) {
|
|
alert('환자가 등록되었습니다.');
|
|
$('#patientModal').modal('hide');
|
|
$('#patientForm')[0].reset();
|
|
loadPatients();
|
|
}
|
|
},
|
|
error: function(xhr) {
|
|
alert('오류: ' + xhr.responseJSON.error);
|
|
}
|
|
});
|
|
});
|
|
|
|
// 처방 목록 로드
|
|
function loadFormulas() {
|
|
$.get('/api/formulas', function(response) {
|
|
if (response.success) {
|
|
const tbody = $('#formulasList');
|
|
tbody.empty();
|
|
|
|
response.data.forEach(formula => {
|
|
tbody.append(`
|
|
<tr>
|
|
<td>${formula.formula_code || '-'}</td>
|
|
<td>${formula.formula_name}</td>
|
|
<td>${formula.base_cheop}첩</td>
|
|
<td>${formula.base_pouches}파우치</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-info view-ingredients"
|
|
data-id="${formula.formula_id}">
|
|
<i class="bi bi-eye"></i> 보기
|
|
</button>
|
|
</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-primary">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`);
|
|
});
|
|
|
|
// 구성 약재 보기
|
|
$('.view-ingredients').on('click', function() {
|
|
const formulaId = $(this).data('id');
|
|
$.get(`/api/formulas/${formulaId}/ingredients`, function(response) {
|
|
if (response.success) {
|
|
let ingredientsList = response.data.map(ing =>
|
|
`${ing.herb_name}: ${ing.grams_per_cheop}g`
|
|
).join(', ');
|
|
alert('구성 약재:\n' + ingredientsList);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// 처방 구성 약재 추가 (모달)
|
|
let formulaIngredientCount = 0;
|
|
$('#addFormulaIngredientBtn').on('click', function() {
|
|
formulaIngredientCount++;
|
|
$('#formulaIngredients').append(`
|
|
<tr data-row="${formulaIngredientCount}">
|
|
<td>
|
|
<select class="form-control form-control-sm herb-select">
|
|
<option value="">약재 선택</option>
|
|
</select>
|
|
</td>
|
|
<td>
|
|
<input type="number" class="form-control form-control-sm grams-input"
|
|
min="0.1" step="0.1" placeholder="0.0">
|
|
</td>
|
|
<td>
|
|
<input type="text" class="form-control form-control-sm notes-input">
|
|
</td>
|
|
<td>
|
|
<button type="button" class="btn btn-sm btn-outline-danger remove-ingredient">
|
|
<i class="bi bi-x"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`);
|
|
|
|
// 약재 목록 로드
|
|
const selectElement = $(`#formulaIngredients tr[data-row="${formulaIngredientCount}"] .herb-select`);
|
|
loadHerbsForSelect(selectElement);
|
|
|
|
// 삭제 버튼 이벤트
|
|
$(`#formulaIngredients tr[data-row="${formulaIngredientCount}"] .remove-ingredient`).on('click', function() {
|
|
$(this).closest('tr').remove();
|
|
});
|
|
});
|
|
|
|
// 처방 저장
|
|
$('#saveFormulaBtn').on('click', function() {
|
|
const ingredients = [];
|
|
$('#formulaIngredients tr').each(function() {
|
|
const herbId = $(this).find('.herb-select').val();
|
|
const grams = $(this).find('.grams-input').val();
|
|
|
|
if (herbId && grams) {
|
|
ingredients.push({
|
|
herb_item_id: parseInt(herbId),
|
|
grams_per_cheop: parseFloat(grams),
|
|
notes: $(this).find('.notes-input').val()
|
|
});
|
|
}
|
|
});
|
|
|
|
const formulaData = {
|
|
formula_code: $('#formulaCode').val(),
|
|
formula_name: $('#formulaName').val(),
|
|
formula_type: $('#formulaType').val(),
|
|
base_cheop: parseInt($('#baseCheop').val()),
|
|
base_pouches: parseInt($('#basePouches').val()),
|
|
description: $('#formulaDescription').val(),
|
|
ingredients: ingredients
|
|
};
|
|
|
|
$.ajax({
|
|
url: '/api/formulas',
|
|
method: 'POST',
|
|
contentType: 'application/json',
|
|
data: JSON.stringify(formulaData),
|
|
success: function(response) {
|
|
if (response.success) {
|
|
alert('처방이 등록되었습니다.');
|
|
$('#formulaModal').modal('hide');
|
|
$('#formulaForm')[0].reset();
|
|
$('#formulaIngredients').empty();
|
|
loadFormulas();
|
|
}
|
|
},
|
|
error: function(xhr) {
|
|
alert('오류: ' + xhr.responseJSON.error);
|
|
}
|
|
});
|
|
});
|
|
|
|
// 조제 관리
|
|
$('#newCompoundBtn').on('click', function() {
|
|
$('#compoundForm').show();
|
|
$('#compoundEntryForm')[0].reset();
|
|
$('#compoundIngredients').empty();
|
|
});
|
|
|
|
$('#cancelCompoundBtn').on('click', function() {
|
|
$('#compoundForm').hide();
|
|
});
|
|
|
|
// 제수 변경 시 첩수 자동 계산
|
|
$('#jeCount').on('input', function() {
|
|
const jeCount = parseFloat($(this).val()) || 0;
|
|
const cheopTotal = jeCount * 20;
|
|
const pouchTotal = jeCount * 30;
|
|
|
|
$('#cheopTotal').val(cheopTotal);
|
|
$('#pouchTotal').val(pouchTotal);
|
|
|
|
// 약재별 총 용량 재계산
|
|
updateIngredientTotals();
|
|
});
|
|
|
|
// 처방 선택 시 구성 약재 로드
|
|
$('#compoundFormula').on('change', function() {
|
|
const formulaId = $(this).val();
|
|
if (!formulaId) {
|
|
$('#compoundIngredients').empty();
|
|
return;
|
|
}
|
|
|
|
$.get(`/api/formulas/${formulaId}/ingredients`, function(response) {
|
|
if (response.success) {
|
|
$('#compoundIngredients').empty();
|
|
|
|
response.data.forEach(ing => {
|
|
const cheopTotal = parseFloat($('#cheopTotal').val()) || 0;
|
|
const totalGrams = ing.grams_per_cheop * cheopTotal;
|
|
|
|
$('#compoundIngredients').append(`
|
|
<tr data-herb-id="${ing.herb_item_id}">
|
|
<td>${ing.herb_name}</td>
|
|
<td>
|
|
<input type="number" class="form-control form-control-sm grams-per-cheop"
|
|
value="${ing.grams_per_cheop}" min="0.1" step="0.1">
|
|
</td>
|
|
<td class="total-grams">${totalGrams.toFixed(1)}</td>
|
|
<td class="stock-status">확인중...</td>
|
|
<td>
|
|
<button type="button" class="btn btn-sm btn-outline-danger remove-compound-ingredient">
|
|
<i class="bi bi-x"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`);
|
|
});
|
|
|
|
// 재고 확인
|
|
checkStockForCompound();
|
|
|
|
// 용량 변경 이벤트
|
|
$('.grams-per-cheop').on('input', updateIngredientTotals);
|
|
|
|
// 삭제 버튼 이벤트
|
|
$('.remove-compound-ingredient').on('click', function() {
|
|
$(this).closest('tr').remove();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// 약재별 총 용량 업데이트
|
|
function updateIngredientTotals() {
|
|
const cheopTotal = parseFloat($('#cheopTotal').val()) || 0;
|
|
|
|
$('#compoundIngredients tr').each(function() {
|
|
const gramsPerCheop = parseFloat($(this).find('.grams-per-cheop').val()) || 0;
|
|
const totalGrams = gramsPerCheop * cheopTotal;
|
|
$(this).find('.total-grams').text(totalGrams.toFixed(1));
|
|
});
|
|
|
|
checkStockForCompound();
|
|
}
|
|
|
|
// 재고 확인
|
|
function checkStockForCompound() {
|
|
$('#compoundIngredients tr').each(function() {
|
|
const herbId = $(this).data('herb-id');
|
|
const totalGrams = parseFloat($(this).find('.total-grams').text()) || 0;
|
|
const $stockStatus = $(this).find('.stock-status');
|
|
|
|
// TODO: API 호출로 실제 재고 확인
|
|
$stockStatus.text('재고 확인 필요');
|
|
});
|
|
}
|
|
|
|
// 조제 약재 추가
|
|
$('#addIngredientBtn').on('click', function() {
|
|
const newRow = $(`
|
|
<tr>
|
|
<td>
|
|
<select class="form-control form-control-sm herb-select-compound">
|
|
<option value="">약재 선택</option>
|
|
</select>
|
|
</td>
|
|
<td>
|
|
<input type="number" class="form-control form-control-sm grams-per-cheop"
|
|
min="0.1" step="0.1" placeholder="0.0">
|
|
</td>
|
|
<td class="total-grams">0.0</td>
|
|
<td class="stock-status">-</td>
|
|
<td>
|
|
<button type="button" class="btn btn-sm btn-outline-danger remove-compound-ingredient">
|
|
<i class="bi bi-x"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`);
|
|
|
|
$('#compoundIngredients').append(newRow);
|
|
|
|
// 약재 목록 로드
|
|
loadHerbsForSelect(newRow.find('.herb-select-compound'));
|
|
|
|
// 이벤트 바인딩
|
|
newRow.find('.grams-per-cheop').on('input', updateIngredientTotals);
|
|
newRow.find('.remove-compound-ingredient').on('click', function() {
|
|
$(this).closest('tr').remove();
|
|
});
|
|
newRow.find('.herb-select-compound').on('change', function() {
|
|
const herbId = $(this).val();
|
|
$(this).closest('tr').attr('data-herb-id', herbId);
|
|
updateIngredientTotals();
|
|
});
|
|
});
|
|
|
|
// 조제 실행
|
|
$('#compoundEntryForm').on('submit', function(e) {
|
|
e.preventDefault();
|
|
|
|
const ingredients = [];
|
|
$('#compoundIngredients tr').each(function() {
|
|
const herbId = $(this).data('herb-id');
|
|
const gramsPerCheop = parseFloat($(this).find('.grams-per-cheop').val());
|
|
const totalGrams = parseFloat($(this).find('.total-grams').text());
|
|
|
|
if (herbId && gramsPerCheop) {
|
|
ingredients.push({
|
|
herb_item_id: parseInt(herbId),
|
|
grams_per_cheop: gramsPerCheop,
|
|
total_grams: totalGrams
|
|
});
|
|
}
|
|
});
|
|
|
|
const compoundData = {
|
|
patient_id: $('#compoundPatient').val() ? parseInt($('#compoundPatient').val()) : null,
|
|
formula_id: $('#compoundFormula').val() ? parseInt($('#compoundFormula').val()) : null,
|
|
je_count: parseFloat($('#jeCount').val()),
|
|
cheop_total: parseFloat($('#cheopTotal').val()),
|
|
pouch_total: parseFloat($('#pouchTotal').val()),
|
|
ingredients: ingredients
|
|
};
|
|
|
|
$.ajax({
|
|
url: '/api/compounds',
|
|
method: 'POST',
|
|
contentType: 'application/json',
|
|
data: JSON.stringify(compoundData),
|
|
success: function(response) {
|
|
if (response.success) {
|
|
alert(`조제가 완료되었습니다.\n원가: ${formatCurrency(response.total_cost)}`);
|
|
$('#compoundForm').hide();
|
|
loadCompounds();
|
|
}
|
|
},
|
|
error: function(xhr) {
|
|
alert('오류: ' + xhr.responseJSON.error);
|
|
}
|
|
});
|
|
});
|
|
|
|
// 조제 내역 로드
|
|
function loadCompounds() {
|
|
// TODO: 조제 내역 API 구현 필요
|
|
$('#compoundsList').html('<tr><td colspan="7" class="text-center">조제 내역이 없습니다.</td></tr>');
|
|
}
|
|
|
|
// 재고 현황 로드
|
|
function loadInventory() {
|
|
$.get('/api/inventory/summary', function(response) {
|
|
if (response.success) {
|
|
const tbody = $('#inventoryList');
|
|
tbody.empty();
|
|
|
|
response.data.forEach(item => {
|
|
tbody.append(`
|
|
<tr>
|
|
<td>${item.insurance_code || '-'}</td>
|
|
<td>${item.herb_name}</td>
|
|
<td>${item.total_quantity.toFixed(1)}</td>
|
|
<td>${item.lot_count}</td>
|
|
<td>${item.avg_price ? formatCurrency(item.avg_price) : '-'}</td>
|
|
<td>${formatCurrency(item.total_value)}</td>
|
|
</tr>
|
|
`);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// 약재 목록 로드
|
|
function loadHerbs() {
|
|
$.get('/api/herbs', function(response) {
|
|
if (response.success) {
|
|
const tbody = $('#herbsList');
|
|
tbody.empty();
|
|
|
|
response.data.forEach(herb => {
|
|
tbody.append(`
|
|
<tr>
|
|
<td>${herb.insurance_code || '-'}</td>
|
|
<td>${herb.herb_name}</td>
|
|
<td>${herb.specification || '-'}</td>
|
|
<td>${herb.current_stock ? herb.current_stock.toFixed(1) + 'g' : '0g'}</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-primary">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// 입고장 업로드
|
|
$('#purchaseUploadForm').on('submit', function(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData();
|
|
const fileInput = $('#purchaseFile')[0];
|
|
|
|
if (fileInput.files.length === 0) {
|
|
alert('파일을 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
formData.append('file', fileInput.files[0]);
|
|
|
|
$('#uploadResult').html('<div class="alert alert-info">업로드 중...</div>');
|
|
|
|
$.ajax({
|
|
url: '/api/upload/purchase',
|
|
method: 'POST',
|
|
data: formData,
|
|
processData: false,
|
|
contentType: false,
|
|
success: function(response) {
|
|
if (response.success) {
|
|
$('#uploadResult').html(
|
|
`<div class="alert alert-success">
|
|
<i class="bi bi-check-circle"></i> ${response.message}
|
|
</div>`
|
|
);
|
|
$('#purchaseUploadForm')[0].reset();
|
|
}
|
|
},
|
|
error: function(xhr) {
|
|
$('#uploadResult').html(
|
|
`<div class="alert alert-danger">
|
|
<i class="bi bi-x-circle"></i> 오류: ${xhr.responseJSON.error}
|
|
</div>`
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// 검색 기능
|
|
$('#patientSearch').on('keyup', function() {
|
|
const value = $(this).val().toLowerCase();
|
|
$('#patientsList tr').filter(function() {
|
|
$(this).toggle($(this).text().toLowerCase().indexOf(value) > -1);
|
|
});
|
|
});
|
|
|
|
$('#inventorySearch').on('keyup', function() {
|
|
const value = $(this).val().toLowerCase();
|
|
$('#inventoryList tr').filter(function() {
|
|
$(this).toggle($(this).text().toLowerCase().indexOf(value) > -1);
|
|
});
|
|
});
|
|
|
|
// 헬퍼 함수들
|
|
function loadPatientsForSelect() {
|
|
$.get('/api/patients', function(response) {
|
|
if (response.success) {
|
|
const select = $('#compoundPatient');
|
|
select.empty().append('<option value="">환자를 선택하세요</option>');
|
|
|
|
response.data.forEach(patient => {
|
|
select.append(`<option value="${patient.patient_id}">${patient.name} (${patient.phone})</option>`);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function loadFormulasForSelect() {
|
|
$.get('/api/formulas', function(response) {
|
|
if (response.success) {
|
|
const select = $('#compoundFormula');
|
|
select.empty().append('<option value="">처방을 선택하세요</option>');
|
|
|
|
response.data.forEach(formula => {
|
|
select.append(`<option value="${formula.formula_id}">${formula.formula_name}</option>`);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function loadHerbsForSelect(selectElement) {
|
|
$.get('/api/herbs', function(response) {
|
|
if (response.success) {
|
|
selectElement.empty().append('<option value="">약재 선택</option>');
|
|
|
|
response.data.forEach(herb => {
|
|
selectElement.append(`<option value="${herb.herb_item_id}">${herb.herb_name}</option>`);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function formatCurrency(amount) {
|
|
if (amount === null || amount === undefined) return '0원';
|
|
return new Intl.NumberFormat('ko-KR', {
|
|
style: 'currency',
|
|
currency: 'KRW'
|
|
}).format(amount);
|
|
}
|
|
}); |