Allow for creating buckets of hours that logged time can fill up, with adjustable ceiling and warnings
This commit is contained in:
parent
34871b72e2
commit
58333bf93c
8 changed files with 1737 additions and 11 deletions
289
bouquin/db.py
289
bouquin/db.py
|
|
@ -308,6 +308,22 @@ 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 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,
|
||||||
|
|
@ -1219,6 +1235,242 @@ 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 upsert_project_bucket(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
baseline_minutes: int,
|
||||||
|
bucket_ceiling_minutes: int,
|
||||||
|
warn_at_percent: float = 80.0,
|
||||||
|
) -> None:
|
||||||
|
"""Save cumulative prepaid-hour bucket settings for a project.
|
||||||
|
|
||||||
|
``baseline_minutes`` represents already-spent hours that pre-date
|
||||||
|
Bouquin time logging. ``bucket_ceiling_minutes`` is the cumulative
|
||||||
|
prepaid ceiling purchased for this project.
|
||||||
|
"""
|
||||||
|
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 = min(100.0, max(0.0, float(warn_at_percent or 0.0)))
|
||||||
|
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
|
||||||
|
baseline_minutes = excluded.baseline_minutes,
|
||||||
|
bucket_ceiling_minutes = excluded.bucket_ceiling_minutes,
|
||||||
|
warn_at_percent = excluded.warn_at_percent,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
project_id,
|
||||||
|
baseline_minutes,
|
||||||
|
bucket_ceiling_minutes,
|
||||||
|
warn_at_percent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_to_project_bucket_ceiling(self, project_id: int, add_minutes: int) -> 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
|
||||||
|
with self.conn:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO project_buckets (
|
||||||
|
project_id,
|
||||||
|
baseline_minutes,
|
||||||
|
bucket_ceiling_minutes,
|
||||||
|
warn_at_percent
|
||||||
|
)
|
||||||
|
VALUES (?, 0, ?, 80.0)
|
||||||
|
ON CONFLICT(project_id) DO UPDATE SET
|
||||||
|
bucket_ceiling_minutes = bucket_ceiling_minutes + excluded.bucket_ceiling_minutes,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');
|
||||||
|
""",
|
||||||
|
(project_id, add_minutes),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_project_bucket(self, project_id: int):
|
||||||
|
"""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()
|
||||||
|
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 time_log_count_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 COUNT(*) AS c FROM time_log WHERE project_id = ?;",
|
||||||
|
(project_id,),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["c"] 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 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 +2353,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 +2455,41 @@ 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,
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@
|
||||||
"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",
|
||||||
|
|
@ -276,9 +276,8 @@
|
||||||
"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_with_total": "Time log ({hours:.2f}h)",
|
|
||||||
"time_log_total_hours": "Total for day: {hours:.2f}h",
|
"time_log_total_hours": "Total for day: {hours:.2f}h",
|
||||||
|
"time_log_with_total": "Time log ({hours:.2f}h)",
|
||||||
"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",
|
||||||
|
|
@ -314,22 +313,19 @@
|
||||||
"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",
|
||||||
"set_reminder": "Set Reminder",
|
|
||||||
"edit_reminder": "Edit Reminder",
|
"edit_reminder": "Edit Reminder",
|
||||||
"delete_reminder": "Delete Reminder",
|
"delete_reminder": "Delete Reminder",
|
||||||
"delete_reminders": "Delete Reminders",
|
"delete_reminders": "Delete Reminders",
|
||||||
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
|
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
|
||||||
"this_is_a_reminder_of_type": "Note: This is a reminder of type",
|
"this_is_a_reminder_of_type": "Note: This is a reminder of type",
|
||||||
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
|
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
|
||||||
"reminder": "Reminder",
|
|
||||||
"reminders": "Reminders",
|
"reminders": "Reminders",
|
||||||
"time": "Time",
|
"time": "Time",
|
||||||
"once": "Once",
|
|
||||||
"every_day": "Every day",
|
"every_day": "Every day",
|
||||||
"every_weekday": "Every weekday (Mon-Fri)",
|
"every_weekday": "Every weekday (Mon-Fri)",
|
||||||
"every_week": "Every week",
|
"every_week": "Every week",
|
||||||
|
|
@ -437,5 +433,41 @@
|
||||||
"invoice_invalid_tax_rate": "The tax rate is invalid",
|
"invoice_invalid_tax_rate": "The tax rate is invalid",
|
||||||
"invoice_no_items": "There are no items in the invoice",
|
"invoice_no_items": "There are no items in the invoice",
|
||||||
"invoice_number_required": "An invoice number is required",
|
"invoice_number_required": "An invoice number is required",
|
||||||
"invoice_required": "Please select a specific invoice before trying to delete an invoice."
|
"invoice_required": "Please select a specific invoice before trying to delete an invoice.",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"status": "Status",
|
||||||
|
"client": "Client",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
672
bouquin/projects.py
Normal file
672
bouquin/projects.py
Normal file
|
|
@ -0,0 +1,672 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
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.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_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 _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)
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
580
tests/test_projects.py
Normal file
580
tests/test_projects.py
Normal file
|
|
@ -0,0 +1,580 @@
|
||||||
|
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() == ""
|
||||||
Loading…
Add table
Add a link
Reference in a new issue