초기 커밋: 한약 재고관리 시스템
✨ 주요 기능 - 환자 관리: 환자 등록 및 조회 (이름, 전화번호, 주민번호, 성별) - 입고 관리: 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>
This commit is contained in:
596
static/app.js
Normal file
596
static/app.js
Normal file
@@ -0,0 +1,596 @@
|
||||
// 한약 재고관리 시스템 - 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user