diff --git a/.gitignore b/.gitignore index 851b242..07c956d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ __pycache__ dist .coverage *.db +*.pdf +*.csv +*.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee1413..26e9853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 0.7.0 + + * New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features. + * Add 'Last week' to Time Report dialog range option + # 0.6.4 * Time reports: Fix report 'group by' logic to not show ambiguous 'note' data. diff --git a/README.md b/README.md index e2c5297..da87442 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,6 @@ report from within the app, or optionally to check for new versions to upgrade t Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions). -It's also recommended that you have Noto Sans fonts installed, but it's up to you. It just can impact the display of unicode symbols such as checkboxes. - If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc). ### From PyPi/pip diff --git a/bouquin/db.py b/bouquin/db.py index 2ebfa4c..46f72b1 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -41,6 +41,26 @@ DocumentRow = Tuple[ int, # size_bytes str, # uploaded_at (ISO) ] +ProjectBillingRow = Tuple[ + int, # project_id + int, # hourly_rate_cents + str, # currency + str | None, # tax_label + float | None, # tax_rate_percent + str | None, # client_name + str | None, # client_company + str | None, # client_address + str | None, # client_email +] +CompanyProfileRow = Tuple[ + str | None, # name + str | None, # address + str | None, # phone + str | None, # email + str | None, # tax_id + str | None, # payment_details + bytes | None, # logo +] _TAG_COLORS = [ "#FFB3BA", # soft red @@ -77,11 +97,31 @@ class DBConfig: time_log: bool = True reminders: bool = True documents: bool = True + invoicing: bool = False locale: str = "en" font_size: int = 11 class DBManager: + # Allow list of invoice columns allowed for dynamic field helpers + _INVOICE_COLUMN_ALLOWLIST = frozenset( + { + "invoice_number", + "issue_date", + "due_date", + "currency", + "tax_label", + "tax_rate_percent", + "subtotal_cents", + "tax_cents", + "total_cents", + "detail_mode", + "paid_at", + "payment_note", + "document_id", + } + ) + def __init__(self, cfg: DBConfig): self.cfg = cfg self.conn: sqlite.Connection | None = None @@ -252,6 +292,76 @@ class DBManager: CREATE INDEX IF NOT EXISTS ix_document_tags_tag_id ON document_tags(tag_id); + + CREATE TABLE IF NOT EXISTS project_billing ( + project_id INTEGER PRIMARY KEY + REFERENCES projects(id) ON DELETE CASCADE, + hourly_rate_cents INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'AUD', + tax_label TEXT, + tax_rate_percent REAL, + client_name TEXT, -- contact person + client_company TEXT, -- business name + client_address TEXT, + client_email TEXT + ); + + CREATE TABLE IF NOT EXISTS company_profile ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT, + address TEXT, + phone TEXT, + email TEXT, + tax_id TEXT, + payment_details TEXT, + logo BLOB + ); + + CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL + REFERENCES projects(id) ON DELETE RESTRICT, + invoice_number TEXT NOT NULL, + issue_date TEXT NOT NULL, -- yyyy-MM-dd + due_date TEXT, + currency TEXT NOT NULL, + tax_label TEXT, + tax_rate_percent REAL, + subtotal_cents INTEGER NOT NULL, + tax_cents INTEGER NOT NULL, + total_cents INTEGER NOT NULL, + detail_mode TEXT NOT NULL, -- 'detailed' | 'summary' + paid_at TEXT, + payment_note TEXT, + document_id INTEGER, + FOREIGN KEY(document_id) REFERENCES project_documents(id) + ON DELETE SET NULL, + UNIQUE(project_id, invoice_number) + ); + + CREATE INDEX IF NOT EXISTS ix_invoices_project + ON invoices(project_id); + + CREATE TABLE IF NOT EXISTS invoice_line_items ( + id INTEGER PRIMARY KEY, + invoice_id INTEGER NOT NULL + REFERENCES invoices(id) ON DELETE CASCADE, + description TEXT NOT NULL, + hours REAL NOT NULL, + rate_cents INTEGER NOT NULL, + amount_cents INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS ix_invoice_line_items_invoice + ON invoice_line_items(invoice_id); + + CREATE TABLE IF NOT EXISTS invoice_time_log ( + invoice_id INTEGER NOT NULL + REFERENCES invoices(id) ON DELETE CASCADE, + time_log_id INTEGER NOT NULL + REFERENCES time_log(id) ON DELETE RESTRICT, + PRIMARY KEY (invoice_id, time_log_id) + ); """ ) self.conn.commit() @@ -942,6 +1052,14 @@ class DBManager: ).fetchall() return [(r["id"], r["name"]) for r in rows] + def list_projects_by_id(self, project_id: int) -> str: + cur = self.conn.cursor() + row = cur.execute( + "SELECT name FROM projects WHERE id = ?;", + (project_id,), + ).fetchone() + return row["name"] if row else "" + def add_project(self, name: str) -> int: name = name.strip() if not name: @@ -1718,3 +1836,431 @@ class DBManager: (tag_name,), ).fetchall() return [(r["doc_id"], r["project_name"], r["file_name"]) for r in rows] + + # ------------------------- Billing settings ------------------------# + + def get_project_billing(self, project_id: int) -> ProjectBillingRow | None: + cur = self.conn.cursor() + row = cur.execute( + """ + SELECT + project_id, + hourly_rate_cents, + currency, + tax_label, + tax_rate_percent, + client_name, + client_company, + client_address, + client_email + FROM project_billing + WHERE project_id = ? + """, + (project_id,), + ).fetchone() + if not row: + return None + return ( + row["project_id"], + row["hourly_rate_cents"], + row["currency"], + row["tax_label"], + row["tax_rate_percent"], + row["client_name"], + row["client_company"], + row["client_address"], + row["client_email"], + ) + + def upsert_project_billing( + self, + project_id: int, + hourly_rate_cents: int, + currency: str, + tax_label: str | None, + tax_rate_percent: float | None, + client_name: str | None, + client_company: str | None, + client_address: str | None, + client_email: str | None, + ) -> None: + with self.conn: + self.conn.execute( + """ + INSERT INTO project_billing ( + project_id, + hourly_rate_cents, + currency, + tax_label, + tax_rate_percent, + client_name, + client_company, + client_address, + client_email + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id) DO UPDATE SET + hourly_rate_cents = excluded.hourly_rate_cents, + currency = excluded.currency, + tax_label = excluded.tax_label, + tax_rate_percent = excluded.tax_rate_percent, + client_name = excluded.client_name, + client_company = excluded.client_company, + client_address = excluded.client_address, + client_email = excluded.client_email; + """, + ( + project_id, + hourly_rate_cents, + currency, + tax_label, + tax_rate_percent, + client_name, + client_company, + client_address, + client_email, + ), + ) + + def list_client_companies(self) -> list[str]: + """Return distinct client display names from project_billing.""" + cur = self.conn.cursor() + rows = cur.execute( + """ + SELECT DISTINCT client_company + FROM project_billing + WHERE client_company IS NOT NULL + AND TRIM(client_company) <> '' + ORDER BY LOWER(client_company); + """ + ).fetchall() + return [r["client_company"] for r in rows] + + def get_client_by_company( + self, client_company: str + ) -> tuple[str | None, str | None, str | None, str | None] | None: + """ + Return (contact_name, client_display_name, address, email) + for a given client display name, based on the most recent project using it. + """ + cur = self.conn.cursor() + row = cur.execute( + """ + SELECT client_name, client_company, client_address, client_email + FROM project_billing + WHERE client_company = ? + AND client_company IS NOT NULL + AND TRIM(client_company) <> '' + ORDER BY project_id DESC + LIMIT 1 + """, + (client_company,), + ).fetchone() + if not row: + return None + return ( + row["client_name"], + row["client_company"], + row["client_address"], + row["client_email"], + ) + + # ------------------------- Company profile ------------------------# + + def get_company_profile(self) -> CompanyProfileRow | None: + cur = self.conn.cursor() + row = cur.execute( + """ + SELECT name, address, phone, email, tax_id, payment_details, logo + FROM company_profile + WHERE id = 1 + """ + ).fetchone() + if not row: + return None + return ( + row["name"], + row["address"], + row["phone"], + row["email"], + row["tax_id"], + row["payment_details"], + row["logo"], + ) + + def save_company_profile( + self, + name: str | None, + address: str | None, + phone: str | None, + email: str | None, + tax_id: str | None, + payment_details: str | None, + logo: bytes | None, + ) -> None: + with self.conn: + self.conn.execute( + """ + INSERT INTO company_profile (id, name, address, phone, email, tax_id, payment_details, logo) + VALUES (1, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + address = excluded.address, + phone = excluded.phone, + email = excluded.email, + tax_id = excluded.tax_id, + payment_details = excluded.payment_details, + logo = excluded.logo; + """, + ( + name, + address, + phone, + email, + tax_id, + payment_details, + Binary(logo) if logo else None, + ), + ) + + # ------------------------- Invoices -------------------------------# + + def create_invoice( + self, + project_id: int, + invoice_number: str, + issue_date: str, + due_date: str | None, + currency: str, + tax_label: str | None, + tax_rate_percent: float | None, + detail_mode: str, # 'detailed' or 'summary' + line_items: list[tuple[str, float, int]], # (description, hours, rate_cents) + time_log_ids: list[int], + ) -> int: + """ + Create invoice + line items + link time logs. + Returns invoice ID. + """ + if line_items: + first_rate_cents = line_items[0][2] + else: + first_rate_cents = 0 + + total_hours = sum(hours for _desc, hours, _rate in line_items) + subtotal_cents = int(round(total_hours * first_rate_cents)) + tax_cents = int(round(subtotal_cents * (tax_rate_percent or 0) / 100.0)) + total_cents = subtotal_cents + tax_cents + + with self.conn: + cur = self.conn.cursor() + cur.execute( + """ + INSERT INTO invoices ( + project_id, + invoice_number, + issue_date, + due_date, + currency, + tax_label, + tax_rate_percent, + subtotal_cents, + tax_cents, + total_cents, + detail_mode + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + project_id, + invoice_number, + issue_date, + due_date, + currency, + tax_label, + tax_rate_percent, + subtotal_cents, + tax_cents, + total_cents, + detail_mode, + ), + ) + invoice_id = cur.lastrowid + + # Line items + for desc, hours, rate_cents in line_items: + amount_cents = int(round(hours * rate_cents)) + cur.execute( + """ + INSERT INTO invoice_line_items ( + invoice_id, description, hours, rate_cents, amount_cents + ) + VALUES (?, ?, ?, ?, ?) + """, + (invoice_id, desc, hours, rate_cents, amount_cents), + ) + + # Link time logs + for tl_id in time_log_ids: + cur.execute( + "INSERT INTO invoice_time_log (invoice_id, time_log_id) VALUES (?, ?)", + (invoice_id, tl_id), + ) + + return invoice_id + + def get_invoice_count_by_project_id_and_year( + self, project_id: int, year: str + ) -> None: + with self.conn: + row = self.conn.execute( + "SELECT COUNT(*) AS c FROM invoices WHERE project_id = ? AND issue_date LIKE ?", + (project_id, year), + ).fetchone() + return row["c"] + + def get_all_invoices(self, project_id: int | None = None) -> None: + with self.conn: + if project_id is None: + 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 + FROM invoices AS i + LEFT JOIN projects AS p ON p.id = i.project_id + ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE; + """ + ).fetchall() + else: + 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 + FROM invoices AS i + LEFT JOIN projects AS p ON p.id = i.project_id + WHERE i.project_id = ? + ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE; + """, + (project_id,), + ).fetchall() + return rows + + def _validate_invoice_field(self, field: str) -> str: + if field not in self._INVOICE_COLUMN_ALLOWLIST: + raise ValueError(f"Invalid invoice field name: {field!r}") + return field + + def get_invoice_field_by_id(self, invoice_id: int, field: str) -> None: + field = self._validate_invoice_field(field) + + with self.conn: + row = self.conn.execute( + f"SELECT {field} FROM invoices WHERE id = ?", # nosec B608 + (invoice_id,), + ).fetchone() + return row + + def set_invoice_field_by_id( + self, invoice_id: int, field: str, value: str | None = None + ) -> None: + field = self._validate_invoice_field(field) + + with self.conn: + self.conn.execute( + f"UPDATE invoices SET {field} = ? WHERE id = ?", # nosec B608 + ( + value, + invoice_id, + ), + ) + + def update_invoice_number(self, invoice_id: int, invoice_number: str) -> None: + with self.conn: + self.conn.execute( + "UPDATE invoices SET invoice_number = ? WHERE id = ?", + (invoice_number, invoice_id), + ) + + def set_invoice_document(self, invoice_id: int, document_id: int) -> None: + with self.conn: + self.conn.execute( + "UPDATE invoices SET document_id = ? WHERE id = ?", + (document_id, invoice_id), + ) + + def time_logs_for_range( + self, + project_id: int, + start_date_iso: str, + end_date_iso: str, + ) -> list[TimeLogRow]: + """ + Return raw time log rows for a project/date range. + + Shape matches time_log_for_date: TimeLogRow. + """ + cur = self.conn.cursor() + rows = cur.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 AS created_at + FROM time_log t + JOIN projects p ON p.id = t.project_id + JOIN activities a ON a.id = t.activity_id + WHERE t.project_id = ? + AND t.page_date BETWEEN ? AND ? + ORDER BY t.page_date, LOWER(a.name), t.id; + """, + (project_id, start_date_iso, end_date_iso), + ).fetchall() + + result: list[TimeLogRow] = [] + for r in rows: + result.append( + ( + r["id"], + r["page_date"], + r["project_id"], + r["project_name"], + r["activity_id"], + r["activity_name"], + r["minutes"], + r["note"], + r["created_at"], + ) + ) + return result diff --git a/bouquin/invoices.py b/bouquin/invoices.py new file mode 100644 index 0000000..ee8d3a4 --- /dev/null +++ b/bouquin/invoices.py @@ -0,0 +1,1450 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from sqlcipher3 import dbapi2 as sqlite3 + +from PySide6.QtCore import Qt, QDate, QUrl, Signal +from PySide6.QtGui import ( + QImage, + QTextDocument, + QPageLayout, + QDesktopServices, +) +from PySide6.QtPrintSupport import QPrinter +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QFormLayout, + QLabel, + QLineEdit, + QComboBox, + QDateEdit, + QCheckBox, + QTextEdit, + QTableWidget, + QTableWidgetItem, + QAbstractItemView, + QHeaderView, + QPushButton, + QRadioButton, + QButtonGroup, + QDoubleSpinBox, + QFileDialog, + QMessageBox, + QWidget, +) + +from .db import DBManager, TimeLogRow +from .reminders import Reminder, ReminderType +from .settings import load_db_config +from . import strings + + +class InvoiceDetailMode(str, Enum): + DETAILED = "detailed" + SUMMARY = "summary" + + +@dataclass +class InvoiceLineItem: + description: str + hours: float + rate_cents: int + amount_cents: int + + +# Default time of day for automatically created invoice reminders (HH:MM) +_INVOICE_REMINDER_TIME = "09:00" + + +def _invoice_due_reminder_text(project_name: str, invoice_number: str) -> str: + """Build the human-readable text for an invoice due-date reminder. + + Using a single helper keeps the text consistent between creation and + removal of reminders. + """ + project = project_name.strip() or "(no project)" + number = invoice_number.strip() or "?" + return f"Invoice {number} for {project} is due" + + +class InvoiceDialog(QDialog): + """ + Create an invoice for a project + date range from time logs. + """ + + COL_INCLUDE = 0 + COL_DATE = 1 + COL_ACTIVITY = 2 + COL_NOTE = 3 + COL_HOURS = 4 + COL_AMOUNT = 5 + + remindersChanged = Signal() + + def __init__( + self, + db: DBManager, + project_id: int, + start_date_iso: str, + end_date_iso: str, + time_rows: list[TimeLogRow] | None = None, + parent=None, + ): + super().__init__(parent) + self._db = db + self._project_id = project_id + self._start = start_date_iso + self._end = end_date_iso + + self.cfg = load_db_config() + + if time_rows is not None: + self._time_rows = time_rows + else: + # Fallback if dialog is ever used standalone + self._time_rows = db.time_logs_for_range( + project_id, start_date_iso, end_date_iso + ) + + self.setWindowTitle(strings._("invoice_dialog_title")) + + layout = QVBoxLayout(self) + + # -------- Header / metadata -------- + form = QFormLayout() + + # Project label + proj_name = self._project_name() + self.project_label = QLabel(proj_name) + form.addRow(strings._("project") + ":", self.project_label) + + # Invoice number + self.invoice_number_edit = QLineEdit(self._suggest_invoice_number()) + form.addRow(strings._("invoice_number") + ":", self.invoice_number_edit) + + # Issue + due dates + today = QDate.currentDate() + self.issue_date_edit = QDateEdit(today) + self.issue_date_edit.setDisplayFormat("yyyy-MM-dd") + self.issue_date_edit.setCalendarPopup(True) + form.addRow(strings._("invoice_issue_date") + ":", self.issue_date_edit) + + self.due_date_edit = QDateEdit(today.addDays(14)) + self.due_date_edit.setDisplayFormat("yyyy-MM-dd") + self.due_date_edit.setCalendarPopup(True) + form.addRow(strings._("invoice_due_date") + ":", self.due_date_edit) + + # Billing defaults from project_billing + pb = db.get_project_billing(project_id) + if pb: + ( + _pid, + hourly_rate_cents, + currency, + tax_label, + tax_rate_percent, + client_name, + client_company, + client_address, + client_email, + ) = pb + else: + hourly_rate_cents = 0 + currency = "AUD" + tax_label = "GST" + tax_rate_percent = None + client_name = client_company = client_address = client_email = "" + + # Currency + self.currency_edit = QLineEdit(currency) + form.addRow(strings._("invoice_currency") + ":", self.currency_edit) + + # Hourly rate + self.rate_spin = QDoubleSpinBox() + self.rate_spin.setRange(0, 1_000_000) + self.rate_spin.setDecimals(2) + self.rate_spin.setValue(hourly_rate_cents / 100.0) + self.rate_spin.valueChanged.connect(self._recalc_amounts) + form.addRow(strings._("invoice_hourly_rate") + ":", self.rate_spin) + + # Tax + self.tax_checkbox = QCheckBox(strings._("invoice_apply_tax")) + self.tax_label = QLabel(strings._("invoice_tax_label") + ":") + self.tax_label_edit = QLineEdit(tax_label or "") + + self.tax_rate_label = QLabel(strings._("invoice_tax_rate") + " %:") + self.tax_rate_spin = QDoubleSpinBox() + self.tax_rate_spin.setRange(0, 100) + self.tax_rate_spin.setDecimals(2) + + tax_row = QHBoxLayout() + tax_row.addWidget(self.tax_checkbox) + tax_row.addWidget(self.tax_label) + tax_row.addWidget(self.tax_label_edit) + tax_row.addWidget(self.tax_rate_label) + tax_row.addWidget(self.tax_rate_spin) + form.addRow(strings._("invoice_tax") + ":", tax_row) + + if tax_rate_percent is None: + self.tax_rate_spin.setValue(10.0) + self.tax_checkbox.setChecked(False) + self.tax_label.hide() + self.tax_label_edit.hide() + self.tax_rate_label.hide() + self.tax_rate_spin.hide() + else: + self.tax_rate_spin.setValue(tax_rate_percent) + self.tax_checkbox.setChecked(True) + self.tax_label.show() + self.tax_label_edit.show() + self.tax_rate_label.show() + self.tax_rate_spin.show() + + # When tax settings change, recalc totals + self.tax_checkbox.toggled.connect(self._on_tax_toggled) + self.tax_rate_spin.valueChanged.connect(self._recalc_totals) + + # Client info + self.client_name_edit = QLineEdit(client_name or "") + + # Client company as an editable combo box with existing clients + self.client_company_combo = QComboBox() + self.client_company_combo.setEditable(True) + + companies = self._db.list_client_companies() + # Add existing companies + for comp in companies: + if comp: + self.client_company_combo.addItem(comp) + + # If this project already has a client_company, select it or set as text + if client_company: + idx = self.client_company_combo.findText( + client_company, Qt.MatchFixedString + ) + if idx >= 0: + self.client_company_combo.setCurrentIndex(idx) + else: + self.client_company_combo.setEditText(client_company) + + # When the company text changes (selection or typed), try autofill + self.client_company_combo.currentTextChanged.connect( + self._on_client_company_changed + ) + + self.client_addr_edit = QTextEdit() + self.client_addr_edit.setPlainText(client_address or "") + self.client_email_edit = QLineEdit(client_email or "") + + form.addRow(strings._("invoice_client_name") + ":", self.client_name_edit) + form.addRow( + strings._("invoice_client_company") + ":", self.client_company_combo + ) + form.addRow(strings._("invoice_client_address") + ":", self.client_addr_edit) + form.addRow(strings._("invoice_client_email") + ":", self.client_email_edit) + + layout.addLayout(form) + + # -------- Detail mode + table -------- + mode_row = QHBoxLayout() + self.rb_detailed = QRadioButton(strings._("invoice_mode_detailed")) + self.rb_summary = QRadioButton(strings._("invoice_mode_summary")) + self.rb_detailed.setChecked(True) + self.mode_group = QButtonGroup(self) + self.mode_group.addButton(self.rb_detailed) + self.mode_group.addButton(self.rb_summary) + self.rb_detailed.toggled.connect(self._update_mode_enabled) + mode_row.addWidget(self.rb_detailed) + mode_row.addWidget(self.rb_summary) + mode_row.addStretch() + layout.addLayout(mode_row) + + # Detailed table (time entries) + self.table = QTableWidget() + self.table.setColumnCount(6) + self.table.setHorizontalHeaderLabels( + [ + "", # include checkbox + strings._("date"), + strings._("activity"), + strings._("note"), + strings._("invoice_hours"), + strings._("invoice_amount"), + ] + ) + self.table.setSelectionMode(QAbstractItemView.NoSelection) + header = self.table.horizontalHeader() + header.setSectionResizeMode(self.COL_INCLUDE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_DATE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_ACTIVITY, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_NOTE, QHeaderView.Stretch) + header.setSectionResizeMode(self.COL_HOURS, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_AMOUNT, QHeaderView.ResizeToContents) + layout.addWidget(self.table) + + self._populate_detailed_rows(hourly_rate_cents) + self.table.itemChanged.connect(self._on_table_item_changed) + + # Summary line + self.summary_desc_label = QLabel(strings._("invoice_summary_desc") + ":") + self.summary_desc_edit = QLineEdit(strings._("invoice_summary_default_desc")) + self.summary_hours_label = QLabel(strings._("invoice_summary_hours") + ":") + self.summary_hours_spin = QDoubleSpinBox() + self.summary_hours_spin.setRange(0, 10_000) + self.summary_hours_spin.setDecimals(2) + self.summary_hours_spin.setValue(self._total_hours_from_table()) + self.summary_hours_spin.valueChanged.connect(self._recalc_totals) + + summary_row = QHBoxLayout() + summary_row.addWidget(self.summary_desc_label) + summary_row.addWidget(self.summary_desc_edit) + summary_row.addWidget(self.summary_hours_label) + summary_row.addWidget(self.summary_hours_spin) + layout.addLayout(summary_row) + + # -------- Totals -------- + totals_row = QHBoxLayout() + self.subtotal_label = QLabel("0.00") + self.tax_label_total = QLabel("0.00") + self.total_label = QLabel("0.00") + totals_row.addStretch() + totals_row.addWidget(QLabel(strings._("invoice_subtotal") + ":")) + totals_row.addWidget(self.subtotal_label) + totals_row.addWidget(QLabel(strings._("invoice_tax_total") + ":")) + totals_row.addWidget(self.tax_label_total) + totals_row.addWidget(QLabel(strings._("invoice_total") + ":")) + totals_row.addWidget(self.total_label) + layout.addLayout(totals_row) + + # -------- Buttons -------- + btn_row = QHBoxLayout() + btn_row.addStretch() + self.btn_save = QPushButton(strings._("invoice_save_and_export")) + self.btn_save.clicked.connect(self._on_save_clicked) + btn_row.addWidget(self.btn_save) + + cancel_btn = QPushButton(strings._("cancel")) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + self._update_mode_enabled() + self._recalc_totals() + + def _project_name(self) -> str: + # relies on TimeLogRow including project_name + if self._time_rows: + return self._time_rows[0][3] + # fallback: query projects table + return self._db.list_projects_by_id(self._project_id) + + def _suggest_invoice_number(self) -> str: + # Very simple example: YYYY-XXX based on count + today = QDate.currentDate() + year = today.toString("yyyy") + last = self._db.get_invoice_count_by_project_id_and_year( + self._project_id, f"{year}-%" + ) + seq = int(last) + 1 + return f"{year}-{seq:03d}" + + def _create_due_date_reminder( + self, invoice_id: int, invoice_number: str, due_date_iso: str + ) -> None: + """Create a one-off reminder on the invoice's due date. + + The reminder is purely informational and is keyed by its text so + that it can be found and deleted later when the invoice is paid. + """ + # No due date, nothing to remind about. + if not due_date_iso: + return + + # Build consistent text and create a Reminder dataclass instance. + project_name = self._project_name() + text = _invoice_due_reminder_text(project_name, invoice_number) + + reminder = Reminder( + id=None, + text=text, + time_str=_INVOICE_REMINDER_TIME, + reminder_type=ReminderType.ONCE, + weekday=None, + active=True, + date_iso=due_date_iso, + ) + + try: + # Save without failing the invoice flow if something goes wrong. + self._db.save_reminder(reminder) + self.remindersChanged.emit() + except Exception: + pass + + def _populate_detailed_rows(self, hourly_rate_cents: int) -> None: + self.table.blockSignals(True) + try: + self.table.setRowCount(len(self._time_rows)) + rate = hourly_rate_cents / 100.0 if hourly_rate_cents else 0.0 + + for row_idx, tl in enumerate(self._time_rows): + ( + tl_id, + page_date, + _proj_id, + _proj_name, + _act_id, + activity_name, + minutes, + note, + _created_at, + ) = tl + + # include checkbox + chk_item = QTableWidgetItem() + chk_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + chk_item.setCheckState(Qt.Checked) + chk_item.setData(Qt.UserRole, tl_id) + self.table.setItem(row_idx, self.COL_INCLUDE, chk_item) + + self.table.setItem(row_idx, self.COL_DATE, QTableWidgetItem(page_date)) + self.table.setItem( + row_idx, self.COL_ACTIVITY, QTableWidgetItem(activity_name) + ) + self.table.setItem(row_idx, self.COL_NOTE, QTableWidgetItem(note or "")) + + hours = minutes / 60.0 + + # Hours – editable via spin box (override allowed) + hours_spin = QDoubleSpinBox() + hours_spin.setRange(0, 24) + hours_spin.setDecimals(2) + hours_spin.setValue(hours) + hours_spin.valueChanged.connect(self._recalc_totals) + self.table.setCellWidget(row_idx, self.COL_HOURS, hours_spin) + + amount = hours * rate + amount_item = QTableWidgetItem(f"{amount:.2f}") + amount_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + amount_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + self.table.setItem(row_idx, self.COL_AMOUNT, amount_item) + finally: + self.table.blockSignals(False) + + def _total_hours_from_table(self) -> float: + total = 0.0 + for r in range(self.table.rowCount()): + include_item = self.table.item(r, self.COL_INCLUDE) + if include_item and include_item.checkState() == Qt.Checked: + hours_widget = self.table.cellWidget(r, self.COL_HOURS) + if isinstance(hours_widget, QDoubleSpinBox): + total += hours_widget.value() + return total + + def _detail_line_items(self) -> list[InvoiceLineItem]: + rate_cents = int(round(self.rate_spin.value() * 100)) + items: list[InvoiceLineItem] = [] + for r in range(self.table.rowCount()): + include_item = self.table.item(r, self.COL_INCLUDE) + if include_item and include_item.checkState() == Qt.Checked: + date_str = self.table.item(r, self.COL_DATE).text() + activity = self.table.item(r, self.COL_ACTIVITY).text() + note = self.table.item(r, self.COL_NOTE).text() + + descr_parts = [date_str, activity] + if note: + descr_parts.append(note) + descr = " – ".join(descr_parts) + + hours_widget = self.table.cellWidget(r, self.COL_HOURS) + hours = ( + hours_widget.value() + if isinstance(hours_widget, QDoubleSpinBox) + else 0.0 + ) + amount_cents = int(round(hours * rate_cents)) + items.append( + InvoiceLineItem( + description=descr, + hours=hours, + rate_cents=rate_cents, + amount_cents=amount_cents, + ) + ) + return items + + def _summary_line_items(self) -> list[InvoiceLineItem]: + rate_cents = int(round(self.rate_spin.value() * 100)) + hours = self.summary_hours_spin.value() + amount_cents = int(round(hours * rate_cents)) + return [ + InvoiceLineItem( + description=self.summary_desc_edit.text().strip() or "Services", + hours=hours, + rate_cents=rate_cents, + amount_cents=amount_cents, + ) + ] + + def _update_mode_enabled(self) -> None: + detailed = self.rb_detailed.isChecked() + self.table.setEnabled(detailed) + if not detailed: + self.summary_desc_label.show() + self.summary_desc_edit.show() + self.summary_hours_label.show() + self.summary_hours_spin.show() + else: + self.summary_desc_label.hide() + self.summary_desc_edit.hide() + self.summary_hours_label.hide() + self.summary_hours_spin.hide() + self.resize(self.sizeHint().width(), self.sizeHint().height()) + self._recalc_totals() + + def _recalc_amounts(self) -> None: + # Called when rate changes + rate = self.rate_spin.value() + for r in range(self.table.rowCount()): + hours_widget = self.table.cellWidget(r, self.COL_HOURS) + if isinstance(hours_widget, QDoubleSpinBox): + hours = hours_widget.value() + amount = hours * rate + amount_item = self.table.item(r, self.COL_AMOUNT) + if amount_item: + amount_item.setText(f"{amount:.2f}") + self._recalc_totals() + + def _recalc_totals(self) -> None: + if self.rb_detailed.isChecked(): + items = self._detail_line_items() + else: + items = self._summary_line_items() + + rate_cents = int(round(self.rate_spin.value() * 100)) + total_hours = sum(li.hours for li in items) + subtotal_cents = int(round(total_hours * rate_cents)) + + tax_rate = self.tax_rate_spin.value() if self.tax_checkbox.isChecked() else 0.0 + tax_cents = int(round(subtotal_cents * (tax_rate / 100.0))) + total_cents = subtotal_cents + tax_cents + + self.subtotal_label.setText(f"{subtotal_cents / 100.0:.2f}") + self.tax_label_total.setText(f"{tax_cents / 100.0:.2f}") + self.total_label.setText(f"{total_cents / 100.0:.2f}") + + def _on_table_item_changed(self, item: QTableWidgetItem) -> None: + """Handle changes to table items, particularly checkbox toggles.""" + if item and item.column() == self.COL_INCLUDE: + self._recalc_totals() + + def _on_tax_toggled(self, checked: bool) -> None: + # if on, show the other tax fields + if checked: + self.tax_label.show() + self.tax_label_edit.show() + self.tax_rate_label.show() + self.tax_rate_spin.show() + else: + self.tax_label.hide() + self.tax_label_edit.hide() + self.tax_rate_label.hide() + self.tax_rate_spin.hide() + + # If user just turned tax ON and the rate is 0, give a sensible default + if checked and self.tax_rate_spin.value() == 0.0: + self.tax_rate_spin.setValue(10.0) + self.resize(self.sizeHint().width(), self.sizeHint().height()) + self._recalc_totals() + + def _on_client_company_changed(self, text: str) -> None: + text = text.strip() + if not text: + return + + details = self._db.get_client_by_company(text) + if not details: + # New client – leave other fields as-is + return + + # We don't touch the company combo text – user already chose/typed it. + client_name, client_company, client_address, client_email = details + if client_name: + self.client_name_edit.setText(client_name) + if client_address: + self.client_addr_edit.setPlainText(client_address) + if client_email: + self.client_email_edit.setText(client_email) + + def _on_save_clicked(self) -> None: + invoice_number = self.invoice_number_edit.text().strip() + if not invoice_number: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_number_required"), + ) + return + + issue_date = self.issue_date_edit.date() + due_date = self.due_date_edit.date() + issue_date_iso = issue_date.toString("yyyy-MM-dd") + due_date_iso = due_date.toString("yyyy-MM-dd") + + # Guard against due date before issue date + if due_date.isValid() and issue_date.isValid() and due_date < issue_date: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_due_before_issue"), + ) + return + + detail_mode = ( + InvoiceDetailMode.DETAILED + if self.rb_detailed.isChecked() + else InvoiceDetailMode.SUMMARY + ) + + # Build line items + collect time_log_ids + if detail_mode == InvoiceDetailMode.DETAILED: + items = self._detail_line_items() + time_log_ids: list[int] = [] + for r in range(self.table.rowCount()): + include_item = self.table.item(r, self.COL_INCLUDE) + if include_item and include_item.checkState() == Qt.Checked: + tl_id = int(include_item.data(Qt.UserRole)) + time_log_ids.append(tl_id) + else: + items = self._summary_line_items() + # In summary mode we still link all rows used for the report + time_log_ids = [tl[0] for tl in self._time_rows] + + if not items: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_no_items"), + ) + return + + # Rate + tax info + rate_cents = int(round(self.rate_spin.value() * 100)) + currency = self.currency_edit.text().strip() + tax_label = self.tax_label_edit.text().strip() or None + tax_rate_percent = ( + self.tax_rate_spin.value() if self.tax_checkbox.isChecked() else None + ) + + # Persist billing settings for this project (fills project_billing) + self._db.upsert_project_billing( + project_id=self._project_id, + hourly_rate_cents=rate_cents, + currency=currency, + tax_label=tax_label, + tax_rate_percent=tax_rate_percent, + client_name=self.client_name_edit.text().strip() or None, + client_company=self.client_company_combo.currentText().strip() or None, + client_address=self.client_addr_edit.toPlainText().strip() or None, + client_email=self.client_email_edit.text().strip() or None, + ) + + try: + # Create invoice in DB + invoice_id = self._db.create_invoice( + project_id=self._project_id, + invoice_number=invoice_number, + issue_date=issue_date_iso, + due_date=due_date_iso, + currency=currency, + tax_label=tax_label, + tax_rate_percent=tax_rate_percent, + detail_mode=detail_mode.value, + line_items=[(li.description, li.hours, li.rate_cents) for li in items], + time_log_ids=time_log_ids, + ) + + # Automatically create a reminder for the invoice due date + if self.cfg.reminders: + self._create_due_date_reminder(invoice_id, invoice_number, due_date_iso) + + except sqlite3.IntegrityError: + # (project_id, invoice_number) must be unique + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_number_unique"), + ) + return + + # Generate PDF + pdf_path = self._export_pdf(invoice_id, items) + # Save to Documents if the Documents feature is enabled + if pdf_path and self.cfg.documents: + doc_id = self._db.add_document_from_path( + self._project_id, + pdf_path, + description=f"Invoice {invoice_number}", + ) + self._db.set_invoice_document(invoice_id, doc_id) + + self.accept() + + def _export_pdf(self, invoice_id: int, items: list[InvoiceLineItem]) -> str | None: + proj_name = self._project_name() + safe_proj = proj_name.replace(" ", "_") or "project" + invoice_number = self.invoice_number_edit.text().strip() + filename = f"{safe_proj}_invoice_{invoice_number}.pdf" + + path, _ = QFileDialog.getSaveFileName( + self, + strings._("invoice_save_pdf_title"), + filename, + "PDF (*.pdf)", + ) + if not path: + return None + + printer = QPrinter(QPrinter.HighResolution) + printer.setOutputFormat(QPrinter.PdfFormat) + printer.setOutputFileName(path) + printer.setPageOrientation(QPageLayout.Portrait) + + doc = QTextDocument() + + # 🔹 Load company profile *before* building HTML + profile = self._db.get_company_profile() + self._company_profile = None + if profile: + name, address, phone, email, tax_id, payment_details, logo_bytes = profile + self._company_profile = { + "name": name, + "address": address, + "phone": phone, + "email": email, + "tax_id": tax_id, + "payment_details": payment_details, + } + if logo_bytes: + img = QImage.fromData(logo_bytes) + if not img.isNull(): + doc.addResource( + QTextDocument.ImageResource, QUrl("company_logo"), img + ) + + html = self._build_invoice_html(items) + doc.setHtml(html) + doc.print_(printer) + + QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + return path + + def _build_invoice_html(self, items: list[InvoiceLineItem]) -> str: + # Monetary values based on current labels (these are kept in sync by _recalc_totals) + try: + subtotal = float(self.subtotal_label.text()) + except ValueError: + subtotal = 0.0 + try: + tax_total = float(self.tax_label_total.text()) + except ValueError: + tax_total = 0.0 + total = subtotal + tax_total + + currency = self.currency_edit.text().strip() + issue = self.issue_date_edit.date().toString("yyyy-MM-dd") + due = self.due_date_edit.date().toString("yyyy-MM-dd") + inv_no = self.invoice_number_edit.text().strip() or "-" + proj = self._project_name() + + # --- Client block (Bill to) ------------------------------------- + client_lines = [ + self.client_company_combo.currentText().strip(), + self.client_name_edit.text().strip(), + self.client_addr_edit.toPlainText().strip(), + self.client_email_edit.text().strip(), + ] + client_lines = [ln for ln in client_lines if ln] + client_block = "
".join( + line.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + for line in client_lines + ) + + # --- Company block (From) --------------------------------------- + company_html = "" + if self._company_profile: + cp = self._company_profile + lines = [ + cp.get("name"), + cp.get("address"), + cp.get("phone"), + cp.get("email"), + "Tax ID/Business No: " + cp.get("tax_id"), + ] + lines = [ln for ln in lines if ln] + company_html = "
".join( + line.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + for line in lines + ) + + logo_html = "" + if self._company_profile: + # "company_logo" resource is registered in _export_pdf + logo_html = ( + '' + ) + + # --- Items table ------------------------------------------------- + item_rows_html = "" + for idx, li in enumerate(items, start=1): + desc = li.description or "" + desc = ( + desc.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + ) + hours_str = f"{li.hours:.2f}".rstrip("0").rstrip(".") + price = li.rate_cents / 100.0 + amount = li.amount_cents / 100.0 + item_rows_html += f""" + + + {desc} + + + {hours_str} + + + {price:,.2f} {currency} + + + {amount:,.2f} {currency} + + + """ + + if not item_rows_html: + item_rows_html = """ + + + (No items) + + + """ + + # --- Tax summary line ------------------------------------------- + if tax_total > 0.0: + tax_label = self.tax_label_edit.text().strip() or "Tax" + tax_summary_text = f"{tax_label} has been added." + tax_line_label = tax_label + invoice_title = "TAX INVOICE" + else: + tax_summary_text = "No tax has been charged." + tax_line_label = "Tax" + invoice_title = "INVOICE" + + # --- Optional payment / terms text ----------------------------- + if self._company_profile and self._company_profile.get("payment_details"): + raw_payment = self._company_profile["payment_details"] + else: + raw_payment = "Please pay by the due date. Thank you!" + + lines = [ln.strip() for ln in raw_payment.splitlines()] + payment_text = "\n".join(lines).strip() + + # --- Build final HTML ------------------------------------------- + html = f""" + + + + + + + + + + + + +
+ {logo_html} +
+ {company_html} +
+
+
{invoice_title}
+ + + + + + + + + + + + + + + + + +
Invoice no:{inv_no}
Invoice date:{issue}
Reference:{proj}
Due date:{due}
+
+ + + + + + + + + +
+
BILL TO
+
{client_block}
+
+ + + + + + + + + + + + + +
Subtotal{subtotal:,.2f} {currency}
{tax_line_label}{tax_total:,.2f} {currency}
TOTAL{total:,.2f} {currency}
+
{tax_summary_text}
+
+ + + + + + + + + + {item_rows_html} +
ITEMS AND DESCRIPTIONQTY/HRSPRICEAMOUNT ({currency})
+ + + + + + + +
+
PAYMENT DETAILS
+
+{payment_text} +
+
+ + + + + + +
AMOUNT DUE{total:,.2f} {currency}
+
+ + + + """ + + return html + + +class InvoicesDialog(QDialog): + """Manager for viewing and editing existing invoices.""" + + COL_NUMBER = 0 + COL_PROJECT = 1 + COL_ISSUE_DATE = 2 + COL_DUE_DATE = 3 + COL_CURRENCY = 4 + COL_TAX_LABEL = 5 + COL_TAX_RATE = 6 + COL_SUBTOTAL = 7 + COL_TAX = 8 + COL_TOTAL = 9 + COL_PAID_AT = 10 + COL_PAYMENT_NOTE = 11 + + remindersChanged = Signal() + + def __init__( + self, + db: DBManager, + parent: QWidget | None = None, + initial_project_id: int | None = None, + ) -> None: + super().__init__(parent) + self._db = db + self._reloading_invoices = False + self.cfg = load_db_config() + self.setWindowTitle(strings._("manage_invoices")) + self.resize(1100, 500) + + root = QVBoxLayout(self) + + # --- Project selector ------------------------------------------------- + form = QFormLayout() + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + root.addLayout(form) + + proj_row = QHBoxLayout() + self.project_combo = QComboBox() + proj_row.addWidget(self.project_combo, 1) + form.addRow(strings._("project"), proj_row) + + self._reload_projects() + self._select_initial_project(initial_project_id) + + self.project_combo.currentIndexChanged.connect(self._on_project_changed) + + # --- Table of invoices ----------------------------------------------- + self.table = QTableWidget() + self.table.setColumnCount(12) + self.table.setHorizontalHeaderLabels( + [ + strings._("invoice_number"), # COL_NUMBER + strings._("project"), # COL_PROJECT + strings._("invoice_issue_date"), # COL_ISSUE_DATE + strings._("invoice_due_date"), # COL_DUE_DATE + strings._("invoice_currency"), # COL_CURRENCY + strings._("invoice_tax_label"), # COL_TAX_LABEL + strings._("invoice_tax_rate"), # COL_TAX_RATE + strings._("invoice_subtotal"), # COL_SUBTOTAL + strings._("invoice_tax_total"), # COL_TAX + strings._("invoice_total"), # COL_TOTAL + strings._("invoice_paid_at"), # COL_PAID_AT + strings._("invoice_payment_note"), # COL_PAYMENT_NOTE + ] + ) + + header = self.table.horizontalHeader() + header.setSectionResizeMode(self.COL_NUMBER, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_PROJECT, QHeaderView.Stretch) + header.setSectionResizeMode(self.COL_ISSUE_DATE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_DUE_DATE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_CURRENCY, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_TAX_LABEL, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_TAX_RATE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_SUBTOTAL, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_TAX, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_TOTAL, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_PAID_AT, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_PAYMENT_NOTE, QHeaderView.Stretch) + + self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.table.setEditTriggers( + QAbstractItemView.EditTrigger.DoubleClicked + | QAbstractItemView.EditTrigger.EditKeyPressed + | QAbstractItemView.EditTrigger.SelectedClicked + ) + + root.addWidget(self.table, 1) + + # Connect after constructing the table + self.table.itemChanged.connect(self._on_item_changed) + + # --- Buttons ---------------------------------------------------------- + btn_row = QHBoxLayout() + btn_row.addStretch(1) + + close_btn = QPushButton(strings._("close")) + close_btn.clicked.connect(self.accept) + btn_row.addWidget(close_btn) + + root.addLayout(btn_row) + + self._reload_invoices() + + # ------------------------------------------------------------------ helpers + + def _reload_projects(self) -> None: + """Populate the project combo box.""" + self.project_combo.blockSignals(True) + try: + self.project_combo.clear() + for proj_id, name in self._db.list_projects(): + self.project_combo.addItem(name, proj_id) + finally: + self.project_combo.blockSignals(False) + + def _select_initial_project(self, project_id: int | None) -> None: + if project_id is None: + if self.project_combo.count() > 0: + self.project_combo.setCurrentIndex(0) + return + + idx = self.project_combo.findData(project_id) + if idx >= 0: + self.project_combo.setCurrentIndex(idx) + elif self.project_combo.count() > 0: + self.project_combo.setCurrentIndex(0) + + def _current_project(self) -> int | None: + idx = self.project_combo.currentIndex() + if idx < 0: + return None + data = self.project_combo.itemData(idx) + return int(data) if data is not None else None + + # ----------------------------------------------------------------- reloading + + def _on_project_changed(self, idx: int) -> None: + _ = idx + self._reload_invoices() + + def _reload_invoices(self) -> None: + """Load invoices for the current project into the table.""" + self._reloading_invoices = True + try: + self.table.setRowCount(0) + project_id = self._current_project() + rows = self._db.get_all_invoices(project_id) + + self.table.setRowCount(len(rows) or 0) + + for row_idx, r in enumerate(rows): + inv_id = int(r["id"]) + proj_name = r["project_name"] or "" + invoice_number = r["invoice_number"] or "" + issue_date = r["issue_date"] or "" + due_date = r["due_date"] or "" + currency = r["currency"] or "" + tax_label = r["tax_label"] or "" + tax_rate = ( + r["tax_rate_percent"] if r["tax_rate_percent"] is not None else None + ) + subtotal_cents = r["subtotal_cents"] or 0 + tax_cents = r["tax_cents"] or 0 + total_cents = r["total_cents"] or 0 + paid_at = r["paid_at"] or "" + payment_note = r["payment_note"] or "" + + # Column 0: invoice number (store invoice_id in UserRole) + num_item = QTableWidgetItem(invoice_number) + num_item.setData(Qt.ItemDataRole.UserRole, inv_id) + self.table.setItem(row_idx, self.COL_NUMBER, num_item) + + # Column 1: project name (read-only) + proj_item = QTableWidgetItem(proj_name) + proj_item.setFlags(proj_item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(row_idx, self.COL_PROJECT, proj_item) + + # Column 2: issue date + self.table.setItem( + row_idx, self.COL_ISSUE_DATE, QTableWidgetItem(issue_date) + ) + + # Column 3: due date + self.table.setItem( + row_idx, self.COL_DUE_DATE, QTableWidgetItem(due_date or "") + ) + + # Column 4: currency + self.table.setItem( + row_idx, self.COL_CURRENCY, QTableWidgetItem(currency) + ) + + # Column 5: tax label + self.table.setItem( + row_idx, self.COL_TAX_LABEL, QTableWidgetItem(tax_label or "") + ) + + # Column 6: tax rate + tax_rate_text = "" if tax_rate is None else f"{tax_rate:.2f}" + self.table.setItem( + row_idx, self.COL_TAX_RATE, QTableWidgetItem(tax_rate_text) + ) + + # Column 7–9: amounts (cents → dollars) + self.table.setItem( + row_idx, + self.COL_SUBTOTAL, + QTableWidgetItem(f"{subtotal_cents / 100.0:.2f}"), + ) + self.table.setItem( + row_idx, + self.COL_TAX, + QTableWidgetItem(f"{tax_cents / 100.0:.2f}"), + ) + self.table.setItem( + row_idx, + self.COL_TOTAL, + QTableWidgetItem(f"{total_cents / 100.0:.2f}"), + ) + + # Column 10: paid_at + self.table.setItem( + row_idx, self.COL_PAID_AT, QTableWidgetItem(paid_at or "") + ) + + # Column 11: payment note + self.table.setItem( + row_idx, + self.COL_PAYMENT_NOTE, + QTableWidgetItem(payment_note or ""), + ) + + finally: + self._reloading_invoices = False + + # ----------------------------------------------------------------- editing + + def _remove_invoice_due_reminder(self, row: int, inv_id: int) -> None: + """Delete any one-off reminder created for this invoice's due date. + + We look up reminders by the same text we used when creating them + to avoid needing extra schema just for this linkage. + """ + proj_item = self.table.item(row, self.COL_PROJECT) + num_item = self.table.item(row, self.COL_NUMBER) + if proj_item is None or num_item is None: + return + + project_name = proj_item.text().strip() + invoice_number = num_item.text().strip() + if not project_name or not invoice_number: + return + + target_text = _invoice_due_reminder_text(project_name, invoice_number) + + removed_any = False + + try: + reminders = self._db.get_all_reminders() + except Exception: + return + + for reminder in reminders: + if ( + reminder.id is not None + and reminder.reminder_type == ReminderType.ONCE + and reminder.text == target_text + ): + try: + self._db.delete_reminder(reminder.id) + removed_any = True + except Exception: + # Best effort; if deletion fails we silently continue. + pass + + if removed_any: + # Tell Reminders that reminders have changed + self.remindersChanged.emit() + + def _on_item_changed(self, item: QTableWidgetItem) -> None: + """Handle inline edits and write them back to the database.""" + if self._reloading_invoices: + return + + row = item.row() + col = item.column() + + base_item = self.table.item(row, self.COL_NUMBER) + if base_item is None: + return + + inv_id = base_item.data(Qt.ItemDataRole.UserRole) + if not inv_id: + return + + text = item.text().strip() + + def _reset_from_db(field: str, formatter=lambda v: v) -> None: + """Reload a single field from DB and reset the cell.""" + self._reloading_invoices = True + try: + row_db = self._db.get_invoice_field_by_id(inv_id, field) + + if row_db is None: + return + value = row_db[field] + item.setText("" if value is None else formatter(value)) + finally: + self._reloading_invoices = False + + # ---- Invoice number (unique per project) ---------------------------- + if col == self.COL_NUMBER: + if not text: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_number_required"), + ) + _reset_from_db("invoice_number", lambda v: v or "") + return + try: + self._db.update_invoice_number(inv_id, text) + except sqlite3.IntegrityError: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_number_unique"), + ) + _reset_from_db("invoice_number", lambda v: v or "") + return + + # ---- Dates: issue, due, paid_at (YYYY-MM-DD) ------------------------ + if col in (self.COL_ISSUE_DATE, self.COL_DUE_DATE, self.COL_PAID_AT): + new_date: QDate | None = None + if text: + new_date = QDate.fromString(text, "yyyy-MM-dd") + if not new_date.isValid(): + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_invalid_date_format"), + ) + field = { + self.COL_ISSUE_DATE: "issue_date", + self.COL_DUE_DATE: "due_date", + self.COL_PAID_AT: "paid_at", + }[col] + _reset_from_db(field, lambda v: v or "") + return + + # Cross-field validation: due/paid must not be before issue date + issue_item = self.table.item(row, self.COL_ISSUE_DATE) + issue_qd: QDate | None = None + if issue_item is not None: + issue_text = issue_item.text().strip() + if issue_text: + issue_qd = QDate.fromString(issue_text, "yyyy-MM-dd") + if not issue_qd.isValid(): + issue_qd = None + + if issue_qd is not None and new_date is not None: + if col == self.COL_DUE_DATE and new_date < issue_qd: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_due_before_issue"), + ) + _reset_from_db("due_date", lambda v: v or "") + return + if col == self.COL_PAID_AT and new_date < issue_qd: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_paid_before_issue"), + ) + _reset_from_db("paid_at", lambda v: v or "") + return + + field = { + self.COL_ISSUE_DATE: "issue_date", + self.COL_DUE_DATE: "due_date", + self.COL_PAID_AT: "paid_at", + }[col] + + self._db.set_invoice_field_by_id(inv_id, field, text or None) + + # If the invoice has just been marked as paid, remove any + # auto-created reminder for its due date. + if col == self.COL_PAID_AT and text and self.cfg.reminders: + self._remove_invoice_due_reminder(row, inv_id) + + return + + # ---- Simple text fields: currency, tax label, payment_note --- + if col in ( + self.COL_CURRENCY, + self.COL_TAX_LABEL, + self.COL_PAYMENT_NOTE, + ): + field = { + self.COL_CURRENCY: "currency", + self.COL_TAX_LABEL: "tax_label", + self.COL_PAYMENT_NOTE: "payment_note", + }[col] + + self._db.set_invoice_field_by_id(inv_id, field, text or None) + + if col == self.COL_CURRENCY and text: + # Normalize currency code display + self._reloading_invoices = True + try: + item.setText(text.upper()) + finally: + self._reloading_invoices = False + return + + # ---- Tax rate percent (float) --------------------------------------- + if col == self.COL_TAX_RATE: + if text: + try: + rate = float(text) + except ValueError: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_invalid_tax_rate"), + ) + _reset_from_db( + "tax_rate_percent", + lambda v: "" if v is None else f"{v:.2f}", + ) + return + value = rate + else: + value = None + + self._db.set_invoice_field_by_id(inv_id, "tax_rate_percent", value) + return + + # ---- Monetary fields (subtotal, tax, total) in dollars -------------- + if col in (self.COL_SUBTOTAL, self.COL_TAX, self.COL_TOTAL): + field = { + self.COL_SUBTOTAL: "subtotal_cents", + self.COL_TAX: "tax_cents", + self.COL_TOTAL: "total_cents", + }[col] + if not text: + cents = 0 + else: + try: + value = float(text.replace(",", "")) + except ValueError: + QMessageBox.warning( + self, + strings._("error"), + strings._("invoice_invalid_amount"), + ) + _reset_from_db( + field, + lambda v: f"{(v or 0) / 100.0:.2f}", + ) + return + cents = int(round(value * 100)) + + self._db.set_invoice_field_by_id(inv_id, field, cents) + + # Normalize formatting in the table + self._reloading_invoices = True + try: + item.setText(f"{cents / 100.0:.2f}") + finally: + self._reloading_invoices = False + return diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index b8c56f5..2a7baea 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -52,7 +52,6 @@ "backup_failed": "Backup failed", "quit": "Quit", "cancel": "Cancel", - "close": "Close", "save": "Save", "help": "Help", "saved": "Saved", @@ -202,6 +201,7 @@ "by_week": "by week", "date_range": "Date range", "custom_range": "Custom", + "last_week": "Last week", "this_week": "This week", "this_month": "This month", "this_year": "This year", @@ -234,6 +234,8 @@ "projects": "Projects", "rename_activity": "Rename activity", "rename_project": "Rename project", + "reporting": "Reporting", + "reporting_and_invoicing": "Reporting and Invoicing", "run_report": "Run report", "add_activity_title": "Add activity", "add_activity_label": "Add an activity", @@ -359,5 +361,54 @@ "documents_search_label": "Search", "documents_search_placeholder": "Type to search documents (all projects)", "todays_documents": "Documents from this day", - "todays_documents_none": "No documents yet." + "todays_documents_none": "No documents yet.", + "manage_invoices": "Manage Invoices", + "create_invoice": "Create Invoice", + "invoice_amount": "Amount", + "invoice_apply_tax": "Apply Tax", + "invoice_client_address": "Client Address", + "invoice_client_company": "Client Company", + "invoice_client_email": "Client E-mail", + "invoice_client_name": "Client Contact", + "invoice_currency": "Currency", + "invoice_dialog_title": "Create Invoice", + "invoice_due_date": "Due Date", + "invoice_hourly_rate": "Hourly Rate", + "invoice_hours": "Hours", + "invoice_issue_date": "Issue Date", + "invoice_mode_detailed": "Detailed mode", + "invoice_mode_summary": "Summary mode", + "invoice_number": "Invoice Number", + "invoice_save_and_export": "Save and export", + "invoice_save_pdf_title": "Save PDF", + "invoice_subtotal": "Subtotal", + "invoice_summary_default_desc": "Consultant services for the month of", + "invoice_summary_desc": "Summary description", + "invoice_summary_hours": "Summary hours", + "invoice_tax": "Tax details", + "invoice_tax_label": "Tax type", + "invoice_tax_rate": "Tax rate", + "invoice_tax_total": "Tax total", + "invoice_total": "Total", + "invoice_paid_at": "Paid on", + "invoice_payment_note": "Payment notes", + "invoice_project_required_title": "Project required", + "invoice_project_required_message": "Please select a specific project before trying to create an invoice.", + "invoice_need_report_title": "Report required", + "invoice_need_report_message": "Please run a time report before trying to create an invoice from it.", + "invoice_due_before_issue": "Due date cannot be earlier than the issue date.", + "invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.", + "enable_invoicing_feature": "Enable Invoicing (requires Time Logging)", + "invoice_company_profile": "Business Profile", + "invoice_company_name": "Business Name", + "invoice_company_address": "Address", + "invoice_company_phone": "Phone", + "invoice_company_email": "E-mail", + "invoice_company_tax_id": "Tax number", + "invoice_company_payment_details": "Payment details", + "invoice_company_logo": "Logo", + "invoice_company_logo_choose": "Choose logo", + "invoice_company_logo_set": "Logo has been set", + "invoice_company_logo_not_set": "Logo not set", + "invoice_number_unique": "Invoice number must be unique. This invoice number already exists." } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index aab7bbb..737b11a 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -117,6 +117,9 @@ class MainWindow(QMainWindow): self.upcoming_reminders = UpcomingRemindersWidget(self.db) self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder) + # When invoices change reminders (e.g. invoice paid), refresh the Reminders widget + self.time_log.remindersChanged.connect(self.upcoming_reminders.refresh) + self.pomodoro_manager = PomodoroManager(self.db, self) # Lock the calendar to the left panel at the top to stop it stretching @@ -1448,6 +1451,7 @@ class MainWindow(QMainWindow): self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log) self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders) self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents) + self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing) self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale) self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size) diff --git a/bouquin/settings.py b/bouquin/settings.py index cfd8939..91a6074 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -45,6 +45,7 @@ def load_db_config() -> DBConfig: time_log = s.value("ui/time_log", True, type=bool) reminders = s.value("ui/reminders", True, type=bool) documents = s.value("ui/documents", True, type=bool) + invoicing = s.value("ui/invoicing", True, type=bool) locale = s.value("ui/locale", "en", type=str) font_size = s.value("ui/font_size", 11, type=int) return DBConfig( @@ -57,6 +58,7 @@ def load_db_config() -> DBConfig: time_log=time_log, reminders=reminders, documents=documents, + invoicing=invoicing, locale=locale, font_size=font_size, ) @@ -73,5 +75,6 @@ def save_db_config(cfg: DBConfig) -> None: s.setValue("ui/time_log", str(cfg.time_log)) s.setValue("ui/reminders", str(cfg.reminders)) s.setValue("ui/documents", str(cfg.documents)) + s.setValue("ui/invoicing", str(cfg.invoicing)) s.setValue("ui/locale", str(cfg.locale)) s.setValue("ui/font_size", str(cfg.font_size)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 68599ca..e209e9e 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -7,8 +7,11 @@ from PySide6.QtWidgets import ( QComboBox, QDialog, QFrame, + QFileDialog, QGroupBox, QLabel, + QLineEdit, + QFormLayout, QHBoxLayout, QVBoxLayout, QPushButton, @@ -19,6 +22,7 @@ from PySide6.QtWidgets import ( QMessageBox, QWidget, QTabWidget, + QTextEdit, ) from PySide6.QtCore import Qt, Slot from PySide6.QtGui import QPalette @@ -176,6 +180,17 @@ class SettingsDialog(QDialog): self.time_log.setCursor(Qt.PointingHandCursor) features_layout.addWidget(self.time_log) + self.invoicing = QCheckBox(strings._("enable_invoicing_feature")) + invoicing_enabled = getattr(self.current_settings, "invoicing", False) + self.invoicing.setChecked(invoicing_enabled and self.current_settings.time_log) + self.invoicing.setCursor(Qt.PointingHandCursor) + features_layout.addWidget(self.invoicing) + # Invoicing only if time_log is enabled + if not self.current_settings.time_log: + self.invoicing.setChecked(False) + self.invoicing.setEnabled(False) + self.time_log.toggled.connect(self._on_time_log_toggled) + self.reminders = QCheckBox(strings._("enable_reminders_feature")) self.reminders.setChecked(self.current_settings.reminders) self.reminders.setCursor(Qt.PointingHandCursor) @@ -187,6 +202,68 @@ class SettingsDialog(QDialog): features_layout.addWidget(self.documents) layout.addWidget(features_group) + + # --- Invoicing / company profile section ------------------------- + self.invoicing_group = QGroupBox(strings._("invoice_company_profile")) + invoicing_layout = QFormLayout(self.invoicing_group) + + profile = self._db.get_company_profile() or ( + None, + None, + None, + None, + None, + None, + None, + ) + name, address, phone, email, tax_id, payment_details, logo_bytes = profile + + self.company_name_edit = QLineEdit(name or "") + self.company_address_edit = QTextEdit(address or "") + self.company_phone_edit = QLineEdit(phone or "") + self.company_email_edit = QLineEdit(email or "") + self.company_tax_id_edit = QLineEdit(tax_id or "") + self.company_payment_details_edit = QTextEdit() + self.company_payment_details_edit.setPlainText(payment_details or "") + + invoicing_layout.addRow( + strings._("invoice_company_name") + ":", self.company_name_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_address") + ":", self.company_address_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_phone") + ":", self.company_phone_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_email") + ":", self.company_email_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit + ) + invoicing_layout.addRow( + strings._("invoice_company_payment_details") + ":", + self.company_payment_details_edit, + ) + + # Logo picker – store bytes on self._logo_bytes + self._logo_bytes = logo_bytes + logo_row = QHBoxLayout() + self.logo_label = QLabel(strings._("invoice_company_logo_not_set")) + if logo_bytes: + self.logo_label.setText(strings._("invoice_company_logo_set")) + logo_btn = QPushButton(strings._("invoice_company_logo_choose")) + logo_btn.clicked.connect(self._on_choose_logo) + logo_row.addWidget(self.logo_label) + logo_row.addWidget(logo_btn) + invoicing_layout.addRow(strings._("invoice_company_logo") + ":", logo_row) + + # Show/hide this whole block based on invoicing checkbox + self.invoicing_group.setVisible(self.invoicing.isChecked()) + self.invoicing.toggled.connect(self.invoicing_group.setVisible) + + layout.addWidget(self.invoicing_group) + layout.addStretch() return page @@ -314,14 +391,60 @@ class SettingsDialog(QDialog): time_log=self.time_log.isChecked(), reminders=self.reminders.isChecked(), documents=self.documents.isChecked(), + invoicing=( + self.invoicing.isChecked() if self.time_log.isChecked() else False + ), locale=self.locale_combobox.currentText(), font_size=self.font_size.value(), ) save_db_config(self._cfg) + + # Save company profile only if invoicing is enabled + if self.invoicing.isChecked() and self.time_log.isChecked(): + self._db.save_company_profile( + name=self.company_name_edit.text().strip() or None, + address=self.company_address_edit.toPlainText().strip() or None, + phone=self.company_phone_edit.text().strip() or None, + email=self.company_email_edit.text().strip() or None, + tax_id=self.company_tax_id_edit.text().strip() or None, + payment_details=self.company_payment_details_edit.toPlainText().strip() + or None, + logo=getattr(self, "_logo_bytes", None), + ) + self.parent().themes.set(selected_theme) self.accept() + def _on_time_log_toggled(self, checked: bool) -> None: + """ + Enforce 'invoicing depends on time logging'. + """ + if not checked: + # Turn off + disable invoicing if time logging is disabled + self.invoicing.setChecked(False) + self.invoicing.setEnabled(False) + else: + # Let the user enable invoicing when time logging is enabled + self.invoicing.setEnabled(True) + + def _on_choose_logo(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + strings._("company_logo_choose"), + "", + "Images (*.png *.jpg *.jpeg *.bmp)", + ) + if not path: + return + + try: + with open(path, "rb") as f: + self._logo_bytes = f.read() + self.logo_label.setText(Path(path).name) + except OSError as exc: + QMessageBox.warning(self, strings._("error"), str(exc)) + def _change_key(self): p1 = KeyPrompt( self, diff --git a/bouquin/time_log.py b/bouquin/time_log.py index e5e9b64..c8aaa14 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -8,7 +8,7 @@ from datetime import datetime from sqlcipher3.dbapi2 import IntegrityError from typing import Optional -from PySide6.QtCore import Qt, QDate, QUrl +from PySide6.QtCore import Qt, QDate, QUrl, Signal from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout from PySide6.QtPrintSupport import QPrinter from PySide6.QtWidgets import ( @@ -43,6 +43,7 @@ from PySide6.QtWidgets import ( ) from .db import DBManager +from .settings import load_db_config from .theme import ThemeManager from . import strings @@ -53,6 +54,8 @@ class TimeLogWidget(QFrame): Shown in the left sidebar above the Tags widget. """ + remindersChanged = Signal() + def __init__( self, db: DBManager, @@ -61,6 +64,7 @@ class TimeLogWidget(QFrame): ): super().__init__(parent) self._db = db + self.cfg = load_db_config() self._themes = themes self._current_date: Optional[str] = None @@ -82,6 +86,15 @@ class TimeLogWidget(QFrame): self.log_btn.setAutoRaise(True) self.log_btn.clicked.connect(self._open_dialog_log_only) + self.report_btn = QToolButton() + self.report_btn.setText("📈") + self.report_btn.setAutoRaise(True) + self.report_btn.clicked.connect(self._on_run_report) + if self.cfg.invoicing: + self.report_btn.setToolTip(strings._("reporting_and_invoicing")) + else: + self.report_btn.setToolTip(strings._("reporting")) + self.open_btn = QToolButton() self.open_btn.setIcon( self.style().standardIcon(QStyle.SP_FileDialogDetailedView) @@ -95,6 +108,7 @@ class TimeLogWidget(QFrame): header.addWidget(self.toggle_btn) header.addStretch(1) header.addWidget(self.log_btn) + header.addWidget(self.report_btn) header.addWidget(self.open_btn) # Body: simple summary label for the day @@ -149,6 +163,14 @@ class TimeLogWidget(QFrame): # ----- internals --------------------------------------------------- + def _on_run_report(self) -> None: + dlg = TimeReportDialog(self._db, self) + + # Bubble the remindersChanged signal further up + dlg.remindersChanged.connect(self.remindersChanged.emit) + + dlg.exec() + def _on_toggle(self, checked: bool) -> None: self.body.setVisible(checked) self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) @@ -247,6 +269,7 @@ class TimeLogDialog(QDialog): self._themes = themes self._date_iso = date_iso self._current_entry_id: Optional[int] = None + self.cfg = load_db_config() # Guard flag used when repopulating the table so we don’t treat # programmatic item changes as user edits. self._reloading_entries: bool = False @@ -320,13 +343,9 @@ class TimeLogDialog(QDialog): self.delete_btn.clicked.connect(self._on_delete_entry) self.delete_btn.setEnabled(False) - self.report_btn = QPushButton("&" + strings._("run_report")) - self.report_btn.clicked.connect(self._on_run_report) - btn_row.addStretch(1) btn_row.addWidget(self.add_update_btn) btn_row.addWidget(self.delete_btn) - btn_row.addWidget(self.report_btn) root.addLayout(btn_row) # --- Table of entries for this date @@ -355,12 +374,19 @@ class TimeLogDialog(QDialog): self.table.itemChanged.connect(self._on_table_item_changed) root.addWidget(self.table, 1) - # --- Total time and Close button + # --- Total time, Reporting and Close button close_row = QHBoxLayout() self.total_label = QLabel( strings._("time_log_total_hours").format(hours=self.total_hours) ) + if self.cfg.invoicing: + self.report_btn = QPushButton("&" + strings._("reporting_and_invoicing")) + else: + self.report_btn = QPushButton("&" + strings._("reporting")) + self.report_btn.clicked.connect(self._on_run_report) + close_row.addWidget(self.total_label) + close_row.addWidget(self.report_btn) close_row.addStretch(1) close_btn = QPushButton(strings._("close")) close_btn.clicked.connect(self.accept) @@ -981,9 +1007,12 @@ class TimeReportDialog(QDialog): Shows decimal hours per time period. """ + remindersChanged = Signal() + def __init__(self, db: DBManager, parent=None): super().__init__(parent) self._db = db + self.cfg = load_db_config() # state for last run self._last_rows: list[tuple[str, str, str, str, int]] = [] @@ -992,6 +1021,7 @@ class TimeReportDialog(QDialog): self._last_start: str = "" self._last_end: str = "" self._last_gran_label: str = "" + self._last_time_logs: list = [] self.setWindowTitle(strings._("time_log_report")) self.resize(600, 400) @@ -999,9 +1029,20 @@ class TimeReportDialog(QDialog): root = QVBoxLayout(self) form = QFormLayout() + + self.invoice_btn = QPushButton(strings._("create_invoice")) + self.invoice_btn.clicked.connect(self._on_create_invoice) + + self.manage_invoices_btn = QPushButton(strings._("manage_invoices")) + self.manage_invoices_btn.clicked.connect(self._on_manage_invoices) + # Project self.project_combo = QComboBox() self.project_combo.addItem(strings._("all_projects"), None) + self.project_combo.currentIndexChanged.connect( + self._update_invoice_button_state + ) + self._update_invoice_button_state() for proj_id, name in self._db.list_projects(): self.project_combo.addItem(name, proj_id) form.addRow(strings._("project"), self.project_combo) @@ -1013,6 +1054,7 @@ class TimeReportDialog(QDialog): self.range_preset = QComboBox() self.range_preset.addItem(strings._("custom_range"), "custom") self.range_preset.addItem(strings._("today"), "today") + self.range_preset.addItem(strings._("last_week"), "last_week") self.range_preset.addItem(strings._("this_week"), "this_week") self.range_preset.addItem(strings._("this_month"), "this_month") self.range_preset.addItem(strings._("this_year"), "this_year") @@ -1061,6 +1103,10 @@ class TimeReportDialog(QDialog): run_row.addWidget(run_btn) run_row.addWidget(export_btn) run_row.addWidget(pdf_btn) + # Only show invoicing if the feature is enabled + if getattr(self._db.cfg, "invoicing", False): + run_row.addWidget(self.invoice_btn) + run_row.addWidget(self.manage_invoices_btn) root.addLayout(run_row) # Table @@ -1146,6 +1192,14 @@ class TimeReportDialog(QDialog): start = today.addDays(1 - today.dayOfWeek()) end = today + elif preset == "last_week": + # Compute Monday–Sunday of the previous week (Monday-based weeks) + # 1. Monday of this week: + start_of_this_week = today.addDays(1 - today.dayOfWeek()) + # 2. Last week is 7 days before that: + start = start_of_this_week.addDays(-7) # last week's Monday + end = start_of_this_week.addDays(-1) # last week's Sunday + elif preset == "this_month": start = QDate(today.year(), today.month(), 1) end = today @@ -1187,11 +1241,13 @@ class TimeReportDialog(QDialog): if proj_data is None: # All projects self._last_all_projects = True + self._last_time_logs = [] self._last_project_name = strings._("all_projects") rows_for_table = self._db.time_report_all(start, end, gran) else: self._last_all_projects = False proj_id = int(proj_data) + self._last_time_logs = self._db.time_logs_for_range(proj_id, start, end) project_name = self.project_combo.currentText() self._last_project_name = project_name @@ -1525,3 +1581,55 @@ class TimeReportDialog(QDialog): strings._("export_pdf_error_title"), strings._("export_pdf_error_message").format(error=str(exc)), ) + + def _update_invoice_button_state(self) -> None: + data = self.project_combo.currentData() + if data is not None: + self.invoice_btn.show() + else: + self.invoice_btn.hide() + + def _on_manage_invoices(self) -> None: + from .invoices import InvoicesDialog + + dlg = InvoicesDialog(self._db, parent=self) + + # When the dialog says "reminders changed", forward that outward + dlg.remindersChanged.connect(self.remindersChanged.emit) + + dlg.exec() + + def _on_create_invoice(self) -> None: + idx = self.project_combo.currentIndex() + if idx < 0: + return + + project_id_data = self.project_combo.itemData(idx) + if project_id_data is None: + # Currently invoices are per-project, not cross-project + QMessageBox.information( + self, + strings._("invoice_project_required_title"), + strings._("invoice_project_required_message"), + ) + return + + proj_id = int(project_id_data) + + # Ensure we have a recent run to base this on + if not self._last_time_logs: + QMessageBox.information( + self, + strings._("invoice_need_report_title"), + strings._("invoice_need_report_message"), + ) + return + + start = self.from_date.date().toString("yyyy-MM-dd") + end = self.to_date.date().toString("yyyy-MM-dd") + + from .invoices import InvoiceDialog + + dlg = InvoiceDialog(self._db, proj_id, start, end, self._last_time_logs, self) + dlg.remindersChanged.connect(self.remindersChanged.emit) + dlg.exec() diff --git a/tests/test_code_block_editor_dialog.py b/tests/test_code_block_editor_dialog.py index 9a59aa8..6779bca 100644 --- a/tests/test_code_block_editor_dialog.py +++ b/tests/test_code_block_editor_dialog.py @@ -159,7 +159,7 @@ def test_line_number_area_paint_with_multiple_blocks(qtbot, app): rect = QRect(0, 0, line_area.width(), line_area.height()) paint_event = QPaintEvent(rect) - # This should exercise the painting loop (lines 87-130) + # This should exercise the painting loop editor.line_number_area_paint_event(paint_event) # Should not crash diff --git a/tests/test_invoices.py b/tests/test_invoices.py new file mode 100644 index 0000000..80f1a90 --- /dev/null +++ b/tests/test_invoices.py @@ -0,0 +1,1348 @@ +import pytest +from datetime import date, timedelta + +from PySide6.QtCore import Qt, QDate +from PySide6.QtWidgets import QMessageBox + +from bouquin.invoices import ( + InvoiceDetailMode, + InvoiceLineItem, + _invoice_due_reminder_text, + InvoiceDialog, + InvoicesDialog, + _INVOICE_REMINDER_TIME, +) +from bouquin.reminders import Reminder, ReminderType + + +# ============================================================================ +# Tests for InvoiceDetailMode enum +# ============================================================================ + + +def test_invoice_detail_mode_enum_values(app): + """Test InvoiceDetailMode enum has expected values.""" + assert InvoiceDetailMode.DETAILED == "detailed" + assert InvoiceDetailMode.SUMMARY == "summary" + + +def test_invoice_detail_mode_is_string(app): + """Test InvoiceDetailMode enum inherits from str.""" + assert isinstance(InvoiceDetailMode.DETAILED, str) + assert isinstance(InvoiceDetailMode.SUMMARY, str) + + +# ============================================================================ +# Tests for InvoiceLineItem dataclass +# ============================================================================ + + +def test_invoice_line_item_creation(app): + """Test creating an InvoiceLineItem instance.""" + item = InvoiceLineItem( + description="Development work", + hours=5.5, + rate_cents=10000, + amount_cents=55000, + ) + + assert item.description == "Development work" + assert item.hours == 5.5 + assert item.rate_cents == 10000 + assert item.amount_cents == 55000 + + +def test_invoice_line_item_with_zero_values(app): + """Test InvoiceLineItem with zero values.""" + item = InvoiceLineItem( + description="", + hours=0.0, + rate_cents=0, + amount_cents=0, + ) + + assert item.description == "" + assert item.hours == 0.0 + assert item.rate_cents == 0 + assert item.amount_cents == 0 + + +# ============================================================================ +# Tests for _invoice_due_reminder_text helper function +# ============================================================================ + + +def test_invoice_due_reminder_text_normal(app): + """Test reminder text generation with normal inputs.""" + result = _invoice_due_reminder_text("Project Alpha", "INV-001") + assert result == "Invoice INV-001 for Project Alpha is due" + + +def test_invoice_due_reminder_text_with_whitespace(app): + """Test reminder text strips whitespace from inputs.""" + result = _invoice_due_reminder_text(" Project Beta ", " INV-002 ") + assert result == "Invoice INV-002 for Project Beta is due" + + +def test_invoice_due_reminder_text_empty_project(app): + """Test reminder text with empty project name.""" + result = _invoice_due_reminder_text("", "INV-003") + assert result == "Invoice INV-003 for (no project) is due" + + +def test_invoice_due_reminder_text_empty_invoice_number(app): + """Test reminder text with empty invoice number.""" + result = _invoice_due_reminder_text("Project Gamma", "") + assert result == "Invoice ? for Project Gamma is due" + + +def test_invoice_due_reminder_text_both_empty(app): + """Test reminder text with both inputs empty.""" + result = _invoice_due_reminder_text("", "") + assert result == "Invoice ? for (no project) is due" + + +# ============================================================================ +# Tests for InvoiceDialog +# ============================================================================ + + +@pytest.fixture +def invoice_dialog_setup(qtbot, fresh_db): + """Set up a project with time logs for InvoiceDialog testing.""" + # Create a project + proj_id = fresh_db.add_project("Test Project") + + # Create an activity + act_id = fresh_db.add_activity("Development") + + # Set billing info + fresh_db.upsert_project_billing( + proj_id, + hourly_rate_cents=15000, # $150/hr + currency="USD", + tax_label="VAT", + tax_rate_percent=20.0, + client_name="John Doe", + client_company="Acme Corp", + client_address="123 Main St", + client_email="john@acme.com", + ) + + # Create some time logs + today = date.today() + start_date = (today - timedelta(days=7)).isoformat() + end_date = today.isoformat() + + # Add time logs for testing (2.5 hours = 150 minutes) + for i in range(3): + log_date = (today - timedelta(days=i)).isoformat() + fresh_db.add_time_log( + log_date, + proj_id, + act_id, + 150, # 2.5 hours in minutes + f"Note {i}", + ) + + time_rows = fresh_db.time_logs_for_range(proj_id, start_date, end_date) + + return { + "db": fresh_db, + "proj_id": proj_id, + "act_id": act_id, + "start_date": start_date, + "end_date": end_date, + "time_rows": time_rows, + } + + +def test_invoice_dialog_init(qtbot, invoice_dialog_setup): + """Test InvoiceDialog initialization.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + assert dialog._db is setup["db"] + assert dialog._project_id == setup["proj_id"] + assert dialog._start == setup["start_date"] + assert dialog._end == setup["end_date"] + assert len(dialog._time_rows) == 3 + + +def test_invoice_dialog_init_without_time_rows(qtbot, invoice_dialog_setup): + """Test InvoiceDialog initialization without explicit time_rows.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + ) + qtbot.addWidget(dialog) + + # Should fetch time rows from DB + assert len(dialog._time_rows) == 3 + + +def test_invoice_dialog_loads_billing_defaults(qtbot, invoice_dialog_setup): + """Test that InvoiceDialog loads billing defaults from project.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + assert dialog.currency_edit.text() == "USD" + assert dialog.rate_spin.value() == 150.0 + assert dialog.client_name_edit.text() == "John Doe" + assert dialog.client_company_combo.currentText() == "Acme Corp" + + +def test_invoice_dialog_no_billing_defaults(qtbot, fresh_db): + """Test InvoiceDialog with project that has no billing info.""" + proj_id = fresh_db.add_project("Test Project No Billing") + today = date.today() + start = (today - timedelta(days=1)).isoformat() + end = today.isoformat() + + dialog = InvoiceDialog(fresh_db, proj_id, start, end) + qtbot.addWidget(dialog) + + # Should use defaults + assert dialog.currency_edit.text() == "AUD" + assert dialog.rate_spin.value() == 0.0 + assert dialog.client_name_edit.text() == "" + + +def test_invoice_dialog_project_name(qtbot, invoice_dialog_setup): + """Test _project_name method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + project_name = dialog._project_name() + assert project_name == "Test Project" + + +def test_invoice_dialog_suggest_invoice_number(qtbot, invoice_dialog_setup): + """Test _suggest_invoice_number method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + invoice_number = dialog._suggest_invoice_number() + # Should be in format YYYY-001 for first invoice (3 digits) + current_year = date.today().year + assert invoice_number.startswith(str(current_year)) + assert invoice_number.endswith("-001") + + +def test_invoice_dialog_suggest_invoice_number_increments(qtbot, invoice_dialog_setup): + """Test that invoice number suggestions increment.""" + setup = invoice_dialog_setup + + # Create an invoice first + dialog1 = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog1) + + # Save an invoice to increment the counter + invoice_number_1 = dialog1._suggest_invoice_number() + setup["db"].create_invoice( + project_id=setup["proj_id"], + invoice_number=invoice_number_1, + issue_date=date.today().isoformat(), + due_date=(date.today() + timedelta(days=14)).isoformat(), + currency="USD", + tax_label=None, + tax_rate_percent=None, + detail_mode=InvoiceDetailMode.DETAILED, + line_items=[], + time_log_ids=[], + ) + + # Create another dialog and check the number increments + dialog2 = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog2) + + invoice_number_2 = dialog2._suggest_invoice_number() + current_year = date.today().year + assert invoice_number_2 == f"{current_year}-002" + + +def test_invoice_dialog_populate_detailed_rows(qtbot, invoice_dialog_setup): + """Test _populate_detailed_rows method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog._populate_detailed_rows(15000) # $150/hr in cents + + # Check that table has rows + assert dialog.table.rowCount() == 3 + + # Check that hours are displayed (COL_HOURS uses cellWidget, not item) + for row in range(3): + hours_widget = dialog.table.cellWidget(row, dialog.COL_HOURS) + assert hours_widget is not None + assert hours_widget.value() == 2.5 + + +def test_invoice_dialog_total_hours_from_table(qtbot, invoice_dialog_setup): + """Test _total_hours_from_table method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog._populate_detailed_rows(15000) + + total_hours = dialog._total_hours_from_table() + # 3 rows * 2.5 hours = 7.5 hours + assert total_hours == 7.5 + + +def test_invoice_dialog_detail_line_items(qtbot, invoice_dialog_setup): + """Test _detail_line_items method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(150.0) + dialog._populate_detailed_rows(15000) + + line_items = dialog._detail_line_items() + assert len(line_items) == 3 + + for item in line_items: + assert isinstance(item, InvoiceLineItem) + assert item.hours == 2.5 + assert item.rate_cents == 15000 + assert item.amount_cents == 37500 # 2.5 * 15000 + + +def test_invoice_dialog_summary_line_items(qtbot, invoice_dialog_setup): + """Test _summary_line_items method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(150.0) + dialog._populate_detailed_rows(15000) + + line_items = dialog._summary_line_items() + assert len(line_items) == 1 # Summary should have one line + + item = line_items[0] + assert isinstance(item, InvoiceLineItem) + # The description comes from summary_desc_edit which has a localized default + # Just check it's not empty + assert len(item.description) > 0 + assert item.hours == 7.5 # Total of 3 * 2.5 + assert item.rate_cents == 15000 + assert item.amount_cents == 112500 # 7.5 * 15000 + + +def test_invoice_dialog_recalc_amounts(qtbot, invoice_dialog_setup): + """Test _recalc_amounts method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog._populate_detailed_rows(15000) + dialog.rate_spin.setValue(200.0) # Change rate to $200/hr + + dialog._recalc_amounts() + + # Check that amounts were recalculated + for row in range(3): + amount_item = dialog.table.item(row, dialog.COL_AMOUNT) + assert amount_item is not None + # 2.5 hours * $200 = $500 + assert amount_item.text() == "500.00" + + +def test_invoice_dialog_recalc_totals(qtbot, invoice_dialog_setup): + """Test _recalc_totals method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(100.0) + dialog._populate_detailed_rows(10000) + + # Enable tax + dialog.tax_checkbox.setChecked(True) + dialog.tax_rate_spin.setValue(10.0) + + dialog._recalc_totals() + + # 7.5 hours * $100 = $750 + # Tax: $750 * 10% = $75 + # Total: $750 + $75 = $825 + assert "750.00" in dialog.subtotal_label.text() + assert "75.00" in dialog.tax_label_total.text() + assert "825.00" in dialog.total_label.text() + + +def test_invoice_dialog_on_tax_toggled(qtbot, invoice_dialog_setup): + """Test _on_tax_toggled method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + dialog.show() + + # Initially unchecked (from fixture setup with tax) + dialog.tax_checkbox.setChecked(False) + dialog._on_tax_toggled(False) + + # Tax fields should be hidden + assert not dialog.tax_label.isVisible() + assert not dialog.tax_label_edit.isVisible() + assert not dialog.tax_rate_label.isVisible() + assert not dialog.tax_rate_spin.isVisible() + + # Check the box + dialog.tax_checkbox.setChecked(True) + dialog._on_tax_toggled(True) + + # Tax fields should be visible + assert dialog.tax_label.isVisible() + assert dialog.tax_label_edit.isVisible() + assert dialog.tax_rate_label.isVisible() + assert dialog.tax_rate_spin.isVisible() + + +def test_invoice_dialog_on_client_company_changed(qtbot, invoice_dialog_setup): + """Test _on_client_company_changed method for autofill.""" + setup = invoice_dialog_setup + + # Create another project with different client + proj_id_2 = setup["db"].add_project("Project 2") + setup["db"].upsert_project_billing( + proj_id_2, + hourly_rate_cents=20000, + currency="EUR", + tax_label="GST", + tax_rate_percent=15.0, + client_name="Jane Smith", + client_company="Tech Industries", + client_address="456 Oak Ave", + client_email="jane@tech.com", + ) + + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + # Initially should have first project's client + assert dialog.client_name_edit.text() == "John Doe" + + # Change to second company + dialog.client_company_combo.setCurrentText("Tech Industries") + dialog._on_client_company_changed("Tech Industries") + + # Should autofill with second client's info + assert dialog.client_name_edit.text() == "Jane Smith" + assert dialog.client_addr_edit.toPlainText() == "456 Oak Ave" + assert dialog.client_email_edit.text() == "jane@tech.com" + + +def test_invoice_dialog_create_due_date_reminder(qtbot, invoice_dialog_setup): + """Test _create_due_date_reminder method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + due_date = (date.today() + timedelta(days=14)).isoformat() + invoice_number = "INV-TEST-001" + invoice_id = 999 # Fake invoice ID for testing + + dialog._create_due_date_reminder(invoice_id, invoice_number, due_date) + + # Check that reminder was created + reminders = setup["db"].get_all_reminders() + assert len(reminders) > 0 + + # Find our reminder + expected_text = _invoice_due_reminder_text("Test Project", invoice_number) + matching_reminders = [r for r in reminders if r.text == expected_text] + assert len(matching_reminders) == 1 + + reminder = matching_reminders[0] + assert reminder.reminder_type == ReminderType.ONCE + assert reminder.date_iso == due_date + assert reminder.time_str == _INVOICE_REMINDER_TIME + + +# ============================================================================ +# Tests for InvoicesDialog +# ============================================================================ + + +@pytest.fixture +def invoices_dialog_setup(qtbot, fresh_db): + """Set up projects with invoices for InvoicesDialog testing.""" + # Create projects + proj_id_1 = fresh_db.add_project("Project Alpha") + proj_id_2 = fresh_db.add_project("Project Beta") + + # Create invoices for project 1 + today = date.today() + for i in range(3): + issue_date = (today - timedelta(days=i * 7)).isoformat() + due_date = (today - timedelta(days=i * 7) + timedelta(days=14)).isoformat() + paid_at = today.isoformat() if i == 0 else None # First one is paid + + fresh_db.create_invoice( + project_id=proj_id_1, + invoice_number=f"ALPHA-{i+1}", + issue_date=issue_date, + due_date=due_date, + currency="USD", + tax_label="VAT", + tax_rate_percent=20.0, + detail_mode=InvoiceDetailMode.DETAILED, + line_items=[("Development work", 10.0, 15000)], # 10 hours at $150/hr + time_log_ids=[], + ) + + # Update paid_at separately if needed + if paid_at: + invoice_rows = fresh_db.get_all_invoices(proj_id_1) + if invoice_rows: + inv_id = invoice_rows[0]["id"] + fresh_db.set_invoice_field_by_id(inv_id, "paid_at", paid_at) + + # Create invoices for project 2 + for i in range(2): + issue_date = (today - timedelta(days=i * 10)).isoformat() + due_date = (today - timedelta(days=i * 10) + timedelta(days=30)).isoformat() + + fresh_db.create_invoice( + project_id=proj_id_2, + invoice_number=f"BETA-{i+1}", + issue_date=issue_date, + due_date=due_date, + currency="EUR", + tax_label=None, + tax_rate_percent=None, + detail_mode=InvoiceDetailMode.SUMMARY, + line_items=[("Consulting services", 10.0, 20000)], # 10 hours at $200/hr + time_log_ids=[], + ) + + return { + "db": fresh_db, + "proj_id_1": proj_id_1, + "proj_id_2": proj_id_2, + } + + +def test_invoices_dialog_init(qtbot, invoices_dialog_setup): + """Test InvoicesDialog initialization.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"]) + qtbot.addWidget(dialog) + + assert dialog._db is setup["db"] + assert dialog.project_combo.count() >= 2 # 2 projects + + +def test_invoices_dialog_init_with_project_id(qtbot, invoices_dialog_setup): + """Test InvoicesDialog initialization with specific project.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Should select the specified project + current_proj = dialog._current_project() + assert current_proj == setup["proj_id_1"] + + +def test_invoices_dialog_reload_projects(qtbot, invoices_dialog_setup): + """Test _reload_projects method.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"]) + qtbot.addWidget(dialog) + + initial_count = dialog.project_combo.count() + assert initial_count >= 2 # Should have 2 projects from setup + + # Create a new project + setup["db"].add_project("Project Gamma") + + # Reload projects + dialog._reload_projects() + + # Should have one more project + assert dialog.project_combo.count() == initial_count + 1 + + +def test_invoices_dialog_current_project_specific(qtbot, invoices_dialog_setup): + """Test _current_project method when specific project is selected.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + current_proj = dialog._current_project() + assert current_proj == setup["proj_id_1"] + + +def test_invoices_dialog_reload_invoices_all_projects(qtbot, invoices_dialog_setup): + """Test _reload_invoices with first project selected by default.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"]) + qtbot.addWidget(dialog) + + # First project should be selected by default (Project Alpha with 3 invoices) + # The exact project depends on creation order, so just check we have some invoices + assert dialog.table.rowCount() in [2, 3] # Either proj1 (3) or proj2 (2) + + +def test_invoices_dialog_reload_invoices_single_project(qtbot, invoices_dialog_setup): + """Test _reload_invoices with single project selected.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + dialog._reload_invoices() + + # Should show only 3 invoices from proj1 + assert dialog.table.rowCount() == 3 + + +def test_invoices_dialog_on_project_changed(qtbot, invoices_dialog_setup): + """Test _on_project_changed method.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_2"]) + qtbot.addWidget(dialog) + + # Start with project 2 (2 invoices) + assert dialog.table.rowCount() == 2 + + # Find the index of project 1 + for i in range(dialog.project_combo.count()): + if dialog.project_combo.itemData(i) == setup["proj_id_1"]: + dialog.project_combo.setCurrentIndex(i) + break + + dialog._on_project_changed(dialog.project_combo.currentIndex()) + + # Should now show 3 invoices from proj1 + assert dialog.table.rowCount() == 3 + + +def test_invoices_dialog_remove_invoice_due_reminder(qtbot, invoices_dialog_setup): + """Test _remove_invoice_due_reminder method.""" + setup = invoices_dialog_setup + + # Create a reminder for an invoice + due_date = (date.today() + timedelta(days=7)).isoformat() + invoice_number = "TEST-REMINDER-001" + project_name = "Project Alpha" + + reminder_text = _invoice_due_reminder_text(project_name, invoice_number) + reminder = Reminder( + id=None, + text=reminder_text, + time_str=_INVOICE_REMINDER_TIME, + reminder_type=ReminderType.ONCE, + date_iso=due_date, + active=True, + ) + reminder.id = setup["db"].save_reminder(reminder) + + # Verify reminder exists + reminders = setup["db"].get_all_reminders() + assert len(reminders) == 1 + + # Create dialog and populate with invoices + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Manually add a row to test the removal (simulating the invoice row) + row = dialog.table.rowCount() + dialog.table.insertRow(row) + + # Set the project and invoice number items + from PySide6.QtWidgets import QTableWidgetItem + + proj_item = QTableWidgetItem(project_name) + num_item = QTableWidgetItem(invoice_number) + dialog.table.setItem(row, dialog.COL_PROJECT, proj_item) + dialog.table.setItem(row, dialog.COL_NUMBER, num_item) + + # Mock invoice_id + num_item.setData(Qt.ItemDataRole.UserRole, 999) + + # Call the removal method + dialog._remove_invoice_due_reminder(row, 999) + + # Reminder should be deleted + reminders_after = setup["db"].get_all_reminders() + assert len(reminders_after) == 0 + + +def test_invoices_dialog_on_item_changed_invoice_number(qtbot, invoices_dialog_setup): + """Test _on_item_changed for invoice number editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row's invoice ID + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + # Change the invoice number + num_item.setText("ALPHA-MODIFIED") + + # Trigger the change handler + dialog._on_item_changed(num_item) + + # Verify the change was saved to DB + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number") + assert invoice_data["invoice_number"] == "ALPHA-MODIFIED" + + +def test_invoices_dialog_on_item_changed_empty_invoice_number( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects empty invoice number.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox to auto-close + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + # Get the first row's invoice number item + num_item = dialog.table.item(0, dialog.COL_NUMBER) + original_number = num_item.text() + + # Try to set empty invoice number + num_item.setText("") + dialog._on_item_changed(num_item) + + # Should be reset to original + assert num_item.text() == original_number + + +def test_invoices_dialog_on_item_changed_issue_date(qtbot, invoices_dialog_setup): + """Test _on_item_changed for issue date editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE) + new_date = "2024-01-15" + issue_item.setText(new_date) + + dialog._on_item_changed(issue_item) + + # Verify change was saved + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "issue_date") + assert invoice_data["issue_date"] == new_date + + +def test_invoices_dialog_on_item_changed_invalid_date( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects invalid date format.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE) + original_date = issue_item.text() + + # Try to set invalid date + issue_item.setText("not-a-date") + dialog._on_item_changed(issue_item) + + # Should be reset to original + assert issue_item.text() == original_date + + +def test_invoices_dialog_on_item_changed_due_before_issue( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects due date before issue date.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + # Set issue date + issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE) + issue_item.setText("2024-02-01") + dialog._on_item_changed(issue_item) + + # Try to set due date before issue date + due_item = dialog.table.item(0, dialog.COL_DUE_DATE) + original_due = due_item.text() + due_item.setText("2024-01-01") + dialog._on_item_changed(due_item) + + # Should be reset + assert due_item.text() == original_due + + +def test_invoices_dialog_on_item_changed_currency(qtbot, invoices_dialog_setup): + """Test _on_item_changed for currency editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + currency_item = dialog.table.item(0, dialog.COL_CURRENCY) + currency_item.setText("gbp") # lowercase + + dialog._on_item_changed(currency_item) + + # Should be normalized to uppercase + assert currency_item.text() == "GBP" + + # Verify change was saved + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "currency") + assert invoice_data["currency"] == "GBP" + + +def test_invoices_dialog_on_item_changed_tax_rate(qtbot, invoices_dialog_setup): + """Test _on_item_changed for tax rate editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE) + tax_rate_item.setText("15.5") + + dialog._on_item_changed(tax_rate_item) + + # Verify change was saved + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "tax_rate_percent") + assert invoice_data["tax_rate_percent"] == 15.5 + + +def test_invoices_dialog_on_item_changed_invalid_tax_rate( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects invalid tax rate.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE) + original_rate = tax_rate_item.text() + + # Try to set invalid tax rate + tax_rate_item.setText("not-a-number") + dialog._on_item_changed(tax_rate_item) + + # Should be reset to original + assert tax_rate_item.text() == original_rate + + +def test_invoices_dialog_on_item_changed_subtotal(qtbot, invoices_dialog_setup): + """Test _on_item_changed for subtotal editing.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Get the first row + num_item = dialog.table.item(0, dialog.COL_NUMBER) + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL) + subtotal_item.setText("1234.56") + + dialog._on_item_changed(subtotal_item) + + # Verify change was saved (in cents) + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "subtotal_cents") + assert invoice_data["subtotal_cents"] == 123456 + + # Should be normalized to 2 decimals + assert subtotal_item.text() == "1234.56" + + +def test_invoices_dialog_on_item_changed_invalid_amount( + qtbot, invoices_dialog_setup, monkeypatch +): + """Test _on_item_changed rejects invalid amount.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Mock QMessageBox + def mock_warning(*args, **kwargs): + return QMessageBox.Ok + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL) + original_subtotal = subtotal_item.text() + + # Try to set invalid amount + subtotal_item.setText("not-a-number") + dialog._on_item_changed(subtotal_item) + + # Should be reset to original + assert subtotal_item.text() == original_subtotal + + +def test_invoices_dialog_on_item_changed_paid_at_removes_reminder( + qtbot, invoices_dialog_setup +): + """Test that marking invoice as paid removes due date reminder.""" + setup = invoices_dialog_setup + + # Create a reminder for an invoice + due_date = (date.today() + timedelta(days=7)).isoformat() + invoice_number = "ALPHA-1" + project_name = "Project Alpha" + + reminder_text = _invoice_due_reminder_text(project_name, invoice_number) + reminder = Reminder( + id=None, + text=reminder_text, + time_str=_INVOICE_REMINDER_TIME, + reminder_type=ReminderType.ONCE, + date_iso=due_date, + active=True, + ) + reminder.id = setup["db"].save_reminder(reminder) + + # Verify reminder exists + reminders = setup["db"].get_all_reminders() + assert any(r.text == reminder_text for r in reminders) + + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Find the ALPHA-1 invoice row + for row in range(dialog.table.rowCount()): + num_item = dialog.table.item(row, dialog.COL_NUMBER) + if num_item and num_item.text() == "ALPHA-1": + # Mark as paid + paid_item = dialog.table.item(row, dialog.COL_PAID_AT) + paid_item.setText(date.today().isoformat()) + dialog._on_item_changed(paid_item) + break + + # Reminder should be removed + reminders_after = setup["db"].get_all_reminders() + assert not any(r.text == reminder_text for r in reminders_after) + + +def test_invoices_dialog_ignores_changes_while_reloading(qtbot, invoices_dialog_setup): + """Test that _on_item_changed is ignored during reload.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"]) + qtbot.addWidget(dialog) + + # Set reloading flag + dialog._reloading_invoices = True + + # Try to change an item + num_item = dialog.table.item(0, dialog.COL_NUMBER) + original_number = num_item.text() + inv_id = num_item.data(Qt.ItemDataRole.UserRole) + + num_item.setText("SHOULD-BE-IGNORED") + dialog._on_item_changed(num_item) + + # Change should not be saved to DB + invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number") + assert invoice_data["invoice_number"] == original_number + + +def test_invoice_dialog_update_mode_enabled(qtbot, invoice_dialog_setup): + """Test _update_mode_enabled method.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + dialog.show() + + # Initially detailed mode should be selected + assert dialog.rb_detailed.isChecked() + + # Table should be enabled in detailed mode + assert dialog.table.isEnabled() + + # Switch to summary mode + dialog.rb_summary.setChecked(True) + dialog._update_mode_enabled() + + # Table should be disabled in summary mode + assert not dialog.table.isEnabled() + + +def test_invoice_dialog_with_no_time_logs(qtbot, fresh_db): + """Test InvoiceDialog with project that has no time logs.""" + proj_id = fresh_db.add_project("Empty Project") + today = date.today() + start = (today - timedelta(days=7)).isoformat() + end = today.isoformat() + + dialog = InvoiceDialog(fresh_db, proj_id, start, end) + qtbot.addWidget(dialog) + + # Should handle empty time logs gracefully + assert len(dialog._time_rows) == 0 + assert dialog.table.rowCount() == 0 + + +def test_invoice_dialog_loads_client_company_list(qtbot, invoice_dialog_setup): + """Test that InvoiceDialog loads existing client companies.""" + setup = invoice_dialog_setup + + # Create another project with a different client company + proj_id_2 = setup["db"].add_project("Project 2") + setup["db"].upsert_project_billing( + proj_id_2, + hourly_rate_cents=10000, + currency="EUR", + tax_label="VAT", + tax_rate_percent=19.0, + client_name="Jane Doe", + client_company="Beta Corp", + client_address="456 Main St", + client_email="jane@beta.com", + ) + + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + # Should have both companies in the combo + companies = [ + dialog.client_company_combo.itemText(i) + for i in range(dialog.client_company_combo.count()) + ] + assert "Acme Corp" in companies + assert "Beta Corp" in companies + + +def test_invoice_line_item_equality(app): + """Test InvoiceLineItem equality.""" + item1 = InvoiceLineItem("Work", 5.0, 10000, 50000) + item2 = InvoiceLineItem("Work", 5.0, 10000, 50000) + item3 = InvoiceLineItem("Other", 5.0, 10000, 50000) + + assert item1 == item2 + assert item1 != item3 + + +def test_invoices_dialog_empty_database(qtbot, fresh_db): + """Test InvoicesDialog with no projects or invoices.""" + dialog = InvoicesDialog(fresh_db) + qtbot.addWidget(dialog) + + # Should have no projects in combo + assert dialog.project_combo.count() == 0 + assert dialog.table.rowCount() == 0 + + +def test_invoice_dialog_tax_initially_disabled(qtbot, fresh_db): + """Test that tax fields are hidden when tax_rate_percent is None.""" + proj_id = fresh_db.add_project("No Tax Project") + fresh_db.upsert_project_billing( + proj_id, + hourly_rate_cents=10000, + currency="USD", + tax_label="Tax", + tax_rate_percent=None, # No tax + client_name="Client", + client_company="Company", + client_address="Address", + client_email="email@test.com", + ) + + today = date.today() + start = (today - timedelta(days=1)).isoformat() + end = today.isoformat() + + dialog = InvoiceDialog(fresh_db, proj_id, start, end) + qtbot.addWidget(dialog) + dialog.show() + + # Tax checkbox should be unchecked + assert not dialog.tax_checkbox.isChecked() + + # Tax fields should be hidden + assert not dialog.tax_label.isVisible() + assert not dialog.tax_label_edit.isVisible() + assert not dialog.tax_rate_label.isVisible() + assert not dialog.tax_rate_spin.isVisible() + + +def test_invoice_dialog_dates_default_values(qtbot, invoice_dialog_setup): + """Test that issue and due dates have correct default values.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + # Issue date should be today + assert dialog.issue_date_edit.date() == QDate.currentDate() + + # Due date should be 14 days from today + QDate.currentDate().addDays(14) + assert dialog.issue_date_edit.date() == QDate.currentDate() + + +def test_invoice_dialog_checkbox_toggle_updates_totals(qtbot, invoice_dialog_setup): + """Test that unchecking a line item updates the total cost.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(100.0) + dialog._populate_detailed_rows(10000) + dialog.tax_checkbox.setChecked(False) + + # Initial total: 3 rows * 2.5 hours * $100 = $750 + dialog._recalc_totals() + assert "750.00" in dialog.subtotal_label.text() + assert "750.00" in dialog.total_label.text() + + # Uncheck the first row + include_item = dialog.table.item(0, dialog.COL_INCLUDE) + include_item.setCheckState(Qt.Unchecked) + + # Wait for signal processing + qtbot.wait(10) + + # New total: 2 rows * 2.5 hours * $100 = $500 + assert "500.00" in dialog.subtotal_label.text() + assert "500.00" in dialog.total_label.text() + + +def test_invoice_dialog_checkbox_toggle_with_tax(qtbot, invoice_dialog_setup): + """Test that checkbox toggling works correctly with tax enabled.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(100.0) + dialog._populate_detailed_rows(10000) + dialog.tax_checkbox.setChecked(True) + dialog.tax_rate_spin.setValue(10.0) + + # Initial: 3 rows * 2.5 hours * $100 = $750 + # Tax: $750 * 10% = $75 + # Total: $825 + dialog._recalc_totals() + assert "750.00" in dialog.subtotal_label.text() + assert "75.00" in dialog.tax_label_total.text() + assert "825.00" in dialog.total_label.text() + + # Uncheck two rows + dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked) + dialog.table.item(1, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked) + + # Wait for signal processing + qtbot.wait(10) + + # New total: 1 row * 2.5 hours * $100 = $250 + # Tax: $250 * 10% = $25 + # Total: $275 + assert "250.00" in dialog.subtotal_label.text() + assert "25.00" in dialog.tax_label_total.text() + assert "275.00" in dialog.total_label.text() + + +def test_invoice_dialog_rechecking_items_updates_totals(qtbot, invoice_dialog_setup): + """Test that rechecking a previously unchecked item updates totals.""" + setup = invoice_dialog_setup + dialog = InvoiceDialog( + setup["db"], + setup["proj_id"], + setup["start_date"], + setup["end_date"], + setup["time_rows"], + ) + qtbot.addWidget(dialog) + + dialog.rate_spin.setValue(100.0) + dialog._populate_detailed_rows(10000) + dialog.tax_checkbox.setChecked(False) + + # Uncheck all items + for row in range(dialog.table.rowCount()): + dialog.table.item(row, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked) + + qtbot.wait(10) + + # Total should be 0 + assert "0.00" in dialog.total_label.text() + + # Re-check first item + dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Checked) + qtbot.wait(10) + + # Total should be 1 row * 2.5 hours * $100 = $250 + assert "250.00" in dialog.total_label.text() + + +def test_invoices_dialog_select_initial_project(qtbot, invoices_dialog_setup): + """Test _select_initial_project method.""" + setup = invoices_dialog_setup + dialog = InvoicesDialog(setup["db"]) + qtbot.addWidget(dialog) + + # Initially should have first project selected (either proj1 or proj2) + initial_proj = dialog._current_project() + assert initial_proj in [setup["proj_id_1"], setup["proj_id_2"]] + + # Select specific project + dialog._select_initial_project(setup["proj_id_2"]) + + # Should now have proj_id_2 selected + assert dialog._current_project() == setup["proj_id_2"] diff --git a/tests/test_key_prompt.py b/tests/test_key_prompt.py index f044fac..70ad1da 100644 --- a/tests/test_key_prompt.py +++ b/tests/test_key_prompt.py @@ -97,7 +97,7 @@ def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path): def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app): - """Test KeyPrompt with show_db_change but no initial_db_path - covers line 57""" + """Test KeyPrompt with show_db_change but no initial_db_path""" prompt = KeyPrompt(show_db_change=True, initial_db_path=None) qtbot.addWidget(prompt) @@ -168,7 +168,7 @@ def test_key_prompt_db_path_method(qtbot, app, tmp_path): def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch): - """Test browsing when initial_db_path is set - covers line 57 with non-None path""" + """Test browsing when initial_db_path is set""" initial_db = tmp_path / "initial.db" initial_db.touch() @@ -180,7 +180,7 @@ def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch): # Mock the file dialog to return a different file def mock_get_open_filename(*args, **kwargs): - # Verify that start_dir was passed correctly (line 57) + # Verify that start_dir was passed correctly return str(new_db), "SQLCipher DB (*.db)" monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename) diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index a4025ea..8d869a9 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -1928,7 +1928,7 @@ def test_editor_delete_operations(qtbot, app): def test_markdown_highlighter_dark_theme(qtbot, app): - """Test markdown highlighter with dark theme - covers lines 74-75""" + """Test markdown highlighter with dark theme""" # Create theme manager with dark theme themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK)) @@ -2293,7 +2293,7 @@ def test_highlighter_code_block_with_language(editor, qtbot): # Force rehighlight editor.highlighter.rehighlight() - # Verify syntax highlighting was applied (lines 186-193) + # Verify syntax highlighting was applied # We can't easily verify the exact formatting, but we ensure no crash @@ -2305,13 +2305,10 @@ def test_highlighter_bold_italic_overlap_detection(editor, qtbot): # Force rehighlight editor.highlighter.rehighlight() - # The overlap detection (lines 252, 264) should prevent issues - def test_highlighter_italic_edge_cases(editor, qtbot): """Test italic formatting edge cases.""" # Test edge case: avoiding stealing markers that are part of double - # This tests lines 267-270 editor.setPlainText("**not italic* text**") # Force rehighlight diff --git a/tests/test_markdown_editor_additional.py b/tests/test_markdown_editor_additional.py index 070d954..2584baa 100644 --- a/tests/test_markdown_editor_additional.py +++ b/tests/test_markdown_editor_additional.py @@ -44,7 +44,6 @@ def editor(app, qtbot): return ed -# Test for line 215: document is None guard def test_update_code_block_backgrounds_with_no_document(app, qtbot): """Test _update_code_block_row_backgrounds when document is None.""" themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -60,7 +59,6 @@ def test_update_code_block_backgrounds_with_no_document(app, qtbot): editor._update_code_block_row_backgrounds() -# Test for lines 295, 309, 313-319, 324, 326, 334: _find_code_block_bounds edge cases def test_find_code_block_bounds_invalid_block(editor): """Test _find_code_block_bounds with invalid block.""" editor.setPlainText("some text") @@ -124,7 +122,6 @@ def test_find_code_block_bounds_no_opening_fence(editor): assert result is None -# Test for lines 356, 413, 417-418, 428-434: code block editing edge cases def test_edit_code_block_checks_document(app, qtbot): """Test _edit_code_block when editor has no document.""" themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -249,7 +246,6 @@ def test_edit_code_block_language_change(editor, qtbot, monkeypatch): assert lang == "javascript" -# Test for lines 443-490: _delete_code_block def test_delete_code_block_no_bounds(editor): """Test _delete_code_block when bounds can't be found.""" editor.setPlainText("not a code block") @@ -307,7 +303,6 @@ def test_delete_code_block_with_text_after(editor): assert "text after" in new_text -# Test for line 496: _apply_line_spacing with no document def test_apply_line_spacing_no_document(app): """Test _apply_line_spacing when document is None.""" themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -319,7 +314,6 @@ def test_apply_line_spacing_no_document(app): editor._apply_line_spacing(125.0) -# Test for line 517: _apply_code_block_spacing def test_apply_code_block_spacing(editor): """Test _apply_code_block_spacing applies correct spacing.""" editor.setPlainText("```\nline1\nline2\n```") @@ -334,7 +328,6 @@ def test_apply_code_block_spacing(editor): assert block.isValid() -# Test for line 604: to_markdown with metadata def test_to_markdown_with_code_metadata(editor): """Test to_markdown includes code block metadata.""" editor.setPlainText("```python\ncode\n```") @@ -348,7 +341,6 @@ def test_to_markdown_with_code_metadata(editor): assert "code-langs" in md or "code" in md -# Test for line 648: from_markdown without _code_metadata attribute def test_from_markdown_creates_code_metadata(app): """Test from_markdown creates _code_metadata if missing.""" themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -364,7 +356,6 @@ def test_from_markdown_creates_code_metadata(app): assert hasattr(editor, "_code_metadata") -# Test for lines 718-736: image embedding with original size def test_embed_images_preserves_original_size(editor, tmp_path): """Test that embedded images preserve their original dimensions.""" # Create a test image @@ -387,7 +378,6 @@ def test_embed_images_preserves_original_size(editor, tmp_path): assert doc is not None -# Test for lines 782, 791, 813-834: _maybe_trim_list_prefix_from_line_selection def test_trim_list_prefix_no_selection(editor): """Test _maybe_trim_list_prefix_from_line_selection with no selection.""" editor.setPlainText("- item") @@ -447,7 +437,6 @@ def test_trim_list_prefix_during_adjustment(editor): editor._adjusting_selection = False -# Test for lines 848, 860-866: _detect_list_type def test_detect_list_type_checkbox_checked(editor): """Test _detect_list_type with checked checkbox.""" list_type, prefix = editor._detect_list_type( @@ -478,7 +467,6 @@ def test_detect_list_type_not_a_list(editor): assert prefix == "" -# Test for lines 876, 884-886: list prefix length calculation def test_list_prefix_length_numbered(editor): """Test _list_prefix_length_for_block with numbered list.""" editor.setPlainText("123. item") @@ -489,7 +477,6 @@ def test_list_prefix_length_numbered(editor): assert length > 0 -# Test for lines 948-949: keyPressEvent with Ctrl+Home def test_key_press_ctrl_home(editor, qtbot): """Test Ctrl+Home key combination.""" editor.setPlainText("line1\nline2\nline3") @@ -504,7 +491,6 @@ def test_key_press_ctrl_home(editor, qtbot): assert editor.textCursor().position() == 0 -# Test for lines 957-960: keyPressEvent with Ctrl+Left def test_key_press_ctrl_left(editor, qtbot): """Test Ctrl+Left key combination.""" editor.setPlainText("word1 word2 word3") @@ -518,7 +504,6 @@ def test_key_press_ctrl_left(editor, qtbot): # Should move left by word -# Test for lines 984-988, 1044: Home key in list def test_key_press_home_in_list(editor, qtbot): """Test Home key in list item.""" editor.setPlainText("- item text") @@ -534,7 +519,6 @@ def test_key_press_home_in_list(editor, qtbot): assert pos > 0 -# Test for lines 1067-1073: Left key in list prefix def test_key_press_left_in_list_prefix(editor, qtbot): """Test Left key when in list prefix region.""" editor.setPlainText("- item") @@ -549,7 +533,6 @@ def test_key_press_left_in_list_prefix(editor, qtbot): # Should snap to after prefix -# Test for lines 1088, 1095-1104: Up/Down in code blocks def test_key_press_up_in_code_block(editor, qtbot): """Test Up key inside code block.""" editor.setPlainText("```\ncode line 1\ncode line 2\n```") @@ -579,7 +562,6 @@ def test_key_press_down_in_list_item(editor, qtbot): # Should snap to after prefix on next line -# Test for lines 1127-1130, 1134-1137: Enter key with markers def test_key_press_enter_after_markers(editor, qtbot): """Test Enter key after style markers.""" editor.setPlainText("text **") @@ -593,7 +575,6 @@ def test_key_press_enter_after_markers(editor, qtbot): # Should handle markers -# Test for lines 1146-1164: Enter on fence line def test_key_press_enter_on_closing_fence(editor, qtbot): """Test Enter key on closing fence line.""" editor.setPlainText("```\ncode\n```") @@ -608,7 +589,6 @@ def test_key_press_enter_on_closing_fence(editor, qtbot): # Should create new line after fence -# Test for lines 1185-1189: Backspace in empty checkbox def test_key_press_backspace_empty_checkbox(editor, qtbot): """Test Backspace in empty checkbox item.""" editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} ") @@ -622,7 +602,6 @@ def test_key_press_backspace_empty_checkbox(editor, qtbot): # Should remove checkbox -# Test for lines 1205, 1215-1221: Backspace in numbered list def test_key_press_backspace_numbered_list(editor, qtbot): """Test Backspace at start of numbered list item.""" editor.setPlainText("1. ") @@ -634,7 +613,6 @@ def test_key_press_backspace_numbered_list(editor, qtbot): editor.keyPressEvent(event) -# Test for lines 1228, 1232, 1238-1242: Tab/Shift+Tab in lists def test_key_press_tab_in_bullet_list(editor, qtbot): """Test Tab key in bullet list.""" editor.setPlainText("- item") @@ -672,7 +650,6 @@ def test_key_press_tab_in_checkbox(editor, qtbot): editor.keyPressEvent(event) -# Test for lines 1282-1283: Auto-pairing skip def test_apply_weight_to_selection(editor, qtbot): """Test apply_weight makes text bold.""" editor.setPlainText("text to bold") @@ -712,7 +689,6 @@ def test_apply_strikethrough_to_selection(editor, qtbot): assert "~~" in md -# Test for line 1358: apply_code - it opens a dialog, not just wraps in backticks def test_apply_code_on_selection(editor, qtbot): """Test apply_code with selected text.""" editor.setPlainText("some code") @@ -728,7 +704,6 @@ def test_apply_code_on_selection(editor, qtbot): # May contain code block elements depending on dialog behavior -# Test for line 1386: toggle_numbers def test_toggle_numbers_on_plain_text(editor, qtbot): """Test toggle_numbers converts text to numbered list.""" editor.setPlainText("item 1") @@ -742,7 +717,6 @@ def test_toggle_numbers_on_plain_text(editor, qtbot): assert "1." in text -# Test for lines 1402-1407: toggle_bullets def test_toggle_bullets_on_plain_text(editor, qtbot): """Test toggle_bullets converts text to bullet list.""" editor.setPlainText("item 1") @@ -771,7 +745,6 @@ def test_toggle_bullets_removes_bullets(editor, qtbot): assert text.strip() == "item 1" -# Test for line 1429: toggle_checkboxes def test_toggle_checkboxes_on_bullets(editor, qtbot): """Test toggle_checkboxes converts bullets to checkboxes.""" editor.setPlainText(f"{editor._BULLET_DISPLAY} item 1") @@ -786,7 +759,6 @@ def test_toggle_checkboxes_on_bullets(editor, qtbot): assert editor._CHECK_UNCHECKED_DISPLAY in text -# Test for line 1452: apply_heading def test_apply_heading_various_levels(editor, qtbot): """Test apply_heading with different levels.""" test_cases = [ @@ -809,7 +781,6 @@ def test_apply_heading_various_levels(editor, qtbot): assert text.startswith(expected_marker) -# Test for lines 1501-1505: insert_image_from_path def test_insert_image_from_path_invalid_extension(editor, tmp_path): """Test insert_image_from_path with invalid extension.""" invalid_file = tmp_path / "file.txt" @@ -827,7 +798,6 @@ def test_insert_image_from_path_nonexistent(editor, tmp_path): editor.insert_image_from_path(nonexistent) -# Test for lines 1578-1579: mousePressEvent checkbox toggle def test_mouse_press_toggle_unchecked_to_checked(editor, qtbot): """Test clicking checkbox toggles it from unchecked to checked.""" editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task") @@ -872,7 +842,6 @@ def test_mouse_press_toggle_checked_to_unchecked(editor, qtbot): assert editor._CHECK_UNCHECKED_DISPLAY in text -# Test for line 1602: mouseDoubleClickEvent def test_mouse_double_click_suppression(editor, qtbot): """Test double-click suppression for checkboxes.""" editor.setPlainText(f"{editor._CHECK_UNCHECKED_DISPLAY} task") @@ -895,7 +864,6 @@ def test_mouse_double_click_suppression(editor, qtbot): assert not editor._suppress_next_checkbox_double_click -# Test for lines 1692-1738: Context menu (lines 1670 was the image loading, not link handling) def test_context_menu_in_code_block(editor, qtbot): """Test context menu when in code block.""" editor.setPlainText("```python\ncode\n```") @@ -915,7 +883,6 @@ def test_context_menu_in_code_block(editor, qtbot): # Note: actual menu exec is blocked in tests, but we verify it doesn't crash -# Test for lines 1742-1757: _set_code_block_language def test_set_code_block_language(editor, qtbot): """Test _set_code_block_language sets metadata.""" editor.setPlainText("```\ncode\n```") @@ -929,7 +896,6 @@ def test_set_code_block_language(editor, qtbot): assert lang == "python" -# Test for lines 1770-1783: get_current_line_task_text def test_get_current_line_task_text_strips_prefixes(editor, qtbot): """Test get_current_line_task_text removes list/checkbox prefixes.""" test_cases = [ diff --git a/tests/test_statistics_dialog.py b/tests/test_statistics_dialog.py index 8ff73b1..12f96c5 100644 --- a/tests/test_statistics_dialog.py +++ b/tests/test_statistics_dialog.py @@ -632,5 +632,5 @@ def test_heatmap_month_label_continuation(qtbot, fresh_db): # Force a repaint to execute paintEvent heatmap.repaint() - # The month continuation logic (line 175) should prevent duplicate labels + # The month continuation logic should prevent duplicate labels # We can't easily test the visual output, but we ensure no crash