Invoicing
All checks were successful
CI / test (push) Successful in 7m5s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 25s

This commit is contained in:
Miguel Jacq 2025-12-08 20:34:11 +11:00
parent e5c7ccb1da
commit 81878c63d9
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
16 changed files with 3656 additions and 54 deletions

View file

@ -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

1450
bouquin/invoices.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -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."
}

View file

@ -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)

View file

@ -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))

View file

@ -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,

View file

@ -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 dont 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 MondaySunday 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()