Expose note in time log dialog and reports
This commit is contained in:
parent
f41ec9a5a9
commit
119d326eea
4 changed files with 64 additions and 39 deletions
|
|
@ -987,6 +987,7 @@ class DBManager:
|
||||||
SELECT
|
SELECT
|
||||||
{bucket_expr} AS bucket,
|
{bucket_expr} AS bucket,
|
||||||
a.name AS activity_name,
|
a.name AS activity_name,
|
||||||
|
t.note AS note,
|
||||||
SUM(t.minutes) AS total_minutes
|
SUM(t.minutes) AS total_minutes
|
||||||
FROM time_log t
|
FROM time_log t
|
||||||
JOIN activities a ON a.id = t.activity_id
|
JOIN activities a ON a.id = t.activity_id
|
||||||
|
|
@ -998,7 +999,10 @@ class DBManager:
|
||||||
(project_id, start_date_iso, end_date_iso),
|
(project_id, start_date_iso, end_date_iso),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
return [(r["bucket"], r["activity_name"], r["total_minutes"]) for r in rows]
|
return [
|
||||||
|
(r["bucket"], r["activity_name"], r["note"], r["total_minutes"])
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
if self.conn is not None:
|
if self.conn is not None:
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@
|
||||||
"toolbar_alarm": "Set reminder alarm",
|
"toolbar_alarm": "Set reminder alarm",
|
||||||
"activities": "Activities",
|
"activities": "Activities",
|
||||||
"activity": "Activity",
|
"activity": "Activity",
|
||||||
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ class TimeLogDialog(QDialog):
|
||||||
self._current_entry_id: Optional[int] = None
|
self._current_entry_id: Optional[int] = None
|
||||||
|
|
||||||
self.setWindowTitle(strings._("time_log_for").format(date=date_iso))
|
self.setWindowTitle(strings._("time_log_for").format(date=date_iso))
|
||||||
self.resize(600, 400)
|
self.resize(900, 600)
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
|
|
||||||
|
|
@ -211,6 +211,12 @@ class TimeLogDialog(QDialog):
|
||||||
act_row.addWidget(self.manage_activities_btn)
|
act_row.addWidget(self.manage_activities_btn)
|
||||||
form.addRow(strings._("activity"), act_row)
|
form.addRow(strings._("activity"), act_row)
|
||||||
|
|
||||||
|
# Optional Note
|
||||||
|
note_row = QHBoxLayout()
|
||||||
|
self.note = QLineEdit()
|
||||||
|
note_row.addWidget(self.note, 1)
|
||||||
|
form.addRow(strings._("note"), note_row)
|
||||||
|
|
||||||
# Hours (decimal)
|
# Hours (decimal)
|
||||||
self.hours_spin = QDoubleSpinBox()
|
self.hours_spin = QDoubleSpinBox()
|
||||||
self.hours_spin.setRange(0.0, 24.0)
|
self.hours_spin.setRange(0.0, 24.0)
|
||||||
|
|
@ -240,18 +246,20 @@ class TimeLogDialog(QDialog):
|
||||||
|
|
||||||
# --- Table of entries for this date
|
# --- Table of entries for this date
|
||||||
self.table = QTableWidget()
|
self.table = QTableWidget()
|
||||||
self.table.setColumnCount(3)
|
self.table.setColumnCount(4)
|
||||||
self.table.setHorizontalHeaderLabels(
|
self.table.setHorizontalHeaderLabels(
|
||||||
[
|
[
|
||||||
strings._("project"),
|
strings._("project"),
|
||||||
strings._("activity"),
|
strings._("activity"),
|
||||||
|
strings._("note"),
|
||||||
strings._("hours"),
|
strings._("hours"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||||
|
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
||||||
self.table.horizontalHeader().setSectionResizeMode(
|
self.table.horizontalHeader().setSectionResizeMode(
|
||||||
2, QHeaderView.ResizeToContents
|
3, QHeaderView.ResizeToContents
|
||||||
)
|
)
|
||||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
|
|
@ -291,11 +299,13 @@ class TimeLogDialog(QDialog):
|
||||||
entry_id = r[0]
|
entry_id = r[0]
|
||||||
project_name = r[3]
|
project_name = r[3]
|
||||||
activity_name = r[5]
|
activity_name = r[5]
|
||||||
|
note = r[7] or ""
|
||||||
minutes = r[6]
|
minutes = r[6]
|
||||||
hours = minutes / 60.0
|
hours = minutes / 60.0
|
||||||
|
|
||||||
item_proj = QTableWidgetItem(project_name)
|
item_proj = QTableWidgetItem(project_name)
|
||||||
item_act = QTableWidgetItem(activity_name)
|
item_act = QTableWidgetItem(activity_name)
|
||||||
|
item_note = QTableWidgetItem(note)
|
||||||
item_hours = QTableWidgetItem(f"{hours:.2f}")
|
item_hours = QTableWidgetItem(f"{hours:.2f}")
|
||||||
|
|
||||||
# store the entry id on the first column
|
# store the entry id on the first column
|
||||||
|
|
@ -303,7 +313,8 @@ class TimeLogDialog(QDialog):
|
||||||
|
|
||||||
self.table.setItem(row_idx, 0, item_proj)
|
self.table.setItem(row_idx, 0, item_proj)
|
||||||
self.table.setItem(row_idx, 1, item_act)
|
self.table.setItem(row_idx, 1, item_act)
|
||||||
self.table.setItem(row_idx, 2, item_hours)
|
self.table.setItem(row_idx, 2, item_note)
|
||||||
|
self.table.setItem(row_idx, 3, item_hours)
|
||||||
|
|
||||||
self._current_entry_id = None
|
self._current_entry_id = None
|
||||||
self.delete_btn.setEnabled(False)
|
self.delete_btn.setEnabled(False)
|
||||||
|
|
@ -338,6 +349,10 @@ class TimeLogDialog(QDialog):
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
note = self.note.text().strip()
|
||||||
|
if not note:
|
||||||
|
note = None
|
||||||
|
|
||||||
hours = float(self.hours_spin.value())
|
hours = float(self.hours_spin.value())
|
||||||
minutes = int(round(hours * 60))
|
minutes = int(round(hours * 60))
|
||||||
|
|
||||||
|
|
@ -346,16 +361,14 @@ class TimeLogDialog(QDialog):
|
||||||
|
|
||||||
if self._current_entry_id is None:
|
if self._current_entry_id is None:
|
||||||
# New entry
|
# New entry
|
||||||
self._db.add_time_log(self._date_iso, proj_id, activity_id, minutes)
|
self._db.add_time_log(self._date_iso, proj_id, activity_id, minutes, note)
|
||||||
else:
|
else:
|
||||||
# Update existing
|
# Update existing
|
||||||
self._db.update_time_log(
|
self._db.update_time_log(
|
||||||
self._current_entry_id,
|
self._current_entry_id, proj_id, activity_id, minutes, note
|
||||||
proj_id,
|
|
||||||
activity_id,
|
|
||||||
minutes,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.note.setText("")
|
||||||
self._reload_entries()
|
self._reload_entries()
|
||||||
|
|
||||||
def _on_row_selected(self) -> None:
|
def _on_row_selected(self) -> None:
|
||||||
|
|
@ -369,7 +382,8 @@ class TimeLogDialog(QDialog):
|
||||||
row = items[0].row()
|
row = items[0].row()
|
||||||
proj_item = self.table.item(row, 0)
|
proj_item = self.table.item(row, 0)
|
||||||
act_item = self.table.item(row, 1)
|
act_item = self.table.item(row, 1)
|
||||||
hours_item = self.table.item(row, 2)
|
note_item = self.table.item(row, 2)
|
||||||
|
hours_item = self.table.item(row, 3)
|
||||||
entry_id = proj_item.data(Qt.ItemDataRole.UserRole)
|
entry_id = proj_item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
|
||||||
self._current_entry_id = int(entry_id)
|
self._current_entry_id = int(entry_id)
|
||||||
|
|
@ -379,6 +393,7 @@ class TimeLogDialog(QDialog):
|
||||||
# push values into the editors
|
# push values into the editors
|
||||||
proj_name = proj_item.text()
|
proj_name = proj_item.text()
|
||||||
act_name = act_item.text()
|
act_name = act_item.text()
|
||||||
|
note = note_item.text()
|
||||||
hours = float(hours_item.text())
|
hours = float(hours_item.text())
|
||||||
|
|
||||||
# Set project combo by name
|
# Set project combo by name
|
||||||
|
|
@ -387,6 +402,7 @@ class TimeLogDialog(QDialog):
|
||||||
self.project_combo.setCurrentIndex(idx)
|
self.project_combo.setCurrentIndex(idx)
|
||||||
|
|
||||||
self.activity_edit.setText(act_name)
|
self.activity_edit.setText(act_name)
|
||||||
|
self.note.setText(note)
|
||||||
self.hours_spin.setValue(hours)
|
self.hours_spin.setValue(hours)
|
||||||
|
|
||||||
def _on_delete_entry(self) -> None:
|
def _on_delete_entry(self) -> None:
|
||||||
|
|
@ -787,18 +803,20 @@ class TimeReportDialog(QDialog):
|
||||||
|
|
||||||
# Table
|
# Table
|
||||||
self.table = QTableWidget()
|
self.table = QTableWidget()
|
||||||
self.table.setColumnCount(3)
|
self.table.setColumnCount(4)
|
||||||
self.table.setHorizontalHeaderLabels(
|
self.table.setHorizontalHeaderLabels(
|
||||||
[
|
[
|
||||||
strings._("time_period"),
|
strings._("time_period"),
|
||||||
strings._("activity"),
|
strings._("activity"),
|
||||||
|
strings._("note"),
|
||||||
strings._("hours"),
|
strings._("hours"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||||
|
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
||||||
self.table.horizontalHeader().setSectionResizeMode(
|
self.table.horizontalHeader().setSectionResizeMode(
|
||||||
2, QHeaderView.ResizeToContents
|
3, QHeaderView.ResizeToContents
|
||||||
)
|
)
|
||||||
root.addWidget(self.table, 1)
|
root.addWidget(self.table, 1)
|
||||||
|
|
||||||
|
|
@ -833,14 +851,15 @@ class TimeReportDialog(QDialog):
|
||||||
rows = self._db.time_report(proj_id, start, end, gran)
|
rows = self._db.time_report(proj_id, start, end, gran)
|
||||||
|
|
||||||
self._last_rows = rows
|
self._last_rows = rows
|
||||||
self._last_total_minutes = sum(r[2] for r in rows)
|
self._last_total_minutes = sum(r[3] for r in rows)
|
||||||
|
|
||||||
self.table.setRowCount(len(rows))
|
self.table.setRowCount(len(rows))
|
||||||
for i, (time_period, activity_name, minutes) in enumerate(rows):
|
for i, (time_period, activity_name, note, minutes) in enumerate(rows):
|
||||||
hrs = minutes / 60.0
|
hrs = minutes / 60.0
|
||||||
self.table.setItem(i, 0, QTableWidgetItem(time_period))
|
self.table.setItem(i, 0, QTableWidgetItem(time_period))
|
||||||
self.table.setItem(i, 1, QTableWidgetItem(activity_name))
|
self.table.setItem(i, 1, QTableWidgetItem(activity_name))
|
||||||
self.table.setItem(i, 2, QTableWidgetItem(f"{hrs:.2f}"))
|
self.table.setItem(i, 2, QTableWidgetItem(note))
|
||||||
|
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}"))
|
||||||
|
|
||||||
total_hours = self._last_total_minutes / 60.0
|
total_hours = self._last_total_minutes / 60.0
|
||||||
self.total_label.setText(
|
self.total_label.setText(
|
||||||
|
|
@ -874,14 +893,15 @@ class TimeReportDialog(QDialog):
|
||||||
[
|
[
|
||||||
strings._("time_period"),
|
strings._("time_period"),
|
||||||
strings._("activity"),
|
strings._("activity"),
|
||||||
|
strings._("note"),
|
||||||
strings._("hours"),
|
strings._("hours"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Data rows
|
# Data rows
|
||||||
for time_period, activity_name, minutes in self._last_rows:
|
for time_period, activity_name, note, minutes in self._last_rows:
|
||||||
hours = minutes / 60.0
|
hours = minutes / 60.0
|
||||||
writer.writerow([time_period, activity_name, f"{hours:.2f}"])
|
writer.writerow([time_period, activity_name, note, f"{hours:.2f}"])
|
||||||
|
|
||||||
# Blank line + total
|
# Blank line + total
|
||||||
total_hours = self._last_total_minutes / 60.0
|
total_hours = self._last_total_minutes / 60.0
|
||||||
|
|
@ -914,7 +934,7 @@ class TimeReportDialog(QDialog):
|
||||||
|
|
||||||
# ---------- Build chart image (hours per period) ----------
|
# ---------- Build chart image (hours per period) ----------
|
||||||
per_period_minutes: dict[str, int] = defaultdict(int)
|
per_period_minutes: dict[str, int] = defaultdict(int)
|
||||||
for period, _activity, minutes in self._last_rows:
|
for period, _activity, note, minutes in self._last_rows:
|
||||||
per_period_minutes[period] += minutes
|
per_period_minutes[period] += minutes
|
||||||
|
|
||||||
periods = sorted(per_period_minutes.keys())
|
periods = sorted(per_period_minutes.keys())
|
||||||
|
|
@ -1015,7 +1035,7 @@ class TimeReportDialog(QDialog):
|
||||||
|
|
||||||
# Table rows (period, activity, hours)
|
# Table rows (period, activity, hours)
|
||||||
row_html_parts: list[str] = []
|
row_html_parts: list[str] = []
|
||||||
for period, activity, minutes in self._last_rows:
|
for period, activity, note, minutes in self._last_rows:
|
||||||
hours = minutes / 60.0
|
hours = minutes / 60.0
|
||||||
row_html_parts.append(
|
row_html_parts.append(
|
||||||
"<tr>"
|
"<tr>"
|
||||||
|
|
|
||||||
|
|
@ -370,10 +370,10 @@ def test_time_report_by_day(fresh_db):
|
||||||
yesterday_act1 = next(
|
yesterday_act1 = next(
|
||||||
r for r in report if r[0] == _yesterday() and r[1] == "Activity 1"
|
r for r in report if r[0] == _yesterday() and r[1] == "Activity 1"
|
||||||
)
|
)
|
||||||
assert yesterday_act1[2] == 60
|
assert yesterday_act1[3] == 60
|
||||||
|
|
||||||
today_act1 = next(r for r in report if r[0] == _today() and r[1] == "Activity 1")
|
today_act1 = next(r for r in report if r[0] == _today() and r[1] == "Activity 1")
|
||||||
assert today_act1[2] == 90
|
assert today_act1[3] == 90
|
||||||
|
|
||||||
|
|
||||||
def test_time_report_by_week(fresh_db):
|
def test_time_report_by_week(fresh_db):
|
||||||
|
|
@ -396,9 +396,9 @@ def test_time_report_by_week(fresh_db):
|
||||||
assert len(report) == 2
|
assert len(report) == 2
|
||||||
|
|
||||||
# First week total
|
# First week total
|
||||||
assert report[0][2] == 90 # 60 + 30
|
assert report[0][3] == 90 # 60 + 30
|
||||||
# Second week total
|
# Second week total
|
||||||
assert report[1][2] == 45
|
assert report[1][3] == 45
|
||||||
|
|
||||||
|
|
||||||
def test_time_report_by_month(fresh_db):
|
def test_time_report_by_month(fresh_db):
|
||||||
|
|
@ -422,11 +422,11 @@ def test_time_report_by_month(fresh_db):
|
||||||
|
|
||||||
# January total
|
# January total
|
||||||
jan_row = next(r for r in report if r[0] == "2024-01")
|
jan_row = next(r for r in report if r[0] == "2024-01")
|
||||||
assert jan_row[2] == 90 # 60 + 30
|
assert jan_row[3] == 90 # 60 + 30
|
||||||
|
|
||||||
# February total
|
# February total
|
||||||
feb_row = next(r for r in report if r[0] == "2024-02")
|
feb_row = next(r for r in report if r[0] == "2024-02")
|
||||||
assert feb_row[2] == 45
|
assert feb_row[3] == 45
|
||||||
|
|
||||||
|
|
||||||
def test_time_report_multiple_activities(fresh_db):
|
def test_time_report_multiple_activities(fresh_db):
|
||||||
|
|
@ -443,10 +443,10 @@ def test_time_report_multiple_activities(fresh_db):
|
||||||
|
|
||||||
assert len(report) == 2
|
assert len(report) == 2
|
||||||
act1_row = next(r for r in report if r[1] == "Activity 1")
|
act1_row = next(r for r in report if r[1] == "Activity 1")
|
||||||
assert act1_row[2] == 90 # 60 + 30 aggregated
|
assert act1_row[3] == 90 # 60 + 30 aggregated
|
||||||
|
|
||||||
act2_row = next(r for r in report if r[1] == "Activity 2")
|
act2_row = next(r for r in report if r[1] == "Activity 2")
|
||||||
assert act2_row[2] == 45
|
assert act2_row[3] == 45
|
||||||
|
|
||||||
|
|
||||||
def test_time_report_filters_by_project(fresh_db):
|
def test_time_report_filters_by_project(fresh_db):
|
||||||
|
|
@ -461,7 +461,7 @@ def test_time_report_filters_by_project(fresh_db):
|
||||||
report = fresh_db.time_report(proj1_id, _today(), _today(), "day")
|
report = fresh_db.time_report(proj1_id, _today(), _today(), "day")
|
||||||
|
|
||||||
assert len(report) == 1
|
assert len(report) == 1
|
||||||
assert report[0][2] == 60
|
assert report[0][3] == 60
|
||||||
|
|
||||||
|
|
||||||
def test_time_report_empty(fresh_db):
|
def test_time_report_empty(fresh_db):
|
||||||
|
|
@ -658,8 +658,8 @@ def test_time_log_dialog_loads_existing_entries(qtbot, fresh_db):
|
||||||
assert "Project" in dialog.table.item(0, 0).text()
|
assert "Project" in dialog.table.item(0, 0).text()
|
||||||
assert "Activity" in dialog.table.item(0, 1).text()
|
assert "Activity" in dialog.table.item(0, 1).text()
|
||||||
assert (
|
assert (
|
||||||
"1.5" in dialog.table.item(0, 2).text()
|
"1.5" in dialog.table.item(0, 3).text()
|
||||||
or "1.50" in dialog.table.item(0, 2).text()
|
or "1.50" in dialog.table.item(0, 3).text()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -725,8 +725,8 @@ def test_time_log_dialog_add_entry_success(qtbot, fresh_db):
|
||||||
assert dialog.table.rowCount() == 1
|
assert dialog.table.rowCount() == 1
|
||||||
assert "New Activity" in dialog.table.item(0, 1).text()
|
assert "New Activity" in dialog.table.item(0, 1).text()
|
||||||
assert (
|
assert (
|
||||||
"2.5" in dialog.table.item(0, 2).text()
|
"2.5" in dialog.table.item(0, 3).text()
|
||||||
or "2.50" in dialog.table.item(0, 2).text()
|
or "2.50" in dialog.table.item(0, 3).text()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -774,8 +774,8 @@ def test_time_log_dialog_update_entry(qtbot, fresh_db):
|
||||||
assert dialog.table.rowCount() == 1
|
assert dialog.table.rowCount() == 1
|
||||||
assert "Project 2" in dialog.table.item(0, 0).text()
|
assert "Project 2" in dialog.table.item(0, 0).text()
|
||||||
assert (
|
assert (
|
||||||
"2.0" in dialog.table.item(0, 2).text()
|
"2.0" in dialog.table.item(0, 3).text()
|
||||||
or "2.00" in dialog.table.item(0, 2).text()
|
or "2.00" in dialog.table.item(0, 3).text()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1429,7 +1429,7 @@ def test_time_report_dialog_granularity_week(qtbot, fresh_db):
|
||||||
|
|
||||||
# Should aggregate to single week
|
# Should aggregate to single week
|
||||||
assert dialog.table.rowCount() == 1
|
assert dialog.table.rowCount() == 1
|
||||||
hours_text = dialog.table.item(0, 2).text()
|
hours_text = dialog.table.item(0, 3).text()
|
||||||
assert "2.5" in hours_text or "2.50" in hours_text
|
assert "2.5" in hours_text or "2.50" in hours_text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1457,7 +1457,7 @@ def test_time_report_dialog_granularity_month(qtbot, fresh_db):
|
||||||
|
|
||||||
# Should aggregate to single month
|
# Should aggregate to single month
|
||||||
assert dialog.table.rowCount() == 1
|
assert dialog.table.rowCount() == 1
|
||||||
hours_text = dialog.table.item(0, 2).text()
|
hours_text = dialog.table.item(0, 3).text()
|
||||||
assert "2.5" in hours_text or "2.50" in hours_text
|
assert "2.5" in hours_text or "2.50" in hours_text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2196,8 +2196,8 @@ def test_full_workflow_add_project_activity_log_report(
|
||||||
assert report_dialog.table.rowCount() == 1
|
assert report_dialog.table.rowCount() == 1
|
||||||
assert "Test Activity" in report_dialog.table.item(0, 1).text()
|
assert "Test Activity" in report_dialog.table.item(0, 1).text()
|
||||||
assert (
|
assert (
|
||||||
"2.5" in report_dialog.table.item(0, 2).text()
|
"2.5" in report_dialog.table.item(0, 3).text()
|
||||||
or "2.50" in report_dialog.table.item(0, 2).text()
|
or "2.50" in report_dialog.table.item(0, 3).text()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. Export CSV
|
# 5. Export CSV
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue