diff --git a/.gitignore b/.gitignore index bf4c11c..31dedaa 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,6 @@ docker-compose.override.yml tmp/ *.tmp .claude/ + +# GUI settings (user-specific) +gui_settings.json diff --git a/backend/app.py b/backend/app.py index 7abfa9d..38df3e2 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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 diff --git a/backend/gui/pos_sales_gui.py b/backend/gui/pos_sales_gui.py index 03ced60..2f5eb1a 100644 --- a/backend/gui/pos_sales_gui.py +++ b/backend/gui/pos_sales_gui.py @@ -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() diff --git a/backend/templates/claim_form.html b/backend/templates/claim_form.html index d417bd4..83fc7b5 100644 --- a/backend/templates/claim_form.html +++ b/backend/templates/claim_form.html @@ -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 @@