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}
+ |
+
+
+
+
+
+
+ | ITEMS AND DESCRIPTION |
+ QTY/HRS |
+ PRICE |
+ AMOUNT ({currency}) |
+
+ {item_rows_html}
+
+
+
+
+
+ |
+ 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