Invoicing
This commit is contained in:
parent
e5c7ccb1da
commit
81878c63d9
16 changed files with 3656 additions and 54 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,3 +5,6 @@ __pycache__
|
|||
dist
|
||||
.coverage
|
||||
*.db
|
||||
*.pdf
|
||||
*.csv
|
||||
*.html
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
546
bouquin/db.py
546
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
|
||||
|
|
|
|||
1450
bouquin/invoices.py
Normal file
1450
bouquin/invoices.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1348
tests/test_invoices.py
Normal file
1348
tests/test_invoices.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue