diff --git a/bouquin/db.py b/bouquin/db.py index 157aae8..c6b08c2 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -308,6 +308,22 @@ class DBManager: client_email TEXT ); + CREATE INDEX IF NOT EXISTS ix_project_billing_client_company + ON project_billing(client_company); + + + CREATE TABLE IF NOT EXISTS project_buckets ( + project_id INTEGER PRIMARY KEY + REFERENCES projects(id) ON DELETE CASCADE, + baseline_minutes INTEGER NOT NULL DEFAULT 0, + bucket_ceiling_minutes INTEGER NOT NULL DEFAULT 0, + warn_at_percent REAL NOT NULL DEFAULT 80.0, + updated_at TEXT NOT NULL DEFAULT ( + strftime('%Y-%m-%dT%H:%M:%fZ','now') + ) + ); + + CREATE TABLE IF NOT EXISTS company_profile ( id INTEGER PRIMARY KEY CHECK (id = 1), name TEXT, @@ -1219,6 +1235,242 @@ class DBManager: (project_id,), ) + # -------- Time logging: project buckets --------------------------- + + def _normalise_project_id(self, project_id: int) -> int: + project_id = int(project_id) + if project_id <= 0: + raise ValueError("invalid project id") + return project_id + + def upsert_project_bucket( + self, + project_id: int, + baseline_minutes: int, + bucket_ceiling_minutes: int, + warn_at_percent: float = 80.0, + ) -> None: + """Save cumulative prepaid-hour bucket settings for a project. + + ``baseline_minutes`` represents already-spent hours that pre-date + Bouquin time logging. ``bucket_ceiling_minutes`` is the cumulative + prepaid ceiling purchased for this project. + """ + project_id = self._normalise_project_id(project_id) + baseline_minutes = max(0, int(baseline_minutes or 0)) + bucket_ceiling_minutes = max(0, int(bucket_ceiling_minutes or 0)) + warn_at_percent = min(100.0, max(0.0, float(warn_at_percent or 0.0))) + with self.conn: + self.conn.execute( + """ + INSERT INTO project_buckets ( + project_id, + baseline_minutes, + bucket_ceiling_minutes, + warn_at_percent + ) + VALUES (?, ?, ?, ?) + ON CONFLICT(project_id) DO UPDATE SET + baseline_minutes = excluded.baseline_minutes, + bucket_ceiling_minutes = excluded.bucket_ceiling_minutes, + warn_at_percent = excluded.warn_at_percent, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'); + """, + ( + project_id, + baseline_minutes, + bucket_ceiling_minutes, + warn_at_percent, + ), + ) + + def add_to_project_bucket_ceiling(self, project_id: int, add_minutes: int) -> None: + """Increase a project's cumulative bucket ceiling by ``add_minutes``.""" + project_id = self._normalise_project_id(project_id) + add_minutes = max(0, int(add_minutes or 0)) + if add_minutes <= 0: + return + with self.conn: + self.conn.execute( + """ + INSERT INTO project_buckets ( + project_id, + baseline_minutes, + bucket_ceiling_minutes, + warn_at_percent + ) + VALUES (?, 0, ?, 80.0) + ON CONFLICT(project_id) DO UPDATE SET + bucket_ceiling_minutes = bucket_ceiling_minutes + excluded.bucket_ceiling_minutes, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'); + """, + (project_id, add_minutes), + ) + + def get_project_bucket(self, project_id: int): + """Return the bucket row for a project, or None if none is configured.""" + try: + project_id = self._normalise_project_id(project_id) + except (TypeError, ValueError): + return None + row = self.conn.execute( + """ + SELECT + project_id, + baseline_minutes, + bucket_ceiling_minutes, + warn_at_percent, + updated_at + FROM project_buckets + WHERE project_id = ?; + """, + (project_id,), + ).fetchone() + return row + + def logged_minutes_for_project(self, project_id: int) -> int: + try: + project_id = self._normalise_project_id(project_id) + except (TypeError, ValueError): + return 0 + row = self.conn.execute( + """ + SELECT COALESCE(SUM(minutes), 0) AS minutes + FROM time_log + WHERE project_id = ?; + """, + (project_id,), + ).fetchone() + return int(row["minutes"] or 0) + + def time_log_count_for_project(self, project_id: int) -> int: + try: + project_id = self._normalise_project_id(project_id) + except (TypeError, ValueError): + return 0 + row = self.conn.execute( + "SELECT COUNT(*) AS c FROM time_log WHERE project_id = ?;", + (project_id,), + ).fetchone() + return int(row["c"] or 0) + + def project_bucket_status(self, project_id: int) -> dict[str, object] | None: + """Return computed bucket state for a project. + + States: + - ``unconfigured``: no bucket ceiling has been set + - ``ok``: below warning threshold + - ``warning``: at/above warning threshold but below ceiling + - ``reached``: exactly at ceiling + - ``exceeded``: beyond ceiling + """ + try: + project_id = self._normalise_project_id(project_id) + except (TypeError, ValueError): + return None + project_name = self.list_projects_by_id(project_id) + if not project_name: + return None + + bucket = self.get_project_bucket(project_id) + logged = self.logged_minutes_for_project(project_id) + baseline = int(bucket["baseline_minutes"] or 0) if bucket else 0 + ceiling = int(bucket["bucket_ceiling_minutes"] or 0) if bucket else 0 + warn_at = float(bucket["warn_at_percent"] or 80.0) if bucket else 80.0 + used = baseline + logged + if ceiling <= 0: + state = "unconfigured" + remaining = None + pct = None + else: + remaining = ceiling - used + pct = used / ceiling * 100.0 + if used > ceiling: + state = "exceeded" + elif used == ceiling: + state = "reached" + elif pct >= warn_at: + state = "warning" + else: + state = "ok" + return { + "project_id": project_id, + "project_name": project_name, + "baseline_minutes": baseline, + "logged_minutes": logged, + "used_minutes": used, + "bucket_ceiling_minutes": ceiling, + "remaining_minutes": remaining, + "percent_used": pct, + "warn_at_percent": warn_at, + "state": state, + } + + def list_project_summaries(self): + """Return one row per project with aggregate time/docs/invoices.""" + rows = self.conn.execute( + """ + SELECT + p.id AS project_id, + p.name AS project_name, + COALESCE(pb.baseline_minutes, 0) AS baseline_minutes, + COALESCE(pb.bucket_ceiling_minutes, 0) AS bucket_ceiling_minutes, + COALESCE(pb.warn_at_percent, 80.0) AS warn_at_percent, + COALESCE(( + SELECT SUM(t.minutes) + FROM time_log AS t + WHERE t.project_id = p.id + ), 0) AS logged_minutes, + COALESCE(( + SELECT COUNT(*) + FROM time_log AS t2 + WHERE t2.project_id = p.id + ), 0) AS time_log_count, + COALESCE(( + SELECT COUNT(*) + FROM project_documents AS d + WHERE d.project_id = p.id + ), 0) AS document_count, + COALESCE(( + SELECT COUNT(*) + FROM invoices AS i + WHERE i.project_id = p.id + ), 0) AS invoice_count + FROM projects AS p + LEFT JOIN project_buckets AS pb + ON pb.project_id = p.id + ORDER BY LOWER(p.name); + """ + ).fetchall() + return rows + + def time_logs_for_project(self, project_id: int): + """Return all time-log entries for a project, newest first.""" + try: + project_id = self._normalise_project_id(project_id) + except (TypeError, ValueError): + return [] + return self.conn.execute( + """ + SELECT + t.id, + t.page_date, + t.project_id, + p.name AS project_name, + t.activity_id, + a.name AS activity_name, + t.minutes, + t.note, + t.created_at + FROM time_log AS t + JOIN projects AS p ON p.id = t.project_id + JOIN activities AS a ON a.id = t.activity_id + WHERE t.project_id = ? + ORDER BY t.page_date DESC, t.created_at DESC, t.id DESC; + """, + (project_id,), + ).fetchall() + def list_activities(self) -> list[ActivityRow]: cur = self.conn.cursor() rows = cur.execute( @@ -2101,7 +2353,7 @@ class DBManager: ) def list_client_companies(self) -> list[str]: - """Return distinct client display names from project_billing.""" + """Return distinct client display names from project billing settings.""" cur = self.conn.cursor() rows = cur.execute( """ @@ -2203,6 +2455,41 @@ class DBManager: # ------------------------- Invoices -------------------------------# + def invoices_for_project_with_documents(self, project_id: int): + """Return all invoices for a project with linked invoice document metadata.""" + try: + project_id = self._normalise_project_id(project_id) + except (TypeError, ValueError): + return [] + rows = self.conn.execute( + """ + SELECT + i.id, + i.project_id, + p.name AS project_name, + i.invoice_number, + i.issue_date, + i.due_date, + i.currency, + i.tax_label, + i.tax_rate_percent, + i.subtotal_cents, + i.tax_cents, + i.total_cents, + i.paid_at, + i.payment_note, + i.document_id, + d.file_name AS document_file_name + FROM invoices AS i + LEFT JOIN projects AS p ON p.id = i.project_id + LEFT JOIN project_documents AS d ON d.id = i.document_id + WHERE i.project_id = ? + ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE; + """, + (project_id,), + ).fetchall() + return rows + def create_invoice( self, project_id: int, diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index f1f86dd..8788fd0 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -190,7 +190,7 @@ "bug_report_sent_ok": "Bug report sent. Thank you!", "send": "Send", "reminder": "Reminder", - "set_reminder": "Set reminder prompt", + "set_reminder": "Set Reminder", "reminder_no_text_fallback": "You scheduled a reminder to alert you now!", "invalid_time_title": "Invalid time", "invalid_time_message": "Please enter a time in the format HH:MM", @@ -276,9 +276,8 @@ "time_log_report": "Time log report", "time_log_report_title": "Time log for {project}", "time_log_report_meta": "From {start} to {end}, grouped {granularity}", - "time_log_total_hours": "Total time spent", - "time_log_with_total": "Time log ({hours:.2f}h)", "time_log_total_hours": "Total for day: {hours:.2f}h", + "time_log_with_total": "Time log ({hours:.2f}h)", "update_time_entry": "Update time entry", "time_report_total": "Total: {hours:.2f} hours", "no_report_title": "No report", @@ -294,7 +293,7 @@ "enable_time_log_feature": "Enable Time Logging", "enable_reminders_feature": "Enable Reminders", "reminders_webhook_section_title": "Send Reminders to a webhook", - "reminders_webhook_url_label":"Webhook URL", + "reminders_webhook_url_label": "Webhook URL", "reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)", "enable_documents_feature": "Enable storing of documents", "pomodoro_time_log_default_text": "Focus session", @@ -314,22 +313,19 @@ "manage_reminders": "Manage Reminders", "upcoming_reminders": "Upcoming Reminders", "no_upcoming_reminders": "No upcoming reminders", - "once": "once", + "once": "Once", "daily": "daily", "weekdays": "weekdays", "weekly": "weekly", "add_reminder": "Add Reminder", - "set_reminder": "Set Reminder", "edit_reminder": "Edit Reminder", "delete_reminder": "Delete Reminder", "delete_reminders": "Delete Reminders", "deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.", "this_is_a_reminder_of_type": "Note: This is a reminder of type", "this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.", - "reminder": "Reminder", "reminders": "Reminders", "time": "Time", - "once": "Once", "every_day": "Every day", "every_weekday": "Every weekday (Mon-Fri)", "every_week": "Every week", @@ -437,5 +433,41 @@ "invoice_invalid_tax_rate": "The tax rate is invalid", "invoice_no_items": "There are no items in the invoice", "invoice_number_required": "An invoice number is required", - "invoice_required": "Please select a specific invoice before trying to delete an invoice." + "invoice_required": "Please select a specific invoice before trying to delete an invoice.", + "refresh": "Refresh", + "status": "Status", + "client": "Client", + "documents": "Documents", + "invoices": "Invoices", + "documents_select_document": "Please select a document first.", + "toolbar_projects": "Projects", + "projects_title": "Projects", + "projects_none": "No projects have been configured yet. Add a project from the time logging dialog first.", + "projects_summary_tab": "Summary", + "project_bucket": "Project bucket", + "project_bucket_settings": "Bucket settings", + "project_bucket_replenish": "Replenish", + "project_bucket_baseline": "Baseline", + "project_bucket_ceiling": "Bucket ceiling", + "project_bucket_warn_at": "Warn at", + "project_hours_logged": "Logged", + "project_bucket_used": "Used", + "project_bucket_remaining": "Remaining", + "project_bucket_add_to_ceiling": "Add to ceiling", + "project_bucket_no_project": "Select a project to view its bucket.", + "project_bucket_unconfigured": "{project}: {used:.2f}h used so far ({baseline:.2f}h baseline + {logged:.2f}h logged). No bucket ceiling has been set.", + "project_bucket_status": "{project}: {used:.2f}h / {ceiling:.2f}h used{percent}{remaining}. Baseline {baseline:.2f}h, logged in Bouquin {logged:.2f}h. Status: {state}.", + "project_bucket_state_unconfigured": "No bucket", + "project_bucket_state_ok": "OK", + "project_bucket_state_warning": "Approaching bucket ceiling", + "project_bucket_state_reached": "Bucket ceiling reached", + "project_bucket_state_exceeded": "Bucket ceiling exceeded", + "project_bucket_alert_title": "Project bucket alert", + "project_bucket_alert_message": "{status}", + "project_open_invoice_document": "Open invoice document", + "project_invoice_no_document": "This invoice does not have a linked document.", + "project_bucket_invoice_prepaid": "Invoice prepaid hours", + "project_prepaid_invoice_default_desc": "Prepaid support bucket ({hours:.2f} hours)", + "project_prepaid_invoice_hours_required": "Enter a prepaid-hours amount greater than zero before creating an invoice.", + "time_logs": "Time logs" } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 2759272..7328276 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -49,6 +49,7 @@ from PySide6.QtWidgets import ( from . import strings from .bug_report_dialog import BugReportDialog +from .projects import ProjectsDialog from .db import DBManager from .documents import DocumentsDialog, TodaysDocumentsWidget from .find_bar import FindBar @@ -239,6 +240,12 @@ class MainWindow(QMainWindow): act_stats.setShortcut("Ctrl+Shift+S") act_stats.triggered.connect(self._open_statistics) file_menu.addAction(act_stats) + self.actProjects = QAction(strings._("projects"), self) + self.actProjects.setShortcut("Ctrl+Shift+C") + self.actProjects.setShortcutContext(Qt.ApplicationShortcut) + self.actProjects.triggered.connect(self._open_projects) + file_menu.addAction(self.actProjects) + self.addAction(self.actProjects) act_lock = QAction(strings._("main_window_lock_screen_accessibility"), self) act_lock.setShortcut("Ctrl+Shift+L") act_lock.triggered.connect(self._enter_lock) @@ -338,6 +345,9 @@ class MainWindow(QMainWindow): if not self.cfg.time_log: self.time_log.hide() self.toolBar.actTimer.setVisible(False) + self.toolBar.actProjects.setVisible(False) + self.actProjects.setVisible(False) + self.actProjects.setEnabled(False) if not self.cfg.reminders: self.upcoming_reminders.hide() self.toolBar.actAlarm.setVisible(False) @@ -1461,6 +1471,7 @@ class MainWindow(QMainWindow): self._tb_alarm = self._on_alarm_requested self._tb_timer = self._on_timer_requested self._tb_documents = self._on_documents_requested + self._tb_projects = self._open_projects self._tb_font_larger = self._on_font_larger_requested self._tb_font_smaller = self._on_font_smaller_requested @@ -1475,6 +1486,7 @@ class MainWindow(QMainWindow): tb.alarmRequested.connect(self._tb_alarm) tb.timerRequested.connect(self._tb_timer) tb.documentsRequested.connect(self._tb_documents) + tb.projectsRequested.connect(self._tb_projects) tb.insertImageRequested.connect(self._on_insert_image) tb.historyRequested.connect(self._open_history) tb.fontSizeLargerRequested.connect(self._tb_font_larger) @@ -1716,6 +1728,13 @@ class MainWindow(QMainWindow): timer.start(msecs) self._reminder_timers.append(timer) + # ----------- Projects handler ------------# + def _open_projects(self): + if not self.cfg.time_log: + return + dlg = ProjectsDialog(self.db, self) + dlg.exec() + # ----------- Documents handler ------------# def _on_documents_requested(self): documents_dlg = DocumentsDialog(self.db, self) @@ -1868,9 +1887,15 @@ class MainWindow(QMainWindow): if not self.cfg.time_log: self.time_log.hide() self.toolBar.actTimer.setVisible(False) + self.toolBar.actProjects.setVisible(False) + self.actProjects.setVisible(False) + self.actProjects.setEnabled(False) else: self.time_log.show() self.toolBar.actTimer.setVisible(True) + self.toolBar.actProjects.setVisible(True) + self.actProjects.setVisible(True) + self.actProjects.setEnabled(True) if not self.cfg.reminders: self.upcoming_reminders.hide() self.toolBar.actAlarm.setVisible(False) diff --git a/bouquin/projects.py b/bouquin/projects.py new file mode 100644 index 0000000..9a78bf4 --- /dev/null +++ b/bouquin/projects.py @@ -0,0 +1,672 @@ +from __future__ import annotations + +from PySide6.QtCore import QDate, Qt +from PySide6.QtWidgets import ( + QAbstractItemView, + QComboBox, + QDialog, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QMessageBox, + QPushButton, + QSizePolicy, + QTableWidget, + QTableWidgetItem, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from . import strings +from .db import DBManager +from .document_utils import open_document_from_db +from .invoices import InvoiceDialog + + +_WARNING_STYLES = { + "unconfigured": "", + "ok": "QLabel { padding: 6px; border: 1px solid #5b8f5b; border-radius: 4px; }", + "warning": "QLabel { padding: 6px; border: 1px solid #b58900; border-radius: 4px; }", + "reached": ( + "QLabel { padding: 6px; border: 1px solid #b85c00; " + "border-radius: 4px; font-weight: bold; }" + ), + "exceeded": ( + "QLabel { padding: 6px; border: 1px solid #b00020; " + "border-radius: 4px; font-weight: bold; }" + ), +} + + +def hours_from_minutes(minutes: int | float | None) -> float: + return float(minutes or 0) / 60.0 + + +def minutes_from_hours(hours: float | int | None) -> int: + return int(round(float(hours or 0) * 60)) + + +def format_bucket_status(status: dict[str, object] | None) -> str: + """Human-readable project bucket status used by project/time-log UIs.""" + if not status: + return strings._("project_bucket_no_project") + + project = str(status.get("project_name") or "") + state = str(status.get("state") or "unconfigured") + baseline = hours_from_minutes(status.get("baseline_minutes")) + logged = hours_from_minutes(status.get("logged_minutes")) + used = hours_from_minutes(status.get("used_minutes")) + ceiling = hours_from_minutes(status.get("bucket_ceiling_minutes")) + remaining_minutes = status.get("remaining_minutes") + remaining = ( + None if remaining_minutes is None else hours_from_minutes(remaining_minutes) + ) + pct = status.get("percent_used") + pct_text = "" if pct is None else f" ({float(pct):.1f}%)" + + if state == "unconfigured": + return strings._("project_bucket_unconfigured").format( + project=project, + baseline=baseline, + logged=logged, + used=used, + ) + + remaining_text = "" if remaining is None else f", {remaining:.2f}h remaining" + return strings._("project_bucket_status").format( + project=project, + used=used, + ceiling=ceiling, + percent=pct_text, + remaining=remaining_text, + baseline=baseline, + logged=logged, + state=strings._(f"project_bucket_state_{state}"), + ) + + +class ProjectsDialog(QDialog): + """Project overview and prepaid-hours bucket manager.""" + + SUM_PROJECT = 0 + SUM_LOGGED = 1 + SUM_BASELINE = 2 + SUM_USED = 3 + SUM_CEILING = 4 + SUM_REMAINING = 5 + SUM_STATE = 6 + SUM_TIME_LOGS = 7 + SUM_DOCS = 8 + SUM_INVOICES = 9 + + LOG_DATE = 0 + LOG_ACTIVITY = 1 + LOG_NOTE = 2 + LOG_HOURS = 3 + LOG_CREATED = 4 + + DOC_FILE = 0 + DOC_ADDED = 1 + DOC_DESCRIPTION = 2 + DOC_SIZE = 3 + + INV_NUMBER = 0 + INV_ISSUE = 1 + INV_DUE = 2 + INV_TOTAL = 3 + INV_PAID = 4 + INV_DOCUMENT = 5 + + def __init__(self, db: DBManager, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._db = db + self._reloading = False + + self.setWindowTitle(strings._("projects_title")) + self.resize(1100, 700) + + root = QVBoxLayout(self) + + top_form = QFormLayout() + top_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + root.addLayout(top_form) + + project_row = QHBoxLayout() + self.project_combo = QComboBox() + self.project_combo.currentIndexChanged.connect(self._on_project_changed) + project_row.addWidget(self.project_combo, 1) + + self.refresh_btn = QPushButton(strings._("refresh")) + self.refresh_btn.clicked.connect(self.reload) + project_row.addWidget(self.refresh_btn) + top_form.addRow(strings._("project"), project_row) + + self.status_label = QLabel("") + self.status_label.setWordWrap(True) + self.status_label.setAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + ) + self.status_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.MinimumExpanding + ) + self.status_label.setMinimumHeight( + self.status_label.fontMetrics().lineSpacing() * 3 + 18 + ) + top_form.addRow(strings._("project_bucket"), self.status_label) + + bucket_row = QHBoxLayout() + self.baseline_spin = QDoubleSpinBox() + self.baseline_spin.setRange(0.0, 1_000_000.0) + self.baseline_spin.setDecimals(2) + self.baseline_spin.setSingleStep(1.0) + self.baseline_spin.setSuffix(" h") + bucket_row.addWidget(QLabel(strings._("project_bucket_baseline"))) + bucket_row.addWidget(self.baseline_spin) + + self.ceiling_spin = QDoubleSpinBox() + self.ceiling_spin.setRange(0.0, 1_000_000.0) + self.ceiling_spin.setDecimals(2) + self.ceiling_spin.setSingleStep(1.0) + self.ceiling_spin.setSuffix(" h") + bucket_row.addWidget(QLabel(strings._("project_bucket_ceiling"))) + bucket_row.addWidget(self.ceiling_spin) + + self.warn_spin = QDoubleSpinBox() + self.warn_spin.setRange(0.0, 100.0) + self.warn_spin.setDecimals(0) + self.warn_spin.setSingleStep(5.0) + self.warn_spin.setSuffix(" %") + bucket_row.addWidget(QLabel(strings._("project_bucket_warn_at"))) + bucket_row.addWidget(self.warn_spin) + + self.save_bucket_btn = QPushButton(strings._("save")) + self.save_bucket_btn.clicked.connect(self._save_bucket) + bucket_row.addWidget(self.save_bucket_btn) + + top_form.addRow(strings._("project_bucket_settings"), bucket_row) + + topup_row = QHBoxLayout() + self.topup_spin = QDoubleSpinBox() + self.topup_spin.setRange(0.0, 1_000_000.0) + self.topup_spin.setDecimals(2) + self.topup_spin.setSingleStep(1.0) + self.topup_spin.setValue(40.0) + self.topup_spin.setSuffix(" h") + topup_row.addWidget(self.topup_spin) + + self.topup_btn = QPushButton(strings._("project_bucket_add_to_ceiling")) + self.topup_btn.clicked.connect(self._add_to_ceiling) + topup_row.addWidget(self.topup_btn) + + self.invoice_prepaid_btn = QPushButton( + strings._("project_bucket_invoice_prepaid") + ) + self.invoice_prepaid_btn.clicked.connect(self._invoice_prepaid_hours) + topup_row.addWidget(self.invoice_prepaid_btn) + + topup_row.addStretch(1) + top_form.addRow(strings._("project_bucket_replenish"), topup_row) + + self.tabs = QTabWidget() + root.addWidget(self.tabs, 1) + + self.summary_table = QTableWidget() + self.summary_table.setColumnCount(10) + self.summary_table.setHorizontalHeaderLabels( + [ + strings._("project"), + strings._("project_hours_logged"), + strings._("project_bucket_baseline"), + strings._("project_bucket_used"), + strings._("project_bucket_ceiling"), + strings._("project_bucket_remaining"), + strings._("status"), + strings._("time_logs"), + strings._("documents"), + strings._("invoices"), + ] + ) + self.summary_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.summary_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.summary_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.summary_table.itemSelectionChanged.connect(self._on_summary_selected) + header = self.summary_table.horizontalHeader() + header.setSectionResizeMode(self.SUM_PROJECT, QHeaderView.Stretch) + for col in range(1, 10): + header.setSectionResizeMode(col, QHeaderView.ResizeToContents) + self.tabs.addTab(self.summary_table, strings._("projects_summary_tab")) + + logs_tab = QWidget() + logs_layout = QVBoxLayout(logs_tab) + self.time_logs_table = QTableWidget() + self.time_logs_table.setColumnCount(5) + self.time_logs_table.setHorizontalHeaderLabels( + [ + strings._("date"), + strings._("activity"), + strings._("note"), + strings._("hours"), + strings._("created_at"), + ] + ) + self.time_logs_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.time_logs_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.time_logs_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + log_header = self.time_logs_table.horizontalHeader() + log_header.setSectionResizeMode(self.LOG_DATE, QHeaderView.ResizeToContents) + log_header.setSectionResizeMode(self.LOG_ACTIVITY, QHeaderView.ResizeToContents) + log_header.setSectionResizeMode(self.LOG_NOTE, QHeaderView.Stretch) + log_header.setSectionResizeMode(self.LOG_HOURS, QHeaderView.ResizeToContents) + log_header.setSectionResizeMode(self.LOG_CREATED, QHeaderView.ResizeToContents) + logs_layout.addWidget(self.time_logs_table, 1) + self.tabs.addTab(logs_tab, strings._("time_logs")) + + docs_tab = QWidget() + docs_layout = QVBoxLayout(docs_tab) + self.documents_table = QTableWidget() + self.documents_table.setColumnCount(4) + self.documents_table.setHorizontalHeaderLabels( + [ + strings._("documents_col_file"), + strings._("documents_col_added"), + strings._("documents_col_description"), + strings._("documents_col_size"), + ] + ) + self.documents_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.documents_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.documents_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.documents_table.itemDoubleClicked.connect(self._open_selected_document) + doc_header = self.documents_table.horizontalHeader() + doc_header.setSectionResizeMode(self.DOC_FILE, QHeaderView.Stretch) + doc_header.setSectionResizeMode(self.DOC_ADDED, QHeaderView.ResizeToContents) + doc_header.setSectionResizeMode(self.DOC_DESCRIPTION, QHeaderView.Stretch) + doc_header.setSectionResizeMode(self.DOC_SIZE, QHeaderView.ResizeToContents) + docs_layout.addWidget(self.documents_table, 1) + + docs_buttons = QHBoxLayout() + docs_buttons.addStretch(1) + self.open_doc_btn = QPushButton(strings._("documents_open")) + self.open_doc_btn.clicked.connect(self._open_selected_document) + docs_buttons.addWidget(self.open_doc_btn) + docs_layout.addLayout(docs_buttons) + self.tabs.addTab(docs_tab, strings._("documents")) + + invoices_tab = QWidget() + invoices_layout = QVBoxLayout(invoices_tab) + self.invoices_table = QTableWidget() + self.invoices_table.setColumnCount(6) + self.invoices_table.setHorizontalHeaderLabels( + [ + strings._("invoice_number"), + strings._("invoice_issue_date"), + strings._("invoice_due_date"), + strings._("invoice_total"), + strings._("invoice_paid_at"), + strings._("documents_col_file"), + ] + ) + self.invoices_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.invoices_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.invoices_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.invoices_table.itemDoubleClicked.connect(self._open_invoice_document) + inv_header = self.invoices_table.horizontalHeader() + inv_header.setSectionResizeMode(self.INV_NUMBER, QHeaderView.ResizeToContents) + inv_header.setSectionResizeMode(self.INV_ISSUE, QHeaderView.ResizeToContents) + inv_header.setSectionResizeMode(self.INV_DUE, QHeaderView.ResizeToContents) + inv_header.setSectionResizeMode(self.INV_TOTAL, QHeaderView.ResizeToContents) + inv_header.setSectionResizeMode(self.INV_PAID, QHeaderView.ResizeToContents) + inv_header.setSectionResizeMode(self.INV_DOCUMENT, QHeaderView.Stretch) + invoices_layout.addWidget(self.invoices_table, 1) + + invoice_buttons = QHBoxLayout() + invoice_buttons.addStretch(1) + self.open_invoice_doc_btn = QPushButton( + strings._("project_open_invoice_document") + ) + self.open_invoice_doc_btn.clicked.connect(self._open_invoice_document) + invoice_buttons.addWidget(self.open_invoice_doc_btn) + invoices_layout.addLayout(invoice_buttons) + self.tabs.addTab(invoices_tab, strings._("invoices")) + + bottom = QHBoxLayout() + bottom.addStretch(1) + close_btn = QPushButton(strings._("close")) + close_btn.clicked.connect(self.accept) + bottom.addWidget(close_btn) + root.addLayout(bottom) + + self.reload() + + def reload(self) -> None: + current = self._current_project_id() + self._load_projects(current) + self._reload_summary() + self._load_selected_project() + + def _load_projects(self, preferred: int | None = None) -> None: + self._reloading = True + try: + self.project_combo.clear() + for r in self._db.list_project_summaries(): + self.project_combo.addItem(str(r["project_name"]), int(r["project_id"])) + if preferred is not None: + idx = self.project_combo.findData(preferred) + if idx >= 0: + self.project_combo.setCurrentIndex(idx) + elif self.project_combo.count() > 0: + self.project_combo.setCurrentIndex(0) + finally: + self._reloading = False + + def _current_project_id(self) -> int | None: + data = self.project_combo.currentData() + return int(data) if data is not None else None + + def _on_project_changed(self, _idx: int) -> None: + if self._reloading: + return + self._load_selected_project() + + def _on_summary_selected(self) -> None: + if self._reloading: + return + row = self.summary_table.currentRow() + if row < 0: + return + item = self.summary_table.item(row, self.SUM_PROJECT) + if item is None: + return + project_id = item.data(Qt.ItemDataRole.UserRole) + if project_id is None: + return + idx = self.project_combo.findData(int(project_id)) + if idx >= 0: + self.project_combo.setCurrentIndex(idx) + + def _state_for_row(self, r) -> str: + baseline = int(r["baseline_minutes"] or 0) + logged = int(r["logged_minutes"] or 0) + used = baseline + logged + ceiling = int(r["bucket_ceiling_minutes"] or 0) + warn_at = float(r["warn_at_percent"] or 80.0) + if ceiling <= 0: + return "unconfigured" + pct = used / ceiling * 100.0 + if used > ceiling: + return "exceeded" + if used == ceiling: + return "reached" + if pct >= warn_at: + return "warning" + return "ok" + + def _reload_summary(self) -> None: + self._reloading = True + try: + rows = self._db.list_project_summaries() + self.summary_table.setRowCount(len(rows)) + for row_idx, r in enumerate(rows): + project_id = int(r["project_id"]) + baseline = int(r["baseline_minutes"] or 0) + logged = int(r["logged_minutes"] or 0) + used = baseline + logged + ceiling = int(r["bucket_ceiling_minutes"] or 0) + remaining = ceiling - used if ceiling > 0 else None + state = self._state_for_row(r) + + project_item = QTableWidgetItem(str(r["project_name"])) + project_item.setData(Qt.ItemDataRole.UserRole, project_id) + self.summary_table.setItem(row_idx, self.SUM_PROJECT, project_item) + self.summary_table.setItem( + row_idx, + self.SUM_LOGGED, + QTableWidgetItem(f"{hours_from_minutes(logged):.2f}"), + ) + self.summary_table.setItem( + row_idx, + self.SUM_BASELINE, + QTableWidgetItem(f"{hours_from_minutes(baseline):.2f}"), + ) + self.summary_table.setItem( + row_idx, + self.SUM_USED, + QTableWidgetItem(f"{hours_from_minutes(used):.2f}"), + ) + ceiling_text = ( + "" if ceiling <= 0 else f"{hours_from_minutes(ceiling):.2f}" + ) + remaining_text = ( + "" if remaining is None else f"{hours_from_minutes(remaining):.2f}" + ) + state_text = strings._(f"project_bucket_state_{state}") + self.summary_table.setItem( + row_idx, self.SUM_CEILING, QTableWidgetItem(ceiling_text) + ) + self.summary_table.setItem( + row_idx, self.SUM_REMAINING, QTableWidgetItem(remaining_text) + ) + self.summary_table.setItem( + row_idx, self.SUM_STATE, QTableWidgetItem(state_text) + ) + self.summary_table.setItem( + row_idx, + self.SUM_TIME_LOGS, + QTableWidgetItem(str(r["time_log_count"] or 0)), + ) + self.summary_table.setItem( + row_idx, + self.SUM_DOCS, + QTableWidgetItem(str(r["document_count"] or 0)), + ) + self.summary_table.setItem( + row_idx, + self.SUM_INVOICES, + QTableWidgetItem(str(r["invoice_count"] or 0)), + ) + finally: + self._reloading = False + + def _load_selected_project(self) -> None: + project_id = self._current_project_id() + if project_id is None: + self.status_label.setText(strings._("projects_none")) + self.status_label.setStyleSheet("") + self.baseline_spin.setValue(0.0) + self.ceiling_spin.setValue(0.0) + self.warn_spin.setValue(80.0) + self.time_logs_table.setRowCount(0) + self.documents_table.setRowCount(0) + self.invoices_table.setRowCount(0) + return + + status = self._db.project_bucket_status(project_id) + self.status_label.setText(format_bucket_status(status)) + state = str(status.get("state") if status else "unconfigured") + self.status_label.setStyleSheet(_WARNING_STYLES.get(state, "")) + + bucket = self._db.get_project_bucket(project_id) + self.baseline_spin.setValue( + hours_from_minutes(bucket["baseline_minutes"] if bucket else 0) + ) + self.ceiling_spin.setValue( + hours_from_minutes(bucket["bucket_ceiling_minutes"] if bucket else 0) + ) + self.warn_spin.setValue(float(bucket["warn_at_percent"] if bucket else 80.0)) + + self._reload_time_logs(project_id) + self._reload_documents(project_id) + self._reload_invoices(project_id) + + def _reload_time_logs(self, project_id: int) -> None: + rows = self._db.time_logs_for_project(project_id) + self.time_logs_table.setRowCount(len(rows)) + for row_idx, r in enumerate(rows): + self.time_logs_table.setItem( + row_idx, self.LOG_DATE, QTableWidgetItem(r["page_date"] or "") + ) + self.time_logs_table.setItem( + row_idx, self.LOG_ACTIVITY, QTableWidgetItem(r["activity_name"] or "") + ) + self.time_logs_table.setItem( + row_idx, self.LOG_NOTE, QTableWidgetItem(r["note"] or "") + ) + self.time_logs_table.setItem( + row_idx, + self.LOG_HOURS, + QTableWidgetItem(f"{hours_from_minutes(r['minutes']):.2f}"), + ) + self.time_logs_table.setItem( + row_idx, self.LOG_CREATED, QTableWidgetItem(r["created_at"] or "") + ) + + def _reload_documents(self, project_id: int) -> None: + rows = self._db.documents_for_project(project_id) + self.documents_table.setRowCount(len(rows)) + for row_idx, r in enumerate(rows): + doc_id, _project_id, _project_name, file_name, description, size, added = r + file_item = QTableWidgetItem(file_name or "") + file_item.setData(Qt.ItemDataRole.UserRole, int(doc_id)) + self.documents_table.setItem(row_idx, self.DOC_FILE, file_item) + self.documents_table.setItem( + row_idx, self.DOC_ADDED, QTableWidgetItem(added or "") + ) + self.documents_table.setItem( + row_idx, self.DOC_DESCRIPTION, QTableWidgetItem(description or "") + ) + self.documents_table.setItem( + row_idx, self.DOC_SIZE, QTableWidgetItem(str(size or 0)) + ) + + def _reload_invoices(self, project_id: int) -> None: + rows = self._db.invoices_for_project_with_documents(project_id) + self.invoices_table.setRowCount(len(rows)) + for row_idx, r in enumerate(rows): + document_id = r["document_id"] + file_name = r["document_file_name"] or "" + num_item = QTableWidgetItem(r["invoice_number"] or "") + num_item.setData(Qt.ItemDataRole.UserRole, int(r["id"])) + self.invoices_table.setItem(row_idx, self.INV_NUMBER, num_item) + self.invoices_table.setItem( + row_idx, self.INV_ISSUE, QTableWidgetItem(r["issue_date"] or "") + ) + self.invoices_table.setItem( + row_idx, self.INV_DUE, QTableWidgetItem(r["due_date"] or "") + ) + total = int(r["total_cents"] or 0) / 100.0 + currency = r["currency"] or "" + self.invoices_table.setItem( + row_idx, + self.INV_TOTAL, + QTableWidgetItem(f"{total:.2f} {currency}".strip()), + ) + self.invoices_table.setItem( + row_idx, self.INV_PAID, QTableWidgetItem(r["paid_at"] or "") + ) + doc_item = QTableWidgetItem(file_name) + doc_item.setData( + Qt.ItemDataRole.UserRole, + int(document_id) if document_id else None, + ) + self.invoices_table.setItem(row_idx, self.INV_DOCUMENT, doc_item) + + def _save_bucket(self) -> None: + project_id = self._current_project_id() + if project_id is None: + return + self._db.upsert_project_bucket( + project_id, + minutes_from_hours(self.baseline_spin.value()), + minutes_from_hours(self.ceiling_spin.value()), + float(self.warn_spin.value()), + ) + self.reload() + + def _add_to_ceiling(self) -> None: + project_id = self._current_project_id() + if project_id is None: + return + add_minutes = minutes_from_hours(self.topup_spin.value()) + if add_minutes <= 0: + return + self._db.add_to_project_bucket_ceiling(project_id, add_minutes) + self.reload() + + def _invoice_prepaid_hours(self) -> None: + project_id = self._current_project_id() + if project_id is None: + return + + hours = float(self.topup_spin.value()) + if hours <= 0.0: + QMessageBox.warning( + self, + strings._("project_bucket_invoice_prepaid"), + strings._("project_prepaid_invoice_hours_required"), + ) + return + + today = QDate.currentDate().toString("yyyy-MM-dd") + dialog = InvoiceDialog( + self._db, + project_id, + today, + today, + time_rows=[], + parent=self, + ) + dialog.rb_summary.setChecked(True) + dialog.summary_desc_edit.setText( + strings._("project_prepaid_invoice_default_desc").format(hours=hours) + ) + dialog.summary_hours_spin.setValue(hours) + dialog._recalc_totals() + + if dialog.exec() == QDialog.Accepted: + self.reload() + + def _selected_doc_id(self) -> tuple[int, str] | None: + row = self.documents_table.currentRow() + if row < 0: + return None + item = self.documents_table.item(row, self.DOC_FILE) + if item is None: + return None + doc_id = item.data(Qt.ItemDataRole.UserRole) + file_name = item.text() + if doc_id is None or not file_name: + return None + return int(doc_id), file_name + + def _open_selected_document(self, *_args) -> None: + selected = self._selected_doc_id() + if not selected: + QMessageBox.information( + self, + strings._("documents_open"), + strings._("documents_select_document"), + ) + return + doc_id, file_name = selected + open_document_from_db(self._db, doc_id, file_name, parent_widget=self) + + def _open_invoice_document(self, *_args) -> None: + row = self.invoices_table.currentRow() + if row < 0: + return + doc_item = self.invoices_table.item(row, self.INV_DOCUMENT) + if doc_item is None: + return + doc_id = doc_item.data(Qt.ItemDataRole.UserRole) + file_name = doc_item.text() + if doc_id is None or not file_name: + QMessageBox.information( + self, + strings._("project_open_invoice_document"), + strings._("project_invoice_no_document"), + ) + return + open_document_from_db(self._db, int(doc_id), file_name, parent_widget=self) diff --git a/bouquin/time_log.py b/bouquin/time_log.py index 1e4b303..eaee522 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -42,6 +42,7 @@ from PySide6.QtWidgets import ( from sqlcipher4.dbapi2 import IntegrityError from . import strings +from .projects import format_bucket_status from .db import DBManager from .settings import load_db_config from .theme import ThemeManager @@ -302,6 +303,7 @@ class TimeLogDialog(QDialog): # Project proj_row = QHBoxLayout() self.project_combo = QComboBox() + self.project_combo.currentIndexChanged.connect(self._refresh_bucket_indicator) self.manage_projects_btn = QPushButton(strings._("manage_projects")) self.manage_projects_btn.clicked.connect(self._manage_projects) proj_row.addWidget(self.project_combo, 1) @@ -331,6 +333,10 @@ class TimeLogDialog(QDialog): self.hours_spin.setValue(0.25) form.addRow(strings._("hours"), self.hours_spin) + self.bucket_label = QLabel("") + self.bucket_label.setWordWrap(True) + form.addRow(strings._("project_bucket"), self.bucket_label) + root.addLayout(form) # --- Buttons for entry @@ -409,6 +415,7 @@ class TimeLogDialog(QDialog): self.project_combo.clear() for proj_id, name in self._db.list_projects(): self.project_combo.addItem(name, proj_id) + self._refresh_bucket_indicator() def _reload_activities(self) -> None: activities = [name for _, name in self._db.list_activities()] @@ -461,11 +468,48 @@ class TimeLogDialog(QDialog): self.total_label.setText( strings._("time_log_total_hours").format(hours=self.total_hours) ) + self._refresh_bucket_indicator() self._current_entry_id = None self.delete_btn.setEnabled(False) self.add_update_btn.setText("&" + strings._("add_time_entry")) + # ----- Project bucket indicator ----------------------------------- + + def _refresh_bucket_indicator(self, *_args) -> None: + if not hasattr(self, "bucket_label"): + return + proj_id = self._ensure_project_id() + status = self._db.project_bucket_status(proj_id) if proj_id else None + self.bucket_label.setText(format_bucket_status(status)) + state = str(status.get("state") if status else "unconfigured") + if state == "warning": + style = "QLabel { padding: 4px; border: 1px solid #b58900; border-radius: 4px; }" + elif state in ("reached", "exceeded"): + style = "QLabel { padding: 4px; border: 1px solid #b00020; border-radius: 4px; font-weight: bold; }" + elif state == "ok": + style = "QLabel { padding: 4px; border: 1px solid #5b8f5b; border-radius: 4px; }" + else: + style = "" + self.bucket_label.setStyleSheet(style) + + def _maybe_show_bucket_alert(self, project_id: int | None) -> None: + if project_id is None: + return + status = self._db.project_bucket_status(project_id) + if not status: + return + state = str(status.get("state") or "") + if state not in {"reached", "exceeded"}: + return + QMessageBox.warning( + self, + strings._("project_bucket_alert_title"), + strings._("project_bucket_alert_message").format( + status=format_bucket_status(status) + ), + ) + # ----- Actions ----------------------------------------------------- def _on_change_date_clicked(self) -> None: @@ -561,6 +605,7 @@ class TimeLogDialog(QDialog): ) self._reload_entries() + self._maybe_show_bucket_alert(proj_id) if self.close_after_add: self.close() @@ -1135,6 +1180,10 @@ class TimeReportDialog(QDialog): self.total_label = QLabel("") root.addWidget(self.total_label) + self.bucket_label = QLabel("") + self.bucket_label.setWordWrap(True) + root.addWidget(self.bucket_label) + # Close close_row = QHBoxLayout() close_row.addStretch(1) @@ -1328,6 +1377,28 @@ class TimeReportDialog(QDialog): strings._("time_report_total").format(hours=total_hours) ) + if proj_data is None: + self.bucket_label.setText("") + self.bucket_label.setStyleSheet("") + else: + status = self._db.project_bucket_status(int(proj_data)) + self.bucket_label.setText(format_bucket_status(status)) + state = str(status.get("state") if status else "unconfigured") + if state == "warning": + self.bucket_label.setStyleSheet( + "QLabel { padding: 4px; border: 1px solid #b58900; border-radius: 4px; }" + ) + elif state in ("reached", "exceeded"): + self.bucket_label.setStyleSheet( + "QLabel { padding: 4px; border: 1px solid #b00020; border-radius: 4px; font-weight: bold; }" + ) + elif state == "ok": + self.bucket_label.setStyleSheet( + "QLabel { padding: 4px; border: 1px solid #5b8f5b; border-radius: 4px; }" + ) + else: + self.bucket_label.setStyleSheet("") + def _export_csv(self): if not self._last_rows: QMessageBox.information( diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 8e8c4bf..2004f43 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -21,6 +21,7 @@ class ToolBar(QToolBar): alarmRequested = Signal() timerRequested = Signal() documentsRequested = Signal() + projectsRequested = Signal() fontSizeLargerRequested = Signal() fontSizeSmallerRequested = Signal() @@ -127,6 +128,11 @@ class ToolBar(QToolBar): self.actDocuments = QAction("📁", self) self.actDocuments.setToolTip(strings._("toolbar_documents")) self.actDocuments.triggered.connect(self.documentsRequested) + + # Projects + self.actProjects = QAction("📌", self) + self.actProjects.setToolTip(strings._("toolbar_projects")) + self.actProjects.triggered.connect(self.projectsRequested) # Headings are mutually exclusive (like radio buttons) self.grpHeadings = QActionGroup(self) self.grpHeadings.setExclusive(True) @@ -159,6 +165,7 @@ class ToolBar(QToolBar): self.actInsertImg, self.actAlarm, self.actTimer, + self.actProjects, self.actDocuments, self.actHistory, ] @@ -186,6 +193,7 @@ class ToolBar(QToolBar): self._style_letter_button(self.actCheckboxes, "☑") self._style_letter_button(self.actAlarm, "⏰") self._style_letter_button(self.actTimer, "⌛") + self._style_letter_button(self.actProjects, "📌") self._style_letter_button(self.actDocuments, "📁") # History diff --git a/tests/test_main_window.py b/tests/test_main_window.py index 6c09e71..94bd91b 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -1900,9 +1900,60 @@ def test_main_window_without_time_log(qtbot, app, tmp_db_cfg): qtbot.addWidget(window) window.show() - # Verify time_log widget is hidden + # Verify time_log widget is hidden, including dependent Projects entry points. assert window.time_log.isHidden() assert not window.toolBar.actTimer.isVisible() + assert not window.toolBar.actProjects.isVisible() + assert not window.actProjects.isVisible() + assert not window.actProjects.isEnabled() + + +def test_main_window_projects_action_visible_with_time_log(qtbot, app, tmp_db_cfg): + """Projects is available from the menu/shortcut only when time logging is enabled.""" + s = get_settings() + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", True) + s.setValue("ui/time_log", True) + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() + + assert window.toolBar.actProjects.isVisible() + assert window.actProjects.isVisible() + assert window.actProjects.isEnabled() + + +def test_main_window_open_projects_noops_when_time_log_disabled(qtbot, app, tmp_db_cfg): + """The handler is also guarded, so a stale shortcut cannot open Projects.""" + s = get_settings() + s.setValue("db/default_db", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + s.setValue("ui/idle_minutes", 0) + s.setValue("ui/theme", "light") + s.setValue("ui/move_todos", True) + s.setValue("ui/tags", True) + s.setValue("ui/time_log", False) + s.setValue("ui/reminders", True) + s.setValue("ui/locale", "en") + s.setValue("ui/font_size", 11) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + window = MainWindow(themes=themes) + qtbot.addWidget(window) + window.show() + + with patch("bouquin.main_window.ProjectsDialog") as projects_dialog: + window._open_projects() + projects_dialog.assert_not_called() def test_main_window_without_documents(qtbot, app, tmp_db_cfg): diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..bf75165 --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,580 @@ +from unittest.mock import patch + +from PySide6.QtCore import QDate +from PySide6.QtWidgets import QDialog, QMessageBox + +from bouquin.projects import ( + ProjectsDialog, + format_bucket_status, + hours_from_minutes, + minutes_from_hours, +) +from bouquin.time_log import TimeLogDialog, TimeReportDialog + + +def _add_project(fresh_db, project_name: str) -> int: + project_id = fresh_db.add_project(project_name) + fresh_db.upsert_project_billing( + project_id, + hourly_rate_cents=15000, + currency="AUD", + tax_label="GST", + tax_rate_percent=10.0, + client_name=f"{project_name} Contact", + client_company=project_name, + client_address="1 Example Street", + client_email="client@example.test", + ) + return project_id + + +def _add_minutes(fresh_db, project_id: int, minutes: int, note: str = "Work") -> int: + activity_id = fresh_db.add_activity("Support") + return fresh_db.add_time_log("2026-01-01", project_id, activity_id, minutes, note) + + +# ============================================================================ +# Unit helpers +# ============================================================================ + + +def test_project_hour_minute_helpers_round_trip(): + assert hours_from_minutes(None) == 0.0 + assert hours_from_minutes(90) == 1.5 + assert minutes_from_hours(None) == 0 + assert minutes_from_hours(1.25) == 75 + assert minutes_from_hours(1.333) == 80 + + +def test_format_bucket_status_handles_none_unconfigured_and_reached(fresh_db): + assert "Select a project" in format_bucket_status(None) + + project_id = _add_project(fresh_db, "No Bucket Project") + _add_minutes(fresh_db, project_id, 30) + unconfigured = fresh_db.project_bucket_status(project_id) + text = format_bucket_status(unconfigured) + assert "No Bucket Project" in text + assert "No bucket ceiling" in text + assert "0.50h used" in text + + fresh_db.upsert_project_bucket(project_id, 30, 60, 50.0) + reached = fresh_db.project_bucket_status(project_id) + text = format_bucket_status(reached) + assert "1.00h / 1.00h" in text + assert "Bucket ceiling reached" in text + + +# ============================================================================ +# DB behaviour +# ============================================================================ + + +def test_project_bucket_status_includes_baseline_and_logged_time(fresh_db): + project_id = _add_project(fresh_db, "Support Retainer") + _add_minutes(fresh_db, project_id, 90) + fresh_db.upsert_project_bucket( + project_id, + baseline_minutes=60, + bucket_ceiling_minutes=180, + warn_at_percent=80.0, + ) + + status = fresh_db.project_bucket_status(project_id) + assert status["project_id"] == project_id + assert status["project_name"] == "Support Retainer" + assert status["baseline_minutes"] == 60 + assert status["logged_minutes"] == 90 + assert status["used_minutes"] == 150 + assert status["remaining_minutes"] == 30 + assert status["percent_used"] == 150 / 180 * 100.0 + assert status["state"] == "warning" + + fresh_db.add_to_project_bucket_ceiling(project_id, 60) + status = fresh_db.project_bucket_status(project_id) + assert status["bucket_ceiling_minutes"] == 240 + assert status["state"] == "ok" + + +def test_project_bucket_status_state_boundaries(fresh_db): + project_id = _add_project(fresh_db, "Boundary Project") + + _add_minutes(fresh_db, project_id, 30) + status = fresh_db.project_bucket_status(project_id) + assert status["state"] == "unconfigured" + assert status["bucket_ceiling_minutes"] == 0 + assert status["remaining_minutes"] is None + assert status["percent_used"] is None + + fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0) + assert fresh_db.project_bucket_status(project_id)["state"] == "ok" + + fresh_db.upsert_project_bucket(project_id, 66, 120, 80.0) + status = fresh_db.project_bucket_status(project_id) + assert status["used_minutes"] == 96 + assert status["percent_used"] == 80.0 + assert status["state"] == "warning" + + fresh_db.upsert_project_bucket(project_id, 90, 120, 80.0) + status = fresh_db.project_bucket_status(project_id) + assert status["used_minutes"] == 120 + assert status["remaining_minutes"] == 0 + assert status["state"] == "reached" + + fresh_db.upsert_project_bucket(project_id, 91, 120, 80.0) + status = fresh_db.project_bucket_status(project_id) + assert status["used_minutes"] == 121 + assert status["remaining_minutes"] == -1 + assert status["state"] == "exceeded" + + +def test_project_bucket_input_sanitisation_and_invalid_projects(fresh_db): + project_id = _add_project(fresh_db, "Sanitised Project") + fresh_db.upsert_project_bucket( + project_id, + baseline_minutes=-60, + bucket_ceiling_minutes=-120, + warn_at_percent=150.0, + ) + bucket = fresh_db.get_project_bucket(project_id) + assert bucket["project_id"] == project_id + assert bucket["baseline_minutes"] == 0 + assert bucket["bucket_ceiling_minutes"] == 0 + assert bucket["warn_at_percent"] == 100.0 + + fresh_db.upsert_project_bucket(project_id, 10, 60, -5.0) + bucket = fresh_db.get_project_bucket(project_id) + assert bucket["warn_at_percent"] == 0.0 + + fresh_db.add_to_project_bucket_ceiling(project_id, -60) + assert fresh_db.get_project_bucket(project_id)["bucket_ceiling_minutes"] == 60 + + for bad_project_id in (0, -1): + try: + fresh_db.upsert_project_bucket(bad_project_id, 0, 60, 80.0) + except ValueError as exc: + assert "invalid project id" in str(exc) + else: + raise AssertionError("invalid project id should raise") + + assert fresh_db.get_project_bucket(0) is None + assert fresh_db.logged_minutes_for_project(0) == 0 + assert fresh_db.project_bucket_status(0) is None + assert fresh_db.time_logs_for_project(0) == [] + assert fresh_db.invoices_for_project_with_documents(0) == [] + + +def test_project_summaries_include_all_projects_and_are_sorted(fresh_db): + alpha = _add_project(fresh_db, "alpha project") + zulu = _add_project(fresh_db, "Zulu Project") + fresh_db.upsert_project_bucket(zulu, 15, 120, 80.0) + + rows = fresh_db.list_project_summaries() + names = [r["project_name"] for r in rows] + assert names == ["alpha project", "Zulu Project"] + + alpha_row = next(r for r in rows if r["project_id"] == alpha) + assert alpha_row["document_count"] == 0 + assert alpha_row["invoice_count"] == 0 + assert alpha_row["time_log_count"] == 0 + assert alpha_row["logged_minutes"] == 0 + assert alpha_row["baseline_minutes"] == 0 + + zulu_row = next(r for r in rows if r["project_id"] == zulu) + assert zulu_row["baseline_minutes"] == 15 + assert zulu_row["bucket_ceiling_minutes"] == 120 + + +def test_project_documents_time_logs_and_invoices_are_isolated(fresh_db, tmp_path): + project_id = _add_project(fresh_db, "Project With Docs") + other_project = _add_project(fresh_db, "Other Project") + _add_minutes(fresh_db, project_id, 120, "Build work") + _add_minutes(fresh_db, other_project, 300, "Other work") + fresh_db.upsert_project_bucket(project_id, 0, 240, 80.0) + + doc_path = tmp_path / "invoice.pdf" + doc_path.write_bytes(b"not really a pdf") + doc_id = fresh_db.add_document_from_path( + project_id, + str(doc_path), + description="Invoice document", + uploaded_at="2026-02-02", + ) + + other_doc = tmp_path / "other.pdf" + other_doc.write_bytes(b"other") + fresh_db.add_document_from_path(other_project, str(other_doc)) + + invoice_id = fresh_db.create_invoice( + project_id=project_id, + invoice_number="INV-1", + issue_date="2026-02-03", + due_date="2026-02-17", + currency="AUD", + tax_label=None, + tax_rate_percent=None, + detail_mode="summary", + line_items=[("Prepaid bucket", 2.0, 10000)], + time_log_ids=[], + ) + fresh_db.set_invoice_document(invoice_id, doc_id) + fresh_db.create_invoice( + project_id=other_project, + invoice_number="INV-OTHER", + issue_date="2026-02-03", + due_date="2026-02-17", + currency="AUD", + tax_label=None, + tax_rate_percent=None, + detail_mode="summary", + line_items=[("Other", 1.0, 10000)], + time_log_ids=[], + ) + + row = next( + r for r in fresh_db.list_project_summaries() if r["project_id"] == project_id + ) + assert row["logged_minutes"] == 120 + assert row["time_log_count"] == 1 + assert row["document_count"] == 1 + assert row["invoice_count"] == 1 + + logs = fresh_db.time_logs_for_project(project_id) + assert len(logs) == 1 + assert logs[0]["note"] == "Build work" + + docs = fresh_db.documents_for_project(project_id) + assert len(docs) == 1 + assert docs[0][3] == "invoice.pdf" + + invoices = fresh_db.invoices_for_project_with_documents(project_id) + assert len(invoices) == 1 + assert invoices[0]["invoice_number"] == "INV-1" + assert invoices[0]["document_id"] == doc_id + assert invoices[0]["document_file_name"] == "invoice.pdf" + + +def test_prepaid_invoice_can_exist_without_logged_time_links(fresh_db): + project_id = _add_project(fresh_db, "Prepaid Project") + + invoice_id = fresh_db.create_invoice( + project_id=project_id, + invoice_number="PREPAID-1", + issue_date="2026-02-10", + due_date="2026-02-24", + currency="AUD", + tax_label=None, + tax_rate_percent=None, + detail_mode="summary", + line_items=[("Prepaid support bucket", 40.0, 15000)], + time_log_ids=[], + ) + + invoice = fresh_db.invoices_for_project_with_documents(project_id)[0] + assert invoice["id"] == invoice_id + assert invoice["total_cents"] == 600000 + + linked = fresh_db.conn.execute( + "SELECT COUNT(*) AS c FROM invoice_time_log WHERE invoice_id = ?", + (invoice_id,), + ).fetchone() + assert linked["c"] == 0 + + +# ============================================================================ +# UI behaviour +# ============================================================================ + + +def test_projects_dialog_loads_summary_time_logs_documents_and_invoices( + qtbot, fresh_db, tmp_path +): + project_id = _add_project(fresh_db, "UI Project") + _add_minutes(fresh_db, project_id, 60, "Initial support") + fresh_db.upsert_project_bucket(project_id, 30, 120, 75.0) + + doc_path = tmp_path / "ui-invoice.pdf" + doc_path.write_bytes(b"ui") + doc_id = fresh_db.add_document_from_path( + project_id, + str(doc_path), + description="UI invoice", + uploaded_at="2026-03-01", + ) + invoice_id = fresh_db.create_invoice( + project_id, + "UI-1", + "2026-03-02", + "2026-03-16", + "AUD", + None, + None, + "summary", + [("UI work", 1.0, 10000)], + [], + ) + fresh_db.set_invoice_document(invoice_id, doc_id) + + dialog = ProjectsDialog(fresh_db) + qtbot.addWidget(dialog) + + assert dialog.project_combo.currentData() == project_id + assert dialog.summary_table.rowCount() == 1 + assert dialog.time_logs_table.rowCount() == 1 + assert dialog.documents_table.rowCount() == 1 + assert dialog.invoices_table.rowCount() == 1 + assert dialog.summary_table.item(0, dialog.SUM_USED).text() == "1.50" + assert ( + dialog.summary_table.item(0, dialog.SUM_STATE).text() + == "Approaching bucket ceiling" + ) + assert dialog.time_logs_table.item(0, dialog.LOG_NOTE).text() == "Initial support" + assert dialog.documents_table.item(0, dialog.DOC_FILE).text() == "ui-invoice.pdf" + assert dialog.invoices_table.item(0, dialog.INV_NUMBER).text() == "UI-1" + assert "UI Project" in dialog.status_label.text() + assert "1.50h / 2.00h" in dialog.status_label.text() + assert ( + dialog.status_label.minimumHeight() + >= dialog.status_label.fontMetrics().lineSpacing() * 3 + 18 + ) + + +def test_projects_dialog_saves_and_replenishes_bucket(qtbot, fresh_db): + project_id = _add_project(fresh_db, "Editable Project") + + dialog = ProjectsDialog(fresh_db) + qtbot.addWidget(dialog) + assert dialog.project_combo.currentData() == project_id + + dialog.baseline_spin.setValue(1.5) + dialog.ceiling_spin.setValue(10.0) + dialog.warn_spin.setValue(75.0) + dialog._save_bucket() + + bucket = fresh_db.get_project_bucket(project_id) + assert bucket["baseline_minutes"] == 90 + assert bucket["bucket_ceiling_minutes"] == 600 + assert bucket["warn_at_percent"] == 75.0 + + dialog.topup_spin.setValue(40.0) + dialog._add_to_ceiling() + + bucket = fresh_db.get_project_bucket(project_id) + assert bucket["bucket_ceiling_minutes"] == 3000 + assert "48.50h remaining" in dialog.status_label.text() + + +class _FakeRadioButton: + def __init__(self): + self.checked = False + + def setChecked(self, checked): + self.checked = checked + + +class _FakeLineEdit: + def __init__(self): + self.value = "" + + def setText(self, text): + self.value = text + + +class _FakeSpinBox: + def __init__(self): + self.value_set = None + + def setValue(self, value): + self.value_set = value + + +class _FakeInvoiceDialog: + instances = [] + + def __init__( + self, db, project_id, start_date_iso, end_date_iso, time_rows=None, parent=None + ): + self.db = db + self.project_id = project_id + self.start_date_iso = start_date_iso + self.end_date_iso = end_date_iso + self.time_rows = time_rows + self.parent = parent + self.rb_summary = _FakeRadioButton() + self.summary_desc_edit = _FakeLineEdit() + self.summary_hours_spin = _FakeSpinBox() + self.recalculated = False + self.executed = False + _FakeInvoiceDialog.instances.append(self) + + def _recalc_totals(self): + self.recalculated = True + + def exec(self): + self.executed = True + return QDialog.Accepted + + +def test_projects_dialog_can_open_prepaid_invoice_for_unspent_hours(qtbot, fresh_db): + project_id = _add_project(fresh_db, "Prepaid UI Project") + dialog = ProjectsDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.topup_spin.setValue(40.0) + + _FakeInvoiceDialog.instances.clear() + with patch("bouquin.projects.InvoiceDialog", _FakeInvoiceDialog): + dialog._invoice_prepaid_hours() + + assert len(_FakeInvoiceDialog.instances) == 1 + invoice_dialog = _FakeInvoiceDialog.instances[0] + assert invoice_dialog.db is fresh_db + assert invoice_dialog.project_id == project_id + assert invoice_dialog.time_rows == [] + assert invoice_dialog.parent is dialog + assert invoice_dialog.rb_summary.checked is True + assert invoice_dialog.summary_hours_spin.value_set == 40.0 + assert invoice_dialog.summary_desc_edit.value == ( + "Prepaid support bucket (40.00 hours)" + ) + assert invoice_dialog.recalculated is True + assert invoice_dialog.executed is True + + +def test_projects_dialog_warns_when_prepaid_invoice_hours_are_zero(qtbot, fresh_db): + _add_project(fresh_db, "Zero Project") + dialog = ProjectsDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.topup_spin.setValue(0.0) + + with patch.object(QMessageBox, "warning") as warning: + dialog._invoice_prepaid_hours() + + assert warning.called + assert "greater than zero" in warning.call_args.args[2] + + +def test_projects_dialog_opens_selected_document_and_invoice_document( + qtbot, fresh_db, tmp_path +): + project_id = _add_project(fresh_db, "Open Project") + doc_path = tmp_path / "open.pdf" + doc_path.write_bytes(b"open") + doc_id = fresh_db.add_document_from_path(project_id, str(doc_path)) + invoice_id = fresh_db.create_invoice( + project_id, + "OPEN-1", + "2026-05-01", + None, + "AUD", + None, + None, + "summary", + [("Open work", 1.0, 10000)], + [], + ) + fresh_db.set_invoice_document(invoice_id, doc_id) + + dialog = ProjectsDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.documents_table.selectRow(0) + with patch("bouquin.projects.open_document_from_db") as open_doc: + dialog._open_selected_document() + open_doc.assert_called_once_with( + fresh_db, doc_id, "open.pdf", parent_widget=dialog + ) + + dialog.invoices_table.selectRow(0) + with patch("bouquin.projects.open_document_from_db") as open_doc: + dialog._open_invoice_document() + open_doc.assert_called_once_with( + fresh_db, doc_id, "open.pdf", parent_widget=dialog + ) + + +def test_projects_dialog_reports_missing_invoice_document(qtbot, fresh_db): + project_id = _add_project(fresh_db, "No Doc Project") + fresh_db.create_invoice( + project_id, + "NO-DOC-1", + "2026-05-01", + None, + "AUD", + None, + None, + "summary", + [("Work", 1.0, 10000)], + [], + ) + + dialog = ProjectsDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.invoices_table.selectRow(0) + + with patch.object(QMessageBox, "information") as info: + dialog._open_invoice_document() + assert info.called + + +def test_time_log_dialog_updates_bucket_indicator_and_alerts_on_reach(qtbot, fresh_db): + project_id = _add_project(fresh_db, "Logging Project") + fresh_db.upsert_project_bucket(project_id, 0, 60, 80.0) + + dialog = TimeLogDialog(fresh_db, "2026-06-01") + qtbot.addWidget(dialog) + + idx = dialog.project_combo.findData(project_id) + dialog.project_combo.setCurrentIndex(idx) + assert "0.00h / 1.00h" in dialog.bucket_label.text() + assert "Status: OK" in dialog.bucket_label.text() + + dialog.activity_edit.setText("Support") + dialog.hours_spin.setValue(1.0) + + with patch.object(QMessageBox, "warning") as warning: + dialog._on_add_or_update() + assert warning.called + assert "Bucket ceiling reached" in warning.call_args.args[2] + + status = fresh_db.project_bucket_status(project_id) + assert status["state"] == "reached" + assert "Bucket ceiling reached" in dialog.bucket_label.text() + + +def test_time_log_dialog_does_not_alert_before_reaching_bucket(qtbot, fresh_db): + project_id = _add_project(fresh_db, "Safe Logging") + fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0) + + dialog = TimeLogDialog(fresh_db, "2026-06-02") + qtbot.addWidget(dialog) + dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(project_id)) + dialog.activity_edit.setText("Support") + dialog.hours_spin.setValue(0.5) + + with patch.object(QMessageBox, "warning") as warning: + dialog._on_add_or_update() + warning.assert_not_called() + + assert fresh_db.project_bucket_status(project_id)["state"] == "ok" + + +def test_time_report_dialog_shows_bucket_for_selected_project_and_clears_for_all( + qtbot, fresh_db +): + project_id = _add_project(fresh_db, "Report Project") + _add_minutes(fresh_db, project_id, 90) + fresh_db.upsert_project_bucket(project_id, 0, 120, 75.0) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.from_date.setDate(QDate.fromString("2026-01-01", "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString("2026-01-31", "yyyy-MM-dd")) + + dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(project_id)) + dialog._run_report() + assert "Report Project" in dialog.bucket_label.text() + assert "Approaching bucket ceiling" in dialog.bucket_label.text() + + dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(None)) + dialog._run_report() + assert dialog.bucket_label.text() == ""