1866 lines
71 KiB
Python
1866 lines
71 KiB
Python
"""Element-aligned appointment drawer used by patient and diagnosis workspaces."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Callable, Mapping, Sequence
|
||
from contextlib import suppress
|
||
from types import MappingProxyType
|
||
from typing import Any
|
||
|
||
from PySide6.QtCore import (
|
||
QDate,
|
||
QEvent,
|
||
QPoint,
|
||
QRect,
|
||
QSize,
|
||
Qt,
|
||
QTime,
|
||
QTimer,
|
||
)
|
||
from PySide6.QtGui import QColor, QMouseEvent, QPainter, QPaintEvent, QPen, QResizeEvent
|
||
from PySide6.QtWidgets import (
|
||
QButtonGroup,
|
||
QComboBox,
|
||
QDialog,
|
||
QFrame,
|
||
QGraphicsDropShadowEffect,
|
||
QGraphicsOpacityEffect,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QLayout,
|
||
QLayoutItem,
|
||
QLineEdit,
|
||
QPlainTextEdit,
|
||
QPushButton,
|
||
QRadioButton,
|
||
QScrollArea,
|
||
QSizePolicy,
|
||
QStackedWidget,
|
||
QStyle,
|
||
QToolButton,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from ..core.appointment_modes import APPOINTMENT_MODES
|
||
from .theme import main_window_target_rect
|
||
from .widgets import (
|
||
display_text,
|
||
first_value,
|
||
friendly_error,
|
||
get_value,
|
||
invoke,
|
||
page_items,
|
||
run_async,
|
||
)
|
||
|
||
APPOINTMENT_DRAWER_QSS = r"""
|
||
QDialog#AppointmentDrawerOverlay {
|
||
background-color: transparent;
|
||
color: #1A1C1F;
|
||
font-size: 14px;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel {
|
||
background-color: #FFFFFF;
|
||
border-left: 1px solid #EDEDEE;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader {
|
||
background-color: #FFFFFF;
|
||
border: 0;
|
||
border-bottom: 1px solid #EDEDEE;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle {
|
||
color: #1A1C1F;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
|
||
min-width: 32px;
|
||
max-width: 32px;
|
||
min-height: 32px;
|
||
max-height: 32px;
|
||
padding: 0;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
background-color: transparent;
|
||
color: #606163;
|
||
font-size: 22px;
|
||
font-weight: 400;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover {
|
||
color: #1A1C1F;
|
||
background-color: #F0F0F0;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody,
|
||
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentSlotScroll,
|
||
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody > QWidget > QWidget,
|
||
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentSlotScroll > QWidget > QWidget {
|
||
border: 0;
|
||
background-color: #FFFFFF;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QWidget#AppointmentDrawerBodyContent {
|
||
background-color: #FFFFFF;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] {
|
||
color: #1A1C1F;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] {
|
||
color: #606163; font-size: 13px;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QComboBox,
|
||
QDialog#AppointmentDrawerOverlay QLineEdit,
|
||
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
||
min-height: 30px;
|
||
padding: 0 11px;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 9px;
|
||
background-color: #FFFFFF;
|
||
color: #1A1C1F;
|
||
selection-background-color: #EEF1FA;
|
||
selection-color: #1A1C1F; font-size: 14px;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
||
padding: 7px 11px; font-size: 14px;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QComboBox:hover,
|
||
QDialog#AppointmentDrawerOverlay QLineEdit:hover,
|
||
QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover {
|
||
border-color: #8B9AD9;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QComboBox:focus,
|
||
QDialog#AppointmentDrawerOverlay QLineEdit:focus,
|
||
QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus {
|
||
border: 2px solid #8B9AD9;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
|
||
width: 28px;
|
||
border: 0;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView {
|
||
background-color: #FFFFFF;
|
||
color: #1A1C1F;
|
||
border: 1px solid #EDEDEE;
|
||
selection-background-color: #EEF1FA;
|
||
selection-color: #1A1C1F;
|
||
outline: 0; font-size: 14px;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QRadioButton {
|
||
min-height: 24px;
|
||
spacing: 8px;
|
||
color: #1A1C1F;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 7px;
|
||
border: 1px solid #EDEDEE;
|
||
background-color: #FFFFFF;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover {
|
||
border-color: #4156C4;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked {
|
||
width: 4px;
|
||
height: 4px;
|
||
border: 5px solid #4F63D9;
|
||
border-radius: 7px;
|
||
background-color: #FFFFFF;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QRadioButton:focus {
|
||
color: #4F63D9;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
|
||
min-height: 38px;
|
||
max-height: 38px;
|
||
padding: 0;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 8px;
|
||
background-color: #FFFFFF;
|
||
color: #1A1C1F;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover {
|
||
color: #4156C4;
|
||
border-color: #4156C4;
|
||
background-color: #EEF1FA;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus {
|
||
border-color: #8B9AD9;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked {
|
||
color: #FFFFFF;
|
||
border-color: #4F63D9;
|
||
background-color: #4F63D9;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel {
|
||
background-color: #F7F7F7;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle {
|
||
color: #1A1C1F;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
|
||
min-height: 24px;
|
||
max-height: 24px;
|
||
padding: 0 7px;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
background-color: transparent;
|
||
color: #4F63D9;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover {
|
||
background-color: #EEF1FA;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] {
|
||
min-width: 110px;
|
||
min-height: 70px;
|
||
padding: 0 8px;
|
||
border: 2px solid #EDEDEE;
|
||
border-radius: 8px;
|
||
background-color: #FFFFFF;
|
||
color: #1A1C1F;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime {
|
||
color: #1A1C1F;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus {
|
||
padding: 0 8px;
|
||
border-radius: 4px;
|
||
background-color: #F7F7F7;
|
||
color: #606163;
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus {
|
||
color: #287B65;
|
||
background-color: #EAF9F3;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled {
|
||
color: #4156C4;
|
||
border-color: #4156C4;
|
||
background-color: #EEF1FA;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled {
|
||
border-color: #8B9AD9;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked {
|
||
color: #FFFFFF;
|
||
border-color: #4F63D9;
|
||
background: #4F63D9;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime[slotSelected="true"],
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] {
|
||
color: #FFFFFF;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] {
|
||
background-color: rgba(255, 255, 255, 46);
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled {
|
||
color: #8E8F90;
|
||
border-color: #EDEDEE;
|
||
background-color: #F7F7F7;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime:disabled,
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled {
|
||
color: #8E8F90;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled {
|
||
background-color: #F7F7F7;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
|
||
background-color: transparent;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText {
|
||
color: #606163;
|
||
font-size: 14px;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] {
|
||
background-color: #EEF1FA;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 9px;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] {
|
||
background-color: #FFF5E6;
|
||
border: 1px solid #F6E3C4;
|
||
border-radius: 9px;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] {
|
||
background-color: #FFF1F3;
|
||
border: 1px solid #F7D7DC;
|
||
border-radius: 9px;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] {
|
||
background-color: #EAF9F3;
|
||
border: 1px solid #D4F0E5;
|
||
border-radius: 9px;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel {
|
||
color: #4F63D9;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel {
|
||
color: #A9691D;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel {
|
||
color: #BE4B58;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel {
|
||
color: #287B65;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter {
|
||
background-color: #FFFFFF;
|
||
border: 0;
|
||
border-top: 1px solid #EDEDEE;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton {
|
||
min-height: 30px;
|
||
max-height: 30px;
|
||
padding: 0 15px;
|
||
border: 1px solid #EDEDEE;
|
||
border-radius: 9px;
|
||
background-color: #FFFFFF;
|
||
color: #1A1C1F;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover {
|
||
color: #4156C4;
|
||
border-color: #4156C4;
|
||
background-color: #EEF1FA;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus {
|
||
border-color: #8B9AD9;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] {
|
||
color: #FFFFFF;
|
||
border-color: #4F63D9;
|
||
background: #4F63D9;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover {
|
||
color: #FFFFFF;
|
||
border-color: #4156C4;
|
||
background-color: #4156C4;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled {
|
||
color: #FFFFFF;
|
||
border-color: #EDEDEE;
|
||
background-color: #8E8F90;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
|
||
background-color: rgba(255, 255, 255, 218);
|
||
border: 0;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText {
|
||
color: #606163;
|
||
font-size: 14px;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QScrollBar:vertical {
|
||
width: 6px;
|
||
margin: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical {
|
||
min-height: 30px;
|
||
border-radius: 3px;
|
||
background-color: #EDEDEE;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover {
|
||
background-color: #E4E4E5;
|
||
}
|
||
|
||
QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical,
|
||
QDialog#AppointmentDrawerOverlay QScrollBar::sub-line:vertical {
|
||
height: 0;
|
||
}
|
||
"""
|
||
|
||
_APPOINTMENT_DARK_REPLACEMENTS = (
|
||
("color: #FFFFFF", "color: #EEF2FF"),
|
||
("selection-color: #134E4A", "selection-color: #EEF2FF"),
|
||
("selection-background-color: #A0CFFF", "selection-background-color: #6675F5"),
|
||
("selection-background-color: #ECFEFF", "selection-background-color: #29334F"),
|
||
("rgba(255, 255, 255, 218)", "rgba(8, 11, 20, 224)"),
|
||
("rgba(255, 255, 255, 46)", "rgba(238, 242, 255, 30)"),
|
||
("#FFFFFF", "#101626"),
|
||
("#F8F9FA", "#151D31"),
|
||
("#F5F7FA", "#151D31"),
|
||
("#F4F4F5", "#151D31"),
|
||
("#F0F9FF", "#151D31"),
|
||
("#ECFEFF", "#1B2440"),
|
||
("#FDF6EC", "#151D31"),
|
||
("#FEF0F0", "#151D31"),
|
||
("#F0F9EB", "#151D31"),
|
||
("#DCDFE6", "#29334F"),
|
||
("#E2EBE8", "#29334F"),
|
||
("#D5E5E2", "#29334F"),
|
||
("#D9ECFF", "#29334F"),
|
||
("#FAECD8", "#29334F"),
|
||
("#FDE2E2", "#29334F"),
|
||
("#E1F3D8", "#29334F"),
|
||
("#134E4A", "#EEF2FF"),
|
||
("#5B7A76", "#9AA7C0"),
|
||
("#C0C4CC", "#9AA7C0"),
|
||
("#0891B2", "#6675F5"),
|
||
("#79BBFF", "#6675F5"),
|
||
("#66B1FF", "#78A7FF"),
|
||
("#A5F3FC", "#6675F5"),
|
||
("#A0CFFF", "#29334F"),
|
||
("#67C23A", "#49C6A5"),
|
||
("#E6A23C", "#E4B967"),
|
||
("#F56C6C", "#F07886"),
|
||
# Run last: the text-color rewrite above also matches the suffix of
|
||
# ``background-color``. Restore those surfaces after all palette mapping.
|
||
("background-color: #EEF2FF", "background-color: #101626"),
|
||
("background: #EEF2FF", "background: #101626"),
|
||
)
|
||
# Light is the default application theme. Keep the replacement table available
|
||
# for a future explicit dark-mode switch, but do not mutate the light source QSS.
|
||
|
||
|
||
def _as_int(value: Any, default: int = 0) -> int:
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def _as_bool(value: Any) -> bool:
|
||
if isinstance(value, str):
|
||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||
return bool(value)
|
||
|
||
|
||
def _invoke_first(repository: Any, names: Sequence[str], **kwargs: Any) -> Any:
|
||
for name in names:
|
||
if callable(getattr(repository, name, None)):
|
||
return invoke(repository, name, **kwargs)
|
||
return invoke(repository, names[0], **kwargs)
|
||
|
||
|
||
class FlowLayout(QLayout):
|
||
"""Compact wrapping layout based on Qt's canonical Flow Layout example."""
|
||
|
||
def __init__(
|
||
self,
|
||
parent: QWidget | None = None,
|
||
margin: int = 0,
|
||
horizontal_spacing: int = 10,
|
||
vertical_spacing: int = 10,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self._items: list[QLayoutItem] = []
|
||
self._horizontal_spacing = horizontal_spacing
|
||
self._vertical_spacing = vertical_spacing
|
||
self.setContentsMargins(margin, margin, margin, margin)
|
||
|
||
def addItem(self, item: QLayoutItem) -> None: # noqa: N802 - Qt API
|
||
self._items.append(item)
|
||
|
||
def count(self) -> int:
|
||
return len(self._items)
|
||
|
||
def itemAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt API
|
||
return self._items[index] if 0 <= index < len(self._items) else None
|
||
|
||
def takeAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt API
|
||
return self._items.pop(index) if 0 <= index < len(self._items) else None
|
||
|
||
def expandingDirections(self) -> Qt.Orientations: # noqa: N802 - Qt API
|
||
return Qt.Orientation(0)
|
||
|
||
def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt API
|
||
return True
|
||
|
||
def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt API
|
||
return self._do_layout(QRect(0, 0, width, 0), True)
|
||
|
||
def setGeometry(self, rect: QRect) -> None: # noqa: N802 - Qt API
|
||
super().setGeometry(rect)
|
||
self._do_layout(rect, False)
|
||
|
||
def sizeHint(self) -> QSize: # noqa: N802 - Qt API
|
||
return self.minimumSize()
|
||
|
||
def minimumSize(self) -> QSize: # noqa: N802 - Qt API
|
||
size = QSize()
|
||
for item in self._items:
|
||
size = size.expandedTo(item.minimumSize())
|
||
margins = self.contentsMargins()
|
||
return size + QSize(margins.left() + margins.right(), margins.top() + margins.bottom())
|
||
|
||
def _smart_spacing(self, pixel_metric: QStyle.PixelMetric) -> int:
|
||
parent = self.parent()
|
||
if isinstance(parent, QWidget):
|
||
return parent.style().pixelMetric(pixel_metric, None, parent)
|
||
return 6
|
||
|
||
def _do_layout(self, rect: QRect, test_only: bool) -> int:
|
||
left, top, right, bottom = self.getContentsMargins()
|
||
effective = rect.adjusted(left, top, -right, -bottom)
|
||
x = effective.x()
|
||
y = effective.y()
|
||
line_height = 0
|
||
for item in self._items:
|
||
widget = item.widget()
|
||
if widget is not None and widget.isHidden():
|
||
continue
|
||
horizontal = self._horizontal_spacing
|
||
if horizontal < 0:
|
||
horizontal = self._smart_spacing(QStyle.PixelMetric.PM_LayoutHorizontalSpacing)
|
||
vertical = self._vertical_spacing
|
||
if vertical < 0:
|
||
vertical = self._smart_spacing(QStyle.PixelMetric.PM_LayoutVerticalSpacing)
|
||
next_x = x + item.sizeHint().width() + horizontal
|
||
if next_x - horizontal > effective.right() and line_height > 0:
|
||
x = effective.x()
|
||
y += line_height + vertical
|
||
next_x = x + item.sizeHint().width() + horizontal
|
||
line_height = 0
|
||
if not test_only:
|
||
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
|
||
x = next_x
|
||
line_height = max(line_height, item.sizeHint().height())
|
||
return y + line_height - rect.y() + bottom
|
||
|
||
|
||
class _Spinner(QWidget):
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self._angle = 0
|
||
self.setFixedSize(30, 30)
|
||
self._timer = QTimer(self)
|
||
self._timer.setInterval(45)
|
||
self._timer.timeout.connect(self._advance)
|
||
|
||
def start(self) -> None:
|
||
self._timer.start()
|
||
self.show()
|
||
|
||
def stop(self) -> None:
|
||
self._timer.stop()
|
||
|
||
def _advance(self) -> None:
|
||
self._angle = (self._angle + 24) % 360
|
||
self.update()
|
||
|
||
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API
|
||
del event
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
pen = QPen(QColor("#4F63D9"), 3)
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
painter.setPen(pen)
|
||
painter.drawArc(self.rect().adjusted(4, 4, -4, -4), self._angle * 16, 255 * 16)
|
||
|
||
|
||
class _LoadingOverlay(QFrame):
|
||
def __init__(self, parent: QWidget) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("AppointmentLoadingOverlay")
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(20, 20, 20, 20)
|
||
layout.setSpacing(10)
|
||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.spinner = _Spinner(self)
|
||
layout.addWidget(self.spinner, 0, Qt.AlignmentFlag.AlignCenter)
|
||
self.label = QLabel("正在加载…")
|
||
self.label.setObjectName("AppointmentLoadingText")
|
||
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
layout.addWidget(self.label, 0, Qt.AlignmentFlag.AlignCenter)
|
||
self.hide()
|
||
|
||
def show_message(self, text: str) -> None:
|
||
self.label.setText(text)
|
||
self.setGeometry(self.parentWidget().rect())
|
||
self.raise_()
|
||
self.show()
|
||
self.spinner.start()
|
||
|
||
def clear(self) -> None:
|
||
self.spinner.stop()
|
||
self.hide()
|
||
|
||
|
||
class _Alert(QFrame):
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("AppointmentAlert")
|
||
self.setProperty("kind", "info")
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(12, 9, 12, 9)
|
||
layout.setSpacing(9)
|
||
self.icon = QLabel("i")
|
||
self.icon.setFixedSize(18, 18)
|
||
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.icon.setStyleSheet("font-weight:700;")
|
||
self.label = QLabel()
|
||
self.label.setWordWrap(True)
|
||
layout.addWidget(self.icon)
|
||
layout.addWidget(self.label, 1)
|
||
self.hide()
|
||
|
||
def show_message(self, text: str, kind: str = "info") -> None:
|
||
glyphs = {"info": "i", "warning": "!", "danger": "!", "success": "✓"}
|
||
self.icon.setText(glyphs.get(kind, "i"))
|
||
self.label.setText(text)
|
||
self.setProperty("kind", kind)
|
||
self.style().unpolish(self)
|
||
self.style().polish(self)
|
||
self.show()
|
||
|
||
def clear(self) -> None:
|
||
self.label.clear()
|
||
self.hide()
|
||
|
||
|
||
class _InlineEmpty(QWidget):
|
||
def __init__(self, text: str = "请先选择医生", parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setObjectName("AppointmentInlineEmpty")
|
||
self.setMinimumHeight(138)
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(12, 18, 12, 18)
|
||
layout.setSpacing(5)
|
||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.illustration = _EmptyIllustration(self)
|
||
layout.addWidget(self.illustration, 0, Qt.AlignmentFlag.AlignCenter)
|
||
self.label = QLabel(text)
|
||
self.label.setObjectName("AppointmentEmptyText")
|
||
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.label.setWordWrap(True)
|
||
layout.addWidget(self.label)
|
||
|
||
def set_text(self, text: str) -> None:
|
||
self.label.setText(text)
|
||
|
||
|
||
class _EmptyIllustration(QWidget):
|
||
"""Small neutral illustration matching Element's 80 px empty-state footprint."""
|
||
|
||
def __init__(self, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self.setFixedSize(80, 60)
|
||
self.setAccessibleName("空状态插图")
|
||
|
||
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API
|
||
del event
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(QColor("#F7F7F7"))
|
||
painter.drawEllipse(QRect(9, 48, 62, 8))
|
||
|
||
painter.setPen(QPen(QColor("#EDEDEE"), 1))
|
||
painter.setBrush(QColor("#FFFFFF"))
|
||
painter.drawRoundedRect(QRect(22, 21, 36, 27), 4, 4)
|
||
painter.setBrush(QColor("#F0F0F0"))
|
||
painter.drawRoundedRect(QRect(18, 15, 44, 12), 4, 4)
|
||
painter.setPen(QPen(QColor("#606163"), 2))
|
||
painter.drawLine(30, 35, 50, 35)
|
||
painter.drawLine(34, 41, 46, 41)
|
||
painter.end()
|
||
|
||
|
||
class _HoverLiftButton(QPushButton):
|
||
"""Button with a subtle two-pixel lift and blue-tinted shadow on hover."""
|
||
|
||
def __init__(self, text: str = "", parent: QWidget | None = None) -> None:
|
||
super().__init__(text, parent)
|
||
self._lifted = False
|
||
self._base_position = QPoint()
|
||
|
||
def enterEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
|
||
if self.isEnabled() and not self._lifted:
|
||
self._base_position = self.pos()
|
||
self.move(self.x(), self.y() - 2)
|
||
shadow = QGraphicsDropShadowEffect(self)
|
||
shadow.setBlurRadius(12)
|
||
shadow.setOffset(0, 4)
|
||
shadow.setColor(QColor(26, 28, 31, 72))
|
||
self.setGraphicsEffect(shadow)
|
||
self._lifted = True
|
||
super().enterEvent(event)
|
||
|
||
def leaveEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
|
||
if self._lifted:
|
||
self.move(self._base_position)
|
||
self.setGraphicsEffect(None)
|
||
self._lifted = False
|
||
super().leaveEvent(event)
|
||
|
||
|
||
class _SlotCard(_HoverLiftButton):
|
||
"""Two-line time card matching Element's independent time/status typography."""
|
||
|
||
def __init__(self, time_text: str, status_text: str) -> None:
|
||
super().__init__()
|
||
self.setAccessibleName(f"{time_text} {status_text}")
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(8, 8, 8, 8)
|
||
layout.setSpacing(6)
|
||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.time_label = QLabel(time_text)
|
||
self.time_label.setObjectName("AppointmentSlotTime")
|
||
self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.time_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||
layout.addWidget(self.time_label)
|
||
self.status_label = QLabel(status_text)
|
||
self.status_label.setObjectName("AppointmentSlotStatus")
|
||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||
layout.addWidget(self.status_label)
|
||
self.toggled.connect(self._sync_label_selection)
|
||
|
||
def _sync_label_selection(self, checked: bool) -> None:
|
||
# Qt does not reliably resolve ancestor pseudo states for child labels.
|
||
for label in (self.time_label, self.status_label):
|
||
label.setProperty("slotSelected", checked)
|
||
label.style().unpolish(label)
|
||
label.style().polish(label)
|
||
label.update()
|
||
|
||
|
||
class AppointmentDrawer(QDialog):
|
||
"""Full-window modal overlay with an Element-style 60% right drawer."""
|
||
|
||
CHANNEL_DETAIL_NAMES = {"自媒体4H", "自媒体3Q", "自媒体3H", "自媒体2H", "自媒体2Q"}
|
||
|
||
def __init__(
|
||
self,
|
||
row: Any,
|
||
repository: Any = None,
|
||
parent: QWidget | None = None,
|
||
*,
|
||
autoload: bool = True,
|
||
async_runner: Callable[..., Any] | None = None,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.row = row
|
||
self.repository = repository
|
||
self._run_async = async_runner or run_async
|
||
self.diagnosis_id = _as_int(first_value(row, "diagnosis_id", "id"))
|
||
self._load_generation = 0
|
||
self._roster_generation = 0
|
||
self._slot_generation = 0
|
||
self._setting = False
|
||
self._initial_loaded = False
|
||
self._today_blocking = False
|
||
self._channel_names: dict[str, str] = {}
|
||
self._doctor_buttons: dict[int, QRadioButton] = {}
|
||
self._date_buttons: dict[str, _HoverLiftButton] = {}
|
||
self._slot_buttons: dict[str, _HoverLiftButton] = {}
|
||
self._slot_available: dict[str, bool] = {}
|
||
self._slot_restore_selection = ""
|
||
self._active_loading = ""
|
||
self._owner = parent.window() if parent is not None else None
|
||
|
||
self.setObjectName("AppointmentDrawerOverlay")
|
||
self.setWindowTitle("预约问诊")
|
||
self.setModal(True)
|
||
self.setWindowModality(Qt.WindowModality.WindowModal)
|
||
self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||
self.setStyleSheet(APPOINTMENT_DRAWER_QSS)
|
||
self.resize(self._owner.size() if self._owner is not None else QSize(1024, 640))
|
||
if self._owner is not None:
|
||
self._owner.installEventFilter(self)
|
||
|
||
root = QHBoxLayout(self)
|
||
root.setContentsMargins(0, 0, 0, 0)
|
||
root.setSpacing(0)
|
||
root.addStretch(1)
|
||
|
||
self.panel = QFrame()
|
||
self.panel.setObjectName("AppointmentDrawerPanel")
|
||
self.panel.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
|
||
panel_layout = QVBoxLayout(self.panel)
|
||
panel_layout.setContentsMargins(0, 0, 0, 0)
|
||
panel_layout.setSpacing(0)
|
||
root.addWidget(self.panel)
|
||
|
||
self.header = self._build_header()
|
||
panel_layout.addWidget(self.header)
|
||
self.body_scroll = QScrollArea()
|
||
self.body_scroll.setObjectName("AppointmentDrawerBody")
|
||
self.body_scroll.setWidgetResizable(True)
|
||
self.body_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
self.body_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||
self.body_content = QWidget()
|
||
self.body_content.setObjectName("AppointmentDrawerBodyContent")
|
||
self.body_layout = QVBoxLayout(self.body_content)
|
||
self.body_layout.setContentsMargins(16, 16, 16, 20)
|
||
self.body_layout.setSpacing(14)
|
||
self.body_scroll.setWidget(self.body_content)
|
||
panel_layout.addWidget(self.body_scroll, 1)
|
||
self.footer = self._build_footer()
|
||
panel_layout.addWidget(self.footer)
|
||
|
||
self._body_viewport = self.body_scroll.viewport()
|
||
self.loading_overlay = _LoadingOverlay(self._body_viewport)
|
||
self._body_viewport.installEventFilter(self)
|
||
|
||
self._build_form()
|
||
self._update_drawer_width()
|
||
self._sync_channel_detail_visibility()
|
||
self._show_time_empty("请先选择医生")
|
||
self._update_submit_state()
|
||
if autoload:
|
||
self._load_initial_options()
|
||
|
||
@property
|
||
def drawer_width(self) -> int:
|
||
return self.panel.width()
|
||
|
||
@property
|
||
def doctor_buttons(self) -> Mapping[int, QRadioButton]:
|
||
return MappingProxyType(self._doctor_buttons)
|
||
|
||
@property
|
||
def date_buttons(self) -> Mapping[str, QPushButton]:
|
||
return MappingProxyType(self._date_buttons)
|
||
|
||
@property
|
||
def slot_buttons(self) -> Mapping[str, QPushButton]:
|
||
return MappingProxyType(self._slot_buttons)
|
||
|
||
@staticmethod
|
||
def _rows(value: Any, key: str = "") -> list[Any]:
|
||
rows = page_items(value)
|
||
if rows:
|
||
return rows
|
||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||
return list(value)
|
||
if isinstance(value, Mapping):
|
||
candidate = value.get(key) if key else None
|
||
if isinstance(candidate, Sequence) and not isinstance(candidate, (str, bytes)):
|
||
return list(candidate)
|
||
data = value.get("data")
|
||
if isinstance(data, Mapping):
|
||
candidate = data.get(key)
|
||
if isinstance(candidate, Sequence) and not isinstance(candidate, (str, bytes)):
|
||
return list(candidate)
|
||
return []
|
||
|
||
def _build_header(self) -> QWidget:
|
||
header = QFrame()
|
||
header.setObjectName("AppointmentDrawerHeader")
|
||
header.setFixedHeight(58)
|
||
layout = QHBoxLayout(header)
|
||
layout.setContentsMargins(16, 0, 12, 0)
|
||
title = QLabel("预约问诊")
|
||
title.setObjectName("AppointmentDrawerTitle")
|
||
layout.addWidget(title)
|
||
layout.addStretch(1)
|
||
close_button = QToolButton()
|
||
close_button.setObjectName("AppointmentDrawerClose")
|
||
close_button.setText("×")
|
||
close_button.setToolTip("关闭")
|
||
close_button.clicked.connect(self.reject)
|
||
layout.addWidget(close_button)
|
||
self.close_button = close_button
|
||
return header
|
||
|
||
def _build_footer(self) -> QWidget:
|
||
footer = QFrame()
|
||
footer.setObjectName("AppointmentDrawerFooter")
|
||
layout = QHBoxLayout(footer)
|
||
layout.setContentsMargins(16, 12, 16, 12)
|
||
layout.setSpacing(12)
|
||
layout.addStretch(1)
|
||
self.cancel_button = QPushButton("取消")
|
||
self.cancel_button.clicked.connect(self.reject)
|
||
layout.addWidget(self.cancel_button)
|
||
self.ok_button = QPushButton("确定")
|
||
self.ok_button.setProperty("primary", True)
|
||
self.ok_button.setEnabled(False)
|
||
self.ok_button.clicked.connect(self.accept)
|
||
layout.addWidget(self.ok_button)
|
||
return footer
|
||
|
||
def _build_form(self) -> None:
|
||
self.banner = _Alert()
|
||
self.body_layout.addWidget(self.banner)
|
||
self.conflict_alert = _Alert()
|
||
self.body_layout.addWidget(self.conflict_alert)
|
||
|
||
self.form_labels: list[QLabel] = []
|
||
self.last_visit_label = QLabel("无就诊记录")
|
||
self.last_visit_label.setProperty("appointmentMuted", True)
|
||
self.last_visit_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
self._add_form_row("上次就诊:", self.last_visit_label)
|
||
|
||
method_container = QWidget()
|
||
method_layout = QHBoxLayout(method_container)
|
||
method_layout.setContentsMargins(0, 0, 0, 0)
|
||
method_layout.setSpacing(12)
|
||
self.appointment_method_radio = QRadioButton("选择时段预约有专医生")
|
||
self.appointment_method_radio.setChecked(True)
|
||
method_layout.addWidget(self.appointment_method_radio)
|
||
method_layout.addStretch(1)
|
||
self._add_form_row("预约方式:", method_container)
|
||
|
||
type_container = QWidget()
|
||
type_layout = QHBoxLayout(type_container)
|
||
type_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.appointment_type_radio = QRadioButton("视频问诊")
|
||
self.appointment_type_radio.setChecked(True)
|
||
type_layout.addWidget(self.appointment_type_radio)
|
||
self.text_appointment_type_radio = QRadioButton("图文问诊")
|
||
type_layout.addWidget(self.text_appointment_type_radio)
|
||
self.appointment_type_group = QButtonGroup(self)
|
||
self.appointment_type_group.addButton(self.appointment_type_radio, 0)
|
||
self.appointment_type_group.addButton(self.text_appointment_type_radio, 1)
|
||
type_layout.addStretch(1)
|
||
self._add_form_row("预约类型:", type_container)
|
||
self.appointment_type = QComboBox(self)
|
||
for label, value in APPOINTMENT_MODES:
|
||
self.appointment_type.addItem(label, value)
|
||
self.appointment_type_group.idClicked.connect(self.appointment_type.setCurrentIndex)
|
||
self.appointment_type.currentIndexChanged.connect(
|
||
lambda index: self.appointment_type_group.button(index).setChecked(True)
|
||
if self.appointment_type_group.button(index) is not None else None
|
||
)
|
||
self.appointment_type.hide()
|
||
|
||
patient_container = QWidget()
|
||
patient_layout = QHBoxLayout(patient_container)
|
||
patient_layout.setContentsMargins(0, 0, 0, 0)
|
||
patient_name = display_text(
|
||
first_value(self.row, "patient_name", "name", default="当前患者")
|
||
)
|
||
self.patient_radio = QRadioButton(patient_name)
|
||
self.patient_radio.setChecked(True)
|
||
patient_layout.addWidget(self.patient_radio)
|
||
patient_layout.addStretch(1)
|
||
self._add_form_row("选择患者:", patient_container)
|
||
|
||
self.channel_source = QComboBox()
|
||
self.channel_source.setMaximumWidth(360)
|
||
self.channel_source.addItem("正在加载渠道…", "")
|
||
self.channel_source.currentIndexChanged.connect(self._channel_changed)
|
||
self._add_form_row("渠道来源:", self.channel_source, required=True)
|
||
|
||
self.channel_source_detail = QLineEdit()
|
||
self.channel_source_detail.setMaximumWidth(360)
|
||
self.channel_source_detail.setMaxLength(128)
|
||
self.channel_source_detail.setPlaceholderText("请输入自媒体相关补充内容")
|
||
self.channel_source_detail.textChanged.connect(self._update_submit_state)
|
||
self.channel_detail_row = self._add_form_row(
|
||
"自媒体补充:", self.channel_source_detail, required=True
|
||
)
|
||
self.channel_detail_row.hide()
|
||
|
||
self.doctor_group = QButtonGroup(self)
|
||
self.doctor_group.setExclusive(True)
|
||
self.doctor_container = QWidget()
|
||
self.doctor_flow = FlowLayout(
|
||
self.doctor_container, horizontal_spacing=12, vertical_spacing=8
|
||
)
|
||
self.doctor_empty = QLabel("正在加载医生…")
|
||
self.doctor_empty.setProperty("appointmentMuted", True)
|
||
self.doctor_flow.addWidget(self.doctor_empty)
|
||
self._add_form_row("预约医生:", self.doctor_container, align_top=True)
|
||
self.doctor_combo = QComboBox(self)
|
||
self.doctor_combo.addItem("正在加载医生…", 0)
|
||
self.doctor_combo.hide()
|
||
self.doctor_combo.currentIndexChanged.connect(self._doctor_changed)
|
||
|
||
self.time_container = QWidget()
|
||
self.time_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
|
||
time_layout = QVBoxLayout(self.time_container)
|
||
time_layout.setContentsMargins(0, 0, 0, 0)
|
||
time_layout.setSpacing(0)
|
||
self.time_stack = QStackedWidget()
|
||
self.time_stack.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
|
||
self.time_empty = _InlineEmpty("请先选择医生")
|
||
self.time_stack.addWidget(self.time_empty)
|
||
self.appointment_time_container = QWidget()
|
||
self.appointment_time_container.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum
|
||
)
|
||
appointment_time_layout = QVBoxLayout(self.appointment_time_container)
|
||
appointment_time_layout.setContentsMargins(0, 0, 0, 0)
|
||
appointment_time_layout.setSpacing(20)
|
||
self.date_container = QWidget()
|
||
self.date_flow = FlowLayout(self.date_container, horizontal_spacing=10, vertical_spacing=10)
|
||
appointment_time_layout.addWidget(self.date_container)
|
||
self.slots_panel = QFrame()
|
||
self.slots_panel.setObjectName("AppointmentSlotsPanel")
|
||
self.slots_panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
|
||
self.slots_panel.setFixedHeight(164)
|
||
slots_panel_layout = QVBoxLayout(self.slots_panel)
|
||
slots_panel_layout.setContentsMargins(16, 16, 16, 16)
|
||
slots_panel_layout.setSpacing(14)
|
||
slots_header = QHBoxLayout()
|
||
slots_title = QLabel("可预约时段")
|
||
slots_title.setObjectName("AppointmentSlotsTitle")
|
||
slots_header.addWidget(slots_title)
|
||
slots_header.addStretch(1)
|
||
self.refresh_slots_button = QPushButton("刷新")
|
||
self.refresh_slots_button.setObjectName("AppointmentRefreshSlots")
|
||
self.refresh_slots_button.clicked.connect(self._refresh_slots)
|
||
slots_header.addWidget(self.refresh_slots_button)
|
||
slots_panel_layout.addLayout(slots_header)
|
||
self.slot_state_stack = QStackedWidget()
|
||
self.slot_state_stack.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
self.slot_state_stack.setFixedHeight(94)
|
||
self.slot_empty = _InlineEmpty("请选择日期")
|
||
self.slot_empty.setMinimumHeight(92)
|
||
self.slot_state_stack.addWidget(self.slot_empty)
|
||
self.slot_scroll = QScrollArea()
|
||
self.slot_scroll.setObjectName("AppointmentSlotScroll")
|
||
self.slot_scroll.setWidgetResizable(True)
|
||
self.slot_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
self.slot_scroll.setMinimumHeight(94)
|
||
self.slot_scroll.setMaximumHeight(450)
|
||
self.slot_scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
self.slot_content = QWidget()
|
||
self.slot_flow = FlowLayout(self.slot_content, horizontal_spacing=10, vertical_spacing=10)
|
||
self.slot_scroll.setWidget(self.slot_content)
|
||
self._slot_viewport = self.slot_scroll.viewport()
|
||
self._slot_viewport.installEventFilter(self)
|
||
self.slot_state_stack.addWidget(self.slot_scroll)
|
||
slots_panel_layout.addWidget(self.slot_state_stack)
|
||
appointment_time_layout.addWidget(self.slots_panel)
|
||
self.time_stack.addWidget(self.appointment_time_container)
|
||
time_layout.addWidget(self.time_stack)
|
||
self._add_form_row("预约时间:", self.time_container, align_top=True)
|
||
self.date_combo = QComboBox(self)
|
||
self.date_combo.addItem("请先选择医生", "")
|
||
self.date_combo.hide()
|
||
self.date_combo.currentIndexChanged.connect(self._date_changed)
|
||
self.slot_combo = QComboBox(self)
|
||
self.slot_combo.addItem("请先选择排班日期", "")
|
||
self.slot_combo.hide()
|
||
self.slot_combo.currentIndexChanged.connect(self._slot_selection_changed)
|
||
self.slot_group = QButtonGroup(self)
|
||
self.slot_group.setExclusive(True)
|
||
|
||
self.remark = QPlainTextEdit()
|
||
self.remark.setPlaceholderText("请输入备注信息")
|
||
self.remark.setFixedHeight(70)
|
||
self._add_form_row("备注:", self.remark, align_top=True)
|
||
self.body_layout.addStretch(1)
|
||
|
||
def _add_form_row(
|
||
self,
|
||
label_text: str,
|
||
field: QWidget,
|
||
*,
|
||
required: bool = False,
|
||
align_top: bool = False,
|
||
) -> QWidget:
|
||
row = QWidget()
|
||
layout = QHBoxLayout(row)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(12)
|
||
label = QLabel(
|
||
f"{label_text[:-1]} *:" if required and label_text.endswith(":") else label_text
|
||
)
|
||
label.setProperty("appointmentLabel", True)
|
||
label.setFixedWidth(100)
|
||
label.setAlignment(
|
||
Qt.AlignmentFlag.AlignRight
|
||
| (Qt.AlignmentFlag.AlignTop if align_top else Qt.AlignmentFlag.AlignVCenter)
|
||
)
|
||
if align_top:
|
||
label.setContentsMargins(0, 7, 0, 0)
|
||
self.form_labels.append(label)
|
||
layout.addWidget(label)
|
||
layout.addWidget(field, 1)
|
||
self.body_layout.addWidget(row)
|
||
return row
|
||
|
||
def _load_initial_options(self) -> None:
|
||
self._load_generation += 1
|
||
generation = self._load_generation
|
||
diagnosis_id = self.diagnosis_id
|
||
today = QDate.currentDate().toString("yyyy-MM-dd")
|
||
self._set_loading("initial", "正在加载医生、渠道与挂号状态…")
|
||
|
||
def query() -> dict[str, Any]:
|
||
doctors = _invoke_first(self.repository, ("list_diagnosis_doctors",))
|
||
channels = invoke(self.repository, "get_dictionary", dictionary_type="channels")
|
||
appointments = _invoke_first(
|
||
self.repository,
|
||
("list_appointments",),
|
||
patient_id=diagnosis_id,
|
||
start_date=today,
|
||
end_date=today,
|
||
page_no=1,
|
||
page_size=50,
|
||
)
|
||
try:
|
||
last_visits = _invoke_first(
|
||
self.repository,
|
||
("list_appointments",),
|
||
patient_id=diagnosis_id,
|
||
status=3,
|
||
page_no=1,
|
||
page_size=1,
|
||
)
|
||
except Exception:
|
||
last_visits = []
|
||
return {
|
||
"doctors": doctors,
|
||
"channels": channels,
|
||
"appointments": appointments,
|
||
"last_visits": last_visits,
|
||
}
|
||
|
||
self._run_async(
|
||
query,
|
||
on_success=lambda result: self._apply_initial_options(result, generation),
|
||
on_error=lambda error: self._load_error(error, generation),
|
||
)
|
||
|
||
def _apply_initial_options(self, result: Any, generation: int) -> None:
|
||
if generation != self._load_generation:
|
||
return
|
||
doctors = self._rows(get_value(result, "doctors", []))
|
||
channels = self._rows(get_value(result, "channels", []), "channels")
|
||
appointments = self._rows(get_value(result, "appointments", []))
|
||
last_visits = self._rows(get_value(result, "last_visits", []))
|
||
self._today_blocking = any(
|
||
_as_int(first_value(item, "status", "appointment_status"), -1) in {1, 4}
|
||
for item in appointments
|
||
)
|
||
self._set_last_visit(last_visits)
|
||
self._setting = True
|
||
try:
|
||
self._populate_doctors(doctors)
|
||
self.channel_source.clear()
|
||
self.channel_source.addItem("请选择渠道来源", "")
|
||
self._channel_names.clear()
|
||
ordered_channels = sorted(
|
||
(
|
||
item
|
||
for item in channels
|
||
if _as_int(first_value(item, "status", default=1), 1) != 0
|
||
),
|
||
key=lambda item: (
|
||
-_as_int(first_value(item, "sort", default=0)),
|
||
-_as_int(first_value(item, "id", default=0)),
|
||
),
|
||
)
|
||
for channel in ordered_channels:
|
||
value = str(first_value(channel, "value", "id", default="") or "")
|
||
name = display_text(first_value(channel, "name", "label"), "")
|
||
if not value or not name:
|
||
continue
|
||
self.channel_source.addItem(name, value)
|
||
self._channel_names[value] = name
|
||
finally:
|
||
self._setting = False
|
||
self._initial_loaded = bool(doctors and self._channel_names)
|
||
self._sync_channel_detail_visibility()
|
||
self._update_conflict_alert()
|
||
self._clear_loading("initial")
|
||
if not doctors:
|
||
self.doctor_empty.setText("暂无可预约医生")
|
||
self.doctor_empty.show()
|
||
self._show_time_empty("暂无可预约医生")
|
||
self.banner.show_message("没有可预约医生,已停止提交。", "warning")
|
||
elif not self._channel_names:
|
||
self.banner.show_message("没有可用渠道字典,已停止提交。", "warning")
|
||
else:
|
||
self.banner.clear()
|
||
if not doctors:
|
||
pass
|
||
elif self.doctor_combo.currentData():
|
||
self._doctor_changed()
|
||
else:
|
||
self._show_time_empty("请先选择医生")
|
||
self._update_submit_state()
|
||
|
||
def _set_last_visit(self, rows: Sequence[Any]) -> None:
|
||
if rows:
|
||
item = rows[0]
|
||
date_text = str(first_value(item, "appointment_date", "date", default="") or "")
|
||
time_text = str(
|
||
first_value(
|
||
item,
|
||
"appointment_time",
|
||
"time_text",
|
||
"appointment_time_text",
|
||
default="",
|
||
)
|
||
or ""
|
||
)
|
||
rendered = " ".join(part for part in (date_text, time_text) if part)
|
||
self.last_visit_label.setText(rendered or "无就诊记录")
|
||
return
|
||
seeded = first_value(
|
||
self.row,
|
||
"last_visit",
|
||
"last_visit_at",
|
||
"last_appointment_at",
|
||
default="",
|
||
)
|
||
self.last_visit_label.setText(display_text(seeded, "无就诊记录"))
|
||
|
||
def _populate_doctors(self, doctors: Sequence[Any]) -> None:
|
||
self._clear_flow(self.doctor_flow)
|
||
self._doctor_buttons.clear()
|
||
self.doctor_group = QButtonGroup(self)
|
||
self.doctor_group.setExclusive(True)
|
||
self.doctor_combo.clear()
|
||
self.doctor_combo.addItem("请选择医生", 0)
|
||
preferred = _as_int(first_value(self.row, "appointment_doctor_id", "doctor_id", default=0))
|
||
selected_index = 0
|
||
for doctor in doctors:
|
||
doctor_id = _as_int(first_value(doctor, "id", "doctor_id"))
|
||
if doctor_id <= 0:
|
||
continue
|
||
name = display_text(first_value(doctor, "name", "doctor_name"))
|
||
self.doctor_combo.addItem(name, doctor_id)
|
||
button = QRadioButton(name)
|
||
button.setProperty("doctorId", doctor_id)
|
||
button.clicked.connect(
|
||
lambda _checked=False, value=doctor_id: self._select_doctor(value)
|
||
)
|
||
self.doctor_group.addButton(button, doctor_id)
|
||
self.doctor_flow.addWidget(button)
|
||
self._doctor_buttons[doctor_id] = button
|
||
if doctor_id == preferred:
|
||
selected_index = self.doctor_combo.count() - 1
|
||
if not self._doctor_buttons:
|
||
self.doctor_empty = QLabel("暂无可预约医生")
|
||
self.doctor_empty.setProperty("appointmentMuted", True)
|
||
self.doctor_flow.addWidget(self.doctor_empty)
|
||
else:
|
||
self.doctor_combo.setCurrentIndex(selected_index)
|
||
if selected_index > 0:
|
||
selected_id = _as_int(self.doctor_combo.currentData())
|
||
button = self._doctor_buttons.get(selected_id)
|
||
if button is not None:
|
||
button.setChecked(True)
|
||
|
||
def _load_error(self, error: Exception, generation: int) -> None:
|
||
if generation != self._load_generation:
|
||
return
|
||
self._initial_loaded = False
|
||
self._clear_loading("initial")
|
||
self.banner.show_message(f"预约基础数据加载失败:{friendly_error(error)}", "danger")
|
||
self._show_time_empty("基础数据加载失败")
|
||
self._update_submit_state()
|
||
|
||
def _select_doctor(self, doctor_id: int) -> None:
|
||
index = self.doctor_combo.findData(doctor_id)
|
||
if index >= 0 and index != self.doctor_combo.currentIndex():
|
||
self.doctor_combo.setCurrentIndex(index)
|
||
elif index >= 0:
|
||
self._doctor_changed()
|
||
|
||
def _doctor_changed(self) -> None:
|
||
if self._setting:
|
||
return
|
||
doctor_id = _as_int(self.doctor_combo.currentData())
|
||
for value, button in self._doctor_buttons.items():
|
||
button.setChecked(value == doctor_id)
|
||
self._clear_dates()
|
||
self._clear_slots("请先选择排班日期")
|
||
if doctor_id <= 0:
|
||
self._show_time_empty("请先选择医生")
|
||
self._update_submit_state()
|
||
return
|
||
self._show_time_empty("正在加载医生排班…")
|
||
self._roster_generation += 1
|
||
generation = self._roster_generation
|
||
start = QDate.currentDate().toString("yyyy-MM-dd")
|
||
end = QDate.currentDate().addDays(6).toString("yyyy-MM-dd")
|
||
query = MappingProxyType(
|
||
{
|
||
"doctor_id": doctor_id,
|
||
"start_date": start,
|
||
"end_date": end,
|
||
"status": 1,
|
||
"page_no": 1,
|
||
"page_size": 100,
|
||
}
|
||
)
|
||
self._set_loading("roster", "正在加载医生排班…")
|
||
self._run_async(
|
||
lambda: _invoke_first(
|
||
self.repository,
|
||
("list_appointment_rosters", "appointment_rosters", "roster_lists"),
|
||
**query,
|
||
),
|
||
on_success=lambda result: self._apply_rosters(result, doctor_id, generation),
|
||
on_error=lambda error: self._roster_error(error, doctor_id, generation),
|
||
)
|
||
|
||
def _apply_rosters(self, result: Any, doctor_id: int, generation: int) -> None:
|
||
if generation != self._roster_generation or doctor_id != _as_int(
|
||
self.doctor_combo.currentData()
|
||
):
|
||
return
|
||
today = QDate.currentDate()
|
||
end = today.addDays(6)
|
||
dates: set[str] = set()
|
||
for item in self._rows(result):
|
||
raw = str(first_value(item, "date", "appointment_date", "roster_date", default=""))
|
||
parsed = QDate.fromString(raw[:10], "yyyy-MM-dd")
|
||
if parsed.isValid() and today <= parsed <= end:
|
||
dates.add(parsed.toString("yyyy-MM-dd"))
|
||
ordered = sorted(dates)
|
||
self._setting = True
|
||
try:
|
||
self._populate_dates(ordered)
|
||
if ordered:
|
||
today_value = today.toString("yyyy-MM-dd")
|
||
preferred = next(
|
||
(
|
||
value
|
||
for value in ordered
|
||
if not (self._today_blocking and value == today_value)
|
||
),
|
||
ordered[0],
|
||
)
|
||
self.date_combo.setCurrentIndex(self.date_combo.findData(preferred))
|
||
self._sync_date_buttons(preferred)
|
||
finally:
|
||
self._setting = False
|
||
self._clear_loading("roster")
|
||
if not ordered:
|
||
self._show_time_empty("该医生暂无排班")
|
||
self.banner.show_message("该医生未来 7 天暂无可预约排班。", "warning")
|
||
self._update_conflict_alert()
|
||
self._update_submit_state()
|
||
return
|
||
self.banner.clear()
|
||
self.time_stack.setCurrentWidget(self.appointment_time_container)
|
||
self._date_changed()
|
||
|
||
def _roster_error(self, error: Exception, doctor_id: int, generation: int) -> None:
|
||
if generation != self._roster_generation or doctor_id != _as_int(
|
||
self.doctor_combo.currentData()
|
||
):
|
||
return
|
||
self._clear_loading("roster")
|
||
self._show_time_empty("排班加载失败")
|
||
self.banner.show_message(f"排班加载失败:{friendly_error(error)}", "danger")
|
||
self._update_submit_state()
|
||
|
||
def _populate_dates(self, dates: Sequence[str]) -> None:
|
||
self._clear_flow(self.date_flow)
|
||
self._date_buttons.clear()
|
||
self.date_combo.clear()
|
||
self.date_combo.addItem("请选择排班日期", "")
|
||
week_days = ["日", "一", "二", "三", "四", "五", "六"]
|
||
for value in dates:
|
||
parsed = QDate.fromString(value, "yyyy-MM-dd")
|
||
if not parsed.isValid():
|
||
continue
|
||
label = f"{parsed.toString('MM月dd日')} ({week_days[parsed.dayOfWeek() % 7]})"
|
||
self.date_combo.addItem(label, value)
|
||
button = _HoverLiftButton(label)
|
||
button.setProperty("appointmentDate", True)
|
||
button.setCheckable(True)
|
||
button.setFixedSize(130, 40)
|
||
button.clicked.connect(lambda _checked=False, date=value: self._select_date(date))
|
||
self.date_flow.addWidget(button)
|
||
self._date_buttons[value] = button
|
||
|
||
def _select_date(self, value: str) -> None:
|
||
index = self.date_combo.findData(value)
|
||
if index >= 0 and index != self.date_combo.currentIndex():
|
||
self.date_combo.setCurrentIndex(index)
|
||
elif index >= 0:
|
||
self._date_changed()
|
||
|
||
def _date_changed(self) -> None:
|
||
if self._setting:
|
||
return
|
||
doctor_id = _as_int(self.doctor_combo.currentData())
|
||
appointment_date = str(self.date_combo.currentData() or "")
|
||
self._sync_date_buttons(appointment_date)
|
||
self._clear_slots(
|
||
"正在加载可用号源…" if doctor_id and appointment_date else "请选择排班日期"
|
||
)
|
||
self._update_conflict_alert()
|
||
if doctor_id <= 0 or not appointment_date:
|
||
self._update_submit_state()
|
||
return
|
||
self._request_slots(doctor_id, appointment_date)
|
||
|
||
def _request_slots(
|
||
self,
|
||
doctor_id: int,
|
||
appointment_date: str,
|
||
*,
|
||
restore_selection: str = "",
|
||
) -> None:
|
||
self._slot_generation += 1
|
||
generation = self._slot_generation
|
||
self._slot_restore_selection = restore_selection
|
||
query = MappingProxyType(
|
||
{
|
||
"doctor_id": doctor_id,
|
||
"appointment_date": appointment_date,
|
||
"period": "all",
|
||
}
|
||
)
|
||
self.refresh_slots_button.setEnabled(False)
|
||
self.refresh_slots_button.setText("刷新中…")
|
||
loading_text = "正在刷新可用号源…" if restore_selection else "正在加载可用号源…"
|
||
self._set_loading("slots", loading_text)
|
||
self._run_async(
|
||
lambda: _invoke_first(
|
||
self.repository,
|
||
("get_available_appointment_slots", "available_appointment_slots"),
|
||
**query,
|
||
),
|
||
on_success=lambda result: self._apply_slots(
|
||
result, doctor_id, appointment_date, generation
|
||
),
|
||
on_error=lambda error: self._slot_error(error, doctor_id, appointment_date, generation),
|
||
)
|
||
|
||
def _apply_slots(
|
||
self, result: Any, doctor_id: int, appointment_date: str, generation: int
|
||
) -> None:
|
||
if (
|
||
generation != self._slot_generation
|
||
or doctor_id != _as_int(self.doctor_combo.currentData())
|
||
or appointment_date != str(self.date_combo.currentData() or "")
|
||
):
|
||
return
|
||
rows = self._rows(result, "slots")
|
||
today = QDate.currentDate().toString("yyyy-MM-dd")
|
||
now = QTime.currentTime()
|
||
slots: list[tuple[str, bool, str]] = []
|
||
for item in rows:
|
||
if isinstance(item, str):
|
||
raw_time = item
|
||
available = True
|
||
quota = 1
|
||
else:
|
||
raw_time = str(first_value(item, "time", "appointment_time", default=""))
|
||
available = _as_bool(first_value(item, "available", default=False))
|
||
quota = _as_int(first_value(item, "quota", default=1), 1)
|
||
if not raw_time:
|
||
continue
|
||
parsed = QTime.fromString(raw_time[:5], "HH:mm")
|
||
expired = appointment_date == today and parsed.isValid() and parsed <= now
|
||
can_book = available and quota > 0 and not expired
|
||
# Vue keeps expired slots in the grid and renders every unavailable slot as
|
||
# “已约”. Expiry still participates in the availability calculation above.
|
||
status = "可约" if can_book else "已约"
|
||
slots.append((raw_time, can_book, status))
|
||
restore = self._slot_restore_selection
|
||
self._populate_slots(slots)
|
||
if restore and self._slot_available.get(restore, False):
|
||
index = self.slot_combo.findData(restore)
|
||
if index >= 0:
|
||
self.slot_combo.setCurrentIndex(index)
|
||
self._slot_restore_selection = ""
|
||
self._clear_loading("slots")
|
||
self.refresh_slots_button.setEnabled(True)
|
||
self.refresh_slots_button.setText("刷新")
|
||
if slots:
|
||
self.banner.clear()
|
||
self.slot_state_stack.setCurrentWidget(self.slot_scroll)
|
||
QTimer.singleShot(0, self._update_slot_scroll_height)
|
||
else:
|
||
self.slot_empty.set_text("该排班暂无可用号源")
|
||
self.slot_state_stack.setFixedHeight(94)
|
||
self.slots_panel.setFixedHeight(164)
|
||
self.slot_state_stack.setCurrentWidget(self.slot_empty)
|
||
self._update_submit_state()
|
||
|
||
def _populate_slots(self, slots: Sequence[tuple[str, bool, str]]) -> None:
|
||
self._clear_flow(self.slot_flow)
|
||
self._slot_buttons.clear()
|
||
self._slot_available.clear()
|
||
self.slot_group = QButtonGroup(self)
|
||
self.slot_group.setExclusive(True)
|
||
self._setting = True
|
||
try:
|
||
self.slot_combo.clear()
|
||
self.slot_combo.addItem("请选择可用号源", "")
|
||
for raw_time, available, status in slots:
|
||
self.slot_combo.addItem(f"{raw_time} · {status}", raw_time)
|
||
button = _SlotCard(raw_time, status)
|
||
button.setProperty("appointmentSlot", True)
|
||
button.setProperty("availability", "available" if available else "unavailable")
|
||
button.setProperty(
|
||
"slotState",
|
||
"available" if available else "unavailable",
|
||
)
|
||
button.setCheckable(available)
|
||
button.setEnabled(available)
|
||
button.setMinimumSize(110, 70)
|
||
button.clicked.connect(
|
||
lambda _checked=False, value=raw_time: self._select_slot(value)
|
||
)
|
||
self.slot_group.addButton(button)
|
||
self.slot_flow.addWidget(button)
|
||
self._slot_buttons[raw_time] = button
|
||
self._slot_available[raw_time] = available
|
||
if not available:
|
||
opacity = QGraphicsOpacityEffect(button)
|
||
opacity.setOpacity(0.6)
|
||
button.setGraphicsEffect(opacity)
|
||
finally:
|
||
self._setting = False
|
||
|
||
def _select_slot(self, value: str) -> None:
|
||
if not self._slot_available.get(value, False):
|
||
return
|
||
index = self.slot_combo.findData(value)
|
||
if index >= 0:
|
||
self.slot_combo.setCurrentIndex(index)
|
||
|
||
def _slot_selection_changed(self) -> None:
|
||
if self._setting:
|
||
return
|
||
value = str(self.slot_combo.currentData() or "")
|
||
for slot_value, button in self._slot_buttons.items():
|
||
button.setChecked(slot_value == value and self._slot_available.get(slot_value, False))
|
||
self._update_submit_state()
|
||
|
||
def _slot_error(
|
||
self, error: Exception, doctor_id: int, appointment_date: str, generation: int
|
||
) -> None:
|
||
if (
|
||
generation != self._slot_generation
|
||
or doctor_id != _as_int(self.doctor_combo.currentData())
|
||
or appointment_date != str(self.date_combo.currentData() or "")
|
||
):
|
||
return
|
||
self._slot_restore_selection = ""
|
||
self._clear_loading("slots")
|
||
self.refresh_slots_button.setEnabled(True)
|
||
self.refresh_slots_button.setText("刷新")
|
||
self.slot_empty.set_text("号源加载失败")
|
||
self.slot_state_stack.setFixedHeight(94)
|
||
self.slots_panel.setFixedHeight(164)
|
||
self.slot_state_stack.setCurrentWidget(self.slot_empty)
|
||
self.banner.show_message(f"号源加载失败:{friendly_error(error)}", "danger")
|
||
self._update_submit_state()
|
||
|
||
def _refresh_slots(self) -> None:
|
||
doctor_id = _as_int(self.doctor_combo.currentData())
|
||
appointment_date = str(self.date_combo.currentData() or "")
|
||
if doctor_id <= 0 or not appointment_date:
|
||
return
|
||
selected = str(self.slot_combo.currentData() or "")
|
||
self._clear_slots("正在刷新可用号源…")
|
||
self._request_slots(doctor_id, appointment_date, restore_selection=selected)
|
||
|
||
def _channel_changed(self) -> None:
|
||
if self._setting:
|
||
return
|
||
self._sync_channel_detail_visibility()
|
||
self._update_submit_state()
|
||
|
||
def _sync_channel_detail_visibility(self) -> None:
|
||
requires_detail = self._channel_requires_detail()
|
||
self.channel_detail_row.setVisible(requires_detail)
|
||
if not requires_detail and self.channel_source_detail.text():
|
||
previous = self.channel_source_detail.blockSignals(True)
|
||
self.channel_source_detail.clear()
|
||
self.channel_source_detail.blockSignals(previous)
|
||
|
||
def _channel_requires_detail(self) -> bool:
|
||
value = str(self.channel_source.currentData() or "")
|
||
return self._channel_names.get(value, "") in self.CHANNEL_DETAIL_NAMES
|
||
|
||
def _update_conflict_alert(self) -> None:
|
||
if not self._today_blocking:
|
||
self.conflict_alert.clear()
|
||
return
|
||
today = QDate.currentDate().toString("yyyy-MM-dd")
|
||
if str(self.date_combo.currentData() or "") == today:
|
||
self.conflict_alert.show_message(
|
||
"该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。",
|
||
"warning",
|
||
)
|
||
else:
|
||
self.conflict_alert.show_message(
|
||
"该患者今日已有挂号记录;可为患者预约其他日期的时段。",
|
||
"info",
|
||
)
|
||
|
||
def _update_submit_state(self) -> None:
|
||
if not hasattr(self, "ok_button"):
|
||
return
|
||
date_value = str(self.date_combo.currentData() or "") if hasattr(self, "date_combo") else ""
|
||
slot_value = str(self.slot_combo.currentData() or "") if hasattr(self, "slot_combo") else ""
|
||
today_blocked = self._today_blocking and date_value == QDate.currentDate().toString(
|
||
"yyyy-MM-dd"
|
||
)
|
||
complete = bool(
|
||
self._initial_loaded
|
||
and _as_int(self.doctor_combo.currentData()) > 0
|
||
and date_value
|
||
and slot_value
|
||
and self._slot_available.get(slot_value, False)
|
||
and self.channel_source.currentData()
|
||
and not today_blocked
|
||
and (not self._channel_requires_detail() or self.channel_source_detail.text().strip())
|
||
)
|
||
self.ok_button.setEnabled(complete)
|
||
|
||
def payload(self) -> dict[str, Any]:
|
||
# firstvisit.myPatient/createAppointment deliberately names the diagnosis
|
||
# identifier ``patient_id``. source_patient_id is only used by video.
|
||
return {
|
||
"diagnosis_id": self.diagnosis_id,
|
||
"patient_id": self.diagnosis_id,
|
||
"doctor_id": _as_int(self.doctor_combo.currentData()),
|
||
"appointment_date": str(self.date_combo.currentData() or ""),
|
||
"appointment_time": str(self.slot_combo.currentData() or ""),
|
||
"period": "all",
|
||
"appointment_type": str(self.appointment_type.currentData() or "video"),
|
||
"remark": self.remark.toPlainText().strip(),
|
||
"channel_source": str(self.channel_source.currentData() or ""),
|
||
"channel_source_detail": self.channel_source_detail.text().strip(),
|
||
}
|
||
|
||
def accept(self) -> None:
|
||
self._update_submit_state()
|
||
if not self.ok_button.isEnabled():
|
||
if self._today_blocking and str(
|
||
self.date_combo.currentData() or ""
|
||
) == QDate.currentDate().toString("yyyy-MM-dd"):
|
||
self.banner.show_message("该患者今天已有预约或过号,请选择其他日期。", "warning")
|
||
else:
|
||
self.banner.show_message("请完整选择医生、排班、可用号源和渠道。", "warning")
|
||
return
|
||
super().accept()
|
||
|
||
def reject(self) -> None:
|
||
self._invalidate_requests()
|
||
super().reject()
|
||
|
||
def done(self, result: int) -> None:
|
||
self._invalidate_requests()
|
||
owner = getattr(self, "_owner", None)
|
||
if owner is not None:
|
||
with suppress(RuntimeError):
|
||
owner.removeEventFilter(self)
|
||
super().done(result)
|
||
|
||
def showEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
|
||
self._sync_to_owner()
|
||
super().showEvent(event)
|
||
QTimer.singleShot(0, self._sync_to_owner)
|
||
|
||
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API
|
||
painter = QPainter(self)
|
||
painter.fillRect(self.rect(), QColor(26, 28, 31, 196))
|
||
painter.end()
|
||
super().paintEvent(event)
|
||
|
||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API
|
||
self._update_drawer_width()
|
||
if hasattr(self, "loading_overlay"):
|
||
self.loading_overlay.setGeometry(self.body_scroll.viewport().rect())
|
||
super().resizeEvent(event)
|
||
|
||
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802 - Qt API
|
||
owner = getattr(self, "_owner", None)
|
||
if (
|
||
owner is not None
|
||
and watched is owner
|
||
and event.type()
|
||
in {
|
||
QEvent.Type.Resize,
|
||
QEvent.Type.Move,
|
||
QEvent.Type.Show,
|
||
}
|
||
):
|
||
QTimer.singleShot(0, self._sync_to_owner)
|
||
elif watched is getattr(self, "_body_viewport", None) and event.type() in {
|
||
QEvent.Type.Resize,
|
||
QEvent.Type.Show,
|
||
}:
|
||
try:
|
||
self.loading_overlay.setGeometry(watched.rect())
|
||
except RuntimeError:
|
||
return False
|
||
elif watched is getattr(self, "_slot_viewport", None) and event.type() in {
|
||
QEvent.Type.Resize,
|
||
QEvent.Type.Show,
|
||
}:
|
||
QTimer.singleShot(0, self._update_slot_scroll_height)
|
||
return super().eventFilter(watched, event)
|
||
|
||
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 - Qt API
|
||
if not self.panel.geometry().contains(event.position().toPoint()):
|
||
event.accept()
|
||
return
|
||
super().mousePressEvent(event)
|
||
|
||
def _sync_to_owner(self) -> None:
|
||
target = main_window_target_rect(self)
|
||
if target is not None:
|
||
self.setGeometry(target)
|
||
self._update_drawer_width()
|
||
return
|
||
if self._owner is None:
|
||
self._update_drawer_width()
|
||
return
|
||
top_left = self._owner.mapToGlobal(QPoint(0, 0))
|
||
self.setGeometry(QRect(top_left, self._owner.size()))
|
||
self._update_drawer_width()
|
||
|
||
def _update_drawer_width(self) -> None:
|
||
if not hasattr(self, "panel"):
|
||
return
|
||
target = max(1, int(round(self.width() * 0.60)))
|
||
self.panel.setFixedWidth(target)
|
||
|
||
def _set_loading(self, token: str, message: str) -> None:
|
||
self._active_loading = token
|
||
if hasattr(self, "loading_overlay"):
|
||
self.loading_overlay.show_message(message)
|
||
|
||
def _clear_loading(self, token: str) -> None:
|
||
if self._active_loading != token:
|
||
return
|
||
self._active_loading = ""
|
||
self.loading_overlay.clear()
|
||
|
||
def _show_time_empty(self, text: str) -> None:
|
||
self.time_empty.set_text(text)
|
||
self.time_stack.setCurrentWidget(self.time_empty)
|
||
|
||
def _clear_dates(self) -> None:
|
||
self._setting = True
|
||
try:
|
||
self._clear_flow(self.date_flow)
|
||
self._date_buttons.clear()
|
||
self.date_combo.clear()
|
||
self.date_combo.addItem("请先选择医生", "")
|
||
finally:
|
||
self._setting = False
|
||
|
||
def _clear_slots(self, message: str) -> None:
|
||
self._setting = True
|
||
try:
|
||
self._clear_flow(self.slot_flow)
|
||
self._slot_buttons.clear()
|
||
self._slot_available.clear()
|
||
self.slot_combo.clear()
|
||
self.slot_combo.addItem(message, "")
|
||
finally:
|
||
self._setting = False
|
||
self.slot_empty.set_text(message)
|
||
self.slot_state_stack.setFixedHeight(94)
|
||
self.slots_panel.setFixedHeight(164)
|
||
self.slot_state_stack.setCurrentWidget(self.slot_empty)
|
||
self._update_submit_state()
|
||
|
||
def _update_slot_scroll_height(self) -> None:
|
||
if not self._slot_buttons or self.slot_state_stack.currentWidget() is not self.slot_scroll:
|
||
return
|
||
viewport_width = max(110, self.slot_scroll.viewport().width())
|
||
content_height = max(70, self.slot_flow.heightForWidth(viewport_width))
|
||
target = max(94, min(450, content_height + 4))
|
||
self.slot_content.setMinimumHeight(content_height)
|
||
if self.slot_scroll.height() != target:
|
||
self.slot_scroll.setFixedHeight(target)
|
||
if self.slot_state_stack.height() != target:
|
||
self.slot_state_stack.setFixedHeight(target)
|
||
panel_height = target + 70
|
||
if self.slots_panel.height() != panel_height:
|
||
self.slots_panel.setFixedHeight(panel_height)
|
||
|
||
def _sync_date_buttons(self, selected: str) -> None:
|
||
for value, button in self._date_buttons.items():
|
||
button.setChecked(value == selected)
|
||
|
||
@staticmethod
|
||
def _clear_flow(layout: FlowLayout) -> None:
|
||
while layout.count():
|
||
item = layout.takeAt(0)
|
||
widget = item.widget() if item is not None else None
|
||
if widget is not None:
|
||
widget.hide()
|
||
widget.deleteLater()
|
||
|
||
def _invalidate_requests(self) -> None:
|
||
self._load_generation += 1
|
||
self._roster_generation += 1
|
||
self._slot_generation += 1
|
||
if hasattr(self, "loading_overlay"):
|
||
self.loading_overlay.clear()
|
||
|
||
|
||
__all__ = ["APPOINTMENT_DRAWER_QSS", "AppointmentDrawer", "FlowLayout"]
|