E41222753_NinikYuniarsih_Ju.../gui/custom_notifications.py

1367 lines
50 KiB
Python

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