Make the bucket a ledger event - add project log view for such events and others (time log, invoicing etc)

This commit is contained in:
Miguel Jacq 2026-06-07 17:37:52 +10:00
parent 58333bf93c
commit 54af723b53
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
5 changed files with 1175 additions and 496 deletions

View file

@ -323,6 +323,24 @@ class DBManager:
) )
); );
CREATE TABLE IF NOT EXISTS project_bucket_ledger (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL
REFERENCES projects(id) ON DELETE CASCADE,
occurred_at TEXT NOT NULL DEFAULT (
strftime('%Y-%m-%dT%H:%M:%fZ','now')
),
entry_type TEXT NOT NULL,
baseline_delta_minutes INTEGER NOT NULL DEFAULT 0,
ceiling_delta_minutes INTEGER NOT NULL DEFAULT 0,
description TEXT,
invoice_id INTEGER,
FOREIGN KEY(invoice_id) REFERENCES invoices(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS ix_project_bucket_ledger_project
ON project_bucket_ledger(project_id, occurred_at);
CREATE TABLE IF NOT EXISTS company_profile ( CREATE TABLE IF NOT EXISTS company_profile (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
@ -352,6 +370,9 @@ class DBManager:
paid_at TEXT, paid_at TEXT,
payment_note TEXT, payment_note TEXT,
document_id INTEGER, document_id INTEGER,
created_at TEXT NOT NULL DEFAULT (
strftime('%Y-%m-%dT%H:%M:%fZ','now')
),
FOREIGN KEY(document_id) REFERENCES project_documents(id) FOREIGN KEY(document_id) REFERENCES project_documents(id)
ON DELETE SET NULL, ON DELETE SET NULL,
UNIQUE(project_id, invoice_number) UNIQUE(project_id, invoice_number)
@ -382,8 +403,20 @@ class DBManager:
); );
""" """
) )
self._ensure_column(
"invoices",
"created_at",
"created_at TEXT",
)
self.conn.commit() self.conn.commit()
def _ensure_column(self, table: str, column: str, definition: str) -> None:
"""Add a simple column during startup schema upgrades if needed."""
rows = self.conn.execute(f"PRAGMA table_info({table})").fetchall()
if any(str(r["name"]) == column for r in rows):
return
self.conn.execute(f"ALTER TABLE {table} ADD COLUMN {definition}")
def rekey(self, new_key: str) -> None: def rekey(self, new_key: str) -> None:
""" """
Change the SQLCipher passphrase in-place, then reopen the connection Change the SQLCipher passphrase in-place, then reopen the connection
@ -1243,6 +1276,104 @@ class DBManager:
raise ValueError("invalid project id") raise ValueError("invalid project id")
return project_id return project_id
def _normalise_minutes_delta(self, minutes: int | float | None) -> int:
return int(round(float(minutes or 0)))
def _normalise_bucket_warning(self, warn_at_percent: float | None) -> float:
return min(100.0, max(0.0, float(warn_at_percent or 0.0)))
def _project_bucket_ledger_totals(self, project_id: int) -> tuple[int, int]:
row = self.conn.execute(
"""
SELECT
COALESCE(SUM(baseline_delta_minutes), 0) AS baseline_minutes,
COALESCE(SUM(ceiling_delta_minutes), 0) AS bucket_ceiling_minutes
FROM project_bucket_ledger
WHERE project_id = ?;
""",
(project_id,),
).fetchone()
baseline = max(0, int(row["baseline_minutes"] or 0))
ceiling = max(0, int(row["bucket_ceiling_minutes"] or 0))
return baseline, ceiling
def _sync_project_bucket_cache(self, project_id: int) -> None:
"""Refresh the project_buckets cache from the ledger."""
baseline, ceiling = self._project_bucket_ledger_totals(project_id)
existing = self.conn.execute(
"SELECT warn_at_percent FROM project_buckets WHERE project_id = ?;",
(project_id,),
).fetchone()
warn_at = float(existing["warn_at_percent"] or 80.0) if existing else 80.0
self.conn.execute(
"""
INSERT INTO project_buckets (
project_id,
baseline_minutes,
bucket_ceiling_minutes,
warn_at_percent
)
VALUES (?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
baseline_minutes = excluded.baseline_minutes,
bucket_ceiling_minutes = excluded.bucket_ceiling_minutes,
warn_at_percent = project_buckets.warn_at_percent,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');
""",
(project_id, baseline, ceiling, warn_at),
)
def add_project_bucket_ledger_entry(
self,
project_id: int,
entry_type: str,
baseline_delta_minutes: int = 0,
ceiling_delta_minutes: int = 0,
description: str | None = None,
invoice_id: int | None = None,
) -> int:
"""Append an auditable project bucket ledger entry.
Positive ``baseline_delta_minutes`` increases the already-spent baseline.
Positive ``ceiling_delta_minutes`` increases the prepaid bucket ceiling.
Negative deltas are used for explicit corrections when the user lowers a
previously saved baseline or ceiling.
"""
project_id = self._normalise_project_id(project_id)
entry_type = str(entry_type or "adjustment").strip() or "adjustment"
baseline_delta_minutes = self._normalise_minutes_delta(baseline_delta_minutes)
ceiling_delta_minutes = self._normalise_minutes_delta(ceiling_delta_minutes)
description = (description or "").strip() or None
if baseline_delta_minutes == 0 and ceiling_delta_minutes == 0:
raise ValueError("bucket ledger entry has no minute delta")
with self.conn:
cur = self.conn.cursor()
cur.execute(
"""
INSERT INTO project_bucket_ledger (
project_id,
entry_type,
baseline_delta_minutes,
ceiling_delta_minutes,
description,
invoice_id
)
VALUES (?, ?, ?, ?, ?, ?);
""",
(
project_id,
entry_type,
baseline_delta_minutes,
ceiling_delta_minutes,
description,
invoice_id,
),
)
ledger_id = cur.lastrowid
self._sync_project_bucket_cache(project_id)
return ledger_id
def upsert_project_bucket( def upsert_project_bucket(
self, self,
project_id: int, project_id: int,
@ -1250,16 +1381,23 @@ class DBManager:
bucket_ceiling_minutes: int, bucket_ceiling_minutes: int,
warn_at_percent: float = 80.0, warn_at_percent: float = 80.0,
) -> None: ) -> None:
"""Save cumulative prepaid-hour bucket settings for a project. """Save project bucket settings as an auditable ledger adjustment.
``baseline_minutes`` represents already-spent hours that pre-date ``baseline_minutes`` represents already-spent hours that pre-date
Bouquin time logging. ``bucket_ceiling_minutes`` is the cumulative Bouquin time logging. ``bucket_ceiling_minutes`` is the cumulative
prepaid ceiling purchased for this project. prepaid ceiling purchased for this project. The current values are
derived from ``project_bucket_ledger`` rather than silently overwritten.
""" """
project_id = self._normalise_project_id(project_id) project_id = self._normalise_project_id(project_id)
baseline_minutes = max(0, int(baseline_minutes or 0)) baseline_minutes = max(0, int(baseline_minutes or 0))
bucket_ceiling_minutes = max(0, int(bucket_ceiling_minutes or 0)) bucket_ceiling_minutes = max(0, int(bucket_ceiling_minutes or 0))
warn_at_percent = min(100.0, max(0.0, float(warn_at_percent or 0.0))) warn_at_percent = self._normalise_bucket_warning(warn_at_percent)
current_baseline, current_ceiling = self._project_bucket_ledger_totals(
project_id
)
baseline_delta = baseline_minutes - current_baseline
ceiling_delta = bucket_ceiling_minutes - current_ceiling
with self.conn: with self.conn:
self.conn.execute( self.conn.execute(
""" """
@ -1271,41 +1409,67 @@ class DBManager:
) )
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET ON CONFLICT(project_id) DO UPDATE SET
baseline_minutes = excluded.baseline_minutes,
bucket_ceiling_minutes = excluded.bucket_ceiling_minutes,
warn_at_percent = excluded.warn_at_percent, warn_at_percent = excluded.warn_at_percent,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'); updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');
""", """,
( (
project_id, project_id,
baseline_minutes, current_baseline,
bucket_ceiling_minutes, current_ceiling,
warn_at_percent, warn_at_percent,
), ),
) )
if baseline_delta or ceiling_delta:
self.conn.execute(
"""
INSERT INTO project_bucket_ledger (
project_id,
entry_type,
baseline_delta_minutes,
ceiling_delta_minutes,
description
)
VALUES (?, ?, ?, ?, ?);
""",
(
project_id,
"settings_adjustment",
baseline_delta,
ceiling_delta,
"Bucket settings updated",
),
)
self._sync_project_bucket_cache(project_id)
self.conn.execute(
"""
UPDATE project_buckets
SET warn_at_percent = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE project_id = ?;
""",
(warn_at_percent, project_id),
)
def add_to_project_bucket_ceiling(self, project_id: int, add_minutes: int) -> None: def add_to_project_bucket_ceiling(
self,
project_id: int,
add_minutes: int,
description: str | None = None,
invoice_id: int | None = None,
) -> None:
"""Increase a project's cumulative bucket ceiling by ``add_minutes``.""" """Increase a project's cumulative bucket ceiling by ``add_minutes``."""
project_id = self._normalise_project_id(project_id) project_id = self._normalise_project_id(project_id)
add_minutes = max(0, int(add_minutes or 0)) add_minutes = max(0, int(add_minutes or 0))
if add_minutes <= 0: if add_minutes <= 0:
return return
with self.conn: entry_type = "prepaid_invoice" if invoice_id is not None else "manual_topup"
self.conn.execute( self.add_project_bucket_ledger_entry(
""" project_id,
INSERT INTO project_buckets ( entry_type,
project_id, ceiling_delta_minutes=add_minutes,
baseline_minutes, description=description or "Bucket ceiling increased",
bucket_ceiling_minutes, invoice_id=invoice_id,
warn_at_percent )
)
VALUES (?, 0, ?, 80.0)
ON CONFLICT(project_id) DO UPDATE SET
bucket_ceiling_minutes = bucket_ceiling_minutes + excluded.bucket_ceiling_minutes,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');
""",
(project_id, add_minutes),
)
def get_project_bucket(self, project_id: int): def get_project_bucket(self, project_id: int):
"""Return the bucket row for a project, or None if none is configured.""" """Return the bucket row for a project, or None if none is configured."""
@ -1326,6 +1490,24 @@ class DBManager:
""", """,
(project_id,), (project_id,),
).fetchone() ).fetchone()
if row is None:
baseline, ceiling = self._project_bucket_ledger_totals(project_id)
if baseline == 0 and ceiling == 0:
return None
self._sync_project_bucket_cache(project_id)
row = self.conn.execute(
"""
SELECT
project_id,
baseline_minutes,
bucket_ceiling_minutes,
warn_at_percent,
updated_at
FROM project_buckets
WHERE project_id = ?;
""",
(project_id,),
).fetchone()
return row return row
def logged_minutes_for_project(self, project_id: int) -> int: def logged_minutes_for_project(self, project_id: int) -> int:
@ -1471,6 +1653,122 @@ class DBManager:
(project_id,), (project_id,),
).fetchall() ).fetchall()
def project_bucket_ledger_for_project(self, project_id: int):
"""Return bucket ledger rows, including time-log consumption entries."""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return []
rows = self.conn.execute(
"""
SELECT
l.occurred_at AS occurred_at,
l.entry_type AS entry_type,
l.description AS description,
l.baseline_delta_minutes AS baseline_delta_minutes,
l.ceiling_delta_minutes AS ceiling_delta_minutes,
0 AS used_delta_minutes,
l.invoice_id AS invoice_id,
i.invoice_number AS invoice_number,
NULL AS time_log_id,
NULL AS page_date,
NULL AS activity_name,
l.id AS source_id
FROM project_bucket_ledger AS l
LEFT JOIN invoices AS i ON i.id = l.invoice_id
WHERE l.project_id = ?
UNION ALL
SELECT
t.created_at AS occurred_at,
'time_log' AS entry_type,
COALESCE(NULLIF(t.note, ''), a.name) AS description,
0 AS baseline_delta_minutes,
0 AS ceiling_delta_minutes,
t.minutes AS used_delta_minutes,
NULL AS invoice_id,
NULL AS invoice_number,
t.id AS time_log_id,
t.page_date AS page_date,
a.name AS activity_name,
t.id AS source_id
FROM time_log AS t
JOIN activities AS a ON a.id = t.activity_id
WHERE t.project_id = ?
ORDER BY occurred_at DESC, source_id DESC;
""",
(project_id, project_id),
).fetchall()
return rows
def project_activity_log_for_project(self, project_id: int):
"""Return a generated project changelog from existing dated records."""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return []
rows = self.conn.execute(
"""
SELECT
t.created_at AS occurred_at,
'time_log' AS event_type,
'Time logged' AS title,
printf('%.2f hours for %s%s',
t.minutes / 60.0,
a.name,
CASE
WHEN COALESCE(t.note, '') = '' THEN ''
ELSE ': ' || t.note
END) AS details,
t.id AS source_id
FROM time_log AS t
JOIN activities AS a ON a.id = t.activity_id
WHERE t.project_id = ?
UNION ALL
SELECT
d.uploaded_at AS occurred_at,
'document' AS event_type,
'Document added' AS title,
d.file_name || CASE
WHEN COALESCE(d.description, '') = '' THEN ''
ELSE ': ' || d.description
END AS details,
d.id AS source_id
FROM project_documents AS d
WHERE d.project_id = ?
UNION ALL
SELECT
COALESCE(i.created_at, i.issue_date) AS occurred_at,
'invoice' AS event_type,
'Invoice issued' AS title,
i.invoice_number || '' || printf('%.2f %s', i.total_cents / 100.0, i.currency) AS details,
i.id AS source_id
FROM invoices AS i
WHERE i.project_id = ?
UNION ALL
SELECT
l.occurred_at AS occurred_at,
'bucket' AS event_type,
'Bucket ledger updated' AS title,
COALESCE(l.description, l.entry_type) AS details,
l.id AS source_id
FROM project_bucket_ledger AS l
WHERE l.project_id = ?
ORDER BY occurred_at DESC, source_id DESC;
""",
(project_id, project_id, project_id, project_id),
).fetchall()
return rows
def list_activities(self) -> list[ActivityRow]: def list_activities(self) -> list[ActivityRow]:
cur = self.conn.cursor() cur = self.conn.cursor()
rows = cur.execute( rows = cur.execute(
@ -2479,6 +2777,7 @@ class DBManager:
i.paid_at, i.paid_at,
i.payment_note, i.payment_note,
i.document_id, i.document_id,
i.created_at,
d.file_name AS document_file_name d.file_name AS document_file_name
FROM invoices AS i FROM invoices AS i
LEFT JOIN projects AS p ON p.id = i.project_id LEFT JOIN projects AS p ON p.id = i.project_id

View file

@ -91,6 +91,7 @@ class InvoiceDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self._db = db self._db = db
self._project_id = project_id self._project_id = project_id
self.last_invoice_id: int | None = None
self._start = start_date_iso self._start = start_date_iso
self._end = end_date_iso self._end = end_date_iso
@ -661,6 +662,7 @@ class InvoiceDialog(QDialog):
line_items=[(li.description, li.hours, li.rate_cents) for li in items], line_items=[(li.description, li.hours, li.rate_cents) for li in items],
time_log_ids=time_log_ids, time_log_ids=time_log_ids,
) )
self.last_invoice_id = invoice_id
# Automatically create a reminder for the invoice due date # Automatically create a reminder for the invoice due date
if self.cfg.reminders: if self.cfg.reminders:

View file

@ -1,473 +1,490 @@
{ {
"db_sqlcipher_integrity_check_failed": "SQLCipher integrity check failed", "db_sqlcipher_integrity_check_failed": "SQLCipher integrity check failed",
"db_issues_reported": "issue(s) reported", "db_issues_reported": "issue(s) reported",
"db_reopen_failed_after_rekey": "Re-open failed after rekey", "db_reopen_failed_after_rekey": "Re-open failed after rekey",
"db_version_id_does_not_belong_to_the_given_date": "version_id does not belong to the given date", "db_version_id_does_not_belong_to_the_given_date": "version_id does not belong to the given date",
"db_key_incorrect": "The key is probably incorrect", "db_key_incorrect": "The key is probably incorrect",
"db_database_error": "Database error", "db_database_error": "Database error",
"database_maintenance": "Database maintenance", "database_maintenance": "Database maintenance",
"database_compact": "Compact the database", "database_compact": "Compact the database",
"database_compact_explanation": "Compacting runs VACUUM on the database. This can help reduce its size.", "database_compact_explanation": "Compacting runs VACUUM on the database. This can help reduce its size.",
"database_compacted_successfully": "Database compacted successfully!", "database_compacted_successfully": "Database compacted successfully!",
"encryption": "Encryption", "encryption": "Encryption",
"remember_key": "Remember key", "remember_key": "Remember key",
"change_encryption_key": "Change encryption key", "change_encryption_key": "Change encryption key",
"enter_a_new_encryption_key": "Enter a new encryption key", "enter_a_new_encryption_key": "Enter a new encryption key",
"reenter_the_new_key": "Re-enter the new key", "reenter_the_new_key": "Re-enter the new key",
"key_mismatch": "Key mismatch", "key_mismatch": "Key mismatch",
"key_mismatch_explanation": "The two entries did not match.", "key_mismatch_explanation": "The two entries did not match.",
"empty_key": "Empty key", "empty_key": "Empty key",
"empty_key_explanation": "The key cannot be empty.", "empty_key_explanation": "The key cannot be empty.",
"key_changed": "Key changed", "key_changed": "Key changed",
"key_changed_explanation": "The notebook was re-encrypted with the new key!", "key_changed_explanation": "The notebook was re-encrypted with the new key!",
"error": "Error", "error": "Error",
"success": "Success", "success": "Success",
"close": "&Close", "close": "&Close",
"find": "Find", "find": "Find",
"file": "File", "file": "File",
"locale": "Language", "locale": "Language",
"locale_restart": "Please restart the application to load the new language.", "locale_restart": "Please restart the application to load the new language.",
"settings": "Settings", "settings": "Settings",
"theme": "Theme", "theme": "Theme",
"system": "System", "system": "System",
"light": "Light", "light": "Light",
"dark": "Dark", "dark": "Dark",
"never": "Never", "never": "Never",
"close_tab": "Close tab", "close_tab": "Close tab",
"previous": "Previous", "previous": "Previous",
"previous_day": "Previous day", "previous_day": "Previous day",
"next": "Next", "next": "Next",
"next_day": "Next day", "next_day": "Next day",
"today": "Today", "today": "Today",
"show": "Show", "show": "Show",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"history": "History", "history": "History",
"export_accessible_flag": "&Export", "export_accessible_flag": "&Export",
"export_entries": "Export entries", "export_entries": "Export entries",
"export_complete": "Export complete", "export_complete": "Export complete",
"export_failed": "Export failed", "export_failed": "Export failed",
"backup": "Backup", "backup": "Backup",
"backup_complete": "Backup complete", "backup_complete": "Backup complete",
"backup_failed": "Backup failed", "backup_failed": "Backup failed",
"quit": "Quit", "quit": "Quit",
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save", "save": "Save",
"help": "Help", "help": "Help",
"saved": "Saved", "saved": "Saved",
"saved_to": "Saved to", "saved_to": "Saved to",
"documentation": "Documentation", "documentation": "Documentation",
"couldnt_open": "Couldn't open", "couldnt_open": "Couldn't open",
"report_a_bug": "Report a bug", "report_a_bug": "Report a bug",
"version": "Version", "version": "Version",
"update": "Update", "update": "Update",
"check_for_updates": "Check for updates", "check_for_updates": "Check for updates",
"could_not_check_for_updates": "Could not check for updates:\n", "could_not_check_for_updates": "Could not check for updates:\n",
"update_server_returned_an_empty_version_string": "Update server returned an empty version string", "update_server_returned_an_empty_version_string": "Update server returned an empty version string",
"you_are_running_the_latest_version": "You are running the latest version:\n", "you_are_running_the_latest_version": "You are running the latest version:\n",
"there_is_a_new_version_available": "There is a new version available:\n", "there_is_a_new_version_available": "There is a new version available:\n",
"download_the_appimage": "Download the AppImage?", "download_the_appimage": "Download the AppImage?",
"downloading": "Downloading", "downloading": "Downloading",
"download_cancelled": "Download cancelled", "download_cancelled": "Download cancelled",
"failed_to_download_update": "Failed to download update:\n", "failed_to_download_update": "Failed to download update:\n",
"could_not_read_bundled_gpg_public_key": "Could not read bundled GPG public key:\n", "could_not_read_bundled_gpg_public_key": "Could not read bundled GPG public key:\n",
"could_not_find_gpg_executable": "Could not find the 'gpg' executable to verify the download.", "could_not_find_gpg_executable": "Could not find the 'gpg' executable to verify the download.",
"gpg_signature_verification_failed": "GPG signature verification failed. The downloaded files have been deleted.\n\n", "gpg_signature_verification_failed": "GPG signature verification failed. The downloaded files have been deleted.\n\n",
"downloaded_and_verified_new_appimage": "Downloaded and verified new AppImage:\n\n", "downloaded_and_verified_new_appimage": "Downloaded and verified new AppImage:\n\n",
"navigate": "Navigate", "navigate": "Navigate",
"current": "current", "current": "current",
"selected": "selected", "selected": "selected",
"find_on_page": "Find on page", "find_on_page": "Find on page",
"find_next": "Find next", "find_next": "Find next",
"find_previous": "Find previous", "find_previous": "Find previous",
"find_bar_type_to_search": "Type to search", "find_bar_type_to_search": "Type to search",
"find_bar_match_case": "Match case", "find_bar_match_case": "Match case",
"history_dialog_preview": "Preview", "history_dialog_preview": "Preview",
"history_dialog_diff": "Diff", "history_dialog_diff": "Diff",
"history_dialog_revert_to_selected": "&Revert to selected", "history_dialog_revert_to_selected": "&Revert to selected",
"history_dialog_revert_failed": "Revert failed", "history_dialog_revert_failed": "Revert failed",
"history_dialog_delete": "&Delete revision", "history_dialog_delete": "&Delete revision",
"history_dialog_delete_failed": "Could not delete revision", "history_dialog_delete_failed": "Could not delete revision",
"key_prompt_enter_key": "Enter key", "key_prompt_enter_key": "Enter key",
"lock_overlay_locked": "Locked", "lock_overlay_locked": "Locked",
"lock_overlay_unlock": "Unlock", "lock_overlay_unlock": "Unlock",
"main_window_lock_screen_accessibility": "&Lock screen", "main_window_lock_screen_accessibility": "&Lock screen",
"main_window_ready": "Ready", "main_window_ready": "Ready",
"main_window_save_a_version": "Save a version", "main_window_save_a_version": "Save a version",
"main_window_settings_accessible_flag": "Settin&gs", "main_window_settings_accessible_flag": "Settin&gs",
"set_an_encryption_key": "Set an encryption key", "set_an_encryption_key": "Set an encryption key",
"set_an_encryption_key_explanation": "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!", "set_an_encryption_key_explanation": "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!",
"unlock_encrypted_notebook": "Unlock encrypted notebook", "unlock_encrypted_notebook": "Unlock encrypted notebook",
"unlock_encrypted_notebook_explanation": "Enter your key to unlock the notebook", "unlock_encrypted_notebook_explanation": "Enter your key to unlock the notebook",
"open_in_new_tab": "Open in new tab", "open_in_new_tab": "Open in new tab",
"autosave": "autosave", "autosave": "autosave",
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day", "unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday", "move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
"move_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday", "move_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday",
"insert_images": "Insert images", "insert_images": "Insert images",
"images": "Images", "images": "Images",
"reopen_failed": "Re-open failed", "reopen_failed": "Re-open failed",
"unlock_failed": "Unlock failed", "unlock_failed": "Unlock failed",
"could_not_unlock_database_at_new_path": "Could not unlock database at new path.", "could_not_unlock_database_at_new_path": "Could not unlock database at new path.",
"unencrypted_export": "Unencrypted export", "unencrypted_export": "Unencrypted export",
"unencrypted_export_warning": "Exporting the database will be unencrypted!\nAre you sure you want to continue?\nIf you want an encrypted backup, choose Backup instead of Export.", "unencrypted_export_warning": "Exporting the database will be unencrypted!\nAre you sure you want to continue?\nIf you want an encrypted backup, choose Backup instead of Export.",
"unrecognised_extension": "Unrecognised extension!", "unrecognised_extension": "Unrecognised extension!",
"backup_encrypted_notebook": "Backup encrypted notebook", "backup_encrypted_notebook": "Backup encrypted notebook",
"enter_a_name_for_this_version": "Enter a name for this version", "enter_a_name_for_this_version": "Enter a name for this version",
"new_version_i_saved_at": "New version I saved at", "new_version_i_saved_at": "New version I saved at",
"appearance": "Appearance", "appearance": "Appearance",
"security": "Security", "security": "Security",
"features": "Features", "features": "Features",
"database": "Database", "database": "Database",
"save_key_warning": "If you don't want to be prompted for your encryption key, check this to remember it.\nWARNING: the key is saved to disk and could be recoverable if your disk is compromised.", "save_key_warning": "If you don't want to be prompted for your encryption key, check this to remember it.\nWARNING: the key is saved to disk and could be recoverable if your disk is compromised.",
"lock_screen_when_idle": "Lock screen when idle", "lock_screen_when_idle": "Lock screen when idle",
"autolock_explanation": "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it.\nSet to 0 (never) to never lock.", "autolock_explanation": "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it.\nSet to 0 (never) to never lock.",
"font_size": "Font size", "font_size": "Font size",
"font_size_explanation": "Changing this value will change the size of all paragraph text in all tabs. It does not affect heading or code block size", "font_size_explanation": "Changing this value will change the size of all paragraph text in all tabs. It does not affect heading or code block size",
"search_for_notes_here": "Search for notes here", "search_for_notes_here": "Search for notes here",
"toolbar_format": "Format", "toolbar_format": "Format",
"toolbar_bold": "Bold", "toolbar_bold": "Bold",
"toolbar_italic": "Italic", "toolbar_italic": "Italic",
"toolbar_strikethrough": "Strikethrough", "toolbar_strikethrough": "Strikethrough",
"toolbar_normal_paragraph_text": "Normal paragraph text", "toolbar_normal_paragraph_text": "Normal paragraph text",
"toolbar_font_smaller": "Smaller text", "toolbar_font_smaller": "Smaller text",
"toolbar_font_larger": "Larger text", "toolbar_font_larger": "Larger text",
"toolbar_bulleted_list": "Bulleted list", "toolbar_bulleted_list": "Bulleted list",
"toolbar_numbered_list": "Numbered list", "toolbar_numbered_list": "Numbered list",
"toolbar_code_block": "Code block", "toolbar_code_block": "Code block",
"toolbar_heading": "Heading", "toolbar_heading": "Heading",
"toolbar_toggle_checkboxes": "Toggle checkboxes", "toolbar_toggle_checkboxes": "Toggle checkboxes",
"tags": "Tags", "tags": "Tags",
"tag": "Tag", "tag": "Tag",
"manage_tags": "Manage tags", "manage_tags": "Manage tags",
"add_tag_placeholder": "Add a tag and press Enter", "add_tag_placeholder": "Add a tag and press Enter",
"tag_browser_title": "Tag Browser", "tag_browser_title": "Tag Browser",
"tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.", "tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
"color_hex": "Colour", "color_hex": "Colour",
"date": "Date", "date": "Date",
"page_or_document": "Page / Document", "page_or_document": "Page / Document",
"add_a_tag": "Add a tag", "add_a_tag": "Add a tag",
"edit_tag_name": "Edit tag name", "edit_tag_name": "Edit tag name",
"new_tag_name": "New tag name:", "new_tag_name": "New tag name:",
"change_color": "Change colour", "change_color": "Change colour",
"delete_tag": "Delete tag", "delete_tag": "Delete tag",
"delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.", "delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.",
"tag_already_exists_with_that_name": "A tag already exists with that name", "tag_already_exists_with_that_name": "A tag already exists with that name",
"statistics": "Statistics", "statistics": "Statistics",
"main_window_statistics_accessible_flag": "Stat&istics", "main_window_statistics_accessible_flag": "Stat&istics",
"stats_group_pages": "Pages", "stats_group_pages": "Pages",
"stats_group_tags": "Tags", "stats_group_tags": "Tags",
"stats_group_documents": "Documents", "stats_group_documents": "Documents",
"stats_group_time_logging": "Time logging", "stats_group_time_logging": "Time logging",
"stats_group_reminders": "Reminders", "stats_group_reminders": "Reminders",
"stats_pages_with_content": "Pages with content (current version)", "stats_pages_with_content": "Pages with content (current version)",
"stats_total_revisions": "Total revisions", "stats_total_revisions": "Total revisions",
"stats_page_most_revisions": "Page with most revisions", "stats_page_most_revisions": "Page with most revisions",
"stats_total_words": "Total words (current versions)", "stats_total_words": "Total words (current versions)",
"stats_unique_tags": "Unique tags", "stats_unique_tags": "Unique tags",
"stats_page_most_tags": "Page with most tags", "stats_page_most_tags": "Page with most tags",
"stats_activity_heatmap": "Activity heatmap", "stats_activity_heatmap": "Activity heatmap",
"stats_heatmap_metric": "Colour by", "stats_heatmap_metric": "Colour by",
"stats_metric_words": "Words", "stats_metric_words": "Words",
"stats_metric_revisions": "Revisions", "stats_metric_revisions": "Revisions",
"stats_metric_documents": "Documents", "stats_metric_documents": "Documents",
"stats_total_documents": "Total documents", "stats_total_documents": "Total documents",
"stats_date_most_documents": "Date with most documents", "stats_date_most_documents": "Date with most documents",
"stats_no_data": "No statistics available yet.", "stats_no_data": "No statistics available yet.",
"stats_time_total_hours": "Total hours logged", "stats_time_total_hours": "Total hours logged",
"stats_time_day_most_hours": "Day with most hours logged", "stats_time_day_most_hours": "Day with most hours logged",
"stats_time_project_most_hours": "Project with most hours logged", "stats_time_project_most_hours": "Project with most hours logged",
"stats_time_activity_most_hours": "Activity with most hours logged", "stats_time_activity_most_hours": "Activity with most hours logged",
"stats_total_reminders": "Total reminders", "stats_total_reminders": "Total reminders",
"stats_date_most_reminders": "Day with most reminders", "stats_date_most_reminders": "Day with most reminders",
"stats_metric_hours": "Hours", "stats_metric_hours": "Hours",
"stats_metric_reminders": "Reminders", "stats_metric_reminders": "Reminders",
"select_notebook": "Select notebook", "select_notebook": "Select notebook",
"bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.", "bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
"bug_report_placeholder": "Type your bug report here", "bug_report_placeholder": "Type your bug report here",
"bug_report_empty": "Please enter some details about the bug before sending.", "bug_report_empty": "Please enter some details about the bug before sending.",
"bug_report_send_failed": "Could not send bug report.", "bug_report_send_failed": "Could not send bug report.",
"bug_report_sent_ok": "Bug report sent. Thank you!", "bug_report_sent_ok": "Bug report sent. Thank you!",
"send": "Send", "send": "Send",
"reminder": "Reminder", "reminder": "Reminder",
"set_reminder": "Set Reminder", "set_reminder": "Set Reminder",
"reminder_no_text_fallback": "You scheduled a reminder to alert you now!", "reminder_no_text_fallback": "You scheduled a reminder to alert you now!",
"invalid_time_title": "Invalid time", "invalid_time_title": "Invalid time",
"invalid_time_message": "Please enter a time in the format HH:MM", "invalid_time_message": "Please enter a time in the format HH:MM",
"dismiss": "Dismiss", "dismiss": "Dismiss",
"toolbar_alarm": "Set reminder alarm", "toolbar_alarm": "Set reminder alarm",
"activities": "Activities", "activities": "Activities",
"activity": "Activity", "activity": "Activity",
"note": "Note", "note": "Note",
"activity_delete_error_message": "A problem occurred deleting the activity", "activity_delete_error_message": "A problem occurred deleting the activity",
"activity_delete_error_title": "Problem deleting activity", "activity_delete_error_title": "Problem deleting activity",
"activity_rename_error_message": "A problem occurred renaming the activity", "activity_rename_error_message": "A problem occurred renaming the activity",
"activity_rename_error_title": "Problem renaming activity", "activity_rename_error_title": "Problem renaming activity",
"activity_required_message": "An activity name is required", "activity_required_message": "An activity name is required",
"activity_required_title": "Activity name required", "activity_required_title": "Activity name required",
"add_activity": "Add activity", "add_activity": "Add activity",
"add_project": "Add project", "add_project": "Add project",
"add_time_entry": "Add time entry", "add_time_entry": "Add time entry",
"time_period": "Time period", "time_period": "Time period",
"dont_group": "Don't group", "dont_group": "Don't group",
"by_activity": "by activity", "by_activity": "by activity",
"by_day": "by day", "by_day": "by day",
"by_month": "by month", "by_month": "by month",
"by_week": "by week", "by_week": "by week",
"date_range": "Date range", "date_range": "Date range",
"custom_range": "Custom", "custom_range": "Custom",
"last_week": "Last week", "last_week": "Last week",
"last_month": "Last month", "last_month": "Last month",
"this_week": "This week", "this_week": "This week",
"this_month": "This month", "this_month": "This month",
"this_year": "This year", "this_year": "This year",
"all_projects": "All projects", "all_projects": "All projects",
"delete_activity": "Delete activity", "delete_activity": "Delete activity",
"delete_activity_confirm": "Are you sure you want to delete this activity?", "delete_activity_confirm": "Are you sure you want to delete this activity?",
"delete_activity_title": "Delete activity - are you sure?", "delete_activity_title": "Delete activity - are you sure?",
"delete_project": "Delete project", "delete_project": "Delete project",
"delete_project_confirm": "Are you sure you want to delete this project?", "delete_project_confirm": "Are you sure you want to delete this project?",
"delete_project_title": "Delete project - are you sure?", "delete_project_title": "Delete project - are you sure?",
"delete_time_entry": "Delete time entry", "delete_time_entry": "Delete time entry",
"group_by": "Group by", "group_by": "Group by",
"hours": "Hours", "hours": "Hours",
"created_at": "Created at", "created_at": "Created at",
"invalid_activity_message": "The activity is invalid", "invalid_activity_message": "The activity is invalid",
"invalid_activity_title": "Invalid activity", "invalid_activity_title": "Invalid activity",
"invalid_project_message": "The project is invalid", "invalid_project_message": "The project is invalid",
"invalid_project_title": "Invalid project", "invalid_project_title": "Invalid project",
"manage_activities": "Manage activities", "manage_activities": "Manage activities",
"manage_projects": "Manage projects", "manage_projects": "Manage projects",
"manage_projects_activities": "Manage project activities", "manage_projects_activities": "Manage project activities",
"open_time_log": "Open time log", "open_time_log": "Open time log",
"project": "Project", "project": "Project",
"project_delete_error_message": "A problem occurred deleting the project", "project_delete_error_message": "A problem occurred deleting the project",
"project_delete_error_title": "Problem deleting project", "project_delete_error_title": "Problem deleting project",
"project_rename_error_message": "A problem occurred renaming the project", "project_rename_error_message": "A problem occurred renaming the project",
"project_rename_error_title": "Problem renaming project", "project_rename_error_title": "Problem renaming project",
"project_required_message": "A project is required", "project_required_message": "A project is required",
"project_required_title": "Project required", "project_required_title": "Project required",
"projects": "Projects", "projects": "Projects",
"rename_activity": "Rename activity", "rename_activity": "Rename activity",
"rename_project": "Rename project", "rename_project": "Rename project",
"reporting": "Reporting", "reporting": "Reporting",
"reporting_and_invoicing": "Reporting and Invoicing", "reporting_and_invoicing": "Reporting and Invoicing",
"run_report": "Run report", "run_report": "Run report",
"add_activity_title": "Add activity", "add_activity_title": "Add activity",
"add_activity_label": "Add an activity", "add_activity_label": "Add an activity",
"rename_activity_label": "Rename activity", "rename_activity_label": "Rename activity",
"add_project_title": "Add project", "add_project_title": "Add project",
"add_project_label": "Add a project", "add_project_label": "Add a project",
"rename_activity_title": "Rename this activity", "rename_activity_title": "Rename this activity",
"rename_project_label": "Rename project", "rename_project_label": "Rename project",
"rename_project_title": "Rename this project", "rename_project_title": "Rename this project",
"select_activity_message": "Select an activity", "select_activity_message": "Select an activity",
"select_activity_title": "Select activity", "select_activity_title": "Select activity",
"select_project_message": "Select a project", "select_project_message": "Select a project",
"select_project_title": "Select project", "select_project_title": "Select project",
"time_log": "Time log", "time_log": "Time log",
"time_log_collapsed_hint": "Time log", "time_log_collapsed_hint": "Time log",
"date_label": "Date: {date}", "date_label": "Date: {date}",
"change_date": "Change date", "change_date": "Change date",
"select_date_title": "Select date", "select_date_title": "Select date",
"for": "For {date}", "for": "For {date}",
"time_log_no_date": "Time log", "time_log_no_date": "Time log",
"time_log_no_entries": "No time entries yet", "time_log_no_entries": "No time entries yet",
"time_log_report": "Time log report", "time_log_report": "Time log report",
"time_log_report_title": "Time log for {project}", "time_log_report_title": "Time log for {project}",
"time_log_report_meta": "From {start} to {end}, grouped {granularity}", "time_log_report_meta": "From {start} to {end}, grouped {granularity}",
"time_log_total_hours": "Total for day: {hours:.2f}h", "time_log_total_hours": "Total for day: {hours:.2f}h",
"time_log_with_total": "Time log ({hours:.2f}h)", "time_log_with_total": "Time log ({hours:.2f}h)",
"update_time_entry": "Update time entry", "update_time_entry": "Update time entry",
"time_report_total": "Total: {hours:.2f} hours", "time_report_total": "Total: {hours:.2f} hours",
"no_report_title": "No report", "no_report_title": "No report",
"no_report_message": "Please run a report before exporting.", "no_report_message": "Please run a report before exporting.",
"total": "Total", "total": "Total",
"export_csv": "Export CSV", "export_csv": "Export CSV",
"export_csv_error_title": "Export failed", "export_csv_error_title": "Export failed",
"export_csv_error_message": "Could not write CSV file:\n{error}", "export_csv_error_message": "Could not write CSV file:\n{error}",
"export_pdf": "Export PDF", "export_pdf": "Export PDF",
"export_pdf_error_title": "PDF export failed", "export_pdf_error_title": "PDF export failed",
"export_pdf_error_message": "Could not write PDF file:\n{error}", "export_pdf_error_message": "Could not write PDF file:\n{error}",
"enable_tags_feature": "Enable Tags", "enable_tags_feature": "Enable Tags",
"enable_time_log_feature": "Enable Time Logging", "enable_time_log_feature": "Enable Time Logging",
"enable_reminders_feature": "Enable Reminders", "enable_reminders_feature": "Enable Reminders",
"reminders_webhook_section_title": "Send Reminders to a webhook", "reminders_webhook_section_title": "Send Reminders to a webhook",
"reminders_webhook_url_label": "Webhook URL", "reminders_webhook_url_label": "Webhook URL",
"reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)", "reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)",
"enable_documents_feature": "Enable storing of documents", "enable_documents_feature": "Enable storing of documents",
"pomodoro_time_log_default_text": "Focus session", "pomodoro_time_log_default_text": "Focus session",
"toolbar_pomodoro_timer": "Time-logging timer", "toolbar_pomodoro_timer": "Time-logging timer",
"set_code_language": "Set code language", "set_code_language": "Set code language",
"cut": "Cut", "cut": "Cut",
"copy": "Copy", "copy": "Copy",
"paste": "Paste", "paste": "Paste",
"collapse": "Collapse", "collapse": "Collapse",
"expand": "Expand", "expand": "Expand",
"remove_collapse": "Remove collapse", "remove_collapse": "Remove collapse",
"collapse_selection": "Collapse selection", "collapse_selection": "Collapse selection",
"start": "Start", "start": "Start",
"pause": "Pause", "pause": "Pause",
"resume": "Resume", "resume": "Resume",
"stop_and_log": "Stop and log", "stop_and_log": "Stop and log",
"manage_reminders": "Manage Reminders", "manage_reminders": "Manage Reminders",
"upcoming_reminders": "Upcoming Reminders", "upcoming_reminders": "Upcoming Reminders",
"no_upcoming_reminders": "No upcoming reminders", "no_upcoming_reminders": "No upcoming reminders",
"once": "Once", "once": "Once",
"daily": "daily", "daily": "daily",
"weekdays": "weekdays", "weekdays": "weekdays",
"weekly": "weekly", "weekly": "weekly",
"add_reminder": "Add Reminder", "add_reminder": "Add Reminder",
"edit_reminder": "Edit Reminder", "edit_reminder": "Edit Reminder",
"delete_reminder": "Delete Reminder", "delete_reminder": "Delete Reminder",
"delete_reminders": "Delete Reminders", "delete_reminders": "Delete Reminders",
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.", "deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
"this_is_a_reminder_of_type": "Note: This is a reminder of type", "this_is_a_reminder_of_type": "Note: This is a reminder of type",
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.", "this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
"reminders": "Reminders", "reminders": "Reminders",
"time": "Time", "time": "Time",
"every_day": "Every day", "every_day": "Every day",
"every_weekday": "Every weekday (Mon-Fri)", "every_weekday": "Every weekday (Mon-Fri)",
"every_week": "Every week", "every_week": "Every week",
"every_fortnight": "Every 2 weeks", "every_fortnight": "Every 2 weeks",
"every_month": "Every month (same date)", "every_month": "Every month (same date)",
"every_month_nth_weekday": "Every month (e.g. 3rd Monday)", "every_month_nth_weekday": "Every month (e.g. 3rd Monday)",
"week_in_month": "Week in month", "week_in_month": "Week in month",
"fortnightly": "Fortnightly", "fortnightly": "Fortnightly",
"monthly_same_date": "Monthly (same date)", "monthly_same_date": "Monthly (same date)",
"monthly_nth_weekday": "Monthly (nth weekday)", "monthly_nth_weekday": "Monthly (nth weekday)",
"repeat": "Repeat", "repeat": "Repeat",
"monday": "Monday", "monday": "Monday",
"tuesday": "Tuesday", "tuesday": "Tuesday",
"wednesday": "Wednesday", "wednesday": "Wednesday",
"thursday": "Thursday", "thursday": "Thursday",
"friday": "Friday", "friday": "Friday",
"saturday": "Saturday", "saturday": "Saturday",
"sunday": "Sunday", "sunday": "Sunday",
"monday_short": "Mon", "monday_short": "Mon",
"tuesday_short": "Tue", "tuesday_short": "Tue",
"wednesday_short": "Wed", "wednesday_short": "Wed",
"thursday_short": "Thu", "thursday_short": "Thu",
"friday_short": "Fri", "friday_short": "Fri",
"saturday_short": "Sat", "saturday_short": "Sat",
"sunday_short": "Sun", "sunday_short": "Sun",
"day": "Day", "day": "Day",
"text": "Text", "text": "Text",
"type": "Type", "type": "Type",
"active": "Active", "active": "Active",
"actions": "Actions", "actions": "Actions",
"edit_code_block": "Edit code block", "edit_code_block": "Edit code block",
"delete_code_block": "Delete code block", "delete_code_block": "Delete code block",
"search_result_heading_document": "Document", "search_result_heading_document": "Document",
"toolbar_documents": "Documents Manager", "toolbar_documents": "Documents Manager",
"project_documents_title": "Project documents", "project_documents_title": "Project documents",
"documents_col_file": "File", "documents_col_file": "File",
"documents_col_description": "Description", "documents_col_description": "Description",
"documents_col_added": "Added", "documents_col_added": "Added",
"documents_col_tags": "Tags", "documents_col_tags": "Tags",
"documents_col_size": "Size", "documents_col_size": "Size",
"documents_add": "&Add", "documents_add": "&Add",
"documents_open": "&Open", "documents_open": "&Open",
"documents_delete": "&Delete", "documents_delete": "&Delete",
"documents_no_project_selected": "Please choose a project first.", "documents_no_project_selected": "Please choose a project first.",
"documents_file_filter_all": "All files (*)", "documents_file_filter_all": "All files (*)",
"documents_add_failed": "Could not add document: {error}", "documents_add_failed": "Could not add document: {error}",
"documents_open_failed": "Could not open document: {error}", "documents_open_failed": "Could not open document: {error}",
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)", "documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
"documents_search_label": "Search", "documents_search_label": "Search",
"documents_search_placeholder": "Type to search documents (all projects)", "documents_search_placeholder": "Type to search documents (all projects)",
"documents_invalid_date_format": "Invalid date format", "documents_invalid_date_format": "Invalid date format",
"todays_documents": "Documents from this day", "todays_documents": "Documents from this day",
"todays_documents_none": "No documents yet.", "todays_documents_none": "No documents yet.",
"manage_invoices": "Manage Invoices", "manage_invoices": "Manage Invoices",
"create_invoice": "Create Invoice", "create_invoice": "Create Invoice",
"invoice_amount": "Amount", "invoice_amount": "Amount",
"invoice_apply_tax": "Apply Tax", "invoice_apply_tax": "Apply Tax",
"invoice_client_address": "Client Address", "invoice_client_address": "Client Address",
"invoice_client_company": "Client Company", "invoice_client_company": "Client Company",
"invoice_client_email": "Client E-mail", "invoice_client_email": "Client E-mail",
"invoice_client_name": "Client Contact", "invoice_client_name": "Client Contact",
"invoice_currency": "Currency", "invoice_currency": "Currency",
"invoice_dialog_title": "Create Invoice", "invoice_dialog_title": "Create Invoice",
"invoice_due_date": "Due Date", "invoice_due_date": "Due Date",
"invoice_hourly_rate": "Hourly Rate", "invoice_hourly_rate": "Hourly Rate",
"invoice_hours": "Hours", "invoice_hours": "Hours",
"invoice_issue_date": "Issue Date", "invoice_issue_date": "Issue Date",
"invoice_mode_detailed": "Detailed mode", "invoice_mode_detailed": "Detailed mode",
"invoice_mode_summary": "Summary mode", "invoice_mode_summary": "Summary mode",
"invoice_number": "Invoice Number", "invoice_number": "Invoice Number",
"invoice_save_and_export": "Save and export", "invoice_save_and_export": "Save and export",
"invoice_save_pdf_title": "Save PDF", "invoice_save_pdf_title": "Save PDF",
"invoice_subtotal": "Subtotal", "invoice_subtotal": "Subtotal",
"invoice_summary_default_desc": "Consultant services for the month of", "invoice_summary_default_desc": "Consultant services for the month of",
"invoice_summary_desc": "Summary description", "invoice_summary_desc": "Summary description",
"invoice_summary_hours": "Summary hours", "invoice_summary_hours": "Summary hours",
"invoice_tax": "Tax details", "invoice_tax": "Tax details",
"invoice_tax_label": "Tax type", "invoice_tax_label": "Tax type",
"invoice_tax_rate": "Tax rate", "invoice_tax_rate": "Tax rate",
"invoice_tax_total": "Tax total", "invoice_tax_total": "Tax total",
"invoice_total": "Total", "invoice_total": "Total",
"invoice_paid_at": "Paid on", "invoice_paid_at": "Paid on",
"invoice_payment_note": "Payment notes", "invoice_payment_note": "Payment notes",
"invoice_project_required_title": "Project required", "invoice_project_required_title": "Project required",
"invoice_project_required_message": "Please select a specific project before trying to create an invoice.", "invoice_project_required_message": "Please select a specific project before trying to create an invoice.",
"invoice_need_report_title": "Report required", "invoice_need_report_title": "Report required",
"invoice_need_report_message": "Please run a time report before trying to create an invoice from it.", "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_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.", "invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.",
"enable_invoicing_feature": "Enable Invoicing (requires Time Logging)", "enable_invoicing_feature": "Enable Invoicing (requires Time Logging)",
"invoice_company_profile": "Business Profile", "invoice_company_profile": "Business Profile",
"invoice_company_name": "Business Name", "invoice_company_name": "Business Name",
"invoice_company_address": "Address", "invoice_company_address": "Address",
"invoice_company_phone": "Phone", "invoice_company_phone": "Phone",
"invoice_company_email": "E-mail", "invoice_company_email": "E-mail",
"invoice_company_tax_id": "Tax number", "invoice_company_tax_id": "Tax number",
"invoice_company_payment_details": "Payment details", "invoice_company_payment_details": "Payment details",
"invoice_company_logo": "Logo", "invoice_company_logo": "Logo",
"invoice_company_logo_choose": "Choose logo", "invoice_company_logo_choose": "Choose logo",
"invoice_company_logo_set": "Logo has been set", "invoice_company_logo_set": "Logo has been set",
"invoice_company_logo_not_set": "Logo not set", "invoice_company_logo_not_set": "Logo not set",
"invoice_number_unique": "Invoice number must be unique. This invoice number already exists.", "invoice_number_unique": "Invoice number must be unique. This invoice number already exists.",
"invoice_invalid_amount": "The amount is invalid", "invoice_invalid_amount": "The amount is invalid",
"invoice_invalid_date_format": "Invalid date format", "invoice_invalid_date_format": "Invalid date format",
"invoice_invalid_tax_rate": "The tax rate is invalid", "invoice_invalid_tax_rate": "The tax rate is invalid",
"invoice_no_items": "There are no items in the invoice", "invoice_no_items": "There are no items in the invoice",
"invoice_number_required": "An invoice number is required", "invoice_number_required": "An invoice number is required",
"invoice_required": "Please select a specific invoice before trying to delete an invoice.", "invoice_required": "Please select a specific invoice before trying to delete an invoice.",
"refresh": "Refresh", "refresh": "Refresh",
"status": "Status", "status": "Status",
"client": "Client", "client": "Client",
"documents": "Documents", "documents": "Documents",
"invoices": "Invoices", "invoices": "Invoices",
"documents_select_document": "Please select a document first.", "documents_select_document": "Please select a document first.",
"toolbar_projects": "Projects", "toolbar_projects": "Projects",
"projects_title": "Projects", "projects_title": "Projects",
"projects_none": "No projects have been configured yet. Add a project from the time logging dialog first.", "projects_none": "No projects have been configured yet. Add a project from the time logging dialog first.",
"projects_summary_tab": "Summary", "projects_summary_tab": "Summary",
"project_bucket": "Project bucket", "project_bucket": "Project bucket",
"project_bucket_settings": "Bucket settings", "project_bucket_settings": "Bucket settings",
"project_bucket_replenish": "Replenish", "project_bucket_replenish": "Replenish",
"project_bucket_baseline": "Baseline", "project_bucket_baseline": "Baseline",
"project_bucket_ceiling": "Bucket ceiling", "project_bucket_ceiling": "Bucket ceiling",
"project_bucket_warn_at": "Warn at", "project_bucket_warn_at": "Warn at",
"project_hours_logged": "Logged", "project_hours_logged": "Logged",
"project_bucket_used": "Used", "project_bucket_used": "Used",
"project_bucket_remaining": "Remaining", "project_bucket_remaining": "Remaining",
"project_bucket_add_to_ceiling": "Add to ceiling", "project_bucket_add_to_ceiling": "Add to ceiling",
"project_bucket_no_project": "Select a project to view its bucket.", "project_bucket_no_project": "Select a project to view its bucket.",
"project_bucket_unconfigured": "{project}: {used:.2f}h used so far ({baseline:.2f}h baseline + {logged:.2f}h logged). No bucket ceiling has been set.", "project_bucket_unconfigured": "{project}: {used:.2f}h used so far ({baseline:.2f}h baseline + {logged:.2f}h logged). No bucket ceiling has been set.",
"project_bucket_status": "{project}: {used:.2f}h / {ceiling:.2f}h used{percent}{remaining}. Baseline {baseline:.2f}h, logged in Bouquin {logged:.2f}h. Status: {state}.", "project_bucket_status": "{project}: {used:.2f}h / {ceiling:.2f}h used{percent}{remaining}. Baseline {baseline:.2f}h, logged in Bouquin {logged:.2f}h. Status: {state}.",
"project_bucket_state_unconfigured": "No bucket", "project_bucket_state_unconfigured": "No bucket",
"project_bucket_state_ok": "OK", "project_bucket_state_ok": "OK",
"project_bucket_state_warning": "Approaching bucket ceiling", "project_bucket_state_warning": "Approaching bucket ceiling",
"project_bucket_state_reached": "Bucket ceiling reached", "project_bucket_state_reached": "Bucket ceiling reached",
"project_bucket_state_exceeded": "Bucket ceiling exceeded", "project_bucket_state_exceeded": "Bucket ceiling exceeded",
"project_bucket_alert_title": "Project bucket alert", "project_bucket_alert_title": "Project bucket alert",
"project_bucket_alert_message": "{status}", "project_bucket_alert_message": "{status}",
"project_open_invoice_document": "Open invoice document", "project_open_invoice_document": "Open invoice document",
"project_invoice_no_document": "This invoice does not have a linked document.", "project_invoice_no_document": "This invoice does not have a linked document.",
"project_bucket_invoice_prepaid": "Invoice prepaid hours", "project_bucket_invoice_prepaid": "Invoice prepaid hours",
"project_prepaid_invoice_default_desc": "Prepaid support bucket ({hours:.2f} hours)", "project_prepaid_invoice_default_desc": "Prepaid support bucket ({hours:.2f} hours)",
"project_prepaid_invoice_hours_required": "Enter a prepaid-hours amount greater than zero before creating an invoice.", "project_prepaid_invoice_hours_required": "Enter a prepaid-hours amount greater than zero before creating an invoice.",
"time_logs": "Time logs" "time_logs": "Time logs",
"project_bucket_ledger_tab": "Bucket ledger",
"project_changelog_tab": "Project log",
"project_bucket_baseline_delta": "Baseline Δ",
"project_bucket_ceiling_delta": "Ceiling Δ",
"project_bucket_used_delta": "Used Δ",
"project_bucket_manual_topup_desc": "Manual bucket top-up ({hours:.2f} hours)",
"project_bucket_prepaid_invoice_desc": "Prepaid bucket invoice ({hours:.2f} hours)",
"project_bucket_ledger_type_settings_adjustment": "Bucket settings",
"project_bucket_ledger_type_manual_topup": "Manual top-up",
"project_bucket_ledger_type_prepaid_invoice": "Prepaid invoice",
"project_bucket_ledger_type_time_log": "Time logged",
"project_changelog_type_time_log": "Time log",
"project_changelog_type_document": "Document",
"project_changelog_type_invoice": "Invoice",
"project_changelog_type_bucket": "Bucket",
"summary": "Summary",
"details": "Details"
} }

View file

@ -108,6 +108,18 @@ class ProjectsDialog(QDialog):
LOG_HOURS = 3 LOG_HOURS = 3
LOG_CREATED = 4 LOG_CREATED = 4
LEDGER_DATE = 0
LEDGER_TYPE = 1
LEDGER_BASELINE = 2
LEDGER_CEILING = 3
LEDGER_USED = 4
LEDGER_NOTE = 5
CHANGE_DATE = 0
CHANGE_TYPE = 1
CHANGE_TITLE = 2
CHANGE_DETAILS = 3
DOC_FILE = 0 DOC_FILE = 0
DOC_ADDED = 1 DOC_ADDED = 1
DOC_DESCRIPTION = 2 DOC_DESCRIPTION = 2
@ -264,6 +276,72 @@ class ProjectsDialog(QDialog):
logs_layout.addWidget(self.time_logs_table, 1) logs_layout.addWidget(self.time_logs_table, 1)
self.tabs.addTab(logs_tab, strings._("time_logs")) self.tabs.addTab(logs_tab, strings._("time_logs"))
ledger_tab = QWidget()
ledger_layout = QVBoxLayout(ledger_tab)
self.bucket_ledger_table = QTableWidget()
self.bucket_ledger_table.setColumnCount(6)
self.bucket_ledger_table.setHorizontalHeaderLabels(
[
strings._("date"),
strings._("type"),
strings._("project_bucket_baseline_delta"),
strings._("project_bucket_ceiling_delta"),
strings._("project_bucket_used_delta"),
strings._("note"),
]
)
self.bucket_ledger_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.bucket_ledger_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.bucket_ledger_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
ledger_header = self.bucket_ledger_table.horizontalHeader()
ledger_header.setSectionResizeMode(
self.LEDGER_DATE, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(
self.LEDGER_TYPE, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(
self.LEDGER_BASELINE, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(
self.LEDGER_CEILING, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(
self.LEDGER_USED, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(self.LEDGER_NOTE, QHeaderView.Stretch)
ledger_layout.addWidget(self.bucket_ledger_table, 1)
self.tabs.addTab(ledger_tab, strings._("project_bucket_ledger_tab"))
changelog_tab = QWidget()
changelog_layout = QVBoxLayout(changelog_tab)
self.changelog_table = QTableWidget()
self.changelog_table.setColumnCount(4)
self.changelog_table.setHorizontalHeaderLabels(
[
strings._("date"),
strings._("type"),
strings._("summary"),
strings._("details"),
]
)
self.changelog_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.changelog_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.changelog_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
changelog_header = self.changelog_table.horizontalHeader()
changelog_header.setSectionResizeMode(
self.CHANGE_DATE, QHeaderView.ResizeToContents
)
changelog_header.setSectionResizeMode(
self.CHANGE_TYPE, QHeaderView.ResizeToContents
)
changelog_header.setSectionResizeMode(
self.CHANGE_TITLE, QHeaderView.ResizeToContents
)
changelog_header.setSectionResizeMode(self.CHANGE_DETAILS, QHeaderView.Stretch)
changelog_layout.addWidget(self.changelog_table, 1)
self.tabs.addTab(changelog_tab, strings._("project_changelog_tab"))
docs_tab = QWidget() docs_tab = QWidget()
docs_layout = QVBoxLayout(docs_tab) docs_layout = QVBoxLayout(docs_tab)
self.documents_table = QTableWidget() self.documents_table = QTableWidget()
@ -479,6 +557,8 @@ class ProjectsDialog(QDialog):
self.ceiling_spin.setValue(0.0) self.ceiling_spin.setValue(0.0)
self.warn_spin.setValue(80.0) self.warn_spin.setValue(80.0)
self.time_logs_table.setRowCount(0) self.time_logs_table.setRowCount(0)
self.bucket_ledger_table.setRowCount(0)
self.changelog_table.setRowCount(0)
self.documents_table.setRowCount(0) self.documents_table.setRowCount(0)
self.invoices_table.setRowCount(0) self.invoices_table.setRowCount(0)
return return
@ -498,6 +578,8 @@ class ProjectsDialog(QDialog):
self.warn_spin.setValue(float(bucket["warn_at_percent"] if bucket else 80.0)) self.warn_spin.setValue(float(bucket["warn_at_percent"] if bucket else 80.0))
self._reload_time_logs(project_id) self._reload_time_logs(project_id)
self._reload_bucket_ledger(project_id)
self._reload_changelog(project_id)
self._reload_documents(project_id) self._reload_documents(project_id)
self._reload_invoices(project_id) self._reload_invoices(project_id)
@ -523,6 +605,81 @@ class ProjectsDialog(QDialog):
row_idx, self.LOG_CREATED, QTableWidgetItem(r["created_at"] or "") row_idx, self.LOG_CREATED, QTableWidgetItem(r["created_at"] or "")
) )
def _format_delta_hours(self, minutes: int | None, invert: bool = False) -> str:
minutes = int(minutes or 0)
if minutes == 0:
return ""
if invert:
minutes = -minutes
sign = "+" if minutes > 0 else "-"
return f"{sign}{hours_from_minutes(abs(minutes)):.2f}"
def _reload_bucket_ledger(self, project_id: int) -> None:
rows = self._db.project_bucket_ledger_for_project(project_id)
self.bucket_ledger_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
entry_type = str(r["entry_type"] or "")
type_text = strings._(f"project_bucket_ledger_type_{entry_type}")
if type_text == f"project_bucket_ledger_type_{entry_type}":
type_text = entry_type.replace("_", " ").title()
note = r["description"] or ""
if r["invoice_number"]:
note = (
f"{note} ({r['invoice_number']})" if note else r["invoice_number"]
)
if entry_type == "time_log" and r["page_date"]:
activity = r["activity_name"] or ""
note = f"{r['page_date']}{activity}: {note}".strip()
self.bucket_ledger_table.setItem(
row_idx, self.LEDGER_DATE, QTableWidgetItem(r["occurred_at"] or "")
)
self.bucket_ledger_table.setItem(
row_idx, self.LEDGER_TYPE, QTableWidgetItem(type_text)
)
self.bucket_ledger_table.setItem(
row_idx,
self.LEDGER_BASELINE,
QTableWidgetItem(self._format_delta_hours(r["baseline_delta_minutes"])),
)
self.bucket_ledger_table.setItem(
row_idx,
self.LEDGER_CEILING,
QTableWidgetItem(self._format_delta_hours(r["ceiling_delta_minutes"])),
)
self.bucket_ledger_table.setItem(
row_idx,
self.LEDGER_USED,
QTableWidgetItem(
self._format_delta_hours(r["used_delta_minutes"], invert=True)
),
)
self.bucket_ledger_table.setItem(
row_idx, self.LEDGER_NOTE, QTableWidgetItem(note)
)
def _reload_changelog(self, project_id: int) -> None:
rows = self._db.project_activity_log_for_project(project_id)
self.changelog_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
event_type = str(r["event_type"] or "")
type_text = strings._(f"project_changelog_type_{event_type}")
if type_text == f"project_changelog_type_{event_type}":
type_text = event_type.replace("_", " ").title()
self.changelog_table.setItem(
row_idx, self.CHANGE_DATE, QTableWidgetItem(r["occurred_at"] or "")
)
self.changelog_table.setItem(
row_idx, self.CHANGE_TYPE, QTableWidgetItem(type_text)
)
self.changelog_table.setItem(
row_idx, self.CHANGE_TITLE, QTableWidgetItem(r["title"] or "")
)
self.changelog_table.setItem(
row_idx, self.CHANGE_DETAILS, QTableWidgetItem(r["details"] or "")
)
def _reload_documents(self, project_id: int) -> None: def _reload_documents(self, project_id: int) -> None:
rows = self._db.documents_for_project(project_id) rows = self._db.documents_for_project(project_id)
self.documents_table.setRowCount(len(rows)) self.documents_table.setRowCount(len(rows))
@ -592,7 +749,13 @@ class ProjectsDialog(QDialog):
add_minutes = minutes_from_hours(self.topup_spin.value()) add_minutes = minutes_from_hours(self.topup_spin.value())
if add_minutes <= 0: if add_minutes <= 0:
return return
self._db.add_to_project_bucket_ceiling(project_id, add_minutes) self._db.add_to_project_bucket_ceiling(
project_id,
add_minutes,
description=strings._("project_bucket_manual_topup_desc").format(
hours=hours_from_minutes(add_minutes)
),
)
self.reload() self.reload()
def _invoice_prepaid_hours(self) -> None: def _invoice_prepaid_hours(self) -> None:
@ -626,6 +789,16 @@ class ProjectsDialog(QDialog):
dialog._recalc_totals() dialog._recalc_totals()
if dialog.exec() == QDialog.Accepted: if dialog.exec() == QDialog.Accepted:
invoice_id = getattr(dialog, "last_invoice_id", None)
if invoice_id is not None:
self._db.add_to_project_bucket_ceiling(
project_id,
minutes_from_hours(hours),
description=strings._("project_bucket_prepaid_invoice_desc").format(
hours=hours
),
invoice_id=int(invoice_id),
)
self.reload() self.reload()
def _selected_doc_id(self) -> tuple[int, str] | None: def _selected_doc_id(self) -> tuple[int, str] | None:

View file

@ -578,3 +578,191 @@ def test_time_report_dialog_shows_bucket_for_selected_project_and_clears_for_all
dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(None)) dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(None))
dialog._run_report() dialog._run_report()
assert dialog.bucket_label.text() == "" assert dialog.bucket_label.text() == ""
def test_project_bucket_ledger_records_settings_topups_and_time_use(fresh_db):
project_id = _add_project(fresh_db, "Ledger Project")
fresh_db.upsert_project_bucket(project_id, 30, 120, 80.0)
fresh_db.add_to_project_bucket_ceiling(
project_id,
60,
description="Extra prepaid hours",
)
_add_minutes(fresh_db, project_id, 45, "Ledger work")
rows = fresh_db.project_bucket_ledger_for_project(project_id)
types = [r["entry_type"] for r in rows]
assert "settings_adjustment" in types
assert "manual_topup" in types
assert "time_log" in types
settings = next(r for r in rows if r["entry_type"] == "settings_adjustment")
assert settings["baseline_delta_minutes"] == 30
assert settings["ceiling_delta_minutes"] == 120
topup = next(r for r in rows if r["entry_type"] == "manual_topup")
assert topup["ceiling_delta_minutes"] == 60
assert topup["description"] == "Extra prepaid hours"
time_log = next(r for r in rows if r["entry_type"] == "time_log")
assert time_log["used_delta_minutes"] == 45
assert time_log["description"] == "Ledger work"
status = fresh_db.project_bucket_status(project_id)
assert status["baseline_minutes"] == 30
assert status["logged_minutes"] == 45
assert status["bucket_ceiling_minutes"] == 180
assert status["remaining_minutes"] == 105
def test_project_bucket_ledger_records_negative_corrections(fresh_db):
project_id = _add_project(fresh_db, "Correction Project")
fresh_db.upsert_project_bucket(project_id, 120, 240, 80.0)
fresh_db.upsert_project_bucket(project_id, 60, 180, 80.0)
rows = [
r
for r in fresh_db.project_bucket_ledger_for_project(project_id)
if r["entry_type"] == "settings_adjustment"
]
assert len(rows) == 2
assert any(r["baseline_delta_minutes"] == -60 for r in rows)
assert any(r["ceiling_delta_minutes"] == -60 for r in rows)
status = fresh_db.project_bucket_status(project_id)
assert status["baseline_minutes"] == 60
assert status["bucket_ceiling_minutes"] == 180
def test_project_activity_log_includes_time_documents_invoices_and_bucket_events(
fresh_db, tmp_path
):
project_id = _add_project(fresh_db, "Activity Log Project")
_add_minutes(fresh_db, project_id, 30, "Log work")
fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0)
doc_path = tmp_path / "activity-doc.pdf"
doc_path.write_bytes(b"activity")
fresh_db.add_document_from_path(
project_id,
str(doc_path),
description="Activity document",
uploaded_at="2026-04-01",
)
fresh_db.create_invoice(
project_id=project_id,
invoice_number="ACT-001",
issue_date="2026-04-02",
due_date="2026-04-16",
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Activity prepaid", 2.0, 10000)],
time_log_ids=[],
)
rows = fresh_db.project_activity_log_for_project(project_id)
event_types = [r["event_type"] for r in rows]
assert "time_log" in event_types
assert "document" in event_types
assert "invoice" in event_types
assert "bucket" in event_types
assert any("Log work" in r["details"] for r in rows)
assert any("activity-doc.pdf" in r["details"] for r in rows)
assert any("ACT-001" in r["details"] for r in rows)
assert any("Bucket settings updated" in r["details"] for r in rows)
def test_projects_dialog_shows_bucket_ledger_and_project_log(qtbot, fresh_db, tmp_path):
project_id = _add_project(fresh_db, "Ledger UI Project")
_add_minutes(fresh_db, project_id, 60, "UI ledger work")
fresh_db.upsert_project_bucket(project_id, 30, 120, 75.0)
fresh_db.add_to_project_bucket_ceiling(
project_id,
60,
description="UI top-up",
)
doc_path = tmp_path / "ledger-ui.pdf"
doc_path.write_bytes(b"ledger")
fresh_db.add_document_from_path(project_id, str(doc_path), uploaded_at="2026-05-01")
fresh_db.create_invoice(
project_id=project_id,
invoice_number="LEDGER-UI-001",
issue_date="2026-05-02",
due_date=None,
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Ledger UI prepaid", 1.0, 10000)],
time_log_ids=[],
)
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.bucket_ledger_table.rowCount() == 3
ledger_types = {
dialog.bucket_ledger_table.item(row, dialog.LEDGER_TYPE).text()
for row in range(dialog.bucket_ledger_table.rowCount())
}
assert "Bucket settings" in ledger_types
assert "Manual top-up" in ledger_types
assert "Time logged" in ledger_types
assert any(
dialog.bucket_ledger_table.item(row, dialog.LEDGER_NOTE).text()
for row in range(dialog.bucket_ledger_table.rowCount())
)
changelog_types = {
dialog.changelog_table.item(row, dialog.CHANGE_TYPE).text()
for row in range(dialog.changelog_table.rowCount())
}
assert {"Time log", "Document", "Invoice", "Bucket"}.issubset(changelog_types)
assert any(
"LEDGER-UI-001"
in dialog.changelog_table.item(row, dialog.CHANGE_DETAILS).text()
for row in range(dialog.changelog_table.rowCount())
)
def test_prepaid_invoice_dialog_adds_bucket_ledger_entry_when_invoice_is_created(
qtbot, fresh_db
):
project_id = _add_project(fresh_db, "Prepaid Ledger Project")
invoice_id = fresh_db.create_invoice(
project_id=project_id,
invoice_number="PREPAID-LEDGER-001",
issue_date="2026-06-01",
due_date=None,
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Prepaid", 40.0, 15000)],
time_log_ids=[],
)
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.topup_spin.setValue(40.0)
class _InvoiceDialogWithId(_FakeInvoiceDialog):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.last_invoice_id = invoice_id
with patch("bouquin.projects.InvoiceDialog", _InvoiceDialogWithId):
dialog._invoice_prepaid_hours()
row = next(
r
for r in fresh_db.project_bucket_ledger_for_project(project_id)
if r["entry_type"] == "prepaid_invoice"
)
assert row["ceiling_delta_minutes"] == 2400
assert row["invoice_id"] == invoice_id
assert "40.00 hours" in row["description"]