Compare commits
4 commits
34871b72e2
...
07c5f68352
| Author | SHA1 | Date | |
|---|---|---|---|
| 07c5f68352 | |||
| 4eb761088d | |||
| 54af723b53 | |||
| 58333bf93c |
14 changed files with 3004 additions and 590 deletions
|
|
@ -1,3 +1,10 @@
|
||||||
|
# 0.9.0
|
||||||
|
|
||||||
|
* Add 'Projects' interface for unified time/invoice/docs view.
|
||||||
|
* Add ability to set a 'bucket' of (prepaid) hours for a project and warn when time logged approaches it.
|
||||||
|
* Add ability to invoice for the increase in prepaid project bucket hours (without having had to 'log' them).
|
||||||
|
* Update dependencies
|
||||||
|
|
||||||
# 0.8.4
|
# 0.8.4
|
||||||
|
|
||||||
* Update dependencies
|
* Update dependencies
|
||||||
|
|
|
||||||
577
bouquin/db.py
577
bouquin/db.py
|
|
@ -308,6 +308,40 @@ class DBManager:
|
||||||
client_email TEXT
|
client_email TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_project_billing_client_company
|
||||||
|
ON project_billing(client_company);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_buckets (
|
||||||
|
project_id INTEGER PRIMARY KEY
|
||||||
|
REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
baseline_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bucket_ceiling_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
warn_at_percent REAL NOT NULL DEFAULT 80.0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
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),
|
||||||
name TEXT,
|
name TEXT,
|
||||||
|
|
@ -336,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)
|
||||||
|
|
@ -366,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
|
||||||
|
|
@ -1219,6 +1268,496 @@ class DBManager:
|
||||||
(project_id,),
|
(project_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# -------- Time logging: project buckets ---------------------------
|
||||||
|
|
||||||
|
def _normalise_project_id(self, project_id: int) -> int:
|
||||||
|
project_id = int(project_id)
|
||||||
|
if project_id <= 0:
|
||||||
|
raise ValueError("invalid 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(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
baseline_minutes: int,
|
||||||
|
bucket_ceiling_minutes: int,
|
||||||
|
warn_at_percent: float = 80.0,
|
||||||
|
) -> None:
|
||||||
|
"""Save project bucket settings as an auditable ledger adjustment.
|
||||||
|
|
||||||
|
``baseline_minutes`` represents already-spent hours that pre-date
|
||||||
|
Bouquin time logging. ``bucket_ceiling_minutes`` is the cumulative
|
||||||
|
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)
|
||||||
|
baseline_minutes = max(0, int(baseline_minutes or 0))
|
||||||
|
bucket_ceiling_minutes = max(0, int(bucket_ceiling_minutes or 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:
|
||||||
|
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
|
||||||
|
warn_at_percent = excluded.warn_at_percent,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
project_id,
|
||||||
|
current_baseline,
|
||||||
|
current_ceiling,
|
||||||
|
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,
|
||||||
|
description: str | None = None,
|
||||||
|
invoice_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Increase a project's cumulative bucket ceiling by ``add_minutes``."""
|
||||||
|
project_id = self._normalise_project_id(project_id)
|
||||||
|
add_minutes = max(0, int(add_minutes or 0))
|
||||||
|
if add_minutes <= 0:
|
||||||
|
return
|
||||||
|
entry_type = "prepaid_invoice" if invoice_id is not None else "manual_topup"
|
||||||
|
self.add_project_bucket_ledger_entry(
|
||||||
|
project_id,
|
||||||
|
entry_type,
|
||||||
|
ceiling_delta_minutes=add_minutes,
|
||||||
|
description=description or "Bucket ceiling increased",
|
||||||
|
invoice_id=invoice_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_project_bucket(self, project_id: int):
|
||||||
|
"""Return the bucket row for a project, or None if none is configured."""
|
||||||
|
try:
|
||||||
|
project_id = self._normalise_project_id(project_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
|
||||||
|
def logged_minutes_for_project(self, project_id: int) -> int:
|
||||||
|
try:
|
||||||
|
project_id = self._normalise_project_id(project_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
row = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(SUM(minutes), 0) AS minutes
|
||||||
|
FROM time_log
|
||||||
|
WHERE project_id = ?;
|
||||||
|
""",
|
||||||
|
(project_id,),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["minutes"] or 0)
|
||||||
|
|
||||||
|
def project_bucket_status(self, project_id: int) -> dict[str, object] | None:
|
||||||
|
"""Return computed bucket state for a project.
|
||||||
|
|
||||||
|
States:
|
||||||
|
- ``unconfigured``: no bucket ceiling has been set
|
||||||
|
- ``ok``: below warning threshold
|
||||||
|
- ``warning``: at/above warning threshold but below ceiling
|
||||||
|
- ``reached``: exactly at ceiling
|
||||||
|
- ``exceeded``: beyond ceiling
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
project_id = self._normalise_project_id(project_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
project_name = self.list_projects_by_id(project_id)
|
||||||
|
if not project_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bucket = self.get_project_bucket(project_id)
|
||||||
|
logged = self.logged_minutes_for_project(project_id)
|
||||||
|
baseline = int(bucket["baseline_minutes"] or 0) if bucket else 0
|
||||||
|
ceiling = int(bucket["bucket_ceiling_minutes"] or 0) if bucket else 0
|
||||||
|
warn_at = float(bucket["warn_at_percent"] or 80.0) if bucket else 80.0
|
||||||
|
used = baseline + logged
|
||||||
|
if ceiling <= 0:
|
||||||
|
state = "unconfigured"
|
||||||
|
remaining = None
|
||||||
|
pct = None
|
||||||
|
else:
|
||||||
|
remaining = ceiling - used
|
||||||
|
pct = used / ceiling * 100.0
|
||||||
|
if used > ceiling:
|
||||||
|
state = "exceeded"
|
||||||
|
elif used == ceiling:
|
||||||
|
state = "reached"
|
||||||
|
elif pct >= warn_at:
|
||||||
|
state = "warning"
|
||||||
|
else:
|
||||||
|
state = "ok"
|
||||||
|
return {
|
||||||
|
"project_id": project_id,
|
||||||
|
"project_name": project_name,
|
||||||
|
"baseline_minutes": baseline,
|
||||||
|
"logged_minutes": logged,
|
||||||
|
"used_minutes": used,
|
||||||
|
"bucket_ceiling_minutes": ceiling,
|
||||||
|
"remaining_minutes": remaining,
|
||||||
|
"percent_used": pct,
|
||||||
|
"warn_at_percent": warn_at,
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_project_summaries(self):
|
||||||
|
"""Return one row per project with aggregate time/docs/invoices."""
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
p.id AS project_id,
|
||||||
|
p.name AS project_name,
|
||||||
|
COALESCE(pb.baseline_minutes, 0) AS baseline_minutes,
|
||||||
|
COALESCE(pb.bucket_ceiling_minutes, 0) AS bucket_ceiling_minutes,
|
||||||
|
COALESCE(pb.warn_at_percent, 80.0) AS warn_at_percent,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(t.minutes)
|
||||||
|
FROM time_log AS t
|
||||||
|
WHERE t.project_id = p.id
|
||||||
|
), 0) AS logged_minutes,
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM time_log AS t2
|
||||||
|
WHERE t2.project_id = p.id
|
||||||
|
), 0) AS time_log_count,
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM project_documents AS d
|
||||||
|
WHERE d.project_id = p.id
|
||||||
|
), 0) AS document_count,
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM invoices AS i
|
||||||
|
WHERE i.project_id = p.id
|
||||||
|
), 0) AS invoice_count
|
||||||
|
FROM projects AS p
|
||||||
|
LEFT JOIN project_buckets AS pb
|
||||||
|
ON pb.project_id = p.id
|
||||||
|
ORDER BY LOWER(p.name);
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def time_logs_for_project(self, project_id: int):
|
||||||
|
"""Return all time-log entries for a project, newest first."""
|
||||||
|
try:
|
||||||
|
project_id = self._normalise_project_id(project_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return []
|
||||||
|
return self.conn.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
|
||||||
|
FROM time_log AS t
|
||||||
|
JOIN projects AS p ON p.id = t.project_id
|
||||||
|
JOIN activities AS a ON a.id = t.activity_id
|
||||||
|
WHERE t.project_id = ?
|
||||||
|
ORDER BY t.page_date DESC, t.created_at DESC, t.id DESC;
|
||||||
|
""",
|
||||||
|
(project_id,),
|
||||||
|
).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(
|
||||||
|
|
@ -2101,7 +2640,7 @@ class DBManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
def list_client_companies(self) -> list[str]:
|
def list_client_companies(self) -> list[str]:
|
||||||
"""Return distinct client display names from project_billing."""
|
"""Return distinct client display names from project billing settings."""
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
rows = cur.execute(
|
rows = cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -2203,6 +2742,42 @@ class DBManager:
|
||||||
|
|
||||||
# ------------------------- Invoices -------------------------------#
|
# ------------------------- Invoices -------------------------------#
|
||||||
|
|
||||||
|
def invoices_for_project_with_documents(self, project_id: int):
|
||||||
|
"""Return all invoices for a project with linked invoice document metadata."""
|
||||||
|
try:
|
||||||
|
project_id = self._normalise_project_id(project_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return []
|
||||||
|
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,
|
||||||
|
i.document_id,
|
||||||
|
i.created_at,
|
||||||
|
d.file_name AS document_file_name
|
||||||
|
FROM invoices AS i
|
||||||
|
LEFT JOIN projects AS p ON p.id = i.project_id
|
||||||
|
LEFT JOIN project_documents AS d ON d.id = i.document_id
|
||||||
|
WHERE i.project_id = ?
|
||||||
|
ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE;
|
||||||
|
""",
|
||||||
|
(project_id,),
|
||||||
|
).fetchall()
|
||||||
|
return rows
|
||||||
|
|
||||||
def create_invoice(
|
def create_invoice(
|
||||||
self,
|
self,
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -1,441 +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 prompt",
|
"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 time spent",
|
"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)",
|
||||||
"time_log_total_hours": "Total for day: {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",
|
||||||
"set_reminder": "Set Reminder",
|
"delete_reminder": "Delete Reminder",
|
||||||
"edit_reminder": "Edit Reminder",
|
"delete_reminders": "Delete Reminders",
|
||||||
"delete_reminder": "Delete Reminder",
|
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
|
||||||
"delete_reminders": "Delete Reminders",
|
"this_is_a_reminder_of_type": "Note: This is a reminder of type",
|
||||||
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
|
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
|
||||||
"this_is_a_reminder_of_type": "Note: This is a reminder of type",
|
"reminders": "Reminders",
|
||||||
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
|
"time": "Time",
|
||||||
"reminder": "Reminder",
|
"every_day": "Every day",
|
||||||
"reminders": "Reminders",
|
"every_weekday": "Every weekday (Mon-Fri)",
|
||||||
"time": "Time",
|
"every_week": "Every week",
|
||||||
"once": "Once",
|
"every_fortnight": "Every 2 weeks",
|
||||||
"every_day": "Every day",
|
"every_month": "Every month (same date)",
|
||||||
"every_weekday": "Every weekday (Mon-Fri)",
|
"every_month_nth_weekday": "Every month (e.g. 3rd Monday)",
|
||||||
"every_week": "Every week",
|
"week_in_month": "Week in month",
|
||||||
"every_fortnight": "Every 2 weeks",
|
"fortnightly": "Fortnightly",
|
||||||
"every_month": "Every month (same date)",
|
"monthly_same_date": "Monthly (same date)",
|
||||||
"every_month_nth_weekday": "Every month (e.g. 3rd Monday)",
|
"monthly_nth_weekday": "Monthly (nth weekday)",
|
||||||
"week_in_month": "Week in month",
|
"repeat": "Repeat",
|
||||||
"fortnightly": "Fortnightly",
|
"monday": "Monday",
|
||||||
"monthly_same_date": "Monthly (same date)",
|
"tuesday": "Tuesday",
|
||||||
"monthly_nth_weekday": "Monthly (nth weekday)",
|
"wednesday": "Wednesday",
|
||||||
"repeat": "Repeat",
|
"thursday": "Thursday",
|
||||||
"monday": "Monday",
|
"friday": "Friday",
|
||||||
"tuesday": "Tuesday",
|
"saturday": "Saturday",
|
||||||
"wednesday": "Wednesday",
|
"sunday": "Sunday",
|
||||||
"thursday": "Thursday",
|
"monday_short": "Mon",
|
||||||
"friday": "Friday",
|
"tuesday_short": "Tue",
|
||||||
"saturday": "Saturday",
|
"wednesday_short": "Wed",
|
||||||
"sunday": "Sunday",
|
"thursday_short": "Thu",
|
||||||
"monday_short": "Mon",
|
"friday_short": "Fri",
|
||||||
"tuesday_short": "Tue",
|
"saturday_short": "Sat",
|
||||||
"wednesday_short": "Wed",
|
"sunday_short": "Sun",
|
||||||
"thursday_short": "Thu",
|
"day": "Day",
|
||||||
"friday_short": "Fri",
|
"text": "Text",
|
||||||
"saturday_short": "Sat",
|
"type": "Type",
|
||||||
"sunday_short": "Sun",
|
"active": "Active",
|
||||||
"day": "Day",
|
"actions": "Actions",
|
||||||
"text": "Text",
|
"edit_code_block": "Edit code block",
|
||||||
"type": "Type",
|
"delete_code_block": "Delete code block",
|
||||||
"active": "Active",
|
"search_result_heading_document": "Document",
|
||||||
"actions": "Actions",
|
"toolbar_documents": "Documents Manager",
|
||||||
"edit_code_block": "Edit code block",
|
"project_documents_title": "Project documents",
|
||||||
"delete_code_block": "Delete code block",
|
"documents_col_file": "File",
|
||||||
"search_result_heading_document": "Document",
|
"documents_col_description": "Description",
|
||||||
"toolbar_documents": "Documents Manager",
|
"documents_col_added": "Added",
|
||||||
"project_documents_title": "Project documents",
|
"documents_col_tags": "Tags",
|
||||||
"documents_col_file": "File",
|
"documents_col_size": "Size",
|
||||||
"documents_col_description": "Description",
|
"documents_add": "&Add",
|
||||||
"documents_col_added": "Added",
|
"documents_open": "&Open",
|
||||||
"documents_col_tags": "Tags",
|
"documents_delete": "&Delete",
|
||||||
"documents_col_size": "Size",
|
"documents_no_project_selected": "Please choose a project first.",
|
||||||
"documents_add": "&Add",
|
"documents_file_filter_all": "All files (*)",
|
||||||
"documents_open": "&Open",
|
"documents_add_failed": "Could not add document: {error}",
|
||||||
"documents_delete": "&Delete",
|
"documents_open_failed": "Could not open document: {error}",
|
||||||
"documents_no_project_selected": "Please choose a project first.",
|
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
|
||||||
"documents_file_filter_all": "All files (*)",
|
"documents_search_label": "Search",
|
||||||
"documents_add_failed": "Could not add document: {error}",
|
"documents_search_placeholder": "Type to search documents (all projects)",
|
||||||
"documents_open_failed": "Could not open document: {error}",
|
"documents_invalid_date_format": "Invalid date format",
|
||||||
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
|
"todays_documents": "Documents from this day",
|
||||||
"documents_search_label": "Search",
|
"todays_documents_none": "No documents yet.",
|
||||||
"documents_search_placeholder": "Type to search documents (all projects)",
|
"manage_invoices": "Manage Invoices",
|
||||||
"documents_invalid_date_format": "Invalid date format",
|
"create_invoice": "Create Invoice",
|
||||||
"todays_documents": "Documents from this day",
|
"invoice_amount": "Amount",
|
||||||
"todays_documents_none": "No documents yet.",
|
"invoice_apply_tax": "Apply Tax",
|
||||||
"manage_invoices": "Manage Invoices",
|
"invoice_client_address": "Client Address",
|
||||||
"create_invoice": "Create Invoice",
|
"invoice_client_company": "Client Company",
|
||||||
"invoice_amount": "Amount",
|
"invoice_client_email": "Client E-mail",
|
||||||
"invoice_apply_tax": "Apply Tax",
|
"invoice_client_name": "Client Contact",
|
||||||
"invoice_client_address": "Client Address",
|
"invoice_currency": "Currency",
|
||||||
"invoice_client_company": "Client Company",
|
"invoice_dialog_title": "Create Invoice",
|
||||||
"invoice_client_email": "Client E-mail",
|
"invoice_due_date": "Due Date",
|
||||||
"invoice_client_name": "Client Contact",
|
"invoice_hourly_rate": "Hourly Rate",
|
||||||
"invoice_currency": "Currency",
|
"invoice_hours": "Hours",
|
||||||
"invoice_dialog_title": "Create Invoice",
|
"invoice_issue_date": "Issue Date",
|
||||||
"invoice_due_date": "Due Date",
|
"invoice_mode_detailed": "Detailed mode",
|
||||||
"invoice_hourly_rate": "Hourly Rate",
|
"invoice_mode_summary": "Summary mode",
|
||||||
"invoice_hours": "Hours",
|
"invoice_number": "Invoice Number",
|
||||||
"invoice_issue_date": "Issue Date",
|
"invoice_save_and_export": "Save and export",
|
||||||
"invoice_mode_detailed": "Detailed mode",
|
"invoice_save_pdf_title": "Save PDF",
|
||||||
"invoice_mode_summary": "Summary mode",
|
"invoice_subtotal": "Subtotal",
|
||||||
"invoice_number": "Invoice Number",
|
"invoice_summary_default_desc": "Consultant services for the month of",
|
||||||
"invoice_save_and_export": "Save and export",
|
"invoice_summary_desc": "Summary description",
|
||||||
"invoice_save_pdf_title": "Save PDF",
|
"invoice_summary_hours": "Summary hours",
|
||||||
"invoice_subtotal": "Subtotal",
|
"invoice_tax": "Tax details",
|
||||||
"invoice_summary_default_desc": "Consultant services for the month of",
|
"invoice_tax_label": "Tax type",
|
||||||
"invoice_summary_desc": "Summary description",
|
"invoice_tax_rate": "Tax rate",
|
||||||
"invoice_summary_hours": "Summary hours",
|
"invoice_tax_total": "Tax total",
|
||||||
"invoice_tax": "Tax details",
|
"invoice_total": "Total",
|
||||||
"invoice_tax_label": "Tax type",
|
"invoice_paid_at": "Paid on",
|
||||||
"invoice_tax_rate": "Tax rate",
|
"invoice_payment_note": "Payment notes",
|
||||||
"invoice_tax_total": "Tax total",
|
"invoice_project_required_title": "Project required",
|
||||||
"invoice_total": "Total",
|
"invoice_project_required_message": "Please select a specific project before trying to create an invoice.",
|
||||||
"invoice_paid_at": "Paid on",
|
"invoice_need_report_title": "Report required",
|
||||||
"invoice_payment_note": "Payment notes",
|
"invoice_need_report_message": "Please run a time report before trying to create an invoice from it.",
|
||||||
"invoice_project_required_title": "Project required",
|
"invoice_due_before_issue": "Due date cannot be earlier than the issue date.",
|
||||||
"invoice_project_required_message": "Please select a specific project before trying to create an invoice.",
|
"invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.",
|
||||||
"invoice_need_report_title": "Report required",
|
"enable_invoicing_feature": "Enable Invoicing (requires Time Logging)",
|
||||||
"invoice_need_report_message": "Please run a time report before trying to create an invoice from it.",
|
"invoice_company_profile": "Business Profile",
|
||||||
"invoice_due_before_issue": "Due date cannot be earlier than the issue date.",
|
"invoice_company_name": "Business Name",
|
||||||
"invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.",
|
"invoice_company_address": "Address",
|
||||||
"enable_invoicing_feature": "Enable Invoicing (requires Time Logging)",
|
"invoice_company_phone": "Phone",
|
||||||
"invoice_company_profile": "Business Profile",
|
"invoice_company_email": "E-mail",
|
||||||
"invoice_company_name": "Business Name",
|
"invoice_company_tax_id": "Tax number",
|
||||||
"invoice_company_address": "Address",
|
"invoice_company_payment_details": "Payment details",
|
||||||
"invoice_company_phone": "Phone",
|
"invoice_company_logo": "Logo",
|
||||||
"invoice_company_email": "E-mail",
|
"invoice_company_logo_choose": "Choose logo",
|
||||||
"invoice_company_tax_id": "Tax number",
|
"invoice_company_logo_set": "Logo has been set",
|
||||||
"invoice_company_payment_details": "Payment details",
|
"invoice_company_logo_not_set": "Logo not set",
|
||||||
"invoice_company_logo": "Logo",
|
"invoice_number_unique": "Invoice number must be unique. This invoice number already exists.",
|
||||||
"invoice_company_logo_choose": "Choose logo",
|
"invoice_invalid_amount": "The amount is invalid",
|
||||||
"invoice_company_logo_set": "Logo has been set",
|
"invoice_invalid_date_format": "Invalid date format",
|
||||||
"invoice_company_logo_not_set": "Logo not set",
|
"invoice_invalid_tax_rate": "The tax rate is invalid",
|
||||||
"invoice_number_unique": "Invoice number must be unique. This invoice number already exists.",
|
"invoice_no_items": "There are no items in the invoice",
|
||||||
"invoice_invalid_amount": "The amount is invalid",
|
"invoice_number_required": "An invoice number is required",
|
||||||
"invoice_invalid_date_format": "Invalid date format",
|
"invoice_required": "Please select a specific invoice before trying to delete an invoice.",
|
||||||
"invoice_invalid_tax_rate": "The tax rate is invalid",
|
"refresh": "Refresh",
|
||||||
"invoice_no_items": "There are no items in the invoice",
|
"status": "Status",
|
||||||
"invoice_number_required": "An invoice number is required",
|
"client": "Client",
|
||||||
"invoice_required": "Please select a specific invoice before trying to delete an invoice."
|
"documents": "Documents",
|
||||||
|
"invoices": "Invoices",
|
||||||
|
"documents_select_document": "Please select a document first.",
|
||||||
|
"toolbar_projects": "Projects",
|
||||||
|
"projects_title": "Projects",
|
||||||
|
"projects_none": "No projects have been configured yet. Add a project from the time logging dialog first.",
|
||||||
|
"projects_summary_tab": "Summary",
|
||||||
|
"project_bucket": "Project bucket",
|
||||||
|
"project_bucket_settings": "Bucket settings",
|
||||||
|
"project_bucket_replenish": "Replenish",
|
||||||
|
"project_bucket_baseline": "Baseline",
|
||||||
|
"project_bucket_ceiling": "Bucket ceiling",
|
||||||
|
"project_bucket_warn_at": "Warn at",
|
||||||
|
"project_hours_logged": "Logged",
|
||||||
|
"project_bucket_used": "Used",
|
||||||
|
"project_bucket_remaining": "Remaining",
|
||||||
|
"project_bucket_add_to_ceiling": "Add to ceiling",
|
||||||
|
"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_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_ok": "OK",
|
||||||
|
"project_bucket_state_warning": "Approaching bucket ceiling",
|
||||||
|
"project_bucket_state_reached": "Bucket ceiling reached",
|
||||||
|
"project_bucket_state_exceeded": "Bucket ceiling exceeded",
|
||||||
|
"project_bucket_alert_title": "Project bucket alert",
|
||||||
|
"project_bucket_alert_message": "{status}",
|
||||||
|
"project_open_invoice_document": "Open invoice document",
|
||||||
|
"project_invoice_no_document": "This invoice does not have a linked document.",
|
||||||
|
"project_bucket_invoice_prepaid": "Invoice prepaid 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.",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ from PySide6.QtWidgets import (
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
from .bug_report_dialog import BugReportDialog
|
from .bug_report_dialog import BugReportDialog
|
||||||
|
from .projects import ProjectsDialog
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
from .documents import DocumentsDialog, TodaysDocumentsWidget
|
from .documents import DocumentsDialog, TodaysDocumentsWidget
|
||||||
from .find_bar import FindBar
|
from .find_bar import FindBar
|
||||||
|
|
@ -239,6 +240,12 @@ class MainWindow(QMainWindow):
|
||||||
act_stats.setShortcut("Ctrl+Shift+S")
|
act_stats.setShortcut("Ctrl+Shift+S")
|
||||||
act_stats.triggered.connect(self._open_statistics)
|
act_stats.triggered.connect(self._open_statistics)
|
||||||
file_menu.addAction(act_stats)
|
file_menu.addAction(act_stats)
|
||||||
|
self.actProjects = QAction(strings._("projects"), self)
|
||||||
|
self.actProjects.setShortcut("Ctrl+Shift+C")
|
||||||
|
self.actProjects.setShortcutContext(Qt.ApplicationShortcut)
|
||||||
|
self.actProjects.triggered.connect(self._open_projects)
|
||||||
|
file_menu.addAction(self.actProjects)
|
||||||
|
self.addAction(self.actProjects)
|
||||||
act_lock = QAction(strings._("main_window_lock_screen_accessibility"), self)
|
act_lock = QAction(strings._("main_window_lock_screen_accessibility"), self)
|
||||||
act_lock.setShortcut("Ctrl+Shift+L")
|
act_lock.setShortcut("Ctrl+Shift+L")
|
||||||
act_lock.triggered.connect(self._enter_lock)
|
act_lock.triggered.connect(self._enter_lock)
|
||||||
|
|
@ -338,6 +345,9 @@ class MainWindow(QMainWindow):
|
||||||
if not self.cfg.time_log:
|
if not self.cfg.time_log:
|
||||||
self.time_log.hide()
|
self.time_log.hide()
|
||||||
self.toolBar.actTimer.setVisible(False)
|
self.toolBar.actTimer.setVisible(False)
|
||||||
|
self.toolBar.actProjects.setVisible(False)
|
||||||
|
self.actProjects.setVisible(False)
|
||||||
|
self.actProjects.setEnabled(False)
|
||||||
if not self.cfg.reminders:
|
if not self.cfg.reminders:
|
||||||
self.upcoming_reminders.hide()
|
self.upcoming_reminders.hide()
|
||||||
self.toolBar.actAlarm.setVisible(False)
|
self.toolBar.actAlarm.setVisible(False)
|
||||||
|
|
@ -1461,6 +1471,7 @@ class MainWindow(QMainWindow):
|
||||||
self._tb_alarm = self._on_alarm_requested
|
self._tb_alarm = self._on_alarm_requested
|
||||||
self._tb_timer = self._on_timer_requested
|
self._tb_timer = self._on_timer_requested
|
||||||
self._tb_documents = self._on_documents_requested
|
self._tb_documents = self._on_documents_requested
|
||||||
|
self._tb_projects = self._open_projects
|
||||||
self._tb_font_larger = self._on_font_larger_requested
|
self._tb_font_larger = self._on_font_larger_requested
|
||||||
self._tb_font_smaller = self._on_font_smaller_requested
|
self._tb_font_smaller = self._on_font_smaller_requested
|
||||||
|
|
||||||
|
|
@ -1475,6 +1486,7 @@ class MainWindow(QMainWindow):
|
||||||
tb.alarmRequested.connect(self._tb_alarm)
|
tb.alarmRequested.connect(self._tb_alarm)
|
||||||
tb.timerRequested.connect(self._tb_timer)
|
tb.timerRequested.connect(self._tb_timer)
|
||||||
tb.documentsRequested.connect(self._tb_documents)
|
tb.documentsRequested.connect(self._tb_documents)
|
||||||
|
tb.projectsRequested.connect(self._tb_projects)
|
||||||
tb.insertImageRequested.connect(self._on_insert_image)
|
tb.insertImageRequested.connect(self._on_insert_image)
|
||||||
tb.historyRequested.connect(self._open_history)
|
tb.historyRequested.connect(self._open_history)
|
||||||
tb.fontSizeLargerRequested.connect(self._tb_font_larger)
|
tb.fontSizeLargerRequested.connect(self._tb_font_larger)
|
||||||
|
|
@ -1716,6 +1728,13 @@ class MainWindow(QMainWindow):
|
||||||
timer.start(msecs)
|
timer.start(msecs)
|
||||||
self._reminder_timers.append(timer)
|
self._reminder_timers.append(timer)
|
||||||
|
|
||||||
|
# ----------- Projects handler ------------#
|
||||||
|
def _open_projects(self):
|
||||||
|
if not self.cfg.time_log:
|
||||||
|
return
|
||||||
|
dlg = ProjectsDialog(self.db, self)
|
||||||
|
dlg.exec()
|
||||||
|
|
||||||
# ----------- Documents handler ------------#
|
# ----------- Documents handler ------------#
|
||||||
def _on_documents_requested(self):
|
def _on_documents_requested(self):
|
||||||
documents_dlg = DocumentsDialog(self.db, self)
|
documents_dlg = DocumentsDialog(self.db, self)
|
||||||
|
|
@ -1868,9 +1887,15 @@ class MainWindow(QMainWindow):
|
||||||
if not self.cfg.time_log:
|
if not self.cfg.time_log:
|
||||||
self.time_log.hide()
|
self.time_log.hide()
|
||||||
self.toolBar.actTimer.setVisible(False)
|
self.toolBar.actTimer.setVisible(False)
|
||||||
|
self.toolBar.actProjects.setVisible(False)
|
||||||
|
self.actProjects.setVisible(False)
|
||||||
|
self.actProjects.setEnabled(False)
|
||||||
else:
|
else:
|
||||||
self.time_log.show()
|
self.time_log.show()
|
||||||
self.toolBar.actTimer.setVisible(True)
|
self.toolBar.actTimer.setVisible(True)
|
||||||
|
self.toolBar.actProjects.setVisible(True)
|
||||||
|
self.actProjects.setVisible(True)
|
||||||
|
self.actProjects.setEnabled(True)
|
||||||
if not self.cfg.reminders:
|
if not self.cfg.reminders:
|
||||||
self.upcoming_reminders.hide()
|
self.upcoming_reminders.hide()
|
||||||
self.toolBar.actAlarm.setVisible(False)
|
self.toolBar.actAlarm.setVisible(False)
|
||||||
|
|
|
||||||
845
bouquin/projects.py
Normal file
845
bouquin/projects.py
Normal file
|
|
@ -0,0 +1,845 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QDate, Qt
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QFormLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QTabWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import strings
|
||||||
|
from .db import DBManager
|
||||||
|
from .document_utils import open_document_from_db
|
||||||
|
from .invoices import InvoiceDialog
|
||||||
|
|
||||||
|
|
||||||
|
_WARNING_STYLES = {
|
||||||
|
"unconfigured": "",
|
||||||
|
"ok": "QLabel { padding: 6px; border: 1px solid #5b8f5b; border-radius: 4px; }",
|
||||||
|
"warning": "QLabel { padding: 6px; border: 1px solid #b58900; border-radius: 4px; }",
|
||||||
|
"reached": (
|
||||||
|
"QLabel { padding: 6px; border: 1px solid #b85c00; "
|
||||||
|
"border-radius: 4px; font-weight: bold; }"
|
||||||
|
),
|
||||||
|
"exceeded": (
|
||||||
|
"QLabel { padding: 6px; border: 1px solid #b00020; "
|
||||||
|
"border-radius: 4px; font-weight: bold; }"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hours_from_minutes(minutes: int | float | None) -> float:
|
||||||
|
return float(minutes or 0) / 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def minutes_from_hours(hours: float | int | None) -> int:
|
||||||
|
return int(round(float(hours or 0) * 60))
|
||||||
|
|
||||||
|
|
||||||
|
def format_bucket_status(status: dict[str, object] | None) -> str:
|
||||||
|
"""Human-readable project bucket status used by project/time-log UIs."""
|
||||||
|
if not status:
|
||||||
|
return strings._("project_bucket_no_project")
|
||||||
|
|
||||||
|
project = str(status.get("project_name") or "")
|
||||||
|
state = str(status.get("state") or "unconfigured")
|
||||||
|
baseline = hours_from_minutes(status.get("baseline_minutes"))
|
||||||
|
logged = hours_from_minutes(status.get("logged_minutes"))
|
||||||
|
used = hours_from_minutes(status.get("used_minutes"))
|
||||||
|
ceiling = hours_from_minutes(status.get("bucket_ceiling_minutes"))
|
||||||
|
remaining_minutes = status.get("remaining_minutes")
|
||||||
|
remaining = (
|
||||||
|
None if remaining_minutes is None else hours_from_minutes(remaining_minutes)
|
||||||
|
)
|
||||||
|
pct = status.get("percent_used")
|
||||||
|
pct_text = "" if pct is None else f" ({float(pct):.1f}%)"
|
||||||
|
|
||||||
|
if state == "unconfigured":
|
||||||
|
return strings._("project_bucket_unconfigured").format(
|
||||||
|
project=project,
|
||||||
|
baseline=baseline,
|
||||||
|
logged=logged,
|
||||||
|
used=used,
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining_text = "" if remaining is None else f", {remaining:.2f}h remaining"
|
||||||
|
return strings._("project_bucket_status").format(
|
||||||
|
project=project,
|
||||||
|
used=used,
|
||||||
|
ceiling=ceiling,
|
||||||
|
percent=pct_text,
|
||||||
|
remaining=remaining_text,
|
||||||
|
baseline=baseline,
|
||||||
|
logged=logged,
|
||||||
|
state=strings._(f"project_bucket_state_{state}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectsDialog(QDialog):
|
||||||
|
"""Project overview and prepaid-hours bucket manager."""
|
||||||
|
|
||||||
|
SUM_PROJECT = 0
|
||||||
|
SUM_LOGGED = 1
|
||||||
|
SUM_BASELINE = 2
|
||||||
|
SUM_USED = 3
|
||||||
|
SUM_CEILING = 4
|
||||||
|
SUM_REMAINING = 5
|
||||||
|
SUM_STATE = 6
|
||||||
|
SUM_TIME_LOGS = 7
|
||||||
|
SUM_DOCS = 8
|
||||||
|
SUM_INVOICES = 9
|
||||||
|
|
||||||
|
LOG_DATE = 0
|
||||||
|
LOG_ACTIVITY = 1
|
||||||
|
LOG_NOTE = 2
|
||||||
|
LOG_HOURS = 3
|
||||||
|
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_ADDED = 1
|
||||||
|
DOC_DESCRIPTION = 2
|
||||||
|
DOC_SIZE = 3
|
||||||
|
|
||||||
|
INV_NUMBER = 0
|
||||||
|
INV_ISSUE = 1
|
||||||
|
INV_DUE = 2
|
||||||
|
INV_TOTAL = 3
|
||||||
|
INV_PAID = 4
|
||||||
|
INV_DOCUMENT = 5
|
||||||
|
|
||||||
|
def __init__(self, db: DBManager, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._db = db
|
||||||
|
self._reloading = False
|
||||||
|
|
||||||
|
self.setWindowTitle(strings._("projects_title"))
|
||||||
|
self.resize(1100, 700)
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
|
||||||
|
top_form = QFormLayout()
|
||||||
|
top_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
|
||||||
|
root.addLayout(top_form)
|
||||||
|
|
||||||
|
project_row = QHBoxLayout()
|
||||||
|
self.project_combo = QComboBox()
|
||||||
|
self.project_combo.currentIndexChanged.connect(self._on_project_changed)
|
||||||
|
project_row.addWidget(self.project_combo, 1)
|
||||||
|
|
||||||
|
self.refresh_btn = QPushButton(strings._("refresh"))
|
||||||
|
self.refresh_btn.clicked.connect(self.reload)
|
||||||
|
project_row.addWidget(self.refresh_btn)
|
||||||
|
top_form.addRow(strings._("project"), project_row)
|
||||||
|
|
||||||
|
self.status_label = QLabel("")
|
||||||
|
self.status_label.setWordWrap(True)
|
||||||
|
self.status_label.setAlignment(
|
||||||
|
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||||
|
)
|
||||||
|
self.status_label.setSizePolicy(
|
||||||
|
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.MinimumExpanding
|
||||||
|
)
|
||||||
|
self.status_label.setMinimumHeight(
|
||||||
|
self.status_label.fontMetrics().lineSpacing() * 3 + 18
|
||||||
|
)
|
||||||
|
top_form.addRow(strings._("project_bucket"), self.status_label)
|
||||||
|
|
||||||
|
bucket_row = QHBoxLayout()
|
||||||
|
self.baseline_spin = QDoubleSpinBox()
|
||||||
|
self.baseline_spin.setRange(0.0, 1_000_000.0)
|
||||||
|
self.baseline_spin.setDecimals(2)
|
||||||
|
self.baseline_spin.setSingleStep(1.0)
|
||||||
|
self.baseline_spin.setSuffix(" h")
|
||||||
|
bucket_row.addWidget(QLabel(strings._("project_bucket_baseline")))
|
||||||
|
bucket_row.addWidget(self.baseline_spin)
|
||||||
|
|
||||||
|
self.ceiling_spin = QDoubleSpinBox()
|
||||||
|
self.ceiling_spin.setRange(0.0, 1_000_000.0)
|
||||||
|
self.ceiling_spin.setDecimals(2)
|
||||||
|
self.ceiling_spin.setSingleStep(1.0)
|
||||||
|
self.ceiling_spin.setSuffix(" h")
|
||||||
|
bucket_row.addWidget(QLabel(strings._("project_bucket_ceiling")))
|
||||||
|
bucket_row.addWidget(self.ceiling_spin)
|
||||||
|
|
||||||
|
self.warn_spin = QDoubleSpinBox()
|
||||||
|
self.warn_spin.setRange(0.0, 100.0)
|
||||||
|
self.warn_spin.setDecimals(0)
|
||||||
|
self.warn_spin.setSingleStep(5.0)
|
||||||
|
self.warn_spin.setSuffix(" %")
|
||||||
|
bucket_row.addWidget(QLabel(strings._("project_bucket_warn_at")))
|
||||||
|
bucket_row.addWidget(self.warn_spin)
|
||||||
|
|
||||||
|
self.save_bucket_btn = QPushButton(strings._("save"))
|
||||||
|
self.save_bucket_btn.clicked.connect(self._save_bucket)
|
||||||
|
bucket_row.addWidget(self.save_bucket_btn)
|
||||||
|
|
||||||
|
top_form.addRow(strings._("project_bucket_settings"), bucket_row)
|
||||||
|
|
||||||
|
topup_row = QHBoxLayout()
|
||||||
|
self.topup_spin = QDoubleSpinBox()
|
||||||
|
self.topup_spin.setRange(0.0, 1_000_000.0)
|
||||||
|
self.topup_spin.setDecimals(2)
|
||||||
|
self.topup_spin.setSingleStep(1.0)
|
||||||
|
self.topup_spin.setValue(40.0)
|
||||||
|
self.topup_spin.setSuffix(" h")
|
||||||
|
topup_row.addWidget(self.topup_spin)
|
||||||
|
|
||||||
|
self.topup_btn = QPushButton(strings._("project_bucket_add_to_ceiling"))
|
||||||
|
self.topup_btn.clicked.connect(self._add_to_ceiling)
|
||||||
|
topup_row.addWidget(self.topup_btn)
|
||||||
|
|
||||||
|
self.invoice_prepaid_btn = QPushButton(
|
||||||
|
strings._("project_bucket_invoice_prepaid")
|
||||||
|
)
|
||||||
|
self.invoice_prepaid_btn.clicked.connect(self._invoice_prepaid_hours)
|
||||||
|
topup_row.addWidget(self.invoice_prepaid_btn)
|
||||||
|
|
||||||
|
topup_row.addStretch(1)
|
||||||
|
top_form.addRow(strings._("project_bucket_replenish"), topup_row)
|
||||||
|
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
root.addWidget(self.tabs, 1)
|
||||||
|
|
||||||
|
self.summary_table = QTableWidget()
|
||||||
|
self.summary_table.setColumnCount(10)
|
||||||
|
self.summary_table.setHorizontalHeaderLabels(
|
||||||
|
[
|
||||||
|
strings._("project"),
|
||||||
|
strings._("project_hours_logged"),
|
||||||
|
strings._("project_bucket_baseline"),
|
||||||
|
strings._("project_bucket_used"),
|
||||||
|
strings._("project_bucket_ceiling"),
|
||||||
|
strings._("project_bucket_remaining"),
|
||||||
|
strings._("status"),
|
||||||
|
strings._("time_logs"),
|
||||||
|
strings._("documents"),
|
||||||
|
strings._("invoices"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.summary_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
|
self.summary_table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
|
self.summary_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
|
self.summary_table.itemSelectionChanged.connect(self._on_summary_selected)
|
||||||
|
header = self.summary_table.horizontalHeader()
|
||||||
|
header.setSectionResizeMode(self.SUM_PROJECT, QHeaderView.Stretch)
|
||||||
|
for col in range(1, 10):
|
||||||
|
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
|
||||||
|
self.tabs.addTab(self.summary_table, strings._("projects_summary_tab"))
|
||||||
|
|
||||||
|
logs_tab = QWidget()
|
||||||
|
logs_layout = QVBoxLayout(logs_tab)
|
||||||
|
self.time_logs_table = QTableWidget()
|
||||||
|
self.time_logs_table.setColumnCount(5)
|
||||||
|
self.time_logs_table.setHorizontalHeaderLabels(
|
||||||
|
[
|
||||||
|
strings._("date"),
|
||||||
|
strings._("activity"),
|
||||||
|
strings._("note"),
|
||||||
|
strings._("hours"),
|
||||||
|
strings._("created_at"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.time_logs_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
|
self.time_logs_table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
|
self.time_logs_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
|
log_header = self.time_logs_table.horizontalHeader()
|
||||||
|
log_header.setSectionResizeMode(self.LOG_DATE, QHeaderView.ResizeToContents)
|
||||||
|
log_header.setSectionResizeMode(self.LOG_ACTIVITY, QHeaderView.ResizeToContents)
|
||||||
|
log_header.setSectionResizeMode(self.LOG_NOTE, QHeaderView.Stretch)
|
||||||
|
log_header.setSectionResizeMode(self.LOG_HOURS, QHeaderView.ResizeToContents)
|
||||||
|
log_header.setSectionResizeMode(self.LOG_CREATED, QHeaderView.ResizeToContents)
|
||||||
|
logs_layout.addWidget(self.time_logs_table, 1)
|
||||||
|
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_layout = QVBoxLayout(docs_tab)
|
||||||
|
self.documents_table = QTableWidget()
|
||||||
|
self.documents_table.setColumnCount(4)
|
||||||
|
self.documents_table.setHorizontalHeaderLabels(
|
||||||
|
[
|
||||||
|
strings._("documents_col_file"),
|
||||||
|
strings._("documents_col_added"),
|
||||||
|
strings._("documents_col_description"),
|
||||||
|
strings._("documents_col_size"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.documents_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
|
self.documents_table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
|
self.documents_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
|
self.documents_table.itemDoubleClicked.connect(self._open_selected_document)
|
||||||
|
doc_header = self.documents_table.horizontalHeader()
|
||||||
|
doc_header.setSectionResizeMode(self.DOC_FILE, QHeaderView.Stretch)
|
||||||
|
doc_header.setSectionResizeMode(self.DOC_ADDED, QHeaderView.ResizeToContents)
|
||||||
|
doc_header.setSectionResizeMode(self.DOC_DESCRIPTION, QHeaderView.Stretch)
|
||||||
|
doc_header.setSectionResizeMode(self.DOC_SIZE, QHeaderView.ResizeToContents)
|
||||||
|
docs_layout.addWidget(self.documents_table, 1)
|
||||||
|
|
||||||
|
docs_buttons = QHBoxLayout()
|
||||||
|
docs_buttons.addStretch(1)
|
||||||
|
self.open_doc_btn = QPushButton(strings._("documents_open"))
|
||||||
|
self.open_doc_btn.clicked.connect(self._open_selected_document)
|
||||||
|
docs_buttons.addWidget(self.open_doc_btn)
|
||||||
|
docs_layout.addLayout(docs_buttons)
|
||||||
|
self.tabs.addTab(docs_tab, strings._("documents"))
|
||||||
|
|
||||||
|
invoices_tab = QWidget()
|
||||||
|
invoices_layout = QVBoxLayout(invoices_tab)
|
||||||
|
self.invoices_table = QTableWidget()
|
||||||
|
self.invoices_table.setColumnCount(6)
|
||||||
|
self.invoices_table.setHorizontalHeaderLabels(
|
||||||
|
[
|
||||||
|
strings._("invoice_number"),
|
||||||
|
strings._("invoice_issue_date"),
|
||||||
|
strings._("invoice_due_date"),
|
||||||
|
strings._("invoice_total"),
|
||||||
|
strings._("invoice_paid_at"),
|
||||||
|
strings._("documents_col_file"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.invoices_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
|
self.invoices_table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
|
self.invoices_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
|
self.invoices_table.itemDoubleClicked.connect(self._open_invoice_document)
|
||||||
|
inv_header = self.invoices_table.horizontalHeader()
|
||||||
|
inv_header.setSectionResizeMode(self.INV_NUMBER, QHeaderView.ResizeToContents)
|
||||||
|
inv_header.setSectionResizeMode(self.INV_ISSUE, QHeaderView.ResizeToContents)
|
||||||
|
inv_header.setSectionResizeMode(self.INV_DUE, QHeaderView.ResizeToContents)
|
||||||
|
inv_header.setSectionResizeMode(self.INV_TOTAL, QHeaderView.ResizeToContents)
|
||||||
|
inv_header.setSectionResizeMode(self.INV_PAID, QHeaderView.ResizeToContents)
|
||||||
|
inv_header.setSectionResizeMode(self.INV_DOCUMENT, QHeaderView.Stretch)
|
||||||
|
invoices_layout.addWidget(self.invoices_table, 1)
|
||||||
|
|
||||||
|
invoice_buttons = QHBoxLayout()
|
||||||
|
invoice_buttons.addStretch(1)
|
||||||
|
self.open_invoice_doc_btn = QPushButton(
|
||||||
|
strings._("project_open_invoice_document")
|
||||||
|
)
|
||||||
|
self.open_invoice_doc_btn.clicked.connect(self._open_invoice_document)
|
||||||
|
invoice_buttons.addWidget(self.open_invoice_doc_btn)
|
||||||
|
invoices_layout.addLayout(invoice_buttons)
|
||||||
|
self.tabs.addTab(invoices_tab, strings._("invoices"))
|
||||||
|
|
||||||
|
bottom = QHBoxLayout()
|
||||||
|
bottom.addStretch(1)
|
||||||
|
close_btn = QPushButton(strings._("close"))
|
||||||
|
close_btn.clicked.connect(self.accept)
|
||||||
|
bottom.addWidget(close_btn)
|
||||||
|
root.addLayout(bottom)
|
||||||
|
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
current = self._current_project_id()
|
||||||
|
self._load_projects(current)
|
||||||
|
self._reload_summary()
|
||||||
|
self._load_selected_project()
|
||||||
|
|
||||||
|
def _load_projects(self, preferred: int | None = None) -> None:
|
||||||
|
self._reloading = True
|
||||||
|
try:
|
||||||
|
self.project_combo.clear()
|
||||||
|
for r in self._db.list_project_summaries():
|
||||||
|
self.project_combo.addItem(str(r["project_name"]), int(r["project_id"]))
|
||||||
|
if preferred is not None:
|
||||||
|
idx = self.project_combo.findData(preferred)
|
||||||
|
if idx >= 0:
|
||||||
|
self.project_combo.setCurrentIndex(idx)
|
||||||
|
elif self.project_combo.count() > 0:
|
||||||
|
self.project_combo.setCurrentIndex(0)
|
||||||
|
finally:
|
||||||
|
self._reloading = False
|
||||||
|
|
||||||
|
def _current_project_id(self) -> int | None:
|
||||||
|
data = self.project_combo.currentData()
|
||||||
|
return int(data) if data is not None else None
|
||||||
|
|
||||||
|
def _on_project_changed(self, _idx: int) -> None:
|
||||||
|
if self._reloading:
|
||||||
|
return
|
||||||
|
self._load_selected_project()
|
||||||
|
|
||||||
|
def _on_summary_selected(self) -> None:
|
||||||
|
if self._reloading:
|
||||||
|
return
|
||||||
|
row = self.summary_table.currentRow()
|
||||||
|
if row < 0:
|
||||||
|
return
|
||||||
|
item = self.summary_table.item(row, self.SUM_PROJECT)
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
project_id = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if project_id is None:
|
||||||
|
return
|
||||||
|
idx = self.project_combo.findData(int(project_id))
|
||||||
|
if idx >= 0:
|
||||||
|
self.project_combo.setCurrentIndex(idx)
|
||||||
|
|
||||||
|
def _state_for_row(self, r) -> str:
|
||||||
|
baseline = int(r["baseline_minutes"] or 0)
|
||||||
|
logged = int(r["logged_minutes"] or 0)
|
||||||
|
used = baseline + logged
|
||||||
|
ceiling = int(r["bucket_ceiling_minutes"] or 0)
|
||||||
|
warn_at = float(r["warn_at_percent"] or 80.0)
|
||||||
|
if ceiling <= 0:
|
||||||
|
return "unconfigured"
|
||||||
|
pct = used / ceiling * 100.0
|
||||||
|
if used > ceiling:
|
||||||
|
return "exceeded"
|
||||||
|
if used == ceiling:
|
||||||
|
return "reached"
|
||||||
|
if pct >= warn_at:
|
||||||
|
return "warning"
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
def _reload_summary(self) -> None:
|
||||||
|
self._reloading = True
|
||||||
|
try:
|
||||||
|
rows = self._db.list_project_summaries()
|
||||||
|
self.summary_table.setRowCount(len(rows))
|
||||||
|
for row_idx, r in enumerate(rows):
|
||||||
|
project_id = int(r["project_id"])
|
||||||
|
baseline = int(r["baseline_minutes"] or 0)
|
||||||
|
logged = int(r["logged_minutes"] or 0)
|
||||||
|
used = baseline + logged
|
||||||
|
ceiling = int(r["bucket_ceiling_minutes"] or 0)
|
||||||
|
remaining = ceiling - used if ceiling > 0 else None
|
||||||
|
state = self._state_for_row(r)
|
||||||
|
|
||||||
|
project_item = QTableWidgetItem(str(r["project_name"]))
|
||||||
|
project_item.setData(Qt.ItemDataRole.UserRole, project_id)
|
||||||
|
self.summary_table.setItem(row_idx, self.SUM_PROJECT, project_item)
|
||||||
|
self.summary_table.setItem(
|
||||||
|
row_idx,
|
||||||
|
self.SUM_LOGGED,
|
||||||
|
QTableWidgetItem(f"{hours_from_minutes(logged):.2f}"),
|
||||||
|
)
|
||||||
|
self.summary_table.setItem(
|
||||||
|
row_idx,
|
||||||
|
self.SUM_BASELINE,
|
||||||
|
QTableWidgetItem(f"{hours_from_minutes(baseline):.2f}"),
|
||||||
|
)
|
||||||
|
self.summary_table.setItem(
|
||||||
|
row_idx,
|
||||||
|
self.SUM_USED,
|
||||||
|
QTableWidgetItem(f"{hours_from_minutes(used):.2f}"),
|
||||||
|
)
|
||||||
|
ceiling_text = (
|
||||||
|
"" if ceiling <= 0 else f"{hours_from_minutes(ceiling):.2f}"
|
||||||
|
)
|
||||||
|
remaining_text = (
|
||||||
|
"" if remaining is None else f"{hours_from_minutes(remaining):.2f}"
|
||||||
|
)
|
||||||
|
state_text = strings._(f"project_bucket_state_{state}")
|
||||||
|
self.summary_table.setItem(
|
||||||
|
row_idx, self.SUM_CEILING, QTableWidgetItem(ceiling_text)
|
||||||
|
)
|
||||||
|
self.summary_table.setItem(
|
||||||
|
row_idx, self.SUM_REMAINING, QTableWidgetItem(remaining_text)
|
||||||
|
)
|
||||||
|
self.summary_table.setItem(
|
||||||
|
row_idx, self.SUM_STATE, QTableWidgetItem(state_text)
|
||||||
|
)
|
||||||
|
self.summary_table.setItem(
|
||||||
|
row_idx,
|
||||||
|
self.SUM_TIME_LOGS,
|
||||||
|
QTableWidgetItem(str(r["time_log_count"] or 0)),
|
||||||
|
)
|
||||||
|
self.summary_table.setItem(
|
||||||
|
row_idx,
|
||||||
|
self.SUM_DOCS,
|
||||||
|
QTableWidgetItem(str(r["document_count"] or 0)),
|
||||||
|
)
|
||||||
|
self.summary_table.setItem(
|
||||||
|
row_idx,
|
||||||
|
self.SUM_INVOICES,
|
||||||
|
QTableWidgetItem(str(r["invoice_count"] or 0)),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._reloading = False
|
||||||
|
|
||||||
|
def _load_selected_project(self) -> None:
|
||||||
|
project_id = self._current_project_id()
|
||||||
|
if project_id is None:
|
||||||
|
self.status_label.setText(strings._("projects_none"))
|
||||||
|
self.status_label.setStyleSheet("")
|
||||||
|
self.baseline_spin.setValue(0.0)
|
||||||
|
self.ceiling_spin.setValue(0.0)
|
||||||
|
self.warn_spin.setValue(80.0)
|
||||||
|
self.time_logs_table.setRowCount(0)
|
||||||
|
self.bucket_ledger_table.setRowCount(0)
|
||||||
|
self.changelog_table.setRowCount(0)
|
||||||
|
self.documents_table.setRowCount(0)
|
||||||
|
self.invoices_table.setRowCount(0)
|
||||||
|
return
|
||||||
|
|
||||||
|
status = self._db.project_bucket_status(project_id)
|
||||||
|
self.status_label.setText(format_bucket_status(status))
|
||||||
|
state = str(status.get("state") if status else "unconfigured")
|
||||||
|
self.status_label.setStyleSheet(_WARNING_STYLES.get(state, ""))
|
||||||
|
|
||||||
|
bucket = self._db.get_project_bucket(project_id)
|
||||||
|
self.baseline_spin.setValue(
|
||||||
|
hours_from_minutes(bucket["baseline_minutes"] if bucket else 0)
|
||||||
|
)
|
||||||
|
self.ceiling_spin.setValue(
|
||||||
|
hours_from_minutes(bucket["bucket_ceiling_minutes"] if bucket else 0)
|
||||||
|
)
|
||||||
|
self.warn_spin.setValue(float(bucket["warn_at_percent"] if bucket else 80.0))
|
||||||
|
|
||||||
|
self._reload_time_logs(project_id)
|
||||||
|
self._reload_bucket_ledger(project_id)
|
||||||
|
self._reload_changelog(project_id)
|
||||||
|
self._reload_documents(project_id)
|
||||||
|
self._reload_invoices(project_id)
|
||||||
|
|
||||||
|
def _reload_time_logs(self, project_id: int) -> None:
|
||||||
|
rows = self._db.time_logs_for_project(project_id)
|
||||||
|
self.time_logs_table.setRowCount(len(rows))
|
||||||
|
for row_idx, r in enumerate(rows):
|
||||||
|
self.time_logs_table.setItem(
|
||||||
|
row_idx, self.LOG_DATE, QTableWidgetItem(r["page_date"] or "")
|
||||||
|
)
|
||||||
|
self.time_logs_table.setItem(
|
||||||
|
row_idx, self.LOG_ACTIVITY, QTableWidgetItem(r["activity_name"] or "")
|
||||||
|
)
|
||||||
|
self.time_logs_table.setItem(
|
||||||
|
row_idx, self.LOG_NOTE, QTableWidgetItem(r["note"] or "")
|
||||||
|
)
|
||||||
|
self.time_logs_table.setItem(
|
||||||
|
row_idx,
|
||||||
|
self.LOG_HOURS,
|
||||||
|
QTableWidgetItem(f"{hours_from_minutes(r['minutes']):.2f}"),
|
||||||
|
)
|
||||||
|
self.time_logs_table.setItem(
|
||||||
|
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:
|
||||||
|
rows = self._db.documents_for_project(project_id)
|
||||||
|
self.documents_table.setRowCount(len(rows))
|
||||||
|
for row_idx, r in enumerate(rows):
|
||||||
|
doc_id, _project_id, _project_name, file_name, description, size, added = r
|
||||||
|
file_item = QTableWidgetItem(file_name or "")
|
||||||
|
file_item.setData(Qt.ItemDataRole.UserRole, int(doc_id))
|
||||||
|
self.documents_table.setItem(row_idx, self.DOC_FILE, file_item)
|
||||||
|
self.documents_table.setItem(
|
||||||
|
row_idx, self.DOC_ADDED, QTableWidgetItem(added or "")
|
||||||
|
)
|
||||||
|
self.documents_table.setItem(
|
||||||
|
row_idx, self.DOC_DESCRIPTION, QTableWidgetItem(description or "")
|
||||||
|
)
|
||||||
|
self.documents_table.setItem(
|
||||||
|
row_idx, self.DOC_SIZE, QTableWidgetItem(str(size or 0))
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reload_invoices(self, project_id: int) -> None:
|
||||||
|
rows = self._db.invoices_for_project_with_documents(project_id)
|
||||||
|
self.invoices_table.setRowCount(len(rows))
|
||||||
|
for row_idx, r in enumerate(rows):
|
||||||
|
document_id = r["document_id"]
|
||||||
|
file_name = r["document_file_name"] or ""
|
||||||
|
num_item = QTableWidgetItem(r["invoice_number"] or "")
|
||||||
|
num_item.setData(Qt.ItemDataRole.UserRole, int(r["id"]))
|
||||||
|
self.invoices_table.setItem(row_idx, self.INV_NUMBER, num_item)
|
||||||
|
self.invoices_table.setItem(
|
||||||
|
row_idx, self.INV_ISSUE, QTableWidgetItem(r["issue_date"] or "")
|
||||||
|
)
|
||||||
|
self.invoices_table.setItem(
|
||||||
|
row_idx, self.INV_DUE, QTableWidgetItem(r["due_date"] or "")
|
||||||
|
)
|
||||||
|
total = int(r["total_cents"] or 0) / 100.0
|
||||||
|
currency = r["currency"] or ""
|
||||||
|
self.invoices_table.setItem(
|
||||||
|
row_idx,
|
||||||
|
self.INV_TOTAL,
|
||||||
|
QTableWidgetItem(f"{total:.2f} {currency}".strip()),
|
||||||
|
)
|
||||||
|
self.invoices_table.setItem(
|
||||||
|
row_idx, self.INV_PAID, QTableWidgetItem(r["paid_at"] or "")
|
||||||
|
)
|
||||||
|
doc_item = QTableWidgetItem(file_name)
|
||||||
|
doc_item.setData(
|
||||||
|
Qt.ItemDataRole.UserRole,
|
||||||
|
int(document_id) if document_id else None,
|
||||||
|
)
|
||||||
|
self.invoices_table.setItem(row_idx, self.INV_DOCUMENT, doc_item)
|
||||||
|
|
||||||
|
def _save_bucket(self) -> None:
|
||||||
|
project_id = self._current_project_id()
|
||||||
|
if project_id is None:
|
||||||
|
return
|
||||||
|
self._db.upsert_project_bucket(
|
||||||
|
project_id,
|
||||||
|
minutes_from_hours(self.baseline_spin.value()),
|
||||||
|
minutes_from_hours(self.ceiling_spin.value()),
|
||||||
|
float(self.warn_spin.value()),
|
||||||
|
)
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def _add_to_ceiling(self) -> None:
|
||||||
|
project_id = self._current_project_id()
|
||||||
|
if project_id is None:
|
||||||
|
return
|
||||||
|
add_minutes = minutes_from_hours(self.topup_spin.value())
|
||||||
|
if add_minutes <= 0:
|
||||||
|
return
|
||||||
|
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()
|
||||||
|
|
||||||
|
def _invoice_prepaid_hours(self) -> None:
|
||||||
|
project_id = self._current_project_id()
|
||||||
|
if project_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
hours = float(self.topup_spin.value())
|
||||||
|
if hours <= 0.0:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
strings._("project_bucket_invoice_prepaid"),
|
||||||
|
strings._("project_prepaid_invoice_hours_required"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
today = QDate.currentDate().toString("yyyy-MM-dd")
|
||||||
|
dialog = InvoiceDialog(
|
||||||
|
self._db,
|
||||||
|
project_id,
|
||||||
|
today,
|
||||||
|
today,
|
||||||
|
time_rows=[],
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
|
dialog.rb_summary.setChecked(True)
|
||||||
|
dialog.summary_desc_edit.setText(
|
||||||
|
strings._("project_prepaid_invoice_default_desc").format(hours=hours)
|
||||||
|
)
|
||||||
|
dialog.summary_hours_spin.setValue(hours)
|
||||||
|
dialog._recalc_totals()
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
def _selected_doc_id(self) -> tuple[int, str] | None:
|
||||||
|
row = self.documents_table.currentRow()
|
||||||
|
if row < 0:
|
||||||
|
return None
|
||||||
|
item = self.documents_table.item(row, self.DOC_FILE)
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
doc_id = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
file_name = item.text()
|
||||||
|
if doc_id is None or not file_name:
|
||||||
|
return None
|
||||||
|
return int(doc_id), file_name
|
||||||
|
|
||||||
|
def _open_selected_document(self, *_args) -> None:
|
||||||
|
selected = self._selected_doc_id()
|
||||||
|
if not selected:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
strings._("documents_open"),
|
||||||
|
strings._("documents_select_document"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
doc_id, file_name = selected
|
||||||
|
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
|
||||||
|
|
||||||
|
def _open_invoice_document(self, *_args) -> None:
|
||||||
|
row = self.invoices_table.currentRow()
|
||||||
|
if row < 0:
|
||||||
|
return
|
||||||
|
doc_item = self.invoices_table.item(row, self.INV_DOCUMENT)
|
||||||
|
if doc_item is None:
|
||||||
|
return
|
||||||
|
doc_id = doc_item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
file_name = doc_item.text()
|
||||||
|
if doc_id is None or not file_name:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
strings._("project_open_invoice_document"),
|
||||||
|
strings._("project_invoice_no_document"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
open_document_from_db(self._db, int(doc_id), file_name, parent_widget=self)
|
||||||
|
|
@ -42,6 +42,7 @@ from PySide6.QtWidgets import (
|
||||||
from sqlcipher4.dbapi2 import IntegrityError
|
from sqlcipher4.dbapi2 import IntegrityError
|
||||||
|
|
||||||
from . import strings
|
from . import strings
|
||||||
|
from .projects import format_bucket_status
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
from .settings import load_db_config
|
from .settings import load_db_config
|
||||||
from .theme import ThemeManager
|
from .theme import ThemeManager
|
||||||
|
|
@ -302,6 +303,7 @@ class TimeLogDialog(QDialog):
|
||||||
# Project
|
# Project
|
||||||
proj_row = QHBoxLayout()
|
proj_row = QHBoxLayout()
|
||||||
self.project_combo = QComboBox()
|
self.project_combo = QComboBox()
|
||||||
|
self.project_combo.currentIndexChanged.connect(self._refresh_bucket_indicator)
|
||||||
self.manage_projects_btn = QPushButton(strings._("manage_projects"))
|
self.manage_projects_btn = QPushButton(strings._("manage_projects"))
|
||||||
self.manage_projects_btn.clicked.connect(self._manage_projects)
|
self.manage_projects_btn.clicked.connect(self._manage_projects)
|
||||||
proj_row.addWidget(self.project_combo, 1)
|
proj_row.addWidget(self.project_combo, 1)
|
||||||
|
|
@ -331,6 +333,10 @@ class TimeLogDialog(QDialog):
|
||||||
self.hours_spin.setValue(0.25)
|
self.hours_spin.setValue(0.25)
|
||||||
form.addRow(strings._("hours"), self.hours_spin)
|
form.addRow(strings._("hours"), self.hours_spin)
|
||||||
|
|
||||||
|
self.bucket_label = QLabel("")
|
||||||
|
self.bucket_label.setWordWrap(True)
|
||||||
|
form.addRow(strings._("project_bucket"), self.bucket_label)
|
||||||
|
|
||||||
root.addLayout(form)
|
root.addLayout(form)
|
||||||
|
|
||||||
# --- Buttons for entry
|
# --- Buttons for entry
|
||||||
|
|
@ -409,6 +415,7 @@ class TimeLogDialog(QDialog):
|
||||||
self.project_combo.clear()
|
self.project_combo.clear()
|
||||||
for proj_id, name in self._db.list_projects():
|
for proj_id, name in self._db.list_projects():
|
||||||
self.project_combo.addItem(name, proj_id)
|
self.project_combo.addItem(name, proj_id)
|
||||||
|
self._refresh_bucket_indicator()
|
||||||
|
|
||||||
def _reload_activities(self) -> None:
|
def _reload_activities(self) -> None:
|
||||||
activities = [name for _, name in self._db.list_activities()]
|
activities = [name for _, name in self._db.list_activities()]
|
||||||
|
|
@ -461,11 +468,48 @@ class TimeLogDialog(QDialog):
|
||||||
self.total_label.setText(
|
self.total_label.setText(
|
||||||
strings._("time_log_total_hours").format(hours=self.total_hours)
|
strings._("time_log_total_hours").format(hours=self.total_hours)
|
||||||
)
|
)
|
||||||
|
self._refresh_bucket_indicator()
|
||||||
|
|
||||||
self._current_entry_id = None
|
self._current_entry_id = None
|
||||||
self.delete_btn.setEnabled(False)
|
self.delete_btn.setEnabled(False)
|
||||||
self.add_update_btn.setText("&" + strings._("add_time_entry"))
|
self.add_update_btn.setText("&" + strings._("add_time_entry"))
|
||||||
|
|
||||||
|
# ----- Project bucket indicator -----------------------------------
|
||||||
|
|
||||||
|
def _refresh_bucket_indicator(self, *_args) -> None:
|
||||||
|
if not hasattr(self, "bucket_label"):
|
||||||
|
return
|
||||||
|
proj_id = self._ensure_project_id()
|
||||||
|
status = self._db.project_bucket_status(proj_id) if proj_id else None
|
||||||
|
self.bucket_label.setText(format_bucket_status(status))
|
||||||
|
state = str(status.get("state") if status else "unconfigured")
|
||||||
|
if state == "warning":
|
||||||
|
style = "QLabel { padding: 4px; border: 1px solid #b58900; border-radius: 4px; }"
|
||||||
|
elif state in ("reached", "exceeded"):
|
||||||
|
style = "QLabel { padding: 4px; border: 1px solid #b00020; border-radius: 4px; font-weight: bold; }"
|
||||||
|
elif state == "ok":
|
||||||
|
style = "QLabel { padding: 4px; border: 1px solid #5b8f5b; border-radius: 4px; }"
|
||||||
|
else:
|
||||||
|
style = ""
|
||||||
|
self.bucket_label.setStyleSheet(style)
|
||||||
|
|
||||||
|
def _maybe_show_bucket_alert(self, project_id: int | None) -> None:
|
||||||
|
if project_id is None:
|
||||||
|
return
|
||||||
|
status = self._db.project_bucket_status(project_id)
|
||||||
|
if not status:
|
||||||
|
return
|
||||||
|
state = str(status.get("state") or "")
|
||||||
|
if state not in {"reached", "exceeded"}:
|
||||||
|
return
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
strings._("project_bucket_alert_title"),
|
||||||
|
strings._("project_bucket_alert_message").format(
|
||||||
|
status=format_bucket_status(status)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# ----- Actions -----------------------------------------------------
|
# ----- Actions -----------------------------------------------------
|
||||||
|
|
||||||
def _on_change_date_clicked(self) -> None:
|
def _on_change_date_clicked(self) -> None:
|
||||||
|
|
@ -561,6 +605,7 @@ class TimeLogDialog(QDialog):
|
||||||
)
|
)
|
||||||
|
|
||||||
self._reload_entries()
|
self._reload_entries()
|
||||||
|
self._maybe_show_bucket_alert(proj_id)
|
||||||
if self.close_after_add:
|
if self.close_after_add:
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
|
|
@ -1135,6 +1180,10 @@ class TimeReportDialog(QDialog):
|
||||||
self.total_label = QLabel("")
|
self.total_label = QLabel("")
|
||||||
root.addWidget(self.total_label)
|
root.addWidget(self.total_label)
|
||||||
|
|
||||||
|
self.bucket_label = QLabel("")
|
||||||
|
self.bucket_label.setWordWrap(True)
|
||||||
|
root.addWidget(self.bucket_label)
|
||||||
|
|
||||||
# Close
|
# Close
|
||||||
close_row = QHBoxLayout()
|
close_row = QHBoxLayout()
|
||||||
close_row.addStretch(1)
|
close_row.addStretch(1)
|
||||||
|
|
@ -1328,6 +1377,28 @@ class TimeReportDialog(QDialog):
|
||||||
strings._("time_report_total").format(hours=total_hours)
|
strings._("time_report_total").format(hours=total_hours)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if proj_data is None:
|
||||||
|
self.bucket_label.setText("")
|
||||||
|
self.bucket_label.setStyleSheet("")
|
||||||
|
else:
|
||||||
|
status = self._db.project_bucket_status(int(proj_data))
|
||||||
|
self.bucket_label.setText(format_bucket_status(status))
|
||||||
|
state = str(status.get("state") if status else "unconfigured")
|
||||||
|
if state == "warning":
|
||||||
|
self.bucket_label.setStyleSheet(
|
||||||
|
"QLabel { padding: 4px; border: 1px solid #b58900; border-radius: 4px; }"
|
||||||
|
)
|
||||||
|
elif state in ("reached", "exceeded"):
|
||||||
|
self.bucket_label.setStyleSheet(
|
||||||
|
"QLabel { padding: 4px; border: 1px solid #b00020; border-radius: 4px; font-weight: bold; }"
|
||||||
|
)
|
||||||
|
elif state == "ok":
|
||||||
|
self.bucket_label.setStyleSheet(
|
||||||
|
"QLabel { padding: 4px; border: 1px solid #5b8f5b; border-radius: 4px; }"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.bucket_label.setStyleSheet("")
|
||||||
|
|
||||||
def _export_csv(self):
|
def _export_csv(self):
|
||||||
if not self._last_rows:
|
if not self._last_rows:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ class ToolBar(QToolBar):
|
||||||
alarmRequested = Signal()
|
alarmRequested = Signal()
|
||||||
timerRequested = Signal()
|
timerRequested = Signal()
|
||||||
documentsRequested = Signal()
|
documentsRequested = Signal()
|
||||||
|
projectsRequested = Signal()
|
||||||
fontSizeLargerRequested = Signal()
|
fontSizeLargerRequested = Signal()
|
||||||
fontSizeSmallerRequested = Signal()
|
fontSizeSmallerRequested = Signal()
|
||||||
|
|
||||||
|
|
@ -127,6 +128,11 @@ class ToolBar(QToolBar):
|
||||||
self.actDocuments = QAction("📁", self)
|
self.actDocuments = QAction("📁", self)
|
||||||
self.actDocuments.setToolTip(strings._("toolbar_documents"))
|
self.actDocuments.setToolTip(strings._("toolbar_documents"))
|
||||||
self.actDocuments.triggered.connect(self.documentsRequested)
|
self.actDocuments.triggered.connect(self.documentsRequested)
|
||||||
|
|
||||||
|
# Projects
|
||||||
|
self.actProjects = QAction("📌", self)
|
||||||
|
self.actProjects.setToolTip(strings._("toolbar_projects"))
|
||||||
|
self.actProjects.triggered.connect(self.projectsRequested)
|
||||||
# Headings are mutually exclusive (like radio buttons)
|
# Headings are mutually exclusive (like radio buttons)
|
||||||
self.grpHeadings = QActionGroup(self)
|
self.grpHeadings = QActionGroup(self)
|
||||||
self.grpHeadings.setExclusive(True)
|
self.grpHeadings.setExclusive(True)
|
||||||
|
|
@ -159,6 +165,7 @@ class ToolBar(QToolBar):
|
||||||
self.actInsertImg,
|
self.actInsertImg,
|
||||||
self.actAlarm,
|
self.actAlarm,
|
||||||
self.actTimer,
|
self.actTimer,
|
||||||
|
self.actProjects,
|
||||||
self.actDocuments,
|
self.actDocuments,
|
||||||
self.actHistory,
|
self.actHistory,
|
||||||
]
|
]
|
||||||
|
|
@ -186,6 +193,7 @@ class ToolBar(QToolBar):
|
||||||
self._style_letter_button(self.actCheckboxes, "☑")
|
self._style_letter_button(self.actCheckboxes, "☑")
|
||||||
self._style_letter_button(self.actAlarm, "⏰")
|
self._style_letter_button(self.actAlarm, "⏰")
|
||||||
self._style_letter_button(self.actTimer, "⌛")
|
self._style_letter_button(self.actTimer, "⌛")
|
||||||
|
self._style_letter_button(self.actProjects, "📌")
|
||||||
self._style_letter_button(self.actDocuments, "📁")
|
self._style_letter_button(self.actDocuments, "📁")
|
||||||
|
|
||||||
# History
|
# History
|
||||||
|
|
|
||||||
8
debian/changelog
vendored
8
debian/changelog
vendored
|
|
@ -1,3 +1,11 @@
|
||||||
|
bouquin (0.9.0) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Add 'Projects' interface for unified time/invoice/docs view.
|
||||||
|
* Add ability to set a 'bucket' of (prepaid) hours for a project and warn when time logged approaches it.
|
||||||
|
* Add ability to invoice for the increase in prepaid project bucket hours (without having had to 'log' them).
|
||||||
|
|
||||||
|
-- Miguel Jacq <mig@mig5.net> Sun, 07 Jun 2026 17:18:00 +1000
|
||||||
|
|
||||||
bouquin (0.8.4) unstable; urgency=medium
|
bouquin (0.8.4) unstable; urgency=medium
|
||||||
|
|
||||||
* Dependency updates
|
* Dependency updates
|
||||||
|
|
|
||||||
295
poetry.lock
generated
295
poetry.lock
generated
|
|
@ -13,13 +13,13 @@ files = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.4.22"
|
version = "2026.5.20"
|
||||||
description = "Python package for providing Mozilla's CA Bundle."
|
description = "Python package for providing Mozilla's CA Bundle."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"},
|
{file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"},
|
||||||
{file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"},
|
{file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -173,117 +173,117 @@ files = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coverage"
|
name = "coverage"
|
||||||
version = "7.14.0"
|
version = "7.14.1"
|
||||||
description = "Code coverage measurement for Python"
|
description = "Code coverage measurement for Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
files = [
|
files = [
|
||||||
{file = "coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075"},
|
{file = "coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82"},
|
{file = "coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c"},
|
{file = "coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893"},
|
{file = "coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20"},
|
{file = "coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec"},
|
{file = "coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757"},
|
{file = "coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a"},
|
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea"},
|
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb"},
|
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218"},
|
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85"},
|
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323"},
|
{file = "coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59"},
|
||||||
{file = "coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a"},
|
{file = "coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480"},
|
{file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4"},
|
{file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7"},
|
{file = "coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed"},
|
{file = "coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980"},
|
{file = "coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0"},
|
{file = "coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742"},
|
{file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5"},
|
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327"},
|
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d"},
|
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20"},
|
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c"},
|
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3"},
|
{file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1"},
|
{file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"},
|
||||||
{file = "coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627"},
|
{file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5"},
|
{file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662"},
|
{file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f"},
|
{file = "coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67"},
|
{file = "coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9"},
|
{file = "coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb"},
|
{file = "coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e"},
|
{file = "coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3"},
|
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4"},
|
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1"},
|
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5"},
|
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595"},
|
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27"},
|
{file = "coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2"},
|
{file = "coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e"},
|
||||||
{file = "coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d"},
|
{file = "coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef"},
|
{file = "coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66"},
|
{file = "coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b"},
|
{file = "coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca"},
|
{file = "coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7"},
|
{file = "coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2"},
|
{file = "coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367"},
|
{file = "coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9"},
|
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087"},
|
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef"},
|
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52"},
|
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe"},
|
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae"},
|
{file = "coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e"},
|
{file = "coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96"},
|
{file = "coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90"},
|
{file = "coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1"},
|
{file = "coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd"},
|
{file = "coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc"},
|
{file = "coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426"},
|
{file = "coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899"},
|
{file = "coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b"},
|
{file = "coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90"},
|
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f"},
|
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d"},
|
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47"},
|
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477"},
|
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab"},
|
{file = "coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917"},
|
{file = "coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce"},
|
||||||
{file = "coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8"},
|
{file = "coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d"},
|
{file = "coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63"},
|
{file = "coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212"},
|
{file = "coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3"},
|
{file = "coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97"},
|
{file = "coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8"},
|
{file = "coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb"},
|
{file = "coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe"},
|
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa"},
|
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5"},
|
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c"},
|
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca"},
|
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828"},
|
{file = "coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d"},
|
{file = "coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9"},
|
{file = "coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1"},
|
{file = "coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c"},
|
{file = "coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84"},
|
{file = "coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436"},
|
{file = "coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a"},
|
{file = "coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f"},
|
{file = "coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb"},
|
{file = "coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490"},
|
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9"},
|
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020"},
|
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6"},
|
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db"},
|
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2"},
|
{file = "coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644"},
|
{file = "coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c"},
|
||||||
{file = "coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b"},
|
{file = "coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af"},
|
||||||
{file = "coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1"},
|
{file = "coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2"},
|
||||||
{file = "coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74"},
|
{file = "coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
|
|
@ -325,13 +325,13 @@ test = ["pytest (>=6)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.15"
|
version = "3.18"
|
||||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.9"
|
||||||
files = [
|
files = [
|
||||||
{file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"},
|
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
|
||||||
{file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"},
|
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
|
|
@ -421,57 +421,58 @@ tomli = {version = "*", markers = "python_version < \"3.11\""}
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyside6"
|
name = "pyside6"
|
||||||
version = "6.11.0"
|
version = "6.11.1"
|
||||||
description = "Python bindings for the Qt cross-platform application and UI framework"
|
description = "Python bindings for the Qt cross-platform application and UI framework"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "<3.15,>=3.10"
|
python-versions = "<3.15,>=3.10"
|
||||||
files = [
|
files = [
|
||||||
{file = "pyside6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1f2735dc4f2bd4ec452ae50502c8a22128bba0aced35358a2bbc58384b820c6f"},
|
{file = "pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6"},
|
||||||
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00"},
|
{file = "pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2"},
|
||||||
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:267b344c73580ac938ca63c611881fb42a3922ebfe043e271005f4f06c372c4e"},
|
{file = "pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84"},
|
||||||
{file = "pyside6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc"},
|
{file = "pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6"},
|
||||||
{file = "pyside6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e"},
|
{file = "pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
PySide6_Addons = "6.11.0"
|
PySide6_Addons = "6.11.1"
|
||||||
PySide6_Essentials = "6.11.0"
|
PySide6_Essentials = "6.11.1"
|
||||||
shiboken6 = "6.11.0"
|
shiboken6 = "6.11.1"
|
||||||
|
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyside6-addons"
|
name = "pyside6-addons"
|
||||||
version = "6.11.0"
|
version = "6.11.1"
|
||||||
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
|
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "<3.15,>=3.10"
|
python-versions = "<3.15,>=3.10"
|
||||||
files = [
|
files = [
|
||||||
{file = "pyside6_addons-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d5eaa4643302e3a0fa94c5766234bee4073d7d5ab9c2b7fd222692a176faf182"},
|
{file = "pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9"},
|
||||||
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ac6fe3d4ef4497dde3efc5e896b0acd53ff6c93be4bf485f045690f919419f35"},
|
{file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f"},
|
||||||
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753"},
|
{file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993"},
|
||||||
{file = "pyside6_addons-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e"},
|
{file = "pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923"},
|
||||||
{file = "pyside6_addons-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:aaaee83385977a0fe134b2f4fbfb92b45a880d5b656e4d90a708eef10b1b6de8"},
|
{file = "pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
PySide6_Essentials = "6.11.0"
|
PySide6_Essentials = "6.11.1"
|
||||||
shiboken6 = "6.11.0"
|
shiboken6 = "6.11.1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyside6-essentials"
|
name = "pyside6-essentials"
|
||||||
version = "6.11.0"
|
version = "6.11.1"
|
||||||
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
|
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "<3.15,>=3.10"
|
python-versions = "<3.15,>=3.10"
|
||||||
files = [
|
files = [
|
||||||
{file = "pyside6_essentials-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:85d6ca87ef35fa6565d385ede72ae48420dd3f63113929d10fc800f6b0360e01"},
|
{file = "pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957"},
|
||||||
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc20e7afd5fc6fe51297db91cef997ce60844be578f7a49fc61b7ab9657a8849"},
|
{file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60"},
|
||||||
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4854cb0a1b061e7a576d8fb7bb7cf9f49540d558b1acb7df0742a7afefe61e4e"},
|
{file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d"},
|
||||||
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b3362882ad9389357a80504e600180006a957731fec05786fced7b038461fdf"},
|
{file = "pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40"},
|
||||||
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:81ca603dbf21bc39f89bb42db215c25ebe0c879a1a4c387625c321d2730ec187"},
|
{file = "pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
shiboken6 = "6.11.0"
|
shiboken6 = "6.11.1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
|
|
@ -554,13 +555,13 @@ doc = ["sphinx", "sphinx_rtd_theme"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.34.0"
|
version = "2.34.2"
|
||||||
description = "Python HTTP for Humans."
|
description = "Python HTTP for Humans."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
files = [
|
files = [
|
||||||
{file = "requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60"},
|
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
|
||||||
{file = "requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a"},
|
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
|
|
@ -575,16 +576,16 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shiboken6"
|
name = "shiboken6"
|
||||||
version = "6.11.0"
|
version = "6.11.1"
|
||||||
description = "Python/C++ bindings helper module"
|
description = "Python/C++ bindings helper module"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "<3.15,>=3.10"
|
python-versions = "<3.15,>=3.10"
|
||||||
files = [
|
files = [
|
||||||
{file = "shiboken6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d88e8a1eb705f2b9ad21db08a61ae1dc0c773e5cd86a069de0754c4cf1f9b43b"},
|
{file = "shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02"},
|
||||||
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad54e64f8192ddbdff0c54ac82b89edcd62ed623f502ea21c960541d19514053"},
|
{file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe"},
|
||||||
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a10dc7718104ea2dc15d5b0b96909b77162ce1c76fcc6968e6df692b947a00e9"},
|
{file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f"},
|
||||||
{file = "shiboken6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:483ff78a73c7b3189ca924abc694318084f078bcfeaffa68e32024ff2d025ee1"},
|
{file = "shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7"},
|
||||||
{file = "shiboken6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:3bd76cf56105ab2d62ecaff630366f11264f69b88d488f10f048da9a065781f4"},
|
{file = "shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "bouquin"
|
name = "bouquin"
|
||||||
version = "0.8.4"
|
version = "0.9.0"
|
||||||
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
# provides the Python distribution/module as "sqlcipher4". To keep Fedora's
|
# provides the Python distribution/module as "sqlcipher4". To keep Fedora's
|
||||||
# auto-generated python3dist() Requires correct, we rewrite the dependency key in
|
# auto-generated python3dist() Requires correct, we rewrite the dependency key in
|
||||||
# pyproject.toml at build time.
|
# pyproject.toml at build time.
|
||||||
%global upstream_version 0.8.4
|
%global upstream_version 0.9.0
|
||||||
|
|
||||||
Name: bouquin
|
Name: bouquin
|
||||||
Version: %{upstream_version}
|
Version: %{upstream_version}
|
||||||
|
|
@ -82,6 +82,10 @@ install -Dpm 0644 bouquin/icons/bouquin.svg %{buildroot}%{_datadir}/icons/hicolo
|
||||||
%{_datadir}/icons/hicolor/scalable/apps/bouquin.svg
|
%{_datadir}/icons/hicolor/scalable/apps/bouquin.svg
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Sun Jun 07 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
|
- Add 'Projects' interface for unified time/invoice/docs view.
|
||||||
|
- Add ability to set a 'bucket' of (prepaid) hours for a project and warn when time logged approaches it.
|
||||||
|
- Add ability to invoice for the increase in prepaid project bucket hours (without having had to 'log' them).
|
||||||
* Wed May 13 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
* Wed May 13 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
- Dependency updates
|
- Dependency updates
|
||||||
- SQLCipher 4.16.0
|
- SQLCipher 4.16.0
|
||||||
|
|
|
||||||
|
|
@ -1900,9 +1900,60 @@ def test_main_window_without_time_log(qtbot, app, tmp_db_cfg):
|
||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
window.show()
|
window.show()
|
||||||
|
|
||||||
# Verify time_log widget is hidden
|
# Verify time_log widget is hidden, including dependent Projects entry points.
|
||||||
assert window.time_log.isHidden()
|
assert window.time_log.isHidden()
|
||||||
assert not window.toolBar.actTimer.isVisible()
|
assert not window.toolBar.actTimer.isVisible()
|
||||||
|
assert not window.toolBar.actProjects.isVisible()
|
||||||
|
assert not window.actProjects.isVisible()
|
||||||
|
assert not window.actProjects.isEnabled()
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_window_projects_action_visible_with_time_log(qtbot, app, tmp_db_cfg):
|
||||||
|
"""Projects is available from the menu/shortcut only when time logging is enabled."""
|
||||||
|
s = get_settings()
|
||||||
|
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||||
|
s.setValue("db/key", tmp_db_cfg.key)
|
||||||
|
s.setValue("ui/idle_minutes", 0)
|
||||||
|
s.setValue("ui/theme", "light")
|
||||||
|
s.setValue("ui/move_todos", True)
|
||||||
|
s.setValue("ui/tags", True)
|
||||||
|
s.setValue("ui/time_log", True)
|
||||||
|
s.setValue("ui/reminders", True)
|
||||||
|
s.setValue("ui/locale", "en")
|
||||||
|
s.setValue("ui/font_size", 11)
|
||||||
|
|
||||||
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
window = MainWindow(themes=themes)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
assert window.toolBar.actProjects.isVisible()
|
||||||
|
assert window.actProjects.isVisible()
|
||||||
|
assert window.actProjects.isEnabled()
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_window_open_projects_noops_when_time_log_disabled(qtbot, app, tmp_db_cfg):
|
||||||
|
"""The handler is also guarded, so a stale shortcut cannot open Projects."""
|
||||||
|
s = get_settings()
|
||||||
|
s.setValue("db/default_db", str(tmp_db_cfg.path))
|
||||||
|
s.setValue("db/key", tmp_db_cfg.key)
|
||||||
|
s.setValue("ui/idle_minutes", 0)
|
||||||
|
s.setValue("ui/theme", "light")
|
||||||
|
s.setValue("ui/move_todos", True)
|
||||||
|
s.setValue("ui/tags", True)
|
||||||
|
s.setValue("ui/time_log", False)
|
||||||
|
s.setValue("ui/reminders", True)
|
||||||
|
s.setValue("ui/locale", "en")
|
||||||
|
s.setValue("ui/font_size", 11)
|
||||||
|
|
||||||
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
window = MainWindow(themes=themes)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
with patch("bouquin.main_window.ProjectsDialog") as projects_dialog:
|
||||||
|
window._open_projects()
|
||||||
|
projects_dialog.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_main_window_without_documents(qtbot, app, tmp_db_cfg):
|
def test_main_window_without_documents(qtbot, app, tmp_db_cfg):
|
||||||
|
|
|
||||||
768
tests/test_projects.py
Normal file
768
tests/test_projects.py
Normal file
|
|
@ -0,0 +1,768 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from PySide6.QtCore import QDate
|
||||||
|
from PySide6.QtWidgets import QDialog, QMessageBox
|
||||||
|
|
||||||
|
from bouquin.projects import (
|
||||||
|
ProjectsDialog,
|
||||||
|
format_bucket_status,
|
||||||
|
hours_from_minutes,
|
||||||
|
minutes_from_hours,
|
||||||
|
)
|
||||||
|
from bouquin.time_log import TimeLogDialog, TimeReportDialog
|
||||||
|
|
||||||
|
|
||||||
|
def _add_project(fresh_db, project_name: str) -> int:
|
||||||
|
project_id = fresh_db.add_project(project_name)
|
||||||
|
fresh_db.upsert_project_billing(
|
||||||
|
project_id,
|
||||||
|
hourly_rate_cents=15000,
|
||||||
|
currency="AUD",
|
||||||
|
tax_label="GST",
|
||||||
|
tax_rate_percent=10.0,
|
||||||
|
client_name=f"{project_name} Contact",
|
||||||
|
client_company=project_name,
|
||||||
|
client_address="1 Example Street",
|
||||||
|
client_email="client@example.test",
|
||||||
|
)
|
||||||
|
return project_id
|
||||||
|
|
||||||
|
|
||||||
|
def _add_minutes(fresh_db, project_id: int, minutes: int, note: str = "Work") -> int:
|
||||||
|
activity_id = fresh_db.add_activity("Support")
|
||||||
|
return fresh_db.add_time_log("2026-01-01", project_id, activity_id, minutes, note)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Unit helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_hour_minute_helpers_round_trip():
|
||||||
|
assert hours_from_minutes(None) == 0.0
|
||||||
|
assert hours_from_minutes(90) == 1.5
|
||||||
|
assert minutes_from_hours(None) == 0
|
||||||
|
assert minutes_from_hours(1.25) == 75
|
||||||
|
assert minutes_from_hours(1.333) == 80
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_bucket_status_handles_none_unconfigured_and_reached(fresh_db):
|
||||||
|
assert "Select a project" in format_bucket_status(None)
|
||||||
|
|
||||||
|
project_id = _add_project(fresh_db, "No Bucket Project")
|
||||||
|
_add_minutes(fresh_db, project_id, 30)
|
||||||
|
unconfigured = fresh_db.project_bucket_status(project_id)
|
||||||
|
text = format_bucket_status(unconfigured)
|
||||||
|
assert "No Bucket Project" in text
|
||||||
|
assert "No bucket ceiling" in text
|
||||||
|
assert "0.50h used" in text
|
||||||
|
|
||||||
|
fresh_db.upsert_project_bucket(project_id, 30, 60, 50.0)
|
||||||
|
reached = fresh_db.project_bucket_status(project_id)
|
||||||
|
text = format_bucket_status(reached)
|
||||||
|
assert "1.00h / 1.00h" in text
|
||||||
|
assert "Bucket ceiling reached" in text
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DB behaviour
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_bucket_status_includes_baseline_and_logged_time(fresh_db):
|
||||||
|
project_id = _add_project(fresh_db, "Support Retainer")
|
||||||
|
_add_minutes(fresh_db, project_id, 90)
|
||||||
|
fresh_db.upsert_project_bucket(
|
||||||
|
project_id,
|
||||||
|
baseline_minutes=60,
|
||||||
|
bucket_ceiling_minutes=180,
|
||||||
|
warn_at_percent=80.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
status = fresh_db.project_bucket_status(project_id)
|
||||||
|
assert status["project_id"] == project_id
|
||||||
|
assert status["project_name"] == "Support Retainer"
|
||||||
|
assert status["baseline_minutes"] == 60
|
||||||
|
assert status["logged_minutes"] == 90
|
||||||
|
assert status["used_minutes"] == 150
|
||||||
|
assert status["remaining_minutes"] == 30
|
||||||
|
assert status["percent_used"] == 150 / 180 * 100.0
|
||||||
|
assert status["state"] == "warning"
|
||||||
|
|
||||||
|
fresh_db.add_to_project_bucket_ceiling(project_id, 60)
|
||||||
|
status = fresh_db.project_bucket_status(project_id)
|
||||||
|
assert status["bucket_ceiling_minutes"] == 240
|
||||||
|
assert status["state"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_bucket_status_state_boundaries(fresh_db):
|
||||||
|
project_id = _add_project(fresh_db, "Boundary Project")
|
||||||
|
|
||||||
|
_add_minutes(fresh_db, project_id, 30)
|
||||||
|
status = fresh_db.project_bucket_status(project_id)
|
||||||
|
assert status["state"] == "unconfigured"
|
||||||
|
assert status["bucket_ceiling_minutes"] == 0
|
||||||
|
assert status["remaining_minutes"] is None
|
||||||
|
assert status["percent_used"] is None
|
||||||
|
|
||||||
|
fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0)
|
||||||
|
assert fresh_db.project_bucket_status(project_id)["state"] == "ok"
|
||||||
|
|
||||||
|
fresh_db.upsert_project_bucket(project_id, 66, 120, 80.0)
|
||||||
|
status = fresh_db.project_bucket_status(project_id)
|
||||||
|
assert status["used_minutes"] == 96
|
||||||
|
assert status["percent_used"] == 80.0
|
||||||
|
assert status["state"] == "warning"
|
||||||
|
|
||||||
|
fresh_db.upsert_project_bucket(project_id, 90, 120, 80.0)
|
||||||
|
status = fresh_db.project_bucket_status(project_id)
|
||||||
|
assert status["used_minutes"] == 120
|
||||||
|
assert status["remaining_minutes"] == 0
|
||||||
|
assert status["state"] == "reached"
|
||||||
|
|
||||||
|
fresh_db.upsert_project_bucket(project_id, 91, 120, 80.0)
|
||||||
|
status = fresh_db.project_bucket_status(project_id)
|
||||||
|
assert status["used_minutes"] == 121
|
||||||
|
assert status["remaining_minutes"] == -1
|
||||||
|
assert status["state"] == "exceeded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_bucket_input_sanitisation_and_invalid_projects(fresh_db):
|
||||||
|
project_id = _add_project(fresh_db, "Sanitised Project")
|
||||||
|
fresh_db.upsert_project_bucket(
|
||||||
|
project_id,
|
||||||
|
baseline_minutes=-60,
|
||||||
|
bucket_ceiling_minutes=-120,
|
||||||
|
warn_at_percent=150.0,
|
||||||
|
)
|
||||||
|
bucket = fresh_db.get_project_bucket(project_id)
|
||||||
|
assert bucket["project_id"] == project_id
|
||||||
|
assert bucket["baseline_minutes"] == 0
|
||||||
|
assert bucket["bucket_ceiling_minutes"] == 0
|
||||||
|
assert bucket["warn_at_percent"] == 100.0
|
||||||
|
|
||||||
|
fresh_db.upsert_project_bucket(project_id, 10, 60, -5.0)
|
||||||
|
bucket = fresh_db.get_project_bucket(project_id)
|
||||||
|
assert bucket["warn_at_percent"] == 0.0
|
||||||
|
|
||||||
|
fresh_db.add_to_project_bucket_ceiling(project_id, -60)
|
||||||
|
assert fresh_db.get_project_bucket(project_id)["bucket_ceiling_minutes"] == 60
|
||||||
|
|
||||||
|
for bad_project_id in (0, -1):
|
||||||
|
try:
|
||||||
|
fresh_db.upsert_project_bucket(bad_project_id, 0, 60, 80.0)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "invalid project id" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("invalid project id should raise")
|
||||||
|
|
||||||
|
assert fresh_db.get_project_bucket(0) is None
|
||||||
|
assert fresh_db.logged_minutes_for_project(0) == 0
|
||||||
|
assert fresh_db.project_bucket_status(0) is None
|
||||||
|
assert fresh_db.time_logs_for_project(0) == []
|
||||||
|
assert fresh_db.invoices_for_project_with_documents(0) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_summaries_include_all_projects_and_are_sorted(fresh_db):
|
||||||
|
alpha = _add_project(fresh_db, "alpha project")
|
||||||
|
zulu = _add_project(fresh_db, "Zulu Project")
|
||||||
|
fresh_db.upsert_project_bucket(zulu, 15, 120, 80.0)
|
||||||
|
|
||||||
|
rows = fresh_db.list_project_summaries()
|
||||||
|
names = [r["project_name"] for r in rows]
|
||||||
|
assert names == ["alpha project", "Zulu Project"]
|
||||||
|
|
||||||
|
alpha_row = next(r for r in rows if r["project_id"] == alpha)
|
||||||
|
assert alpha_row["document_count"] == 0
|
||||||
|
assert alpha_row["invoice_count"] == 0
|
||||||
|
assert alpha_row["time_log_count"] == 0
|
||||||
|
assert alpha_row["logged_minutes"] == 0
|
||||||
|
assert alpha_row["baseline_minutes"] == 0
|
||||||
|
|
||||||
|
zulu_row = next(r for r in rows if r["project_id"] == zulu)
|
||||||
|
assert zulu_row["baseline_minutes"] == 15
|
||||||
|
assert zulu_row["bucket_ceiling_minutes"] == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_documents_time_logs_and_invoices_are_isolated(fresh_db, tmp_path):
|
||||||
|
project_id = _add_project(fresh_db, "Project With Docs")
|
||||||
|
other_project = _add_project(fresh_db, "Other Project")
|
||||||
|
_add_minutes(fresh_db, project_id, 120, "Build work")
|
||||||
|
_add_minutes(fresh_db, other_project, 300, "Other work")
|
||||||
|
fresh_db.upsert_project_bucket(project_id, 0, 240, 80.0)
|
||||||
|
|
||||||
|
doc_path = tmp_path / "invoice.pdf"
|
||||||
|
doc_path.write_bytes(b"not really a pdf")
|
||||||
|
doc_id = fresh_db.add_document_from_path(
|
||||||
|
project_id,
|
||||||
|
str(doc_path),
|
||||||
|
description="Invoice document",
|
||||||
|
uploaded_at="2026-02-02",
|
||||||
|
)
|
||||||
|
|
||||||
|
other_doc = tmp_path / "other.pdf"
|
||||||
|
other_doc.write_bytes(b"other")
|
||||||
|
fresh_db.add_document_from_path(other_project, str(other_doc))
|
||||||
|
|
||||||
|
invoice_id = fresh_db.create_invoice(
|
||||||
|
project_id=project_id,
|
||||||
|
invoice_number="INV-1",
|
||||||
|
issue_date="2026-02-03",
|
||||||
|
due_date="2026-02-17",
|
||||||
|
currency="AUD",
|
||||||
|
tax_label=None,
|
||||||
|
tax_rate_percent=None,
|
||||||
|
detail_mode="summary",
|
||||||
|
line_items=[("Prepaid bucket", 2.0, 10000)],
|
||||||
|
time_log_ids=[],
|
||||||
|
)
|
||||||
|
fresh_db.set_invoice_document(invoice_id, doc_id)
|
||||||
|
fresh_db.create_invoice(
|
||||||
|
project_id=other_project,
|
||||||
|
invoice_number="INV-OTHER",
|
||||||
|
issue_date="2026-02-03",
|
||||||
|
due_date="2026-02-17",
|
||||||
|
currency="AUD",
|
||||||
|
tax_label=None,
|
||||||
|
tax_rate_percent=None,
|
||||||
|
detail_mode="summary",
|
||||||
|
line_items=[("Other", 1.0, 10000)],
|
||||||
|
time_log_ids=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
row = next(
|
||||||
|
r for r in fresh_db.list_project_summaries() if r["project_id"] == project_id
|
||||||
|
)
|
||||||
|
assert row["logged_minutes"] == 120
|
||||||
|
assert row["time_log_count"] == 1
|
||||||
|
assert row["document_count"] == 1
|
||||||
|
assert row["invoice_count"] == 1
|
||||||
|
|
||||||
|
logs = fresh_db.time_logs_for_project(project_id)
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0]["note"] == "Build work"
|
||||||
|
|
||||||
|
docs = fresh_db.documents_for_project(project_id)
|
||||||
|
assert len(docs) == 1
|
||||||
|
assert docs[0][3] == "invoice.pdf"
|
||||||
|
|
||||||
|
invoices = fresh_db.invoices_for_project_with_documents(project_id)
|
||||||
|
assert len(invoices) == 1
|
||||||
|
assert invoices[0]["invoice_number"] == "INV-1"
|
||||||
|
assert invoices[0]["document_id"] == doc_id
|
||||||
|
assert invoices[0]["document_file_name"] == "invoice.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepaid_invoice_can_exist_without_logged_time_links(fresh_db):
|
||||||
|
project_id = _add_project(fresh_db, "Prepaid Project")
|
||||||
|
|
||||||
|
invoice_id = fresh_db.create_invoice(
|
||||||
|
project_id=project_id,
|
||||||
|
invoice_number="PREPAID-1",
|
||||||
|
issue_date="2026-02-10",
|
||||||
|
due_date="2026-02-24",
|
||||||
|
currency="AUD",
|
||||||
|
tax_label=None,
|
||||||
|
tax_rate_percent=None,
|
||||||
|
detail_mode="summary",
|
||||||
|
line_items=[("Prepaid support bucket", 40.0, 15000)],
|
||||||
|
time_log_ids=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice = fresh_db.invoices_for_project_with_documents(project_id)[0]
|
||||||
|
assert invoice["id"] == invoice_id
|
||||||
|
assert invoice["total_cents"] == 600000
|
||||||
|
|
||||||
|
linked = fresh_db.conn.execute(
|
||||||
|
"SELECT COUNT(*) AS c FROM invoice_time_log WHERE invoice_id = ?",
|
||||||
|
(invoice_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert linked["c"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# UI behaviour
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_dialog_loads_summary_time_logs_documents_and_invoices(
|
||||||
|
qtbot, fresh_db, tmp_path
|
||||||
|
):
|
||||||
|
project_id = _add_project(fresh_db, "UI Project")
|
||||||
|
_add_minutes(fresh_db, project_id, 60, "Initial support")
|
||||||
|
fresh_db.upsert_project_bucket(project_id, 30, 120, 75.0)
|
||||||
|
|
||||||
|
doc_path = tmp_path / "ui-invoice.pdf"
|
||||||
|
doc_path.write_bytes(b"ui")
|
||||||
|
doc_id = fresh_db.add_document_from_path(
|
||||||
|
project_id,
|
||||||
|
str(doc_path),
|
||||||
|
description="UI invoice",
|
||||||
|
uploaded_at="2026-03-01",
|
||||||
|
)
|
||||||
|
invoice_id = fresh_db.create_invoice(
|
||||||
|
project_id,
|
||||||
|
"UI-1",
|
||||||
|
"2026-03-02",
|
||||||
|
"2026-03-16",
|
||||||
|
"AUD",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"summary",
|
||||||
|
[("UI work", 1.0, 10000)],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
fresh_db.set_invoice_document(invoice_id, doc_id)
|
||||||
|
|
||||||
|
dialog = ProjectsDialog(fresh_db)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
assert dialog.project_combo.currentData() == project_id
|
||||||
|
assert dialog.summary_table.rowCount() == 1
|
||||||
|
assert dialog.time_logs_table.rowCount() == 1
|
||||||
|
assert dialog.documents_table.rowCount() == 1
|
||||||
|
assert dialog.invoices_table.rowCount() == 1
|
||||||
|
assert dialog.summary_table.item(0, dialog.SUM_USED).text() == "1.50"
|
||||||
|
assert (
|
||||||
|
dialog.summary_table.item(0, dialog.SUM_STATE).text()
|
||||||
|
== "Approaching bucket ceiling"
|
||||||
|
)
|
||||||
|
assert dialog.time_logs_table.item(0, dialog.LOG_NOTE).text() == "Initial support"
|
||||||
|
assert dialog.documents_table.item(0, dialog.DOC_FILE).text() == "ui-invoice.pdf"
|
||||||
|
assert dialog.invoices_table.item(0, dialog.INV_NUMBER).text() == "UI-1"
|
||||||
|
assert "UI Project" in dialog.status_label.text()
|
||||||
|
assert "1.50h / 2.00h" in dialog.status_label.text()
|
||||||
|
assert (
|
||||||
|
dialog.status_label.minimumHeight()
|
||||||
|
>= dialog.status_label.fontMetrics().lineSpacing() * 3 + 18
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_dialog_saves_and_replenishes_bucket(qtbot, fresh_db):
|
||||||
|
project_id = _add_project(fresh_db, "Editable Project")
|
||||||
|
|
||||||
|
dialog = ProjectsDialog(fresh_db)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
assert dialog.project_combo.currentData() == project_id
|
||||||
|
|
||||||
|
dialog.baseline_spin.setValue(1.5)
|
||||||
|
dialog.ceiling_spin.setValue(10.0)
|
||||||
|
dialog.warn_spin.setValue(75.0)
|
||||||
|
dialog._save_bucket()
|
||||||
|
|
||||||
|
bucket = fresh_db.get_project_bucket(project_id)
|
||||||
|
assert bucket["baseline_minutes"] == 90
|
||||||
|
assert bucket["bucket_ceiling_minutes"] == 600
|
||||||
|
assert bucket["warn_at_percent"] == 75.0
|
||||||
|
|
||||||
|
dialog.topup_spin.setValue(40.0)
|
||||||
|
dialog._add_to_ceiling()
|
||||||
|
|
||||||
|
bucket = fresh_db.get_project_bucket(project_id)
|
||||||
|
assert bucket["bucket_ceiling_minutes"] == 3000
|
||||||
|
assert "48.50h remaining" in dialog.status_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRadioButton:
|
||||||
|
def __init__(self):
|
||||||
|
self.checked = False
|
||||||
|
|
||||||
|
def setChecked(self, checked):
|
||||||
|
self.checked = checked
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeLineEdit:
|
||||||
|
def __init__(self):
|
||||||
|
self.value = ""
|
||||||
|
|
||||||
|
def setText(self, text):
|
||||||
|
self.value = text
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSpinBox:
|
||||||
|
def __init__(self):
|
||||||
|
self.value_set = None
|
||||||
|
|
||||||
|
def setValue(self, value):
|
||||||
|
self.value_set = value
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeInvoiceDialog:
|
||||||
|
instances = []
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, db, project_id, start_date_iso, end_date_iso, time_rows=None, parent=None
|
||||||
|
):
|
||||||
|
self.db = db
|
||||||
|
self.project_id = project_id
|
||||||
|
self.start_date_iso = start_date_iso
|
||||||
|
self.end_date_iso = end_date_iso
|
||||||
|
self.time_rows = time_rows
|
||||||
|
self.parent = parent
|
||||||
|
self.rb_summary = _FakeRadioButton()
|
||||||
|
self.summary_desc_edit = _FakeLineEdit()
|
||||||
|
self.summary_hours_spin = _FakeSpinBox()
|
||||||
|
self.recalculated = False
|
||||||
|
self.executed = False
|
||||||
|
_FakeInvoiceDialog.instances.append(self)
|
||||||
|
|
||||||
|
def _recalc_totals(self):
|
||||||
|
self.recalculated = True
|
||||||
|
|
||||||
|
def exec(self):
|
||||||
|
self.executed = True
|
||||||
|
return QDialog.Accepted
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_dialog_can_open_prepaid_invoice_for_unspent_hours(qtbot, fresh_db):
|
||||||
|
project_id = _add_project(fresh_db, "Prepaid UI Project")
|
||||||
|
dialog = ProjectsDialog(fresh_db)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
dialog.topup_spin.setValue(40.0)
|
||||||
|
|
||||||
|
_FakeInvoiceDialog.instances.clear()
|
||||||
|
with patch("bouquin.projects.InvoiceDialog", _FakeInvoiceDialog):
|
||||||
|
dialog._invoice_prepaid_hours()
|
||||||
|
|
||||||
|
assert len(_FakeInvoiceDialog.instances) == 1
|
||||||
|
invoice_dialog = _FakeInvoiceDialog.instances[0]
|
||||||
|
assert invoice_dialog.db is fresh_db
|
||||||
|
assert invoice_dialog.project_id == project_id
|
||||||
|
assert invoice_dialog.time_rows == []
|
||||||
|
assert invoice_dialog.parent is dialog
|
||||||
|
assert invoice_dialog.rb_summary.checked is True
|
||||||
|
assert invoice_dialog.summary_hours_spin.value_set == 40.0
|
||||||
|
assert invoice_dialog.summary_desc_edit.value == (
|
||||||
|
"Prepaid support bucket (40.00 hours)"
|
||||||
|
)
|
||||||
|
assert invoice_dialog.recalculated is True
|
||||||
|
assert invoice_dialog.executed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_dialog_warns_when_prepaid_invoice_hours_are_zero(qtbot, fresh_db):
|
||||||
|
_add_project(fresh_db, "Zero Project")
|
||||||
|
dialog = ProjectsDialog(fresh_db)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
dialog.topup_spin.setValue(0.0)
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, "warning") as warning:
|
||||||
|
dialog._invoice_prepaid_hours()
|
||||||
|
|
||||||
|
assert warning.called
|
||||||
|
assert "greater than zero" in warning.call_args.args[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_dialog_opens_selected_document_and_invoice_document(
|
||||||
|
qtbot, fresh_db, tmp_path
|
||||||
|
):
|
||||||
|
project_id = _add_project(fresh_db, "Open Project")
|
||||||
|
doc_path = tmp_path / "open.pdf"
|
||||||
|
doc_path.write_bytes(b"open")
|
||||||
|
doc_id = fresh_db.add_document_from_path(project_id, str(doc_path))
|
||||||
|
invoice_id = fresh_db.create_invoice(
|
||||||
|
project_id,
|
||||||
|
"OPEN-1",
|
||||||
|
"2026-05-01",
|
||||||
|
None,
|
||||||
|
"AUD",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"summary",
|
||||||
|
[("Open work", 1.0, 10000)],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
fresh_db.set_invoice_document(invoice_id, doc_id)
|
||||||
|
|
||||||
|
dialog = ProjectsDialog(fresh_db)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
dialog.documents_table.selectRow(0)
|
||||||
|
with patch("bouquin.projects.open_document_from_db") as open_doc:
|
||||||
|
dialog._open_selected_document()
|
||||||
|
open_doc.assert_called_once_with(
|
||||||
|
fresh_db, doc_id, "open.pdf", parent_widget=dialog
|
||||||
|
)
|
||||||
|
|
||||||
|
dialog.invoices_table.selectRow(0)
|
||||||
|
with patch("bouquin.projects.open_document_from_db") as open_doc:
|
||||||
|
dialog._open_invoice_document()
|
||||||
|
open_doc.assert_called_once_with(
|
||||||
|
fresh_db, doc_id, "open.pdf", parent_widget=dialog
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_dialog_reports_missing_invoice_document(qtbot, fresh_db):
|
||||||
|
project_id = _add_project(fresh_db, "No Doc Project")
|
||||||
|
fresh_db.create_invoice(
|
||||||
|
project_id,
|
||||||
|
"NO-DOC-1",
|
||||||
|
"2026-05-01",
|
||||||
|
None,
|
||||||
|
"AUD",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"summary",
|
||||||
|
[("Work", 1.0, 10000)],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
dialog = ProjectsDialog(fresh_db)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
dialog.invoices_table.selectRow(0)
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, "information") as info:
|
||||||
|
dialog._open_invoice_document()
|
||||||
|
assert info.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_log_dialog_updates_bucket_indicator_and_alerts_on_reach(qtbot, fresh_db):
|
||||||
|
project_id = _add_project(fresh_db, "Logging Project")
|
||||||
|
fresh_db.upsert_project_bucket(project_id, 0, 60, 80.0)
|
||||||
|
|
||||||
|
dialog = TimeLogDialog(fresh_db, "2026-06-01")
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
idx = dialog.project_combo.findData(project_id)
|
||||||
|
dialog.project_combo.setCurrentIndex(idx)
|
||||||
|
assert "0.00h / 1.00h" in dialog.bucket_label.text()
|
||||||
|
assert "Status: OK" in dialog.bucket_label.text()
|
||||||
|
|
||||||
|
dialog.activity_edit.setText("Support")
|
||||||
|
dialog.hours_spin.setValue(1.0)
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, "warning") as warning:
|
||||||
|
dialog._on_add_or_update()
|
||||||
|
assert warning.called
|
||||||
|
assert "Bucket ceiling reached" in warning.call_args.args[2]
|
||||||
|
|
||||||
|
status = fresh_db.project_bucket_status(project_id)
|
||||||
|
assert status["state"] == "reached"
|
||||||
|
assert "Bucket ceiling reached" in dialog.bucket_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_log_dialog_does_not_alert_before_reaching_bucket(qtbot, fresh_db):
|
||||||
|
project_id = _add_project(fresh_db, "Safe Logging")
|
||||||
|
fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0)
|
||||||
|
|
||||||
|
dialog = TimeLogDialog(fresh_db, "2026-06-02")
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(project_id))
|
||||||
|
dialog.activity_edit.setText("Support")
|
||||||
|
dialog.hours_spin.setValue(0.5)
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, "warning") as warning:
|
||||||
|
dialog._on_add_or_update()
|
||||||
|
warning.assert_not_called()
|
||||||
|
|
||||||
|
assert fresh_db.project_bucket_status(project_id)["state"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_report_dialog_shows_bucket_for_selected_project_and_clears_for_all(
|
||||||
|
qtbot, fresh_db
|
||||||
|
):
|
||||||
|
project_id = _add_project(fresh_db, "Report Project")
|
||||||
|
_add_minutes(fresh_db, project_id, 90)
|
||||||
|
fresh_db.upsert_project_bucket(project_id, 0, 120, 75.0)
|
||||||
|
|
||||||
|
dialog = TimeReportDialog(fresh_db)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
dialog.from_date.setDate(QDate.fromString("2026-01-01", "yyyy-MM-dd"))
|
||||||
|
dialog.to_date.setDate(QDate.fromString("2026-01-31", "yyyy-MM-dd"))
|
||||||
|
|
||||||
|
dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(project_id))
|
||||||
|
dialog._run_report()
|
||||||
|
assert "Report Project" in dialog.bucket_label.text()
|
||||||
|
assert "Approaching bucket ceiling" in dialog.bucket_label.text()
|
||||||
|
|
||||||
|
dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(None))
|
||||||
|
dialog._run_report()
|
||||||
|
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"]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue