feat: GUI 칼럼 설정 저장, 010 전화번호 UX 개선, 품목 상세 조회

- GUI: SALES_COLUMNS 상수 정의, 칼럼 폭/윈도우 위치 gui_settings.json에 저장
- 전화번호 입력: 적립페이지/마이페이지에서 010 고정 + 뒷번호만 입력
- 적립페이지: MSSQL SALE_SUB에서 구매 품목 조회 및 토글 표시
- 마이페이지: 적립 내역 탭 시 품목 상세 AJAX 조회 (캐시 적용)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
thug0bin 2026-02-25 01:17:45 +09:00
parent 774c199c1a
commit 82220a4a44
6 changed files with 390 additions and 81 deletions

3
.gitignore vendored
View File

@ -86,3 +86,6 @@ docker-compose.override.yml
tmp/
*.tmp
.claude/
# GUI settings (user-specific)
gui_settings.json

View File

@ -536,8 +536,29 @@ def claim():
if not success:
return render_template('error.html', message=message)
# 간편 적립 페이지 렌더링
return render_template('claim_form.html', token_info=token_info)
# MSSQL에서 구매 품목 조회
sale_items = []
try:
session = db_manager.get_session('PM_PRES')
sale_sub_query = text("""
SELECT
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
S.SL_NM_item AS quantity,
S.SL_TOTAL_PRICE AS total
FROM SALE_SUB S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
WHERE S.SL_NO_order = :transaction_id
ORDER BY S.DrugCode
""")
rows = session.execute(sale_sub_query, {'transaction_id': transaction_id}).fetchall()
sale_items = [
{'name': r.goods_name, 'qty': int(r.quantity or 0), 'total': int(r.total or 0)}
for r in rows
]
except Exception as e:
logging.warning(f"품목 조회 실패 (transaction_id={transaction_id}): {e}")
return render_template('claim_form.html', token_info=token_info, sale_items=sale_items)
@app.route('/api/claim', methods=['POST'])
@ -647,9 +668,9 @@ def my_page():
user = dict(user_raw)
user['created_at'] = utc_to_kst_str(user_raw['created_at'])
# 적립 내역 조회
# 적립 내역 조회 (transaction_id 포함)
cursor.execute("""
SELECT points, balance_after, reason, description, created_at
SELECT points, balance_after, reason, description, created_at, transaction_id
FROM mileage_ledger
WHERE user_id = ?
ORDER BY created_at DESC

View File

@ -13,7 +13,7 @@ from PyQt5.QtWidgets import (
QDialog, QMessageBox, QDateEdit, QCheckBox
)
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QDate, QTimer
from PyQt5.QtGui import QFont
from PyQt5.QtGui import QFont, QColor
# 데이터베이스 연결 (backend/ 폴더를 Python 경로에 추가)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
@ -556,6 +556,20 @@ class POSSalesGUI(QMainWindow):
"""
POS 판매 내역 조회 메인 GUI
"""
CONFIG_FILE = os.path.join(os.path.dirname(__file__), 'gui_settings.json')
# 판매 테이블 컬럼 정의: (헤더명, 기본폭, 데이터키)
SALES_COLUMNS = [
('주문번호', 150, 'order_no'),
('시간', 70, 'time'),
('금액', 100, 'amount'),
('고객명', 80, 'customer'),
('품목수', 55, 'item_count'),
('적립자', 90, 'claimed_name'),
('전화번호', 120, 'claimed_phone'),
('적립포인트', 90, 'claimed_points'),
('QR', 50, 'qr_issued'),
]
def __init__(self):
super().__init__()
@ -563,12 +577,17 @@ class POSSalesGUI(QMainWindow):
self.sales_thread = None
self.qr_thread = None # QR 생성 스레드 추가
self.sales_data = []
self._gui_settings = self._load_settings()
self.init_ui()
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle('POS 판매 조회')
self.setGeometry(100, 100, 1300, 600)
saved_geo = self._gui_settings.get('window_geometry')
if saved_geo and len(saved_geo) == 4:
self.setGeometry(*saved_geo)
else:
self.setGeometry(100, 100, 1300, 600)
# 중앙 위젯
central_widget = QWidget()
@ -659,19 +678,18 @@ class POSSalesGUI(QMainWindow):
sales_group.setLayout(sales_layout)
self.sales_table = QTableWidget()
self.sales_table.setColumnCount(9)
self.sales_table.setHorizontalHeaderLabels([
'주문번호', '시간', '금액', '고객명', '품목수', '적립자명', '전화번호', '적립포인트', 'QR'
])
self.sales_table.setColumnWidth(0, 160)
self.sales_table.setColumnWidth(1, 70)
self.sales_table.setColumnWidth(2, 110)
self.sales_table.setColumnWidth(3, 100)
self.sales_table.setColumnWidth(4, 70)
self.sales_table.setColumnWidth(5, 100)
self.sales_table.setColumnWidth(6, 120)
self.sales_table.setColumnWidth(7, 100)
self.sales_table.setColumnWidth(8, 60)
col_count = len(self.SALES_COLUMNS)
self.sales_table.setColumnCount(col_count)
self.sales_table.setHorizontalHeaderLabels([c[0] for c in self.SALES_COLUMNS])
# 컬럼 폭: 저장된 값 우선, 없으면 SALES_COLUMNS 기본값
saved_widths = self._gui_settings.get('sales_column_widths')
for i, (_, default_w, _) in enumerate(self.SALES_COLUMNS):
w = saved_widths[i] if saved_widths and len(saved_widths) == col_count else default_w
self.sales_table.setColumnWidth(i, w)
self.sales_table.horizontalHeader().setStretchLastSection(True)
self.sales_table.horizontalHeader().sectionResized.connect(self._on_column_resized)
self.sales_table.setSelectionBehavior(QTableWidget.SelectRows)
self.sales_table.doubleClicked.connect(self.show_sale_detail)
self.sales_table.cellClicked.connect(self.on_cell_clicked)
@ -745,79 +763,85 @@ class POSSalesGUI(QMainWindow):
def populate_table(self, sales_list):
"""QTableWidget에 데이터 채우기"""
# 컬럼 인덱스 맵 (SALES_COLUMNS 순서 기반)
COL = {key: i for i, (_, _, key) in enumerate(self.SALES_COLUMNS)}
self.sales_table.setRowCount(len(sales_list))
# 적립 완료 셀 스타일
CLAIMED_COLOR = QColor('#4CAF50')
def make_claimed_font(underline=True):
f = QFont()
f.setBold(True)
if underline:
f.setUnderline(True)
return f
for row, sale in enumerate(sales_list):
# 주문번호
self.sales_table.setItem(row, 0, QTableWidgetItem(sale['order_no']))
self.sales_table.setItem(row, COL['order_no'],
QTableWidgetItem(sale['order_no']))
# 시간
self.sales_table.setItem(row, 1, QTableWidgetItem(sale['time']))
self.sales_table.setItem(row, COL['time'],
QTableWidgetItem(sale['time']))
# 금액 (우측 정렬, 천단위 콤마)
amount_item = QTableWidgetItem(f"{sale['amount']:,.0f}")
amount_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.sales_table.setItem(row, 2, amount_item)
self.sales_table.setItem(row, COL['amount'], amount_item)
# 고객명 (MSSQL)
self.sales_table.setItem(row, 3, QTableWidgetItem(sale['customer']))
# 고객명 (MSSQL POS)
self.sales_table.setItem(row, COL['customer'],
QTableWidgetItem(sale['customer']))
# 품목수 (중앙 정렬)
count_item = QTableWidgetItem(str(sale['item_count']))
count_item.setTextAlignment(Qt.AlignCenter)
self.sales_table.setItem(row, 4, count_item)
self.sales_table.setItem(row, COL['item_count'], count_item)
# 적립자명 (SQLite)
from PyQt5.QtGui import QColor, QFont
# 적립자 (SQLite 마일리지)
claimed_name_item = QTableWidgetItem(sale['claimed_name'])
if sale['claimed_name']:
claimed_name_item.setForeground(QColor('#4CAF50'))
font = QFont()
font.setBold(True)
font.setUnderline(True) # 밑줄 추가로 클릭 가능 표시
claimed_name_item.setFont(font)
claimed_name_item.setForeground(CLAIMED_COLOR)
claimed_name_item.setFont(make_claimed_font())
claimed_name_item.setToolTip('클릭하여 회원 마일리지 내역 보기')
self.sales_table.setItem(row, 5, claimed_name_item)
self.sales_table.setItem(row, COL['claimed_name'], claimed_name_item)
# 전화번호 (SQLite)
# 전화번호 (SQLite 마일리지)
claimed_phone_item = QTableWidgetItem(sale['claimed_phone'])
if sale['claimed_phone']:
claimed_phone_item.setForeground(QColor('#4CAF50'))
font = QFont()
font.setBold(True)
font.setUnderline(True) # 밑줄 추가로 클릭 가능 표시
claimed_phone_item.setFont(font)
claimed_phone_item.setForeground(CLAIMED_COLOR)
claimed_phone_item.setFont(make_claimed_font())
claimed_phone_item.setToolTip('클릭하여 회원 마일리지 내역 보기')
self.sales_table.setItem(row, 6, claimed_phone_item)
self.sales_table.setItem(row, COL['claimed_phone'], claimed_phone_item)
# 적립포인트 (SQLite)
claimed_points_item = QTableWidgetItem(f"{sale['claimed_points']:,}P" if sale['claimed_points'] > 0 else "")
# 적립포인트 (SQLite 마일리지)
points_text = f"{sale['claimed_points']:,}P" if sale['claimed_points'] > 0 else ""
claimed_points_item = QTableWidgetItem(points_text)
if sale['claimed_points'] > 0:
claimed_points_item.setForeground(QColor('#4CAF50'))
claimed_points_item.setForeground(CLAIMED_COLOR)
claimed_points_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
font = QFont()
font.setBold(True)
font.setUnderline(True) # 밑줄 추가로 클릭 가능 표시
claimed_points_item.setFont(font)
claimed_points_item.setFont(make_claimed_font())
claimed_points_item.setToolTip('클릭하여 회원 마일리지 내역 보기')
self.sales_table.setItem(row, 7, claimed_points_item)
self.sales_table.setItem(row, COL['claimed_points'], claimed_points_item)
# QR 발행 여부 (SQLite)
# QR 발행 여부
qr_status_item = QTableWidgetItem()
qr_status_item.setTextAlignment(Qt.AlignCenter)
if sale['qr_issued']:
qr_status_item.setText('')
qr_status_item.setForeground(QColor('#4CAF50'))
font = QFont()
font.setBold(True)
font.setPointSize(14)
qr_status_item.setFont(font)
qr_status_item.setForeground(CLAIMED_COLOR)
f = QFont()
f.setBold(True)
f.setPointSize(14)
qr_status_item.setFont(f)
qr_status_item.setToolTip('QR 발행 완료')
else:
qr_status_item.setText('-')
qr_status_item.setForeground(QColor('#BDBDBD'))
qr_status_item.setToolTip('QR 미발행')
self.sales_table.setItem(row, 8, qr_status_item)
self.sales_table.setItem(row, COL['qr_issued'], qr_status_item)
def on_query_error(self, error_msg):
"""DB 조회 에러 처리"""
@ -1003,8 +1027,36 @@ class POSSalesGUI(QMainWindow):
self.status_label.setStyleSheet('color: red; font-size: 12px; padding: 5px;')
QMessageBox.critical(self, '오류', f'QR 생성 실패:\n{message}')
# --- 설정 저장/로드 ---
def _load_settings(self):
try:
with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _save_settings(self):
try:
with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(self._gui_settings, f, ensure_ascii=False, indent=2)
except Exception:
pass
def _on_column_resized(self, index, old_size, new_size):
widths = [self.sales_table.columnWidth(i) for i in range(self.sales_table.columnCount())]
self._gui_settings['sales_column_widths'] = widths
def closeEvent(self, event):
"""종료 시 정리"""
"""종료 시 정리 + 설정 저장"""
# 컬럼 폭 최종 저장
if hasattr(self, 'sales_table'):
widths = [self.sales_table.columnWidth(i) for i in range(self.sales_table.columnCount())]
self._gui_settings['sales_column_widths'] = widths
# 윈도우 위치/크기 저장
geo = self.geometry()
self._gui_settings['window_geometry'] = [geo.x(), geo.y(), geo.width(), geo.height()]
self._save_settings()
# 자동 새로고침 타이머 중지
if hasattr(self, 'refresh_timer'):
self.refresh_timer.stop()

View File

@ -121,6 +121,81 @@
margin-right: 2px;
}
/* 구매 품목 리스트 */
.items-section {
margin-bottom: 20px;
}
.items-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
cursor: pointer;
border: none;
background: none;
width: 100%;
color: #495057;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.2px;
}
.items-toggle .arrow {
transition: transform 0.2s ease;
font-size: 12px;
color: #adb5bd;
}
.items-toggle.open .arrow {
transform: rotate(180deg);
}
.items-list {
display: none;
border-top: 1px solid #f1f3f5;
}
.items-list.open {
display: block;
}
.item-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f8f9fa;
font-size: 13px;
}
.item-row:last-child {
border-bottom: none;
}
.item-name {
color: #495057;
flex: 1;
font-weight: 500;
letter-spacing: -0.2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.item-qty {
color: #868e96;
margin-right: 12px;
white-space: nowrap;
}
.item-price {
color: #212529;
font-weight: 600;
white-space: nowrap;
}
.form-section {
padding: 8px 0;
}
@ -426,15 +501,36 @@
<div class="points-badge">{{ "{:,}".format(token_info.claimable_points) }}P 적립</div>
</div>
{% if sale_items %}
<div class="items-section">
<button type="button" class="items-toggle" id="itemsToggle" onclick="toggleItems()">
<span>구매 품목 ({{ sale_items|length }}건)</span>
<span class="arrow"></span>
</button>
<div class="items-list" id="itemsList">
{% for item in sale_items %}
<div class="item-row">
<span class="item-name">{{ item.name }}</span>
<span class="item-qty">{{ item.qty }}개</span>
<span class="item-price">{{ "{:,}".format(item.total) }}원</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<form id="formClaim" class="form-section">
<div class="input-group">
<label for="phone">전화번호</label>
<div class="input-wrapper">
<div class="input-wrapper" style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 18px; font-weight: 600; color: #495057; white-space: nowrap; padding: 16px 0 16px 4px;">010 -</span>
<input type="tel" id="phone" name="phone"
placeholder="010-0000-0000"
pattern="[0-9-]*"
placeholder="0000-0000"
inputmode="numeric"
maxlength="9"
autocomplete="tel"
required>
required
style="flex: 1;">
</div>
</div>
@ -496,25 +592,36 @@
const claimFormDiv = document.getElementById('claimForm');
const successScreen = document.getElementById('successScreen');
// 전화번호 자동 하이픈
// 품목 토글
function toggleItems() {
const btn = document.getElementById('itemsToggle');
const list = document.getElementById('itemsList');
if (btn && list) {
btn.classList.toggle('open');
list.classList.toggle('open');
}
}
// 뒷번호 자동 하이픈 (010 고정)
const phoneInput = document.getElementById('phone');
phoneInput.addEventListener('input', function(e) {
let value = e.target.value.replace(/[^0-9]/g, '');
if (value.length <= 3) {
if (value.length <= 4) {
e.target.value = value;
} else if (value.length <= 7) {
e.target.value = value.slice(0, 3) + '-' + value.slice(3);
} else {
e.target.value = value.slice(0, 3) + '-' + value.slice(3, 7) + '-' + value.slice(7, 11);
e.target.value = value.slice(0, 4) + '-' + value.slice(4, 8);
}
});
// 포커스 시 자동으로 전화번호 필드로
phoneInput.focus();
// 폼 제출
form.addEventListener('submit', async function(e) {
e.preventDefault();
const phone = document.getElementById('phone').value.trim();
const phoneRaw = document.getElementById('phone').value.trim().replace(/[^0-9]/g, '');
const phone = '010-' + phoneRaw.slice(0, 4) + '-' + phoneRaw.slice(4, 8);
const name = document.getElementById('name').value.trim();
const privacyConsent = document.getElementById('privacyConsent').checked;

View File

@ -194,6 +194,64 @@
letter-spacing: -0.2px;
}
/* 품목 상세 */
.transaction-item.clickable {
cursor: pointer;
}
.item-detail {
display: none;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f1f3f5;
}
.item-detail.open {
display: block;
}
.item-detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
font-size: 13px;
}
.item-detail-name {
color: #495057;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.item-detail-qty {
color: #868e96;
margin-right: 10px;
white-space: nowrap;
}
.item-detail-price {
color: #212529;
font-weight: 600;
white-space: nowrap;
}
.item-detail-loading {
text-align: center;
color: #adb5bd;
font-size: 13px;
padding: 8px 0;
}
.item-detail-hint {
color: #adb5bd;
font-size: 11px;
margin-top: 4px;
}
/* 모바일 최적화 */
@media (max-width: 480px) {
.header {
@ -228,7 +286,8 @@
{% if transactions %}
<ul class="transaction-list">
{% for tx in transactions %}
<li class="transaction-item">
<li class="transaction-item {% if tx.transaction_id %}clickable{% endif %}"
{% if tx.transaction_id %}onclick="toggleDetail(this, '{{ tx.transaction_id }}')"{% endif %}>
<div class="transaction-header">
<div class="transaction-reason">
{% if tx.reason == 'CLAIM' %}
@ -246,7 +305,13 @@
{% if tx.description %}
<div class="transaction-desc">{{ tx.description }}</div>
{% endif %}
<div class="transaction-date">{{ tx.created_at }}</div>
<div class="transaction-date">
{{ tx.created_at }}
{% if tx.transaction_id %}
<span class="item-detail-hint">탭하여 품목 보기</span>
{% endif %}
</div>
<div class="item-detail" id="detail-{{ tx.transaction_id }}"></div>
</li>
{% endfor %}
</ul>
@ -258,5 +323,53 @@
{% endif %}
</div>
</div>
<script>
const detailCache = {};
async function toggleDetail(el, txId) {
const detail = document.getElementById('detail-' + txId);
if (!detail) return;
// 이미 열려있으면 닫기
if (detail.classList.contains('open')) {
detail.classList.remove('open');
return;
}
// 캐시에 있으면 바로 표시
if (detailCache[txId]) {
detail.innerHTML = detailCache[txId];
detail.classList.add('open');
return;
}
// 로딩 표시
detail.innerHTML = '<div class="item-detail-loading">품목 조회 중...</div>';
detail.classList.add('open');
try {
const res = await fetch('/admin/transaction/' + txId);
const data = await res.json();
if (data.success && data.items && data.items.length > 0) {
let html = '';
data.items.forEach(item => {
html += `<div class="item-detail-row">
<span class="item-detail-name">${item.name}</span>
<span class="item-detail-qty">${item.qty}개</span>
<span class="item-detail-price">${item.total.toLocaleString()}원</span>
</div>`;
});
detailCache[txId] = html;
detail.innerHTML = html;
} else {
detail.innerHTML = '<div class="item-detail-loading">품목 정보를 불러올 수 없습니다</div>';
}
} catch (e) {
detail.innerHTML = '<div class="item-detail-loading">조회 실패</div>';
}
}
</script>
</body>
</html>

View File

@ -149,11 +149,17 @@
<form method="GET" action="/my-page">
<div class="form-group">
<label for="phone">전화번호</label>
<input type="tel" id="phone" name="phone"
placeholder="010-0000-0000"
pattern="[0-9-]*"
autocomplete="tel"
required>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 18px; font-weight: 600; color: #495057; white-space: nowrap; padding: 16px 0 16px 4px;">010 -</span>
<input type="tel" id="phoneInput"
placeholder="0000-0000"
inputmode="numeric"
maxlength="9"
autocomplete="tel"
required
style="flex: 1;">
<input type="hidden" id="phone" name="phone">
</div>
</div>
<button type="submit" class="btn-submit">
@ -165,19 +171,26 @@
</div>
<script>
// 전화번호 자동 하이픈
const phoneInput = document.getElementById('phone');
// 뒷번호 자동 하이픈 (010 고정)
const phoneInput = document.getElementById('phoneInput');
const phoneHidden = document.getElementById('phone');
phoneInput.addEventListener('input', function(e) {
let value = e.target.value.replace(/[^0-9]/g, '');
if (value.length <= 3) {
if (value.length <= 4) {
e.target.value = value;
} else if (value.length <= 7) {
e.target.value = value.slice(0, 3) + '-' + value.slice(3);
} else {
e.target.value = value.slice(0, 3) + '-' + value.slice(3, 7) + '-' + value.slice(7, 11);
e.target.value = value.slice(0, 4) + '-' + value.slice(4, 8);
}
});
// 제출 시 010 합쳐서 hidden 필드에 전달
phoneInput.closest('form').addEventListener('submit', function() {
const raw = phoneInput.value.replace(/[^0-9]/g, '');
phoneHidden.value = '010' + raw;
});
phoneInput.focus();
</script>
</body>
</html>