# edited by glg

from PySide6.QtWidgets import (
    QWidget, QVBoxLayout, QLabel, QComboBox, QTableWidget, QTableWidgetItem,
    QPushButton, QHBoxLayout, QLineEdit, QSpinBox, QHeaderView, QDialog,
    QFormLayout, QDialogButtonBox, QCheckBox, QFrame, QAbstractItemView, QGridLayout,
    QCompleter, QPlainTextEdit, QRadioButton, QGroupBox, QMessageBox, QScrollArea,
    QSizePolicy,
)
from PySide6.QtCore import Qt, QEvent, QTimer, QStringListModel, Signal
from PySide6.QtGui import QShortcut, QKeySequence, QColor
from datetime import datetime
import logging
import re
import time
import textwrap

from pypos.core.utils.db_helper import parse_float_safely, parse_int_from_text, get_ppn_from_profile
from pypos.core.utils.ui_message_utils import sanitize_ui_message
from pypos.modules.penjualan.views.settlement_dialog_view import SettlementDialogView
from pypos.modules.penjualan.views.transaksi_info_pembayaran_panel_mixin import (
    TransaksiInfoPembayaranPanelMixin,
)
from pypos.modules.penjualan.views.transaksi_diskon_info_mixin import (
    TransaksiDiskonInfoMixin,
)
from pypos.modules.penjualan.common.transaksi_view_summary import TransaksiViewSummaryService
from pypos.core.utils.config_utils import read_config
from pypos.core.utils.path_utils import get_db_path
from pypos.core.utils.myhelper import set_editable_only_column
from pypos.core.utils.ui_scale_runtime import scale_ui_px

# upgraded: inherit base class

LOGGER = logging.getLogger(__name__)


