import csv import json import os import config from PyQt5.QtWidgets import ( QAbstractItemView, QDialog, QFrame, QHeaderView, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, QStyle, QStyleOptionViewItem, QVBoxLayout, ) from PyQt5.QtCore import Qt, QEvent from PyQt5.QtGui import QFont class CustomNotificationDialog(QDialog): """Custom styled notification dialog for success, error, warning, and question messages""" def __init__(self, parent=None, dialog_type="success", title="Success!", message="Operation completed successfully", button_text="OK", show_cancel=False, cancel_text="Cancel", compact=False): super().__init__(parent) self.dialog_type = dialog_type # "success", "error", "warning", "question" self.result_value = False self.compact = compact # Remove window frame and set flags self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WA_TranslucentBackground) self.initUI(title, message, button_text, show_cancel, cancel_text) def initUI(self, title, message, button_text, show_cancel, cancel_text): # Main layout with margins for shadow effect main_layout = QVBoxLayout() outer_margin = 14 if self.compact else 20 main_layout.setContentsMargins(outer_margin, outer_margin, outer_margin, outer_margin) # Content widget (the card) content_widget = QDialog() content_widget.setFixedWidth(340 if self.compact else 450) # Set colors based on dialog type if self.dialog_type == "success": border_color = "#48bb78" # Green icon_bg = "#48bb78" icon = "✓" button_color = "#48bb78" button_hover = "#38a169" elif self.dialog_type == "error": border_color = "#f56565" # Red icon_bg = "#f56565" icon = "✕" button_color = "#f56565" button_hover = "#e53e3e" elif self.dialog_type == "warning": border_color = "#ed8936" # Orange icon_bg = "#ed8936" icon = "!" button_color = "#ed8936" button_hover = "#dd6b20" else: # question border_color = "#4299e1" # Blue icon_bg = "#4299e1" icon = "?" button_color = "#4299e1" button_hover = "#3182ce" content_widget.setStyleSheet(f""" QDialog {{ background-color: white; border-top: 4px solid {border_color}; border-radius: 8px; }} """) content_layout = QVBoxLayout() if self.compact: content_layout.setContentsMargins(24, 28, 24, 24) content_layout.setSpacing(12) else: content_layout.setContentsMargins(40, 50, 40, 40) content_layout.setSpacing(20) # Icon at the top icon_label = QLabel(icon) icon_label.setAlignment(Qt.AlignCenter) icon_size = 54 if self.compact else 80 icon_label.setFixedSize(icon_size, icon_size) icon_label.setStyleSheet(f""" QLabel {{ background-color: {icon_bg}; color: white; font-size: {34 if self.compact else 48}px; font-weight: bold; border-radius: {icon_size // 2}px; font-family: 'Segoe UI', Arial, sans-serif; }} """) # Create a container to center the icon icon_container = QHBoxLayout() icon_container.addStretch() icon_container.addWidget(icon_label) icon_container.addStretch() content_layout.addLayout(icon_container) # Title title_label = QLabel(title) title_label.setAlignment(Qt.AlignCenter) title_label.setStyleSheet(f""" QLabel {{ font-size: {22 if self.compact else 32}px; font-weight: bold; color: {border_color if self.dialog_type != 'question' else '#2d3748'}; margin-top: {4 if self.compact else 10}px; margin-bottom: {4 if self.compact else 10}px; font-family: 'Segoe UI', Arial, sans-serif; }} """) content_layout.addWidget(title_label) # Separator line separator = QLabel() separator.setFixedHeight(3) separator.setStyleSheet(f"background-color: {border_color}; border-radius: 1px;") separator.setFixedWidth(100) separator_container = QHBoxLayout() separator_container.addStretch() separator_container.addWidget(separator) separator_container.addStretch() content_layout.addLayout(separator_container) # Message message_label = QLabel(message) message_label.setAlignment(Qt.AlignCenter) message_label.setWordWrap(True) message_font_size = 17 if self.compact else 19 message_label.setStyleSheet(f""" QLabel {{ font-size: {message_font_size}px; color: #718096; line-height: 1.5; margin-top: 8px; margin-bottom: 12px; font-family: 'Segoe UI', Arial, sans-serif; }} """) content_layout.addWidget(message_label) # Buttons button_layout = QHBoxLayout() button_layout.setSpacing(10 if self.compact else 15) if show_cancel: # Cancel button (outlined) cancel_btn = QPushButton(cancel_text) cancel_btn.setFixedHeight(36 if self.compact else 45) cancel_btn.setFixedWidth(120 if self.compact else 150) cancel_btn.setCursor(Qt.PointingHandCursor) cancel_btn.setStyleSheet(f""" QPushButton {{ background-color: white; color: #718096; border: 2px solid #e2e8f0; border-radius: {18 if self.compact else 25}px; font-size: {13 if self.compact else 16}px; font-weight: 600; padding: {6 if self.compact else 8}px {16 if self.compact else 24}px; font-family: 'Segoe UI', Arial, sans-serif; }} QPushButton:hover {{ background-color: #f7fafc; border-color: #cbd5e0; color: #4a5568; }} QPushButton:pressed {{ background-color: #edf2f7; }} """) cancel_btn.clicked.connect(self.on_cancel) button_layout.addWidget(cancel_btn) # Main button (filled) ok_btn = QPushButton(button_text) ok_btn.setFixedHeight(36 if self.compact else 45) ok_btn.setFixedWidth(120 if self.compact else 150) ok_btn.setCursor(Qt.PointingHandCursor) ok_btn.setStyleSheet(f""" QPushButton {{ background-color: {button_color}; color: white; border: none; border-radius: {18 if self.compact else 25}px; font-size: {13 if self.compact else 16}px; font-weight: 600; padding: {6 if self.compact else 8}px {16 if self.compact else 24}px; font-family: 'Segoe UI', Arial, sans-serif; }} QPushButton:hover {{ background-color: {button_hover}; }} QPushButton:pressed {{ background-color: {button_hover}; padding-top: 10px; padding-bottom: 6px; }} """) ok_btn.clicked.connect(self.on_ok) button_layout.addWidget(ok_btn) content_layout.addLayout(button_layout) content_widget.setLayout(content_layout) main_layout.addWidget(content_widget) self.setLayout(main_layout) # Apply shadow effect from PyQt5.QtWidgets import QGraphicsDropShadowEffect from PyQt5.QtGui import QColor shadow = QGraphicsDropShadowEffect() shadow.setBlurRadius(30) shadow.setXOffset(0) shadow.setYOffset(10) shadow.setColor(QColor(0, 0, 0, 60)) content_widget.setGraphicsEffect(shadow) def on_ok(self): self.result_value = True self.accept() def on_cancel(self): self.result_value = False self.reject() @staticmethod def show_success(parent=None, title="Success!", message="Operation completed successfully", button_text="OK", compact=False): """Show a success notification""" dialog = CustomNotificationDialog(parent, "success", title, message, button_text, compact=compact) dialog.exec_() @staticmethod def show_error(parent=None, title="Error!", message="An error occurred", button_text="Try Again", compact=False): """Show an error notification""" dialog = CustomNotificationDialog(parent, "error", title, message, button_text, compact=compact) dialog.exec_() @staticmethod def show_warning(parent=None, title="Warning!", message="Please check your input", button_text="OK", compact=False): """Show a warning notification""" dialog = CustomNotificationDialog(parent, "warning", title, message, button_text, compact=compact) dialog.exec_() @staticmethod def show_question(parent=None, title="Confirm", message="Are you sure?", button_text="Yes", cancel_text="No", compact=False): """Show a question dialog and return True if user clicks Yes""" dialog = CustomNotificationDialog(parent, "question", title, message, button_text, True, cancel_text, compact=compact) dialog.exec_() return dialog.result_value class CustomPredictionDialog(QDialog): """Custom styled dialog for showing prediction results with save option""" def __init__(self, parent=None, nama="", prediction="", paket_info=None): super().__init__(parent) self.result_save = False # Remove window frame and set flags self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WA_TranslucentBackground) self.initUI(nama, prediction, paket_info) def initUI(self, nama, prediction, paket_info): # Main layout with margins for shadow effect main_layout = QVBoxLayout() main_layout.setContentsMargins(20, 20, 20, 20) # Content widget (the card) content_widget = QDialog() content_widget.setFixedWidth(500) border_color = "#4299e1" # Blue content_widget.setStyleSheet(f""" QDialog {{ background-color: white; border-top: 4px solid {border_color}; border-radius: 8px; }} """) content_layout = QVBoxLayout() content_layout.setContentsMargins(40, 50, 40, 40) content_layout.setSpacing(15) # Icon at the top icon_label = QLabel("🎓") icon_label.setAlignment(Qt.AlignCenter) icon_label.setFixedSize(80, 80) icon_label.setStyleSheet(f""" QLabel {{ background-color: {border_color}; color: white; font-size: 48px; font-weight: bold; border-radius: 40px; font-family: 'Segoe UI', Arial, sans-serif; }} """) # Create a container to center the icon icon_container = QHBoxLayout() icon_container.addStretch() icon_container.addWidget(icon_label) icon_container.addStretch() content_layout.addLayout(icon_container) # Title title_label = QLabel("Hasil Prediksi") title_label.setAlignment(Qt.AlignCenter) title_label.setStyleSheet(f""" QLabel {{ font-size: 32px; font-weight: bold; color: #2d3748; margin-top: 10px; margin-bottom: 10px; font-family: 'Segoe UI', Arial, sans-serif; }} """) content_layout.addWidget(title_label) # Separator line separator = QLabel() separator.setFixedHeight(3) separator.setStyleSheet(f"background-color: {border_color}; border-radius: 1px;") separator.setFixedWidth(100) separator_container = QHBoxLayout() separator_container.addStretch() separator_container.addWidget(separator) separator_container.addStretch() content_layout.addLayout(separator_container) # Student name nama_label = QLabel(f"Nama Siswa: {nama}") nama_label.setAlignment(Qt.AlignCenter) nama_label.setStyleSheet(""" QLabel { font-size: 18px; color: #2d3748; font-weight: bold; margin-top: 10px; font-family: 'Segoe UI', Arial, sans-serif; } """) content_layout.addWidget(nama_label) # Prediction result pred_label = QLabel(f"Paket Terpilih: {prediction}") pred_label.setAlignment(Qt.AlignCenter) pred_label.setStyleSheet(""" QLabel { font-size: 20px; color: #4299e1; font-weight: bold; margin-top: 5px; font-family: 'Segoe UI', Arial, sans-serif; } """) content_layout.addWidget(pred_label) # Paket detail if paket_info: kelas_info = f" ({paket_info.get('kelas_tersedia', 1)} kelas tersedia)" detail_text = f"{paket_info.get('nama', 'Unknown')}{kelas_info}" detail_label = QLabel(detail_text) detail_label.setAlignment(Qt.AlignCenter) detail_label.setWordWrap(True) detail_label.setStyleSheet(""" QLabel { font-size: 16px; color: #718096; margin-top: 5px; margin-bottom: 10px; font-family: 'Segoe UI', Arial, sans-serif; } """) content_layout.addWidget(detail_label) # Question question_label = QLabel("Apakah Anda ingin menyimpan hasil prediksi ini?") question_label.setAlignment(Qt.AlignCenter) question_label.setWordWrap(True) question_label.setStyleSheet(""" QLabel { font-size: 16px; color: #4a5568; margin-top: 10px; margin-bottom: 20px; font-family: 'Segoe UI', Arial, sans-serif; } """) content_layout.addWidget(question_label) # Buttons button_layout = QHBoxLayout() button_layout.setSpacing(15) # Cancel button (outlined) cancel_btn = QPushButton("Batal") cancel_btn.setFixedHeight(45) cancel_btn.setFixedWidth(150) cancel_btn.setCursor(Qt.PointingHandCursor) cancel_btn.setStyleSheet(f""" QPushButton {{ background-color: white; color: #718096; border: 2px solid #e2e8f0; border-radius: 25px; font-size: 16px; font-weight: 600; padding: 8px 24px; font-family: 'Segoe UI', Arial, sans-serif; }} QPushButton:hover {{ background-color: #f7fafc; border-color: #cbd5e0; color: #4a5568; }} QPushButton:pressed {{ background-color: #edf2f7; }} """) cancel_btn.clicked.connect(self.on_cancel) button_layout.addWidget(cancel_btn) # Save button (filled) save_btn = QPushButton("💾 Simpan") save_btn.setFixedHeight(45) save_btn.setFixedWidth(150) save_btn.setCursor(Qt.PointingHandCursor) save_btn.setStyleSheet(f""" QPushButton {{ background-color: {border_color}; color: white; border: none; border-radius: 25px; font-size: 16px; font-weight: 600; padding: 8px 24px; font-family: 'Segoe UI', Arial, sans-serif; }} QPushButton:hover {{ background-color: #3182ce; }} QPushButton:pressed {{ background-color: #3182ce; padding-top: 10px; padding-bottom: 6px; }} """) save_btn.clicked.connect(self.on_save) button_layout.addWidget(save_btn) content_layout.addLayout(button_layout) content_widget.setLayout(content_layout) main_layout.addWidget(content_widget) self.setLayout(main_layout) # Apply shadow effect from PyQt5.QtWidgets import QGraphicsDropShadowEffect from PyQt5.QtGui import QColor shadow = QGraphicsDropShadowEffect() shadow.setBlurRadius(30) shadow.setXOffset(0) shadow.setYOffset(10) shadow.setColor(QColor(0, 0, 0, 60)) content_widget.setGraphicsEffect(shadow) def on_save(self): self.result_save = True self.accept() def on_cancel(self): self.result_save = False self.reject() @staticmethod def show_prediction(parent=None, nama="", prediction="", paket_info=None): """Show a prediction result dialog and return True if user wants to save""" dialog = CustomPredictionDialog(parent, nama, prediction, paket_info) dialog.exec_() return dialog.result_save class StudentNamePickerDialog(QDialog): """Dialog untuk memilih nama siswa dari dataset final.""" def __init__(self, parent=None, initial_name=""): super().__init__(parent) self.selected_name = "" self.initial_name = initial_name.strip() self.base_names = self._load_base_names() self.catalog_state = self._load_catalog_state() self.all_names = self._build_name_list() self.filtered_names = list(self.all_names) self.current_page = 1 self.items_per_page = 10 self.visible_table_rows = 6 self.setWindowTitle("Pilih Nama Siswa") self.setWindowFlag(Qt.WindowContextHelpButtonHint, False) self.setModal(True) self.setMinimumSize(760, 620) self.resize(780, 640) self.initUI() self.apply_filter() self._select_initial_name() # Set window position to center of parent if self.parent(): parent_rect = self.parent().frameGeometry() self.move(parent_rect.center() - self.rect().center()) def showEvent(self, event): super().showEvent(event) # Prevent QLineEdit from receiving focus automatically when dialog opens. # Clear focus from search/add inputs and set focus to the dialog itself. try: if hasattr(self, 'search_input'): self.search_input.clearFocus() except Exception: pass try: if hasattr(self, 'add_input'): self.add_input.clearFocus() except Exception: pass try: self.setFocus() except Exception: pass def _dataset_path(self): return config.get_resource_path(os.path.join("data", "dataset_smakom_final.csv")) def _catalog_path(self): return config.get_writable_path(os.path.join("data", "student_name_catalog.json")) def _normalize_name(self, name): return " ".join(str(name).strip().split()).casefold() def _display_name(self, raw_name): return " ".join(str(raw_name).replace("_", " ").split()) def _load_base_names(self): names = [] seen = set() dataset_path = self._dataset_path() if not os.path.exists(dataset_path): return names try: with open(dataset_path, "r", encoding="utf-8-sig", newline="") as csv_file: reader = csv.DictReader(csv_file) for row in reader: raw_name = row.get("nama_siswa", "") display_name = self._display_name(raw_name) if not display_name: continue normalized = self._normalize_name(display_name) if normalized in seen: continue seen.add(normalized) names.append(display_name) except Exception: return names return names def _load_catalog_state(self): default_state = {"added": [], "deleted": []} catalog_path = self._catalog_path() if not os.path.exists(catalog_path): return default_state try: with open(catalog_path, "r", encoding="utf-8") as json_file: data = json.load(json_file) except Exception: return default_state if isinstance(data, list): return {"added": [str(item).strip() for item in data if str(item).strip()], "deleted": []} if isinstance(data, dict): added = data.get("added") or data.get("names") or [] deleted = data.get("deleted") or [] return { "added": [str(item).strip() for item in added if str(item).strip()], "deleted": [str(item).strip() for item in deleted if str(item).strip()], } return default_state def _save_catalog_state(self): catalog_path = self._catalog_path() catalog_dir = os.path.dirname(catalog_path) if catalog_dir: os.makedirs(catalog_dir, exist_ok=True) payload = { "added": self.catalog_state.get("added", []), "deleted": self.catalog_state.get("deleted", []), } with open(catalog_path, "w", encoding="utf-8") as json_file: json.dump(payload, json_file, ensure_ascii=False, indent=2) def _build_name_list(self): deleted = {self._normalize_name(name) for name in self.catalog_state.get("deleted", [])} result = [] seen = set() for name in self.catalog_state.get("added", []): normalized = self._normalize_name(name) if not normalized or normalized in deleted or normalized in seen: continue seen.add(normalized) result.append(name) for name in self.base_names: normalized = self._normalize_name(name) if normalized in deleted or normalized in seen: continue seen.add(normalized) result.append(name) return result def initUI(self): main_layout = QVBoxLayout(self) main_layout.setContentsMargins(12, 12, 12, 12) card = QFrame() card.setStyleSheet(""" QFrame { background: white; border-radius: 16px; } """) card_layout = QVBoxLayout(card) card_layout.setContentsMargins(20, 18, 20, 16) card_layout.setSpacing(10) header_layout = QVBoxLayout() header_layout.setSpacing(0) header_layout.setContentsMargins(0, 0, 0, 0) title = QLabel("Pilih Nama Siswa") title.setContentsMargins(0, 0, 0, 0) title.setStyleSheet("font-size: 28px; font-weight: 700; color: #1f3b66; font-family: 'Segoe UI', Arial, sans-serif; margin: 0; padding: 0; line-height: 1;") header_layout.addWidget(title) subtitle = QLabel("Cari, tambah, hapus, lalu pilih nama yang akan dimasukkan ke form.") subtitle.setWordWrap(True) subtitle.setContentsMargins(0, 0, 0, 0) subtitle.setStyleSheet("font-size: 17px; color: #56708f; font-family: 'Segoe UI', Arial, sans-serif; margin: 0; padding: 0; line-height: 1;") header_layout.addWidget(subtitle) card_layout.addLayout(header_layout) search_row = QHBoxLayout() search_row.setSpacing(10) self.search_input = QLineEdit() self.search_input.setPlaceholderText("Cari nama siswa...") self.search_input.setMinimumHeight(42) self.search_input.setStyleSheet(""" QLineEdit { border: 1px solid #d6deea; border-radius: 10px; padding: 9px 14px; font-size: 17px; background: #f8fbff; color: #1f2937; font-family: 'Segoe UI', Arial, sans-serif; } QLineEdit:focus { border-color: #2c7be5; background: white; } """) self.search_input.textChanged.connect(self.apply_filter) self.search_input.returnPressed.connect(self.apply_filter) search_row.addWidget(self.search_input, 1) self.add_input = QLineEdit() self.add_input.setPlaceholderText("Tambah nama baru...") self.add_input.setMinimumHeight(42) self.add_input.setStyleSheet(self.search_input.styleSheet()) self.add_input.returnPressed.connect(self.add_name) search_row.addWidget(self.add_input, 1) card_layout.addLayout(search_row) action_row = QHBoxLayout() action_row.setContentsMargins(0, 2, 0, 0) action_row.setSpacing(12) add_btn = QPushButton("Tambah") add_btn.setFixedHeight(40) add_btn.setFixedWidth(110) add_btn.setCursor(Qt.PointingHandCursor) add_btn.setStyleSheet(""" QPushButton { background: #118a7e; color: white; border: none; border-radius: 10px; padding: 0 16px; font-size: 16px; font-weight: 600; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: #0e7369; } """) add_btn.clicked.connect(self.add_name) action_row.addWidget(add_btn) self.delete_btn = QPushButton("Hapus Terpilih") self.delete_btn.setFixedHeight(40) self.delete_btn.setFixedWidth(160) self.delete_btn.setCursor(Qt.PointingHandCursor) self.delete_btn.setStyleSheet(""" QPushButton { background: #ef5a5a; color: white; border: none; border-radius: 10px; padding: 0 14px; font-size: 16px; font-weight: 600; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: #d94b4b; } QPushButton:disabled { background: #c8d3e2; color: #7b8aa0; } """) self.delete_btn.clicked.connect(self.delete_selected_names) action_row.addWidget(self.delete_btn) self.pilih_btn = QPushButton("Pilih") self.pilih_btn.setFixedHeight(40) self.pilih_btn.setFixedWidth(96) self.pilih_btn.setCursor(Qt.PointingHandCursor) self.pilih_btn.setStyleSheet(""" QPushButton { background: #1f3b66; color: white; border: none; border-radius: 10px; padding: 0 14px; font-size: 16px; font-weight: 600; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: #182f53; } QPushButton:disabled { background: #c8d3e2; color: #7b8aa0; } """) self.pilih_btn.clicked.connect(self.pick_selected_name) action_row.addWidget(self.pilih_btn) self.edit_btn = QPushButton("Edit") self.edit_btn.setFixedHeight(40) self.edit_btn.setFixedWidth(96) self.edit_btn.setCursor(Qt.PointingHandCursor) self.edit_btn.setStyleSheet(""" QPushButton { background: #16a34a; color: white; border: none; border-radius: 10px; padding: 0 14px; font-size: 16px; font-weight: 600; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: #15803d; } QPushButton:disabled { background: #c8d3e2; color: #7b8aa0; } """) self.edit_btn.clicked.connect(self.edit_selected_name) action_row.addWidget(self.edit_btn) tutup_btn = QPushButton("Tutup") tutup_btn.setFixedHeight(40) tutup_btn.setFixedWidth(96) tutup_btn.setCursor(Qt.PointingHandCursor) tutup_btn.setStyleSheet(""" QPushButton { background: #6c7a92; color: white; border: none; border-radius: 10px; padding: 0 14px; font-size: 16px; font-weight: 600; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: #5d6a7f; } """) tutup_btn.clicked.connect(self.reject) action_row.addWidget(tutup_btn) action_row.addStretch() card_layout.addLayout(action_row) self.table = QTableWidget(0, 3) self.table.setHorizontalHeaderLabels(["No", "Nama Siswa", "Hapus"]) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) self.table.setAlternatingRowColors(True) self.table.verticalHeader().setVisible(False) self.table.setShowGrid(False) self.table.setStyleSheet(""" QTableWidget { border: 1px solid #dbe4f0; border-radius: 12px; background: white; alternate-background-color: #f7faff; font-family: 'Segoe UI', Arial, sans-serif; font-size: 17px; color: #1f2937; } QTableWidget::item { padding: 8px 10px; } QHeaderView::section { background: #eef4fb; color: #173458; font-weight: 700; border: none; border-bottom: 1px solid #dbe4f0; padding: 8px 3px; font-size: 17px; } """) self.table.verticalHeader().setDefaultSectionSize(42) header_height = self.table.horizontalHeader().sizeHint().height() table_frame = self.table.frameWidth() * 2 visible_height = header_height + (self.visible_table_rows * self.table.verticalHeader().defaultSectionSize()) + table_frame + 2 self.table.setFixedHeight(visible_height) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeToContents) header_item = self.table.horizontalHeaderItem(0) if header_item: header_item.setTextAlignment(Qt.AlignCenter | Qt.AlignVCenter) header_item = self.table.horizontalHeaderItem(1) if header_item: header_item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter) header_item = self.table.horizontalHeaderItem(2) if header_item: header_item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter) self.table.cellDoubleClicked.connect(self._handle_cell_double_clicked) self.table.cellClicked.connect(self._handle_cell_clicked) self.table.itemSelectionChanged.connect(self._sync_action_state) self.table.itemChanged.connect(self._sync_action_state) self.table.viewport().installEventFilter(self) card_layout.addWidget(self.table) pagination_row = QHBoxLayout() pagination_row.setSpacing(16) pagination_row.setContentsMargins(2, 8, 2, 0) pagination_row.addStretch() self.prev_btn = QPushButton("◀ Previous") self.prev_btn.setFixedHeight(40) self.prev_btn.setFixedWidth(132) self.prev_btn.setCursor(Qt.PointingHandCursor) self.prev_btn.setStyleSheet(""" QPushButton { background: #e8eef6; color: #38506d; border: none; border-radius: 10px; padding: 0 14px; font-size: 16px; font-weight: 600; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: #dce6f1; } QPushButton:disabled { background: #edf2f7; color: #a0aec0; } """) self.prev_btn.clicked.connect(self.previous_page) pagination_row.addWidget(self.prev_btn) self.page_label = QLabel() self.page_label.setAlignment(Qt.AlignCenter) self.page_label.setStyleSheet("font-size: 16px; color: #3d5574; font-weight: 600; font-family: 'Segoe UI', Arial, sans-serif;") pagination_row.addWidget(self.page_label) self.next_btn = QPushButton("Next ▶") self.next_btn.setFixedHeight(40) self.next_btn.setFixedWidth(132) self.next_btn.setCursor(Qt.PointingHandCursor) self.next_btn.setStyleSheet(self.prev_btn.styleSheet()) self.next_btn.clicked.connect(self.next_page) pagination_row.addWidget(self.next_btn) pagination_row.addStretch() card_layout.addLayout(pagination_row) main_layout.addWidget(card) def _sync_action_state(self): checked = self._has_checked_rows() selected = bool(self.get_selected_name()) if checked and selected: self.table.blockSignals(True) self.table.clearSelection() self.table.setCurrentCell(-1, -1) self.table.blockSignals(False) selected = False if hasattr(self, "pilih_btn"): self.pilih_btn.setEnabled(selected and not checked) if hasattr(self, "edit_btn"): self.edit_btn.setEnabled(selected and not checked) if hasattr(self, "delete_btn"): self.delete_btn.setEnabled(checked) def _clear_checked_rows(self): for row in range(self.table.rowCount()): item = self.table.item(row, 2) if item and item.checkState() == Qt.Checked: item.setCheckState(Qt.Unchecked) def _handle_cell_clicked(self, row, column): del row if column == 2: self.table.blockSignals(True) self.table.clearSelection() self.table.setCurrentCell(-1, -1) self.table.blockSignals(False) self._sync_action_state() return def _handle_cell_double_clicked(self, row, column): # Ignore double-clicks on the checkbox column so they don't trigger name picking if column == 2: return self.pick_selected_name() if column in (0, 1) and self._has_checked_rows(): self.table.blockSignals(True) self._clear_checked_rows() self.table.blockSignals(False) self._sync_action_state() def _has_checked_rows(self): for row in range(self.table.rowCount()): item = self.table.item(row, 2) if item and item.checkState() == Qt.Checked: return True return False def _select_initial_name(self): if not self.initial_name: return normalized_initial = self._normalize_name(self.initial_name) for row, name in enumerate(self.filtered_names): if self._normalize_name(name) == normalized_initial: self.current_page = row // self.items_per_page + 1 self.render_table() self.table.selectRow(row % self.items_per_page) break def clear_search(self): self.search_input.clear() self.apply_filter() def apply_filter(self): search_text = self.search_input.text().strip().casefold() if search_text: self.filtered_names = [name for name in self.all_names if search_text in name.casefold()] else: self.filtered_names = list(self.all_names) self.current_page = 1 self.render_table() def render_table(self): self.table.blockSignals(True) self.table.clearContents() self.table.clearSpans() total_items = len(self.filtered_names) total_pages = max(1, (total_items + self.items_per_page - 1) // self.items_per_page) self.current_page = min(max(1, self.current_page), total_pages) start_index = (self.current_page - 1) * self.items_per_page end_index = min(start_index + self.items_per_page, total_items) page_items = self.filtered_names[start_index:end_index] self.table.setRowCount(len(page_items) if page_items else 1) self.table.setSpan(0, 0, 1, 1) if not page_items: empty_item = QTableWidgetItem("Tidak ada nama ditemukan") empty_item.setTextAlignment(Qt.AlignCenter) empty_item.setFlags(Qt.ItemIsEnabled) self.table.setItem(0, 0, empty_item) self.table.setSpan(0, 0, 1, 3) else: for row, name in enumerate(page_items): no_item = QTableWidgetItem(str(start_index + row + 1)) no_item.setTextAlignment(Qt.AlignCenter) no_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) self.table.setItem(row, 0, no_item) name_item = QTableWidgetItem(name) name_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) self.table.setItem(row, 1, name_item) delete_item = QTableWidgetItem() delete_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable) delete_item.setCheckState(Qt.Unchecked) delete_item.setTextAlignment(Qt.AlignCenter) self.table.setItem(row, 2, delete_item) self.table.blockSignals(False) self.table.clearSelection() self.table.setCurrentCell(-1, -1) self.update_pagination_controls() self._sync_action_state() def eventFilter(self, obj, event): if obj is getattr(self, "table", None).viewport(): if event.type() in (QEvent.MouseButtonPress, QEvent.MouseButtonRelease): index = self.table.indexAt(event.pos()) if index.isValid() and index.column() == 2: # Toggle checkbox state on mouse release and consume the event so # the table's default selection behavior doesn't interfere. if event.type() == QEvent.MouseButtonRelease: item = self.table.item(index.row(), 2) if item: new_state = Qt.Unchecked if item.checkState() == Qt.Checked else Qt.Checked item.setCheckState(new_state) self._sync_action_state() return True return super().eventFilter(obj, event) def update_pagination_controls(self): total_items = len(self.filtered_names) total_pages = max(1, (total_items + self.items_per_page - 1) // self.items_per_page) self.page_label.setText(f"Halaman {self.current_page} dari {total_pages} | {total_items} data") self.prev_btn.setEnabled(self.current_page > 1) self.next_btn.setEnabled(self.current_page < total_pages) def previous_page(self): if self.current_page > 1: self.current_page -= 1 self.render_table() def next_page(self): total_items = len(self.filtered_names) total_pages = max(1, (total_items + self.items_per_page - 1) // self.items_per_page) if self.current_page < total_pages: self.current_page += 1 self.render_table() def get_selected_name(self): current_row = self.table.currentRow() if current_row < 0: return "" name_item = self.table.item(current_row, 1) if not name_item: return "" return name_item.text().strip() def add_name(self): new_name = " ".join(self.add_input.text().strip().split()) if not new_name: QMessageBox.warning(self, "Validasi", "Nama baru masih kosong.") return normalized_new = self._normalize_name(new_name) if any(self._normalize_name(name) == normalized_new for name in self.all_names): QMessageBox.warning(self, "Validasi", "Nama sudah ada di daftar.") return deleted = self.catalog_state.setdefault("deleted", []) deleted[:] = [name for name in deleted if self._normalize_name(name) != normalized_new] added = self.catalog_state.setdefault("added", []) # Insert new names at the beginning so the newest appears at the top added.insert(0, new_name) self.add_input.clear() self._save_catalog_state() self.all_names = self._build_name_list() self.apply_filter() def delete_selected_names(self): checked_names = [] for row in range(self.table.rowCount()): name_item = self.table.item(row, 1) delete_item = self.table.item(row, 2) if not name_item or not delete_item: continue if delete_item.checkState() == Qt.Checked: checked_names.append(name_item.text().strip()) if not checked_names: QMessageBox.information(self, "Info", "Centang minimal satu nama untuk dihapus.") return deleted = self.catalog_state.setdefault("deleted", []) added = self.catalog_state.setdefault("added", []) checked_normalized = {self._normalize_name(name) for name in checked_names} for name in checked_names: normalized = self._normalize_name(name) if any(self._normalize_name(item) == normalized for item in added): added[:] = [item for item in added if self._normalize_name(item) != normalized] elif not any(self._normalize_name(item) == normalized for item in deleted): deleted.append(name) if self.selected_name and self._normalize_name(self.selected_name) in checked_normalized: self.selected_name = "" self._save_catalog_state() self.all_names = self._build_name_list() self.apply_filter() class EditNameDialog(QDialog): """Custom dialog to edit a student name with improved styling.""" def __init__(self, parent=None, initial_name=""): super().__init__(parent) self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WA_TranslucentBackground) self.initial_name = initial_name self.result_name = None self.initUI() def initUI(self): main_layout = QVBoxLayout() main_layout.setContentsMargins(16, 16, 16, 16) card = QDialog() card.setFixedWidth(420) card.setStyleSheet(""" QDialog { background: white; border-radius: 10px; } """) card_layout = QVBoxLayout(card) card_layout.setContentsMargins(20, 18, 20, 18) card_layout.setSpacing(10) title = QLabel("Edit Nama") title.setStyleSheet("font-size: 24px; font-weight: 700; color: #1f3b66; font-family: 'Segoe UI', Arial, sans-serif;") title.setAlignment(Qt.AlignLeft) card_layout.addWidget(title) label = QLabel("Nama baru:") label.setStyleSheet("font-size: 15px; color: #334155; font-weight: 600;") card_layout.addWidget(label) self.input = QLineEdit() self.input.setText(self.initial_name) self.input.setMinimumHeight(42) self.input.setStyleSheet(""" QLineEdit { border: 1px solid #d6deea; border-radius: 8px; padding: 7px 10px; font-size: 16px; color: #0f172a; background: #fbfdff; font-family: 'Segoe UI', Arial, sans-serif; } QLineEdit:focus { border-color: #2c7be5; background: white; } """) self.input.selectAll() card_layout.addWidget(self.input) # Buttons: Close on the left, Edit on the right (Close placed to left of Edit) btn_row = QHBoxLayout() btn_row.addStretch() cancel_btn = QPushButton("Tutup") cancel_btn.setFixedHeight(38) cancel_btn.setFixedWidth(96) cancel_btn.setCursor(Qt.PointingHandCursor) cancel_btn.setStyleSheet(""" QPushButton { background-color: white; color: #718096; border: 2px solid #e2e8f0; border-radius: 20px; font-size: 14px; font-weight: 600; } QPushButton:hover { background-color: #f7fafc; } """) cancel_btn.clicked.connect(self.on_cancel) btn_row.addWidget(cancel_btn) btn_row.addSpacing(10) edit_btn = QPushButton("Edit") edit_btn.setFixedHeight(38) edit_btn.setFixedWidth(96) edit_btn.setCursor(Qt.PointingHandCursor) edit_btn.setStyleSheet(""" QPushButton { background-color: #16a34a; color: white; border: none; border-radius: 20px; font-size: 16px; font-weight: 600; } QPushButton:hover { background-color: #15803d; } """) edit_btn.clicked.connect(self.on_edit) btn_row.addWidget(edit_btn) card_layout.addLayout(btn_row) from PyQt5.QtWidgets import QGraphicsDropShadowEffect from PyQt5.QtGui import QColor shadow = QGraphicsDropShadowEffect() shadow.setBlurRadius(24) shadow.setXOffset(0) shadow.setYOffset(8) shadow.setColor(QColor(0, 0, 0, 60)) card.setGraphicsEffect(shadow) main_layout.addWidget(card) self.setLayout(main_layout) def on_edit(self): text = " ".join(self.input.text().strip().split()) if not text: QMessageBox.warning(self, "Validasi", "Nama baru tidak boleh kosong.") return self.result_name = text self.accept() def on_cancel(self): self.result_name = None self.reject() def edit_selected_name(self): old_name = self.get_selected_name() if not old_name: QMessageBox.information(self, "Info", "Pilih satu nama terlebih dahulu.") return dialog = StudentNamePickerDialog.EditNameDialog(self, initial_name=old_name) result = dialog.exec_() if result != QDialog.Accepted or not dialog.result_name: return new_name = " ".join(dialog.result_name.strip().split()) if not new_name: QMessageBox.warning(self, "Validasi", "Nama baru tidak boleh kosong.") return normalized_new = self._normalize_name(new_name) normalized_old = self._normalize_name(old_name) if normalized_new == normalized_old: return if any(self._normalize_name(name) == normalized_new for name in self.all_names): QMessageBox.warning(self, "Validasi", "Nama baru sudah ada di daftar.") return added = self.catalog_state.setdefault("added", []) deleted = self.catalog_state.setdefault("deleted", []) if any(self._normalize_name(item) == normalized_old for item in added): added[:] = [item for item in added if self._normalize_name(item) != normalized_old] else: if not any(self._normalize_name(item) == normalized_old for item in deleted): deleted.append(old_name) deleted[:] = [item for item in deleted if self._normalize_name(item) != normalized_new] added[:] = [item for item in added if self._normalize_name(item) != normalized_new] added.insert(0, new_name) self.selected_name = new_name self._save_catalog_state() self.all_names = self._build_name_list() self.apply_filter() def pick_selected_name(self, *_): name = self.get_selected_name() if not name: QMessageBox.information(self, "Info", "Pilih nama dari tabel terlebih dahulu.") return self.selected_name = name self.accept() def accept(self): if not self.selected_name: self.selected_name = self.get_selected_name() super().accept() @staticmethod def show_picker(parent=None, initial_name=""): dialog = StudentNamePickerDialog(parent, initial_name=initial_name) result = dialog.exec_() if result == QDialog.Accepted and dialog.selected_name: return dialog.selected_name return None