class TransaksiPenjualanView(TransaksiDiskonInfoMixin, TransaksiInfoPembayaranPanelMixin, QWidget):
    # edited by glg
    # Bridge signal lintas-thread untuk update autocomplete dan settlement guard ke UI thread.
    autocomplete_payload_ready = Signal(int, object)
    settlement_payload_ready = Signal(int, object)

    def __init__(self, controller, user_info, db_path, parent_window=None):
        super().__init__(parent_window)
        self.controller = controller
        self.user_info = user_info
        self.db_path = db_path
        self.parent_window = parent_window
        self.produk_jenis = 'invoice'
        self.transaksi_view_summary_service = TransaksiViewSummaryService()
        self._autocomplete_payload_handler = None
        self._settlement_payload_handler = None
        self.autocomplete_payload_ready.connect(
            self._on_autocomplete_payload_ready,
            Qt.QueuedConnection,
        )
        self.settlement_payload_ready.connect(
            self._on_settlement_payload_ready,
            Qt.QueuedConnection,
        )
        self.init_ui()

    def showEvent(self, event):
        super().showEvent(event)
        # edited by glg
        # Aktifkan polling scanner hanya saat halaman transaksi terlihat.
        self._ensure_scanner_poll_timer_running()
        if hasattr(self, "controller") and hasattr(self.controller, "is_settlement_blocked"):
            if self.controller.is_settlement_blocked():
                if hasattr(self.controller, "nonaktifkan_form"):
                    self.controller.nonaktifkan_form()
                return
        self.force_enable_main_inputs()
        if hasattr(self, "cart_sku_input"):
            self.cart_sku_input.setFocus()
    # edited by glg
    def resizeEvent(self, event):
        super().resizeEvent(event)
        # Re-apply responsive cart table widths saat ukuran jendela berubah.
        try:
            self._apply_responsive_barang_table_layout()
        except (AttributeError, RuntimeError, TypeError):
            pass

    # edited by glg
    def hideEvent(self, event):
        self._stop_scanner_poll_timer()
        super().hideEvent(event)

    # edited by glg
    def closeEvent(self, event):
        self._stop_scanner_poll_timer()
        super().closeEvent(event)

    def keyPressEvent(self, event):
        if hasattr(self, "controller") and hasattr(self.controller, "process_scanner_key_event"):
            if self.controller.process_scanner_key_event(event):
                return
        if event.modifiers() == Qt.NoModifier and event.key() == Qt.Key_Escape:
            if hasattr(self, "cart_sku_input"):
                self.cart_sku_input.setFocus()
            return
        if event.modifiers() == Qt.ShiftModifier and event.key() == Qt.Key_Escape:

            if hasattr(self, "barang_input"):

                self.barang_input.setFocus()

            return

        if event.modifiers() == Qt.ShiftModifier and event.key() in (Qt.Key_Plus, Qt.Key_Equal):

            if hasattr(self, "cart_qty_input"):

                self.cart_qty_input.stepUp()

            return

        if event.modifiers() == Qt.ControlModifier and event.key() == Qt.Key_S:

            self.controller.simpan_transaksi()

        else:

            super().keyPressEvent(event)

    def _install_scanner_event_filter(self):

        widgets = [

            self,

            getattr(self, "cart_sku_input", None),

            getattr(self, "barang_input", None),

            getattr(self, "cart_qty_input", None),

        ]

        for widget in widgets:

            if widget is not None:

                widget.installEventFilter(self)

    def eventFilter(self, watched, event):

        if event is not None and event.type() == QEvent.KeyPress:

            if hasattr(self, "controller") and hasattr(self.controller, "process_scanner_key_event"):

                if self.controller.process_scanner_key_event(event):

                    return True

        return super().eventFilter(watched, event)

    # edited by glg
    def _read_int_config_value(self, config_map, key, default_value, reason_code):
        source = config_map if isinstance(config_map, dict) else {}
        raw_value = source.get(key, default_value)
        try:
            return int(raw_value)
        except (TypeError, ValueError) as exc:
            LOGGER.debug(f"[{reason_code}] key={key} invalid_value={raw_value!r} err={exc}")
            return int(default_value)

    def _start_scanner_poll_timer(self):
        if hasattr(self, "_scanner_poll_timer") and self._scanner_poll_timer:
            if not self._scanner_poll_timer.isActive():
                self._scanner_poll_timer.start()
            return
        self._scanner_poll_timer = QTimer(self)

        # edited by glg
        # Gunakan interval polling scanner yang lebih longgar agar event loop UI tidak over-polling.
        config = read_config()
        scanner_poll_interval_ms = self._read_int_config_value(
            config,
            "scanner_poll_interval_ms",
            120,
            reason_code="VIEW_SCANNER_POLL_INTERVAL_INVALID",
        )
        # edited by glg
        # Clamp minimum lebih aman untuk UI agar timer polling tidak terlalu agresif
        # dan tidak menambah jitter saat hover/click.
        scanner_poll_interval_ms = max(100, scanner_poll_interval_ms)
        # edited by glg
        # Adaptive polling untuk menekan overhead timer ketika scanner idle.
        self._scanner_poll_base_interval_ms = int(scanner_poll_interval_ms)
        self._scanner_poll_current_interval_ms = int(scanner_poll_interval_ms)
        self._scanner_poll_idle_max_interval_ms = max(
            int(self._scanner_poll_base_interval_ms),
            min(420, int(self._scanner_poll_base_interval_ms) * 3),
        )
        self._scanner_poll_idle_step_ms = max(20, int(self._scanner_poll_base_interval_ms // 4))
        self._scanner_poll_idle_promote_every = 5
        self._scanner_poll_idle_ticks = 0
        # edited by glg
        # Throttle probe runtime scanner agar tidak dipanggil setiap tick polling.
        runtime_probe_every = self._read_int_config_value(
            config,
            "scanner_runtime_probe_every_ticks",
            3,
            reason_code="VIEW_SCANNER_RUNTIME_PROBE_INVALID",
        )
        self._scanner_runtime_probe_every_ticks = max(1, runtime_probe_every)
        self._scanner_runtime_probe_counter = 0
        self._scanner_runtime_recent_cache = False
        self._scanner_runtime_last_probe_ms = 0.0
        runtime_cache_ttl_ms = self._read_int_config_value(
            config,
            "scanner_runtime_cache_ttl_ms",
            max(160, int(scanner_poll_interval_ms) * 2),
            reason_code="VIEW_SCANNER_RUNTIME_TTL_INVALID",
        )
        self._scanner_runtime_cache_ttl_ms = max(120, runtime_cache_ttl_ms)
        self._scanner_poll_timer.setInterval(scanner_poll_interval_ms)

        self._scanner_poll_timer.timeout.connect(self._poll_scanner_detector)

        self._scanner_poll_timer.start()

    # edited by glg
    def _ensure_scanner_poll_timer_running(self):
        timer = getattr(self, "_scanner_poll_timer", None)
        if timer is None:
            self._start_scanner_poll_timer()
            return
        if not timer.isActive():
            timer.start()
        # edited by glg
        # Pastikan interval kembali ke mode cepat saat halaman aktif lagi.
        self._reset_scanner_poll_adaptive_state(force_apply=True)

    # edited by glg
    def _stop_scanner_poll_timer(self):
        timer = getattr(self, "_scanner_poll_timer", None)
        if timer and timer.isActive():
            timer.stop()
        # edited by glg
        # Reset state adaptive agar sesi berikutnya dimulai dari interval dasar.
        self._reset_scanner_poll_adaptive_state(force_apply=False)

    # edited by glg
    def _apply_scanner_poll_interval(self, interval_ms):
        timer = getattr(self, "_scanner_poll_timer", None)
        if timer is None:
            return
        try:
            target = int(interval_ms)
        except (TypeError, ValueError) as exc:
            LOGGER.debug(f"[VIEW_SCANNER_APPLY_INTERVAL_INVALID] interval={interval_ms!r} err={exc}")
            target = int(getattr(self, "_scanner_poll_base_interval_ms", 120) or 120)
        target = max(100, target)
        current = int(getattr(self, "_scanner_poll_current_interval_ms", target) or target)
        if current == target:
            return
        timer.setInterval(target)
        self._scanner_poll_current_interval_ms = target

    # edited by glg
    def _reset_scanner_poll_adaptive_state(self, force_apply=True):
        self._scanner_poll_idle_ticks = 0
        self._scanner_runtime_probe_counter = 0
        base_interval = int(getattr(self, "_scanner_poll_base_interval_ms", 120) or 120)
        if bool(force_apply):
            self._apply_scanner_poll_interval(base_interval)
        else:
            self._scanner_poll_current_interval_ms = base_interval

    # edited by glg
    def _is_scanner_recent_activity(self):
        if not hasattr(self, "controller") or not hasattr(self.controller, "get_scanner_runtime_status"):
            return False

        now_ms = float(time.monotonic() * 1000.0)
        ttl_ms = float(getattr(self, "_scanner_runtime_cache_ttl_ms", 180.0) or 180.0)
        probe_every = max(1, int(getattr(self, "_scanner_runtime_probe_every_ticks", 3) or 3))
        probe_counter = int(getattr(self, "_scanner_runtime_probe_counter", 0) or 0) + 1
        last_probe_ms = float(getattr(self, "_scanner_runtime_last_probe_ms", 0.0) or 0.0)

        should_probe = probe_counter >= probe_every
        if ttl_ms > 0 and (now_ms - last_probe_ms) >= ttl_ms:
            should_probe = True
        if not should_probe and ttl_ms > 0 and (now_ms - last_probe_ms) <= ttl_ms:
            self._scanner_runtime_probe_counter = probe_counter
            return bool(getattr(self, "_scanner_runtime_recent_cache", False))

        self._scanner_runtime_probe_counter = 0
        scanner_recent = False
        try:
            runtime_payload = self.controller.get_scanner_runtime_status() or {}
            activity = runtime_payload.get("scanner_activity")
            if isinstance(activity, dict):
                scanner_recent = bool(activity.get("last_scan_recent"))
        except (AttributeError, RuntimeError, TypeError, ValueError) as exc:
            LOGGER.debug(f"[VIEW_SCANNER_RUNTIME_STATUS_ERROR] err={exc}")
            scanner_recent = False

        self._scanner_runtime_recent_cache = bool(scanner_recent)
        self._scanner_runtime_last_probe_ms = now_ms
        return bool(scanner_recent)

    def _poll_scanner_detector(self):
        # edited by glg
        # Skip polling saat halaman tidak terlihat untuk mengurangi overhead periodik.
        if not self.isVisible():
            return

        if not hasattr(self, "controller") or not hasattr(self.controller, "consume_pending_scanner_barcode"):

            return

        consumed = bool(self.controller.consume_pending_scanner_barcode())
        if consumed:
            self._reset_scanner_poll_adaptive_state(force_apply=True)
            return

        if self._is_scanner_recent_activity():
            self._reset_scanner_poll_adaptive_state(force_apply=True)
            return

        self._scanner_poll_idle_ticks = int(getattr(self, "_scanner_poll_idle_ticks", 0) or 0) + 1
        promote_every = max(1, int(getattr(self, "_scanner_poll_idle_promote_every", 5) or 5))
        if self._scanner_poll_idle_ticks < promote_every:
            return
        self._scanner_poll_idle_ticks = 0
        current_interval = int(getattr(self, "_scanner_poll_current_interval_ms", 120) or 120)
        idle_step = max(10, int(getattr(self, "_scanner_poll_idle_step_ms", 20) or 20))
        idle_max = max(current_interval, int(getattr(self, "_scanner_poll_idle_max_interval_ms", current_interval) or current_interval))
        next_interval = min(idle_max, current_interval + idle_step)
        self._apply_scanner_poll_interval(next_interval)

    def tampilkan_harga_second_monitor(self):

        from PySide6.QtWidgets import QApplication, QLabel, QWidget

        from PySide6.QtCore import Qt

        app = QApplication.instance()

        screens = app.screens()

        if len(screens) < 2:

            QMessageBox.information(self, "Monitor Tidak Tersedia", "Second monitor tidak terdeteksi.")

            return

        # Ambil data yang ingin ditampilkan

        total = self.total_label.text()

        customer = self.customer_combo.currentText() if hasattr(self, 'customer_combo') else ""

        # Buat window kecil di second monitor

        second_screen = screens[1]

        window = QWidget()

        window.setWindowTitle("Harga Konsumen")

        window.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.Tool)

        layout = QVBoxLayout()

        label_total = QLabel(f"<span style='font-size:32pt;font-weight:bold;color:green'>{total}</span>")

        label_customer = QLabel(f"<span style='font-size:18pt;'>{customer}</span>")

        layout.addWidget(label_total)

        layout.addWidget(label_customer)

        window.setLayout(layout)

        geo = second_screen.availableGeometry()
        # edited by glg
        # Ukuran second-monitor dibuat relatif terhadap resolusi layar kedua
        # agar tetap proporsional di monitor besar/kecil.
        target_w = max(320, int(float(geo.width()) * 0.30))
        target_h = max(180, int(float(geo.height()) * 0.24))
        window.resize(target_w, target_h)
        center_x = geo.x() + (geo.width() - target_w) // 2
        center_y = geo.y() + (geo.height() - target_h) // 2
        window.move(center_x, center_y)

        window.show()

    # edited by glg
    @staticmethod
    def _is_qt_widget_alive(widget):
        if widget is None:
            return False
        try:
            widget.parent()
            return True
        except RuntimeError:
            return False

    # edited by glg
    def _ensure_info_diskon_static_labels(self):
        if not hasattr(self, "info_diskon_layout"):
            return

        summary_label = getattr(self, "info_diskon_summary_label", None)
        if not self._is_qt_widget_alive(summary_label):
            summary_label = QLabel("Belum ada diskon aktif")
            summary_label.setProperty("class", "tpv-diskon-summary")
            summary_label.setWordWrap(True)
            self.info_diskon_summary_label = summary_label

        empty_label = getattr(self, "info_diskon_empty_label", None)
        if not self._is_qt_widget_alive(empty_label):
            empty_label = QLabel("Belum ada produk diskon aktif pada keranjang.")
            empty_label.setProperty("class", "tpv-diskon-empty")
            empty_label.setWordWrap(True)
            self.info_diskon_empty_label = empty_label

        if self.info_diskon_layout.indexOf(self.info_diskon_summary_label) < 0:
            self.info_diskon_layout.insertWidget(0, self.info_diskon_summary_label)
        if self.info_diskon_layout.indexOf(self.info_diskon_empty_label) < 0:
            self.info_diskon_layout.insertWidget(1, self.info_diskon_empty_label)

    # edited by glg
    def _reset_info_diskon_panel_state(self):
        if not hasattr(self, "info_diskon_layout"):
            return

        self._ensure_info_diskon_static_labels()
        static_labels = []
        summary_label = getattr(self, "info_diskon_summary_label", None)
        empty_label = getattr(self, "info_diskon_empty_label", None)
        if self._is_qt_widget_alive(summary_label):
            static_labels.append(summary_label)
        if self._is_qt_widget_alive(empty_label):
            static_labels.append(empty_label)

        while self.info_diskon_layout.count():
            item = self.info_diskon_layout.takeAt(0)
            widget = item.widget() if item else None
            if not widget or widget in static_labels:
                continue
            try:
                widget.setParent(None)
                widget.deleteLater()
            except RuntimeError:
                continue

        for widget in static_labels:
            if self._is_qt_widget_alive(widget) and self.info_diskon_layout.indexOf(widget) < 0:
                self.info_diskon_layout.addWidget(widget)

        if hasattr(self, "diskon_groupbox_map"):
            self.diskon_groupbox_map.clear()

        self._update_diskon_summary_indicator()

    # edited by glg
    def _reset_form_ui_only(self):

        self.table_barang.setRowCount(0)

        self.diskon_input.setValue(0)

        self.total_label.setText("0")

        self.ppn.setText("")

        self.total_bayar.setText("")

        self.customer_combo.setCurrentIndex(0)

        self.barang_input.clear()

        # Bersihkan info diskon tanpa menghapus label statis indikator.
        self._reset_info_diskon_panel_state()

    def handle_clear_cart(self):

        if hasattr(self, "controller") and hasattr(self.controller, "handle_clear_cart"):

            self.controller.handle_clear_cart()

        else:

            self.reset_form()

    def init_ui(self):

        main_layout = QVBoxLayout()

        main_layout.setSpacing(6)

        main_layout.setContentsMargins(6, 4, 6, 6)

        shortcut_table = self.create_shortcut_table()

        main_layout.addWidget(shortcut_table)

        customer_layout = self.build_customer_section()

        main_layout.addLayout(customer_layout)

        barang_layout = self.build_barang_input_section()

        main_layout.addLayout(barang_layout)

        tabel_dan_diskon_layout = self.build_barang_table_and_diskon_info()

        main_layout.addLayout(tabel_dan_diskon_layout)

        bawah_layout = self.build_ringkasan()

        main_layout.addLayout(bawah_layout)

        self.setLayout(main_layout)

        self.setup_shortcuts()

        self.force_enable_main_inputs()

        self._sedang_proses_pembayaran = False

        if hasattr(self, "button_simpan"):
            self.button_simpan.setEnabled(False)
        if hasattr(self, "button_simpan_draft"):
            self.button_simpan_draft.setEnabled(True)

        self._install_scanner_event_filter()
        self._start_scanner_poll_timer()

    def build_customer_section(self):

        customer_layout = QHBoxLayout()

        # edited by glg
        # Rapatkan jarak label dan input customer agar tidak terlihat "lompat"
        # ke kanan pada resolusi kecil.
        customer_layout.setSpacing(4)
        customer_layout.setContentsMargins(0, 0, 0, 0)

        label_customer = QLabel("Customer:")

        label_customer.setProperty("class", "tpv-section-label")
        # edited by glg
        # Gunakan minimum width agar label tetap responsif.
        label_customer.setMinimumWidth(80)
        # edited by glg
        # Jangan izinkan label menyerap ruang horizontal kosong.
        label_customer.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)

        self.customer_combo = QComboBox()

        self.customer_combo.setMinimumHeight(34)

        self.customer_combo.setFocusPolicy(Qt.StrongFocus)

        self.customer_combo.setProperty("class", "tpv-customer-input")

        self.customer_combo.setEditable(True)

        self.customer_combo.setInsertPolicy(QComboBox.NoInsert)

        self.customer_combo.setCompleter(None)

        self.customer_completer = QCompleter()

        self.customer_completer.setCaseSensitivity(Qt.CaseInsensitive)

        self.customer_completer.setFilterMode(Qt.MatchContains)

        self.customer_completer.setCompletionMode(QCompleter.PopupCompletion)

        self.customer_combo.setCompleter(self.customer_completer)
        # edited by glg
        # Input customer mengisi sisa ruang di kanan label.
        self.customer_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)

        line_edit = self.customer_combo.lineEdit()

        if line_edit:

            line_edit.setPlaceholderText("Ketik nama customer...")

            original_mouse_press = line_edit.mousePressEvent

            def custom_mouse_press(event):

                original_mouse_press(event)

                line_edit.selectAll()

            line_edit.mousePressEvent = custom_mouse_press

            original_focus = line_edit.focusInEvent

            def custom_focus(event):

                original_focus(event)

                line_edit.selectAll()

            line_edit.focusInEvent = custom_focus

        customer_layout.addWidget(label_customer)

        customer_layout.addWidget(self.customer_combo, 1)

        return customer_layout

    def build_barang_input_section(self):
        self.popup_checkbox = QCheckBox("Qty Popup")
        # edited by glg
        # Hindari ukuran fixed agar elemen mengikuti lebar dinamis.
        self.popup_checkbox.setMinimumWidth(110)
        self.popup_checkbox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        self.popup_checkbox.setProperty("class", "tpv-popup-checkbox")
        self.popup_checkbox.setChecked(False)

        self.cart_sku_input = QLineEdit()
        # edited by glg
        # Minimum width agar tetap nyaman tanpa mengunci ukuran.
        self.cart_sku_input.setMinimumWidth(180)
        self.cart_sku_input.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        self.cart_sku_input.setMinimumHeight(36)
        self.cart_sku_input.setPlaceholderText("SKU / Barcode")
        self.cart_sku_input.setProperty("class", "tpv-cart-sku-input")
        self.cart_sku_input.returnPressed.connect(self.submit_cart_from_sku)

        self.barang_input = QLineEdit()
        self.barang_input.setMinimumHeight(36)
        self.barang_input.setFocusPolicy(Qt.StrongFocus)
        self.barang_input.setPlaceholderText("Cari nama produk / barcode")
        self.barang_input.setProperty("class", "tpv-barang-input")
        self.barang_input.returnPressed.connect(self.on_enter_pressed)

        self.cart_qty_input = QSpinBox()
        self.cart_qty_input.setMinimum(1)
        self.cart_qty_input.setMaximum(99)
        self.cart_qty_input.setValue(1)
        # edited by glg
        # Minimum width agar tetap proporsional pada resize.
        self.cart_qty_input.setMinimumWidth(96)
        self.cart_qty_input.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        self.cart_qty_input.setMinimumHeight(36)
        self.cart_qty_input.setProperty("class", "tpv-cart-qty-input")

        self._setup_barang_autocomplete()

        layout = QGridLayout()
        layout.setHorizontalSpacing(8)
        layout.setVerticalSpacing(4)
        layout.setContentsMargins(0, 0, 0, 0)

        layout.addWidget(self._build_cart_header_label("Mode Qty"), 0, 0)
        layout.addWidget(
            self._build_cart_header_label("<span style='color:#cc0000'>(ESC)</span><br>SKU"),
            0,
            1,
        )
        layout.addWidget(
            self._build_cart_header_label("<span style='color:#cc0000'>(SHIFT+ESC)</span><br>ITEMS"),
            0,
            2,
        )
        layout.addWidget(
            self._build_cart_header_label("<span style='color:#cc0000'>(SHIFT+PLUS)</span><br>QTY"),
            0,
            3,
        )

        layout.addWidget(self.popup_checkbox, 1, 0)
        layout.addWidget(self.cart_sku_input, 1, 1)
        layout.addWidget(self.barang_input, 1, 2)
        layout.addWidget(self.cart_qty_input, 1, 3)
        layout.setColumnStretch(2, 1)
        return layout

    def _build_cart_header_label(self, text, width=None):

        label = QLabel(text)

        if width is not None:

            # edited by glg
            # Gunakan minimum width agar header tetap fleksibel.
            label.setMinimumWidth(width)

        label.setAlignment(Qt.AlignCenter)

        label.setProperty("class", "tpv-cart-header-label")

        label.setMinimumHeight(30)

        return label

    # edited by glg
    @staticmethod
    def _get_barang_table_headers():
        return [
            "ID",
            "SKU",
            "ITEMS",
            "H.JUAL",
            "QTY",
            "UOM",
            "TOTAL HARGA",
            "DISC (%)",
            "AKSI",
            "produk_jenis",
            "DISC (RP)",
            "H.SATUAN",
        ]

    # edited by glg
    def _create_barang_table_widget(self):
        self.table_barang = QTableWidget(0, 12)
        self.table_barang.setHorizontalHeaderLabels(self._get_barang_table_headers())
        self.table_barang.setColumnHidden(9, True)
        self.table_barang.setMinimumHeight(200)
        self.table_barang.setProperty("class", "tpv-barang-table")
        return self.table_barang.horizontalHeader()

    # edited by glg
    def _configure_barang_table_header(self, header):
        header.setStretchLastSection(False)
        header.setMinimumSectionSize(scale_ui_px(34, min_value=24))
        header.setMinimumHeight(34)
        header.setDefaultAlignment(Qt.AlignCenter)
        header.setSectionsMovable(True)

    # edited by glg
    def _apply_responsive_barang_table_layout(self):
        if not hasattr(self, "table_barang") or self.table_barang is None:
            return

        header = self.table_barang.horizontalHeader()
        if header is None:
            return

        self.table_barang.setColumnHidden(0, True)
        self.table_barang.setColumnHidden(9, True)

        viewport_width = int(self.table_barang.viewport().width() or 0)
        if viewport_width <= 0:
            return

        # edited by glg
        # Base width (design) + minimum width untuk mode resolusi kecil.
        base_widths = {
            1: scale_ui_px(120, min_value=70),  # SKU
            2: scale_ui_px(280, min_value=140),  # ITEMS (stretch)
            3: scale_ui_px(92, min_value=62),  # H.JUAL
            4: scale_ui_px(62, min_value=44),  # QTY
            5: scale_ui_px(62, min_value=44),  # UOM
            6: scale_ui_px(118, min_value=74),  # TOTAL HARGA
            7: scale_ui_px(78, min_value=52),  # DISC (%)
            8: scale_ui_px(82, min_value=58),  # AKSI
            10: scale_ui_px(96, min_value=62),  # DISC (RP)
            11: scale_ui_px(96, min_value=62),  # H.SATUAN
        }
        min_widths = {
            1: scale_ui_px(78, min_value=54),
            2: scale_ui_px(170, min_value=120),
            3: scale_ui_px(66, min_value=48),
            4: scale_ui_px(48, min_value=36),
            5: scale_ui_px(48, min_value=36),
            6: scale_ui_px(86, min_value=60),
            7: scale_ui_px(60, min_value=44),
            8: scale_ui_px(60, min_value=44),
            10: scale_ui_px(66, min_value=48),
            11: scale_ui_px(66, min_value=48),
        }

        visible_cols = {1, 2, 3, 4, 5, 6, 7, 8, 10, 11}
        optional_hide_order = [11, 10, 5, 7]

        def required_min_width():
            fixed_min = sum(min_widths[c] for c in visible_cols if c != 2)
            return fixed_min + min_widths[2]

        # edited by glg
        # Mode compact: sembunyikan kolom tambahan bertahap agar tabel
        # tetap muat tanpa terpotong di resolusi rendah.
        for col in optional_hide_order:
            if required_min_width() <= viewport_width:
                break
            visible_cols.discard(col)

        # Fallback ekstrem: jika masih tidak cukup, kecilkan semua minimum.
        req_after_hide = required_min_width()
        if req_after_hide > viewport_width:
            squeeze_ratio = max(0.6, float(viewport_width) / float(req_after_hide))
            for key in min_widths:
                min_widths[key] = max(32, int(min_widths[key] * squeeze_ratio))

        all_data_cols = [1, 2, 3, 4, 5, 6, 7, 8, 10, 11]
        for col in all_data_cols:
            self.table_barang.setColumnHidden(col, col not in visible_cols)

        for col in visible_cols:
            if col == 2:
                header.setSectionResizeMode(col, QHeaderView.Stretch)
            else:
                header.setSectionResizeMode(col, QHeaderView.Fixed)

        fixed_cols = [c for c in visible_cols if c != 2]
        fixed_target_total = sum(base_widths[c] for c in fixed_cols)
        available_for_fixed = max(0, viewport_width - min_widths[2])
        if fixed_target_total > 0:
            shrink_ratio = min(1.0, float(available_for_fixed) / float(fixed_target_total))
        else:
            shrink_ratio = 1.0

        for col in fixed_cols:
            target_width = int(base_widths[col] * shrink_ratio)
            self.table_barang.setColumnWidth(col, max(min_widths[col], target_width))

    # edited by glg
    def _configure_barang_table_columns(self):
        self.table_barang.setColumnHidden(0, True)
        self.table_barang.verticalHeader().setDefaultSectionSize(
            scale_ui_px(34, min_value=24)
        )
        self._apply_responsive_barang_table_layout()

    # edited by glg
    def _bind_barang_table_signals(self):
        self.table_barang.cellChanged.connect(self.on_table_cell_changed)
        self.table_barang.cellDoubleClicked.connect(self.on_table_cell_double_clicked)

    # edited by glg
    def _reorder_barang_table_columns(self, header):
        action_visual_index = header.visualIndex(8)
        header.moveSection(action_visual_index, header.count() - 1)
        action_visual_index = header.visualIndex(8)
        total_harga_visual_index = header.visualIndex(6)
        header.moveSection(total_harga_visual_index, max(action_visual_index - 1, 0))
        header.setSectionsMovable(False)

    # edited by glg
    def _setup_info_diskon_panel(self, info_diskon_min_height):
        self.info_diskon_group = QGroupBox("Info Diskon")
        self.info_diskon_layout = QVBoxLayout()
        self.info_diskon_layout.setSpacing(4)
        self.info_diskon_layout.setAlignment(Qt.AlignTop)
        self.info_diskon_layout.setContentsMargins(6, 6, 6, 6)

        self.info_diskon_summary_label = QLabel("Belum ada diskon aktif")
        self.info_diskon_summary_label.setProperty("class", "tpv-diskon-summary")
        self.info_diskon_summary_label.setWordWrap(True)
        self.info_diskon_layout.addWidget(self.info_diskon_summary_label)

        self.info_diskon_empty_label = QLabel(
            "Belum ada produk diskon aktif pada keranjang."
        )
        self.info_diskon_empty_label.setProperty("class", "tpv-diskon-empty")
        self.info_diskon_empty_label.setWordWrap(True)
        self.info_diskon_layout.addWidget(self.info_diskon_empty_label)

        self.info_diskon_container = QWidget()
        self.info_diskon_container.setLayout(self.info_diskon_layout)

        self.info_diskon_scroll = QScrollArea()
        self.info_diskon_scroll.setWidgetResizable(True)
        self.info_diskon_scroll.setFrameShape(QFrame.NoFrame)
        self.info_diskon_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.info_diskon_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.info_diskon_scroll.setWidget(self.info_diskon_container)

        info_diskon_group_layout = QVBoxLayout()
        info_diskon_group_layout.setContentsMargins(4, 4, 4, 4)
        info_diskon_group_layout.addWidget(self.info_diskon_scroll)
        self.info_diskon_group.setLayout(info_diskon_group_layout)
        # edited by glg
        # Hindari ukuran fixed agar panel diskon mengikuti ruang dinamis.
        # edited by glg
        # Panel diskon dibuat lebih fleksibel agar tidak memakan ruang vertikal
        # berlebihan saat resolusi layar kecil.
        resolved_diskon_min_h = max(84, int(info_diskon_min_height or 0))
        self.info_diskon_group.setMinimumHeight(resolved_diskon_min_h)
        # edited by glg
        # Izinkan info diskon mengembang vertikal untuk mengisi gap sampai
        # menyentuh panel info pembayaran.
        self.info_diskon_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
        self.info_diskon_group.setProperty("class", "tpv-info-diskon")
        self._update_diskon_summary_indicator()

    # edited by glg
    def _setup_critical_action_panel(self):
        self.critical_action_group = QGroupBox("Aksi Kasir")
        self.critical_action_group.setProperty("class", "tpv-critical-action-group")

        actions_layout = QHBoxLayout()
        # edited by glg
        # Kompres densitas panel aksi agar tidak terlalu dominan.
        actions_layout.setContentsMargins(6, 8, 6, 6)
        actions_layout.setSpacing(6)

        self.button_simpan = QPushButton("Bayar (F8)")
        self.button_simpan.setProperty("class", "tpv-critical-primary")
        self.button_simpan.setMinimumHeight(34)
        self.button_simpan.clicked.connect(self.handle_shortcut_pembayaran)
        self.button_simpan.setToolTip("Aksi utama pembayaran transaksi aktif.")

        self.button_simpan_draft = QPushButton("Simpan Draft (F9)")
        self.button_simpan_draft.setProperty("class", "tpv-critical-secondary")
        self.button_simpan_draft.setMinimumHeight(34)
        self.button_simpan_draft.clicked.connect(self.controller.buka_penyimpanan_dialog)
        self.button_simpan_draft.setToolTip("Simpan transaksi sementara ke daftar pre-order.")

        # edited by glg
        # Proporsi tombol dibuat lebih seimbang agar visual tidak berat sebelah.
        actions_layout.addWidget(self.button_simpan, 1)
        actions_layout.addWidget(self.button_simpan_draft, 1)

        self.critical_action_group.setLayout(actions_layout)
        # edited by glg
        # Hindari fixed vertical policy agar title group tidak terpotong saat
        # runtime scaling mengecilkan tinggi tombol.
        self.critical_action_group.setMinimumHeight(72)
        self.critical_action_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)

    # edited by glg
    def _build_right_info_panel(self):
        right_panel_layout = QVBoxLayout()
        right_panel_layout.setSpacing(4)
        right_panel_layout.setContentsMargins(0, 0, 0, 0)
        # edited by glg
        # Urutan panel kanan:
        # 1) Info Diskon
        # 2) Info Pembayaran
        # 3) Aksi Kasir
        # edited by glg
        # Info diskon diberi stretch agar mengisi ruang kosong vertikal.
        right_panel_layout.addWidget(self.info_diskon_group, 1)
        right_panel_layout.addWidget(self.info_pembayaran_group, 0)
        right_panel_layout.addWidget(self.critical_action_group, 0)

        right_panel = QWidget()
        right_panel.setLayout(right_panel_layout)
        right_panel.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
        # edited by glg
        # Jaga lebar minimum panel kanan agar angka total tidak terpotong.
        right_panel.setMinimumWidth(340)
        return right_panel

    def build_barang_table_and_diskon_info(self):
        header = self._create_barang_table_widget()
        self._configure_barang_table_header(header)
        self._configure_barang_table_columns()
        self._bind_barang_table_signals()
        self._reorder_barang_table_columns(header)

        # edited by glg
        # Rasio minimum panel kanan: diskon lebih ringkas, pembayaran lebih
        # dominan agar nominal total selalu terlihat.
        info_diskon_min_height = 90
        # edited by glg
        # Tinggi dasar panel pembayaran agar area scroll tidak terlalu kecil.
        info_pembayaran_min_height = 170
        self._setup_critical_action_panel()
        self._setup_info_diskon_panel(info_diskon_min_height)
        self._setup_info_pembayaran_panel(
            info_pembayaran_min_height=info_pembayaran_min_height,
        )
        right_panel = self._build_right_info_panel()

        layout = QHBoxLayout()
        layout.addWidget(self.table_barang, stretch=2)
        layout.addWidget(right_panel, stretch=1)
        return layout

    def build_ringkasan(self):

        ringkasan_widget = QWidget(self)

        ringkasan_layout = QFormLayout(ringkasan_widget)

        ringkasan_widget.setMaximumWidth(250)

        ringkasan_widget.setVisible(False)

        self.diskon_input = QSpinBox(ringkasan_widget)

        self.diskon_input.setSuffix(" %")

        self.diskon_input.setMaximum(100)

        self.diskon_input.valueChanged.connect(self.update_ringkasan)

        self.ppn = QLineEdit(ringkasan_widget)

        self.ppn.setReadOnly(True)

        self.total_bayar = QLineEdit(ringkasan_widget)

        self.total_bayar.setReadOnly(True)

        bawah_layout = QHBoxLayout()
        bawah_layout.addStretch()
        return bawah_layout

    def force_enable_main_inputs(self):

        if hasattr(self, "controller") and hasattr(self.controller, "is_settlement_blocked"):

            if self.controller.is_settlement_blocked():

                return

        try:

            self.customer_combo.setEnabled(True)

            self.cart_sku_input.setEnabled(True)

            self.barang_input.setEnabled(True)

            self.cart_qty_input.setEnabled(True)

            self.popup_checkbox.setEnabled(True)

        except (AttributeError, RuntimeError, TypeError) as e:

            LOGGER.debug(f"[VIEW_FORCE_ENABLE_MAIN_INPUTS_ERROR] {e}")

    def setup_shortcuts(self):

        """Setup semua keyboard shortcuts untuk transaksi penjualan"""

        # F1: Second Monitor

        QShortcut(QKeySequence("F1"), self).activated.connect(self.tampilkan_harga_second_monitor)

        # F2: History

        QShortcut(QKeySequence("F2"), self).activated.connect(self.handle_f2_history)

        # F3: Customer

        QShortcut(QKeySequence("F3"), self).activated.connect(self.controller.buka_customer_dialog)

        # F4: Clear Cart (dengan validasi pre order)

        QShortcut(QKeySequence("F4"), self).activated.connect(self.handle_clear_cart)

        # F6: Pembatalan

        QShortcut(QKeySequence("F6"), self).activated.connect(self.controller.pembatalan_transaksi)

        # F8: Pembayaran (dengan proteksi)

        QShortcut(QKeySequence("F8"), self).activated.connect(self.handle_shortcut_pembayaran)

        # F9: Simpan Transaksi

        QShortcut(QKeySequence("F9"), self).activated.connect(self.controller.buka_penyimpanan_dialog)

        # F10: Buka PreOrder

        QShortcut(QKeySequence("F10"), self).activated.connect(self.controller.load_transaksi_tersimpan)

        # F11: Full Screen

        QShortcut(QKeySequence("F11"), self).activated.connect(self.handle_fullscreen)

        QShortcut(QKeySequence("F12"), self).activated.connect(self.handle_home_penjualan)

        # Ctrl+D: Reprint Last Struk

        QShortcut(QKeySequence("Ctrl+D"), self).activated.connect(self.controller.cetak_struk_terakhir)

        # Ctrl+F7: Settlement

        QShortcut(QKeySequence("Ctrl+F7"), self).activated.connect(self.buka_modal_settlement)

        # Ctrl+P: Cetak Struk

        QShortcut(QKeySequence("Ctrl+P"), self).activated.connect(self.controller.cetak_struk_terakhir)

    def handle_f2_history(self):

        """Handler untuk F2 - History"""

        if self.parent_window and hasattr(self.parent_window, "buka_page_history"):

            self.parent_window.buka_page_history()

            return

        if hasattr(self, "controller") and hasattr(self.controller, "buka_history_penjualan"):
            self.controller.buka_history_penjualan()

    def handle_shortcut_pembayaran(self):

        """Handler untuk F8 dan tombol Simpan Transaksi dengan proteksi"""

        LOGGER.debug("DEBUG: handle_shortcut_pembayaran dipanggil")

        LOGGER.debug(f"  - Jumlah barang: {self.table_barang.rowCount()}")

        LOGGER.debug(f"  - Sedang proses: {getattr(self, '_sedang_proses_pembayaran', False)}")

        LOGGER.debug(f"  - Tombol enabled: {self.button_simpan.isEnabled()}")

        # Cek kondisi

        if self.table_barang.rowCount() == 0:

            QMessageBox.warning(

                self,

                "Keranjang Kosong",

                "Tidak ada barang di keranjang.\nSilakan tambahkan barang terlebih dahulu sebelum pembayaran."

            )

            LOGGER.debug("  Keranjang kosong, abaikan")

            return

        if getattr(self, '_sedang_proses_pembayaran', False):

            LOGGER.debug("  Sedang proses pembayaran, abaikan")

            return

        LOGGER.debug("  Lanjut ke buka_dialog_pembayaran")

        self.controller.buka_dialog_pembayaran()

    def handle_fullscreen(self):

        """Handler untuk F11 - Toggle fullscreen pada parent window"""

        if self.parent_window:

            if self.parent_window.isFullScreen():

                self.parent_window.showNormal()

            else:

                self.parent_window.showFullScreen()

        else:

            # Fallback ke self jika tidak ada parent window

            if self.isFullScreen():

                self.showNormal()

            else:

                self.showFullScreen()

    def handle_home_penjualan(self):

        if self.parent_window and hasattr(self.parent_window, "buka_penjualan"):

            self.parent_window.buka_penjualan()

            return

        if hasattr(self, "cart_sku_input"):

            self.cart_sku_input.setFocus()

    def on_table_cell_changed(self, row, column):

        self.controller.handle_jumlah_berubah(row, column)

    def on_table_cell_double_clicked(self, row, column):

        if column not in (1, 2):

            return

        item = self.table_barang.item(row, column)

        if not item:

            return

        text = str(item.text() or "").strip()

        if not text:

            return

        from PySide6.QtWidgets import QApplication

        app = QApplication.instance()

        if app:

            app.clipboard().setText(text)

        label = "SKU" if column == 1 else "Nama produk"

        if hasattr(self, "show_toast"):

            self.show_toast(f"{label} berhasil disalin", duration_ms=1200, level="success")

    def set_customer_by_id(self, customer_id):

        for index in range(self.customer_combo.count()):

            if self.customer_combo.itemData(index) == customer_id:

                self.customer_combo.setCurrentIndex(index)

                break

    def buka_modal_settlement(self):

        LOGGER.debug("DEBUG: buka_modal_settlement dipanggil, db_path: %s", self.db_path)

        if self.parent_window and hasattr(self.parent_window, "buka_page_settlement"):
            self.parent_window.buka_page_settlement()
            return
        if hasattr(self, "controller") and hasattr(self.controller, "open_settlement_dialog_from_view"):
            self.controller.open_settlement_dialog_from_view(parent_window=self)
            return
        LOGGER.warning("Controller belum menyediakan open_settlement_dialog_from_view")

    def create_shortcut_table(self):

        """Buat shortcut bar dengan tombol yang clickable (mouse & keyboard)"""

        shortcut_list = [

            ("F1", "Second Monitor", self.tampilkan_harga_second_monitor),

            ("F2", "History", self.handle_f2_history),

            ("F3", "Customer", self.controller.buka_customer_dialog),

            ("F4", "Clear Cart", self.handle_clear_cart),

            ("F6", "Pembatalan", self.controller.pembatalan_transaksi),

            ("F8", "Pembayaran", self.handle_shortcut_pembayaran),

            ("F9", "Simpan Transaksi", self.controller.buka_penyimpanan_dialog),

            ("F10", "Buka PreOrder", self.controller.load_transaksi_tersimpan),

            ("F11", "Full Screen", self.handle_fullscreen),

            ("Ctrl+D", "Cetak Struk Terakhir", self.controller.cetak_struk_terakhir),

            ("Ctrl+F7", "Settlement", self.buka_modal_settlement),

            ("F12", "Home Penjualan", self.handle_home_penjualan),

        ]

        rows = (len(shortcut_list) + 3) // 4

        shortcut_table = QTableWidget(rows, 4)

        shortcut_table.verticalHeader().setVisible(False)

        shortcut_table.horizontalHeader().setVisible(False)

        for idx, (key, desc, handler) in enumerate(shortcut_list):

            r, c = divmod(idx, 4)

            btn = QPushButton(f"{key} - {desc}")

            btn.setProperty("class", "tpv-shortcut-btn")
            if key in {"F8", "F9", "Ctrl+F7"}:
                btn.setProperty("critical", True)

            btn.setCursor(Qt.PointingHandCursor)

            btn.setFocusPolicy(Qt.NoFocus)  # Jangan ambil focus dari input fields

            if key == "Ctrl+F7" and desc == "Settlement":

                btn.setProperty("always_enabled", True)

                # Simpan referensi untuk akses mudah

                self.settlement_button = btn

            # Connect ke handler function

            btn.clicked.connect(handler)

            # Masukkan button ke table cell

            shortcut_table.setCellWidget(r, c, btn)

        shortcut_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)

        shortcut_table.setFocusPolicy(Qt.NoFocus)

        shortcut_table.setSelectionMode(QAbstractItemView.NoSelection)

        shortcut_table.setShowGrid(False)

        # Set tinggi row yang pas untuk button

        base_shortcut_row_h = 38
        # edited by glg
        # Selaraskan tinggi row dengan runtime DPI scale agar tidak terjadi
        # mismatch terhadap tinggi kontainer shortcut pada resolusi kecil.
        scaled_shortcut_row_h = scale_ui_px(base_shortcut_row_h, min_value=24)
        for row in range(rows):

            shortcut_table.setRowHeight(row, scaled_shortcut_row_h)

        # edited by glg
        # Kunci tinggi shortcut table berbasis nilai dasar (unscaled) agar
        # runtime scaler hanya mengaplikasikan skala sekali.
        # Tambah buffer kecil untuk frame/style agar tidak memicu scroll.
        shortcut_table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        shortcut_table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        shortcut_total_h_base = (base_shortcut_row_h * rows) + 12
        shortcut_table.setFixedHeight(shortcut_total_h_base)
        shortcut_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)

        return shortcut_table

    def reorder_info_diskon(self):

        if not hasattr(self, "info_diskon_layout"):

            return

        self._ensure_info_diskon_static_labels()

        items = []

        for i in range(self.info_diskon_layout.count()):

            item = self.info_diskon_layout.itemAt(i)

            widget = item.widget() if item else None

            if not widget:

                continue
            if widget in {
                getattr(self, "info_diskon_summary_label", None),
                getattr(self, "info_diskon_empty_label", None),
            }:
                continue

            has_discount = bool(widget.property("has_discount"))

            items.append((0 if has_discount else 1, i, widget))

        if not items:

            self._update_diskon_summary_indicator()
            return

        while self.info_diskon_layout.count():

            self.info_diskon_layout.takeAt(0)

        if self._is_qt_widget_alive(getattr(self, "info_diskon_summary_label", None)):
            self.info_diskon_layout.addWidget(self.info_diskon_summary_label)
        if self._is_qt_widget_alive(getattr(self, "info_diskon_empty_label", None)):
            self.info_diskon_layout.addWidget(self.info_diskon_empty_label)

        for _, _, widget in sorted(items, key=lambda x: (x[0], x[1])):

            self.info_diskon_layout.addWidget(widget)
        self._update_diskon_summary_indicator()

    # edited by glg
    @staticmethod
    def _truncate_text(text, max_length=62):
        raw = str(text or "").strip()
        if len(raw) <= int(max_length):
            return raw
        return f"{raw[:max(0, int(max_length) - 3)].rstrip()}..."

    # edited by glg
    def _compact_radio_text(self, text, *, max_lines=2, max_chars_per_line=54, truncate=True):
        payload = str(text or "").strip()
        if not payload:
            return "-"
        normalized_lines = []
        for raw_line in payload.splitlines():
            line = str(raw_line or "").strip()
            if not line:
                continue
            if truncate:
                normalized_lines.append(self._truncate_text(line, max_length=max_chars_per_line))
            else:
                if max_chars_per_line:
                    wrapped = textwrap.wrap(
                        line,
                        width=int(max_chars_per_line),
                        break_long_words=True,
                        break_on_hyphens=False,
                    )
                    normalized_lines.extend(wrapped or [line])
                else:
                    normalized_lines.append(line)
        if not normalized_lines:
            return "-"
        if truncate:
            compact = normalized_lines[: max(1, int(max_lines or 1))]
            if len(normalized_lines) > len(compact):
                compact[-1] = f"{compact[-1]} ..."
            return "\n".join(compact)
        return "\n".join(normalized_lines)

    # edited by glg
    def _build_diskon_group_title(self, id_barang, nama_barang):
        nama = self._truncate_text(str(nama_barang or "-"), max_length=34)
        return f"{nama} (ID {str(id_barang or '-').strip() or '-'})"

    # edited by glg
    def _update_diskon_summary_indicator(self):
        self._ensure_info_diskon_static_labels()
        if not hasattr(self, "diskon_groupbox_map"):
            self.diskon_groupbox_map = {}
        active_count = len(list(self.diskon_groupbox_map.keys()))
        if self._is_qt_widget_alive(getattr(self, "info_diskon_summary_label", None)):
            if active_count <= 0:
                self.info_diskon_summary_label.setText("Belum ada diskon aktif")
            elif active_count == 1:
                self.info_diskon_summary_label.setText("1 produk memiliki diskon aktif")
            else:
                self.info_diskon_summary_label.setText(
                    f"{active_count} produk memiliki diskon aktif"
                )
        if self._is_qt_widget_alive(getattr(self, "info_diskon_empty_label", None)):
            self.info_diskon_empty_label.setVisible(active_count <= 0)

    def populate_customer_combo(self, customers):

        customer_names = []

        self.customer_combo.setUpdatesEnabled(False)

        self.customer_combo.blockSignals(True)

        try:

            self.customer_combo.clear()

            for c in customers:

                display_name = c["nama"]

                if display_name.lower() == "tunai":

                    display_name = "Customer Umum (Retail/Walk-in)"

                self.customer_combo.addItem(display_name, c["id"])

                customer_names.append(display_name)

        finally:

            self.customer_combo.blockSignals(False)

            self.customer_combo.setUpdatesEnabled(True)

        if hasattr(self, 'customer_completer'):

            model = QStringListModel(customer_names, self.customer_completer)

            self.customer_completer.setModel(model)

    def pilih_barang(self, text=None):

        # Jika dipanggil via enter (returnPressed), ambil dari input

        if text is None:

            text = self.barang_input.text()

        self.barang_input.clear()

        self.controller.proses_pilih_barang(text)

    def set_barang_autocomplete(self, barang_list):

        self._setup_barang_autocomplete()

        suggestions = [str(v or "").strip() for v in (barang_list or []) if str(v or "").strip()]

        self._barang_suggestion_model.setStringList(suggestions)

        if suggestions and self.barang_input.hasFocus():

            self.completer.complete()

    # edited by glg
    def set_autocomplete_payload_handler(self, handler):

        self._autocomplete_payload_handler = handler

    # edited by glg
    def emit_autocomplete_payload(self, request_id, payload):

        try:

            self.autocomplete_payload_ready.emit(int(request_id), payload)

        except (TypeError, ValueError, RuntimeError) as exc:
            LOGGER.debug(f"[VIEW_EMIT_AUTOCOMPLETE_PAYLOAD_ERROR] request_id={request_id!r} err={exc}")

            return

    # edited by glg
    def _on_autocomplete_payload_ready(self, request_id, payload):

        handler = getattr(self, "_autocomplete_payload_handler", None)

        if callable(handler):

            handler(int(request_id), payload)

    # edited by glg
    def set_settlement_payload_handler(self, handler):

        self._settlement_payload_handler = handler

    # edited by glg
    def emit_settlement_payload(self, request_id, payload):

        try:

            self.settlement_payload_ready.emit(int(request_id), payload)

        except (TypeError, ValueError, RuntimeError) as exc:
            LOGGER.debug(f"[VIEW_EMIT_SETTLEMENT_PAYLOAD_ERROR] request_id={request_id!r} err={exc}")

            return

    # edited by glg
    def _on_settlement_payload_ready(self, request_id, payload):

        handler = getattr(self, "_settlement_payload_handler", None)

        if callable(handler):

            handler(int(request_id), payload)

    def configure_barang_autocomplete(self, debounce_ms=220):

        self._setup_barang_autocomplete()

        value = int(debounce_ms or 220)

        self._autocomplete_debounce_timer.setInterval(max(50, value))

    def bind_barang_autocomplete_handler(self, handler):

        self._setup_barang_autocomplete()

        self._autocomplete_handler = handler

        self._emit_barang_keyword_changed()

    def _setup_barang_autocomplete(self):

        if hasattr(self, "_barang_autocomplete_ready") and self._barang_autocomplete_ready:

            return

        self._barang_suggestion_model = QStringListModel([], self)

        self.completer = QCompleter(self._barang_suggestion_model, self)

        self.completer.setCaseSensitivity(Qt.CaseInsensitive)

        self.completer.setFilterMode(Qt.MatchContains)

        self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion)

        self.completer.setMaxVisibleItems(12)

        self.barang_input.setCompleter(self.completer)

        self.completer.activated[str].connect(self.on_barang_selected)

        self._autocomplete_handler = None

        self._autocomplete_debounce_timer = QTimer(self)

        self._autocomplete_debounce_timer.setSingleShot(True)

        self._autocomplete_debounce_timer.setInterval(220)

        self._autocomplete_debounce_timer.timeout.connect(self._emit_barang_keyword_changed)

        self.barang_input.textEdited.connect(self._on_barang_text_edited)

        self._barang_autocomplete_ready = True

    def _on_barang_text_edited(self, _):

        if not hasattr(self, "_autocomplete_debounce_timer"):

            return

        self._autocomplete_debounce_timer.start()

    def _emit_barang_keyword_changed(self):

        handler = getattr(self, "_autocomplete_handler", None)

        if callable(handler):

            handler(self.barang_input.text())

    def on_enter_pressed(self):

        popup = self.completer.popup()

        if popup and popup.isVisible():

            return  # Jangan proses jika sedang pilih dari daftar

        text = self.barang_input.text()

        if text.strip():  # hanya proses jika ada isi

            self.submit_cart_from_items()

    def on_barang_selected(self, text):

        if text.strip():

            self.barang_input.setText(text)

            self.submit_cart_from_items()

    def submit_cart_from_sku(self):

        self._submit_cart_entry(use_barcode=True)

    def submit_cart_from_items(self):

        self._submit_cart_entry(use_barcode=False)

    def _normalize_item_lookup_text(self, text):

        lookup = str(text or "").strip()

        if not lookup:

            return lookup

        parts = [p.strip() for p in lookup.split(" - ") if p.strip()]

        if len(parts) >= 2:

            tail = parts[-1].replace(".", "").replace(",", ".")

            if re.fullmatch(r"\d+(\.\d+)?", tail):

                lookup = " - ".join(parts[:-1]).strip()

        compact = lookup.strip()

        leading = re.match(r"^(\d+)\s+(.+)$", compact)

        if leading:

            right = leading.group(2).strip()

            if right and not right.isdigit():

                lookup = right

        return lookup.strip()

    def _submit_cart_entry(self, use_barcode=None):

        sku_text = self.cart_sku_input.text().strip()

        item_text = self.barang_input.text().strip()

        if use_barcode is None:

            use_barcode = bool(sku_text)

        text = sku_text if use_barcode else item_text

        if not use_barcode:

            text = self._normalize_item_lookup_text(text)

        if not text:

            return

        popup_qty_enabled = bool(self.popup_checkbox.isChecked())

        repeat_count = 1 if popup_qty_enabled else max(int(self.cart_qty_input.value() or 1), 1)

        for _ in range(repeat_count):

            self.barang_input.setText(text)

            if use_barcode:

                self.controller.handle_input_barcode()

            else:

                self.controller.handle_barang_input()

        self.barang_input.clear()

        self.cart_sku_input.clear()

        self.cart_qty_input.setValue(1)

        self.cart_sku_input.setFocus()

    def find_barang_row_by_id(self, id_barang):

        for row in range(self.table_barang.rowCount()):

            item = self.table_barang.item(row, 0)  # kolom 0 = id

            if not item:

                continue

            LOGGER.debug(f"id tabel = {item.text()}")

            if item and item.text() == id_barang:

                jumlah = int(self.table_barang.item(row, 4).text())  # kolom 4 = jumlah

                LOGGER.debug(f"jumlah = {jumlah}")

                return row, jumlah

        return None, 0

    def set_jumlah_row(self, row, jumlah):

        LOGGER.debug('masuk ke update jumlah')

        self.table_barang.blockSignals(True)

        self.table_barang.setItem(row, 4, QTableWidgetItem(str(jumlah)))

        self.table_barang.blockSignals(False)

    # edited by glg
    # Penanda visual source -> free agar operator melihat pasangan bayar/hadiah sebelum pembayaran.
    def _build_promo_free_display(self, barang_detail: dict, produk_jenis: str = ""):

        detail = barang_detail if isinstance(barang_detail, dict) else {}

        nama_asli = str(detail.get("nama") or detail.get("produk_nama") or "").strip() or "-"

        free_nama = str(detail.get("free_produk_nama") or "").strip()

        jumlah_free = parse_int_from_text(str(detail.get("jumlah_free", 0)))

        jenis_norm = str(produk_jenis or "").strip().lower()

        is_free_selected = jenis_norm == "free_produk"

        if not is_free_selected and not jenis_norm:

            is_free_selected = bool(detail.get("flag_diskon_free")) and jumlah_free > 0

        if is_free_selected and jumlah_free > 0 and free_nama:

            nama_tabel = f"{nama_asli}\n  -> FREE {jumlah_free} x {free_nama}"

            tooltip = (
                f"Produk Bayar: {nama_asli}\n"
                f"Produk Hadiah: {free_nama}\n"
                f"Qty Hadiah: {jumlah_free}"
            )

            return nama_tabel, tooltip

        if jenis_norm == "free_produk" and jumlah_free <= 0:

            nama_tabel = f"{nama_asli}\n  -> FREE belum memenuhi syarat"

            return nama_tabel, "Promo free dipilih, tetapi kuota/syarat belum terpenuhi."

        return nama_asli, ""

    # edited by glg
    def _apply_nama_barang_display(self, row: int, barang_detail: dict, produk_jenis: str = ""):

        nama_text, tooltip = self._build_promo_free_display(barang_detail, produk_jenis=produk_jenis)

        nama_item = self.table_barang.item(row, 2)

        if nama_item is None:

            nama_item = QTableWidgetItem()

            self.table_barang.setItem(row, 2, nama_item)

        nama_item.setText(str(nama_text or "-"))

        nama_item.setToolTip(str(tooltip or ""))

    # edited by glg
    def _safe_float(self, value, default=0.0):

        return parse_float_safely(value, default)

    # edited by glg
    def _safe_qty(self, value, default=1):

        qty = parse_int_from_text(value, default)

        return qty if qty > 0 else int(default)

    def update_row_barang(self, row, barang):

        self.table_barang.blockSignals(True)

        jenis_item = self.table_barang.item(row, 9)

        produk_jenis_existing = str(jenis_item.text() if jenis_item else "").strip().lower()

        nama_display, nama_tooltip = self._build_promo_free_display(

            barang,

            produk_jenis=produk_jenis_existing,

        )

        harga_raw = self._safe_float(barang.get("harga"), 0.0)

        qty = self._safe_qty(barang.get("jumlah"), 1)

        diskon_persen = self._safe_float(barang.get("diskon_persen"), 0.0)

        h_satuan = harga_raw - (harga_raw * diskon_persen / 100)

        diskon_rp = max(harga_raw - h_satuan, 0)

        total_price = h_satuan * qty

        kolom_data = [

            (2, nama_display),  # PATCH: Update nama dengan badge

            (3, self.format_rupiah(harga_raw)),

            (4, str(qty)),

            (5, barang.get("satuan") or ""),

            (6, self.format_rupiah(total_price)),

            (7, f"{diskon_persen:.2f}"),

            (9, produk_jenis_existing or "invoice"),

            (10, self.format_rupiah(diskon_rp)),

            (11, self.format_rupiah(h_satuan)),

        ]

        for col, value in kolom_data:

            item = self.table_barang.item(row, col)

            if item:

                item.setText(str(value))

            else:

                self.table_barang.setItem(row, col, QTableWidgetItem(str(value)))

        for col in range(self.table_barang.columnCount()):

            item = self.table_barang.item(row, col)

            if not item:

                continue

            if col == 4:

                item.setFlags(item.flags() | Qt.ItemIsEditable)

                item.setBackground(QColor("#FFFFFF"))

            else:

                item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)

                item.setBackground(QColor("#F5F500"))

        self.table_barang.blockSignals(False)

        nama_item = self.table_barang.item(row, 2)

        if nama_item:

            nama_item.setToolTip(str(nama_tooltip or ""))

    def update_tombol_simpan(self):

        """Update status tombol Simpan Transaksi berdasarkan isi keranjang"""

        self.controller.update_tombol_simpan(self)
        proses_pembayaran = bool(getattr(self, "_sedang_proses_pembayaran", False))
        if hasattr(self, "button_simpan_draft") and self.button_simpan_draft is not None:
            self.button_simpan_draft.setEnabled(not proses_pembayaran)

    def _build_action_delete_widget(self, row_index):

        btn_hapus = QPushButton("✕")

        # edited by glg
        # Minimum size agar tombol tetap proporsional tanpa mengunci ukuran.
        btn_hapus.setMinimumSize(28, 24)
        btn_hapus.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)

        btn_hapus.setProperty("class", "tpv-hapus-btn")

        btn_hapus.clicked.connect(lambda _, row_idx=row_index: self.hapus_baris(row_idx))

        container = QWidget()

        container_layout = QHBoxLayout(container)

        container_layout.addWidget(btn_hapus)

        container_layout.setAlignment(Qt.AlignCenter)

        container_layout.setContentsMargins(0, 0, 0, 0)

        return container

    def tambah_barang_ke_tabel(self, barang):

        # PATCH[FrontEndAgent|DiskonIndicator]: Tambahkan indikator visual diskon di nama barang

        jumlah_baru = self._safe_qty(barang.get("jumlah"), 1)

        LOGGER.debug(f'nilai kolom harga = {barang.get("harga")}')

        harga = self._safe_float(barang.get("harga"), 0.0)

        diskon_persen = self._safe_float(barang.get("diskon_persen"), 0.0)

        # Hitung harga setelah diskon

        harga_diskon = harga - (harga * diskon_persen / 100)

        diskon_rp = max(harga - harga_diskon, 0)

        subtotal_baru = harga_diskon * jumlah_baru

        nama_display, nama_tooltip = self._build_promo_free_display(barang, produk_jenis="invoice")

        row = self.table_barang.rowCount()

        self.table_barang.insertRow(row)

        kolom_data = [

            (0, barang['id'], False),

            (1, barang.get('barcode', ''), False),

            (2, nama_display, False),  # PATCH: Nama dengan badge diskon

            (3, self.format_rupiah(harga), False),

            (4, jumlah_baru, True),  # hanya kolom 4 editable

            (5, barang.get("satuan") or "", False),

            (6, self.format_rupiah(subtotal_baru), False),

            (7, f"{diskon_persen:.2f}", False),

            (9, "invoice", False),

            (10, self.format_rupiah(diskon_rp), False),

            (11, self.format_rupiah(harga_diskon), False),

        ]

        for col, value, editable in kolom_data:

            item = QTableWidgetItem(str(value))

            if not editable:

                item.setFlags(item.flags() & ~Qt.ItemIsEditable)

            if col==4:

                bg_color = "#FFFFFF" 

            else:

                bg_color =  "#F5F500"

            item.setBackground(QColor(bg_color))

            self.table_barang.setItem(row, col, item)

        self.table_barang.setCellWidget(row, 8, self._build_action_delete_widget(row))

        nama_item = self.table_barang.item(row, 2)

        if nama_item:

            nama_item.setToolTip(str(nama_tooltip or ""))

        self.update_tombol_simpan()

    def refresh_tombol_hapus(self):

        """Refresh tombol hapus untuk semua baris dengan style konsisten."""

        self.table_barang.setUpdatesEnabled(False)

        try:

            for r in range(self.table_barang.rowCount()):

                self.table_barang.setCellWidget(r, 8, self._build_action_delete_widget(r))

        finally:

            self.table_barang.setUpdatesEnabled(True)

            self.table_barang.viewport().update()

    def hapus_baris(self, row):

        produk_id_item = self.table_barang.item(row, 0)

        if not produk_id_item:

            return

        produk_id = str(produk_id_item.text() or "").strip()

        # edited by glg
        # Gunakan source of truth dari tabel agar penghapusan panel diskon
        # tidak bergantung pada cache yang bisa kosong saat refresh master data.
        self.hapus_info_diskon_by_id(produk_id)

        if hasattr(self.controller, "handle_barang_row_removed"):

            self.controller.handle_barang_row_removed(row, produk_id)

        # Hapus row

        self.table_barang.removeRow(row)

        self.refresh_tombol_hapus()

        self.update_ringkasan()

        # Update status tombol simpan setelah hapus barang

        self.update_tombol_simpan()

        # edited by glg
        # Hard cleanup untuk panel diskon yatim jika terjadi drift state UI.
        self._cleanup_orphan_diskon_panels()

    def hapus_info_diskon(self, barang_detail: dict):

        id_barang = str((barang_detail or {}).get("id", "") or "").strip()
        self.hapus_info_diskon_by_id(id_barang)

    # edited by glg
    def hapus_info_diskon_by_id(self, id_barang):
        id_barang = str(id_barang or "").strip()
        if not id_barang:
            return

        # Pastikan diskon_groupbox_map sudah diinisialisasi

        if not hasattr(self, 'diskon_groupbox_map'):

            self.diskon_groupbox_map = {}

        # Cek apakah ada GroupBox untuk id_barang ini

        if id_barang in self.diskon_groupbox_map:

            groupbox, _ = self.diskon_groupbox_map[id_barang]

            # Hapus widget groupbox dari layout

            self.info_diskon_layout.removeWidget(groupbox)

            groupbox.deleteLater()

            # Hapus dari dictionary map

            del self.diskon_groupbox_map[id_barang]
            self._update_diskon_summary_indicator()

            LOGGER.debug(f"Info diskon untuk barang ID {id_barang} berhasil dihapus.")

        else:

            LOGGER.debug(f"Tidak ditemukan info diskon untuk barang ID {id_barang}.")
            self._update_diskon_summary_indicator()

    # edited by glg
    def _cleanup_orphan_diskon_panels(self):
        if not hasattr(self, "diskon_groupbox_map"):
            return
        active_ids = set()
        for row in range(self.table_barang.rowCount()):
            id_item = self.table_barang.item(row, 0)
            if not id_item:
                continue
            pid = str(id_item.text() or "").strip()
            if pid:
                active_ids.add(pid)
        stale_ids = [pid for pid in list(self.diskon_groupbox_map.keys()) if pid not in active_ids]
        for stale_id in stale_ids:
            self.hapus_info_diskon_by_id(stale_id)

    def on_diskon_selected(self, id_barang, jenis_diskon):

        """Dipanggil saat user memilih radio diskon (grosir / free)"""

        LOGGER.debug(f"Diskon untuk barang ID {id_barang} dipilih: {jenis_diskon}")

        self.recalculate_diskon_barang(id_barang, jenis_diskon)

    def recalculate_diskon_barang(self, id_barang, jenis_diskon_terpilih):

        """Hitung ulang subtotal berdasarkan diskon yang dipilih"""

        for row in range(self.table_barang.rowCount()):

            id_item = self.table_barang.item(row, 0)  # Kolom 0 = ID

            if not id_item or id_item.text() != str(id_barang):

                continue

            harga_item = self.table_barang.item(row, 3)    # Kolom 3 = Harga

            jumlah_item = self.table_barang.item(row, 4)   # Kolom 4 = Jumlah

            subtotal_item = self.table_barang.item(row, 6) # Kolom 6 = Subtotal

            diskon_item = self.table_barang.item(row, 7)   # Kolom 7 = Diskon Persen

            diskon_rp_item = self.table_barang.item(row, 10)

            h_satuan_item = self.table_barang.item(row, 11)

            if not harga_item or not jumlah_item:

                continue

            harga = parse_float_safely(harga_item.text())

            jumlah = parse_int_from_text(jumlah_item.text())

            # edited by glg
            cache = getattr(self.controller, "data_barang_cache", {}) or {}
            barang_data = cache.get(str(id_barang)) or cache.get(id_barang)
            if barang_data is None:

                LOGGER.warning(f"Cache barang tidak ditemukan untuk id {id_barang}, gunakan fallback tabel.")

                barang_data = {"harga": harga, "diskon_persen": 0, "jumlah_free": 0}

            if not str(barang_data.get("nama") or "").strip():

                nama_item = self.table_barang.item(row, 2)

                nama_text = str(nama_item.text() if nama_item else "").split("\n")[0].strip()

                if nama_text:

                    barang_data["nama"] = nama_text

            jenis_diskon_efektif = jenis_diskon_terpilih
            if str(jenis_diskon_terpilih or "").strip().lower() == "free":

                jumlah_free = parse_int_from_text(str(barang_data.get("jumlah_free", 0)))

                if jumlah_free <= 0:

                    jenis_diskon_efektif = "invoice"

            harga, subtotal, diskon_persen, produk_jenis = self.controller.diskon_controller.hitung_diskon_barang(

                barang_data, jenis_diskon_efektif, harga, jumlah

            )

            self.table_barang.setItem(row, 9, QTableWidgetItem(produk_jenis))

            self._apply_nama_barang_display(row, barang_data, produk_jenis=produk_jenis)

            harga_item.setText(self.format_rupiah(harga))

            if subtotal_item is None:

                subtotal_item = QTableWidgetItem()

                self.table_barang.setItem(row, 6, subtotal_item)

            if diskon_item is None:

                diskon_item = QTableWidgetItem()

                self.table_barang.setItem(row, 7, diskon_item)

            if diskon_rp_item is None:

                diskon_rp_item = QTableWidgetItem()

                self.table_barang.setItem(row, 10, diskon_rp_item)

            if h_satuan_item is None:

                h_satuan_item = QTableWidgetItem()

                self.table_barang.setItem(row, 11, h_satuan_item)

            subtotal_item.setText(self.format_rupiah(subtotal))

            diskon_item.setText(f'{diskon_persen:,.2f}')

            h_satuan = harga - (harga * diskon_persen / 100)

            diskon_rp = max(harga - h_satuan, 0)

            diskon_rp_item.setText(self.format_rupiah(diskon_rp))

            h_satuan_item.setText(self.format_rupiah(h_satuan))

            break  # hanya satu baris per id

        self.update_ringkasan()  # Perbarui total bayar, PPN, dst.

    def update_ringkasan(self):

        self.controller.update_ringkasan_from_view(self)

    # edited by glg
    def _get_transaksi_view_summary_service(self) -> TransaksiViewSummaryService:
        service = getattr(self, "transaksi_view_summary_service", None)
        if service is None:
            service = TransaksiViewSummaryService()
            self.transaksi_view_summary_service = service
        return service

    # edited by glg
    def _build_free_items_display_map(self, free_items):
        display_map = {}

        for nama, qty in (dict(free_items or {})).items():
            nama_norm = str(nama or "").strip()
            if not nama_norm:
                continue
            try:
                qty_int = int(qty or 0)
            except (TypeError, ValueError):
                qty_int = 0
            # edited by glg
            # Sinkron dengan pilihan radio diskon di panel Info Diskon:
            # hanya tampilkan free item yang benar-benar aktif (> 0) dari
            # hasil perhitungan ringkasan saat ini.
            if qty_int <= 0:
                continue
            display_map[nama_norm] = display_map.get(nama_norm, 0) + qty_int

        return display_map

    def update_info_pembayaran(

        self,

        total_produk,

        diskon_produk,

        free_items,

        total_bayar,

        diskon_customer=0,

        cashback=0,

        point=0,

        diskon_member_persen=0,

        additional_diskon=0,

        grand_total=None,

    ):

        free_items_display = self._build_free_items_display_map(free_items)
        payload = self._get_transaksi_view_summary_service().build_summary_label_payload(
            total_produk=total_produk,
            diskon_produk=diskon_produk,
            free_items=free_items_display,
            total_bayar=total_bayar,
            diskon_customer=diskon_customer,
            cashback=cashback,
            point=point,
            diskon_member_persen=diskon_member_persen,
            additional_diskon=additional_diskon,
            grand_total=grand_total,
        )

        if hasattr(self, "info_total_produk_label"):

            self.info_total_produk_label.setText(str(payload.get("info_total_produk") or "0"))

        if hasattr(self, "info_diskon_label"):

            self.info_diskon_label.setText(str(payload.get("info_diskon") or "0"))

        if hasattr(self, "info_diskon_member_persen_label"):

            self.info_diskon_member_persen_label.setText(
                str(payload.get("info_diskon_member_persen") or "0.00 %")
            )

        if hasattr(self, "info_diskon_customer_label"):

            self.info_diskon_customer_label.setText(str(payload.get("info_diskon_customer") or "0"))

        if hasattr(self, "info_additional_diskon_label"):

            self.info_additional_diskon_label.setText(str(payload.get("info_additional_diskon") or "0"))

        if hasattr(self, "info_grand_total_label"):

            self.info_grand_total_label.setText(str(payload.get("info_grand_total") or "0"))

        if hasattr(self, "info_cashback_label"):

            self.info_cashback_label.setText(str(payload.get("info_cashback") or "0"))

        if hasattr(self, "info_point_label"):

            self.info_point_label.setText(str(payload.get("info_point") or "0"))

        if hasattr(self, "info_free_item_label"):

            self.info_free_item_label.setText(str(payload.get("info_free_item") or "-"))

        self.total_label.setText(str(payload.get("total_label") or "0"))

    def format_rupiah(self, angka: float) -> str:

        return self._get_transaksi_view_summary_service().format_rupiah(angka)

    def input_jumlah_dialog(self):

        dialog = QDialog(self)

        dialog.setWindowTitle("Jumlah Barang")

        layout = QFormLayout(dialog)

        spin = QSpinBox()

        spin.setMinimum(1)

        spin.setMaximum(9999)

        spin.setFocus()

        spin.lineEdit().selectAll()

        layout.addRow("Jumlah:", spin)

        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)

        buttons.accepted.connect(dialog.accept)

        buttons.rejected.connect(dialog.reject)

        layout.addWidget(buttons)

        result = dialog.exec()

        return spin.value(), result == QDialog.Accepted

    def kumpulkan_data_transaksi(self):

        return self.controller.kumpulkan_data_transaksi_view(self)

    def notifikasi_sukses(self, transaksi_id):

        QMessageBox.information(self, "Sukses", f"Transaksi #{transaksi_id} berhasil disimpan")

    def notifikasi_error(self, pesan):

        safe_message, _ = sanitize_ui_message("critical", str(pesan))

        QMessageBox.critical(self, "Error simpan", f"Gagal menyimpan transaksi.\n{safe_message}")

    def reset_form(self):
        # edited by glg
        # Single entry-point reset agar tidak ada override method ganda yang membingungkan.
        if hasattr(self, "controller") and hasattr(self.controller, "reset_form_transaksi"):
            self.controller.reset_form_transaksi(self)
            return
        self._reset_form_ui_only()

    def show_payment_dialog(self, info_transaksi, multi_payment_mode=False):

        """

        Wrapper untuk membuka dialog pembayaran agar pemanggil tetap di controller.

        Mengembalikan (result_code, hasil_pembayaran) sesuai kebutuhan TransaksiPenjualanController.

        """

        if hasattr(self, "controller") and hasattr(self.controller, "show_payment_dialog_from_view"):
            return self.controller.show_payment_dialog_from_view(
                parent=self,
                info_transaksi=info_transaksi,
                multi_payment_mode=multi_payment_mode,
            )
        LOGGER.warning("Controller belum menyediakan show_payment_dialog_from_view")
        return QDialog.Rejected, None

