mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 04:08:48 -05:00
feat(workforce): add delete for periods, time-off, leave types, and holidays (fixes #562)
- Backend: WorkforceGovernanceService.delete_period, delete_leave_request, delete_leave_type, delete_holiday with permission and state checks - Web: POST delete routes in workforce blueprint; delete buttons in dashboard for periods (draft/rejected), time-off (draft/submitted/cancelled), leave types list, and company holidays (admin only) - API v1: DELETE endpoints for timesheet-periods, time-off/requests, time-off/leave-types, time-off/holidays (scopes and admin where required) - Desktop: deleteTimesheetPeriod/deleteTimeOffRequest in API client; Delete buttons and handlers in workforce view with confirmation and refresh - Mobile: deleteTimesheetPeriod/deleteTimeOffRequest in API client; Delete in popup menus for periods and time-off requests - Docs: WORKFORCE_DELETE.md, PROJECT_STRUCTURE and API_TOKEN_SCOPES updates
This commit is contained in:
@@ -4266,6 +4266,17 @@ def close_timesheet_period(period_id):
|
||||
return jsonify({"message": "Timesheet period closed", "timesheet_period": result["period"].to_dict()})
|
||||
|
||||
|
||||
@api_v1_bp.route("/timesheet-periods/<int:period_id>", methods=["DELETE"])
|
||||
@require_api_token("write:time_entries")
|
||||
def delete_timesheet_period_api(period_id):
|
||||
from app.services.workforce_governance_service import WorkforceGovernanceService
|
||||
|
||||
result = WorkforceGovernanceService().delete_period(period_id=period_id, actor_id=g.api_user.id)
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not delete period")}), 400
|
||||
return jsonify({"message": "Timesheet period deleted"})
|
||||
|
||||
|
||||
@api_v1_bp.route("/timesheet-policy", methods=["GET"])
|
||||
@require_api_token("read:time_entries")
|
||||
def get_timesheet_policy():
|
||||
@@ -4348,6 +4359,19 @@ def create_leave_type_api():
|
||||
return jsonify({"message": "Leave type created", "leave_type": leave_type.to_dict()}), 201
|
||||
|
||||
|
||||
@api_v1_bp.route("/time-off/leave-types/<int:leave_type_id>", methods=["DELETE"])
|
||||
@require_api_token("write:reports")
|
||||
def delete_leave_type_api(leave_type_id):
|
||||
from app.services.workforce_governance_service import WorkforceGovernanceService
|
||||
|
||||
if not g.api_user.is_admin:
|
||||
return jsonify({"error": "Access denied"}), 403
|
||||
result = WorkforceGovernanceService().delete_leave_type(leave_type_id)
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not delete leave type")}), 400
|
||||
return jsonify({"message": "Leave type deleted"})
|
||||
|
||||
|
||||
@api_v1_bp.route("/time-off/requests", methods=["GET"])
|
||||
@require_api_token("read:time_entries")
|
||||
def list_time_off_requests_api():
|
||||
@@ -4445,6 +4469,21 @@ def reject_time_off_request_api(request_id):
|
||||
return jsonify({"message": "Time-off request rejected", "time_off_request": result["request"].to_dict()})
|
||||
|
||||
|
||||
@api_v1_bp.route("/time-off/requests/<int:request_id>", methods=["DELETE"])
|
||||
@require_api_token("write:time_entries")
|
||||
def delete_time_off_request_api(request_id):
|
||||
from app.services.workforce_governance_service import WorkforceGovernanceService
|
||||
|
||||
result = WorkforceGovernanceService().delete_leave_request(
|
||||
request_id=request_id,
|
||||
actor_id=g.api_user.id,
|
||||
actor_can_approve=_is_api_approver(g.api_user),
|
||||
)
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not delete request")}), 400
|
||||
return jsonify({"message": "Time-off request deleted"})
|
||||
|
||||
|
||||
@api_v1_bp.route("/time-off/balances", methods=["GET"])
|
||||
@require_api_token("read:time_entries")
|
||||
def time_off_balances_api():
|
||||
@@ -4494,6 +4533,19 @@ def create_holiday_api():
|
||||
return jsonify({"message": "Holiday created", "holiday": holiday.to_dict()}), 201
|
||||
|
||||
|
||||
@api_v1_bp.route("/time-off/holidays/<int:holiday_id>", methods=["DELETE"])
|
||||
@require_api_token("write:reports")
|
||||
def delete_holiday_api(holiday_id):
|
||||
from app.services.workforce_governance_service import WorkforceGovernanceService
|
||||
|
||||
if not g.api_user.is_admin:
|
||||
return jsonify({"error": "Access denied"}), 403
|
||||
result = WorkforceGovernanceService().delete_holiday(holiday_id)
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not delete holiday")}), 400
|
||||
return jsonify({"message": "Holiday deleted"})
|
||||
|
||||
|
||||
# ==================== Payroll Export ====================
|
||||
|
||||
|
||||
|
||||
@@ -172,6 +172,14 @@ def close_period(period_id):
|
||||
return redirect(url_for("workforce.dashboard"))
|
||||
|
||||
|
||||
@workforce_bp.route("/workforce/periods/<int:period_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_period(period_id):
|
||||
result = WorkforceGovernanceService().delete_period(period_id=period_id, actor_id=current_user.id)
|
||||
flash(_(result.get("message", "Period deleted")) if result.get("success") else _(result.get("message", "Could not delete period")), "success" if result.get("success") else "error")
|
||||
return redirect(url_for("workforce.dashboard"))
|
||||
|
||||
|
||||
@workforce_bp.route("/workforce/policy", methods=["POST"])
|
||||
@login_required
|
||||
def update_policy():
|
||||
@@ -222,6 +230,17 @@ def create_leave_type():
|
||||
return redirect(url_for("workforce.dashboard"))
|
||||
|
||||
|
||||
@workforce_bp.route("/workforce/leave-types/<int:leave_type_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_leave_type(leave_type_id):
|
||||
if not current_user.is_admin:
|
||||
flash(_("Access denied"), "error")
|
||||
return redirect(url_for("workforce.dashboard"))
|
||||
result = WorkforceGovernanceService().delete_leave_type(leave_type_id)
|
||||
flash(_(result.get("message", "Leave type deleted")) if result.get("success") else _(result.get("message", "Could not delete leave type")), "success" if result.get("success") else "error")
|
||||
return redirect(url_for("workforce.dashboard"))
|
||||
|
||||
|
||||
@workforce_bp.route("/workforce/time-off/request", methods=["POST"])
|
||||
@login_required
|
||||
def create_time_off_request():
|
||||
@@ -284,6 +303,18 @@ def reject_time_off_request(request_id):
|
||||
return redirect(url_for("workforce.dashboard"))
|
||||
|
||||
|
||||
@workforce_bp.route("/workforce/time-off/<int:request_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_time_off_request(request_id):
|
||||
result = WorkforceGovernanceService().delete_leave_request(
|
||||
request_id=request_id,
|
||||
actor_id=current_user.id,
|
||||
actor_can_approve=_can_approve(),
|
||||
)
|
||||
flash(_(result.get("message", "Time-off request deleted")) if result.get("success") else _(result.get("message", "Could not delete request")), "success" if result.get("success") else "error")
|
||||
return redirect(url_for("workforce.dashboard"))
|
||||
|
||||
|
||||
@workforce_bp.route("/workforce/holidays/create", methods=["POST"])
|
||||
@login_required
|
||||
def create_holiday():
|
||||
@@ -305,6 +336,17 @@ def create_holiday():
|
||||
return redirect(url_for("workforce.dashboard"))
|
||||
|
||||
|
||||
@workforce_bp.route("/workforce/holidays/<int:holiday_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_holiday(holiday_id):
|
||||
if not current_user.is_admin:
|
||||
flash(_("Access denied"), "error")
|
||||
return redirect(url_for("workforce.dashboard"))
|
||||
result = WorkforceGovernanceService().delete_holiday(holiday_id)
|
||||
flash(_(result.get("message", "Holiday deleted")) if result.get("success") else _(result.get("message", "Could not delete holiday")), "success" if result.get("success") else "error")
|
||||
return redirect(url_for("workforce.dashboard"))
|
||||
|
||||
|
||||
@workforce_bp.route("/workforce/reports/payroll.csv", methods=["GET"])
|
||||
@login_required
|
||||
def payroll_export_csv():
|
||||
|
||||
@@ -462,3 +462,63 @@ class WorkforceGovernanceService:
|
||||
item["non_billable_hours"] = round(item["non_billable_hours"], 2)
|
||||
out.sort(key=lambda x: (x["week_year"], x["week_number"], x["username"] or ""))
|
||||
return out
|
||||
|
||||
def delete_period(self, period_id: int, actor_id: int) -> Dict[str, Any]:
|
||||
"""Delete a timesheet period. Only draft or rejected periods; actor must be owner or admin."""
|
||||
period = TimesheetPeriod.query.get(period_id)
|
||||
if not period:
|
||||
return {"success": False, "message": "Timesheet period not found"}
|
||||
user = User.query.get(actor_id)
|
||||
if not user:
|
||||
return {"success": False, "message": "User not found"}
|
||||
if period.user_id != actor_id and not user.is_admin:
|
||||
return {"success": False, "message": "Only the period owner or an admin can delete it"}
|
||||
status = period.status.value if hasattr(period.status, "value") else str(period.status)
|
||||
if status not in (TimesheetPeriodStatus.DRAFT.value, TimesheetPeriodStatus.REJECTED.value):
|
||||
return {"success": False, "message": "Only draft or rejected periods can be deleted"}
|
||||
db.session.delete(period)
|
||||
db.session.commit()
|
||||
return {"success": True}
|
||||
|
||||
def delete_leave_request(
|
||||
self, request_id: int, actor_id: int, actor_can_approve: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a time-off request. Only draft, submitted, or cancelled; actor must be owner or approver."""
|
||||
req = TimeOffRequest.query.get(request_id)
|
||||
if not req:
|
||||
return {"success": False, "message": "Time-off request not found"}
|
||||
if req.user_id != actor_id and not actor_can_approve:
|
||||
return {"success": False, "message": "Only the request owner or an approver can delete it"}
|
||||
status = req.status.value if hasattr(req.status, "value") else str(req.status)
|
||||
if status not in (
|
||||
TimeOffRequestStatus.DRAFT.value,
|
||||
TimeOffRequestStatus.SUBMITTED.value,
|
||||
TimeOffRequestStatus.CANCELLED.value,
|
||||
):
|
||||
return {"success": False, "message": "Only draft, submitted, or cancelled requests can be deleted"}
|
||||
db.session.delete(req)
|
||||
db.session.commit()
|
||||
return {"success": True}
|
||||
|
||||
def delete_leave_type(self, leave_type_id: int) -> Dict[str, Any]:
|
||||
"""Delete a leave type. Fails if any time-off request references it."""
|
||||
leave_type = LeaveType.query.get(leave_type_id)
|
||||
if not leave_type:
|
||||
return {"success": False, "message": "Leave type not found"}
|
||||
if leave_type.requests.count() > 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Cannot delete leave type that has time-off requests",
|
||||
}
|
||||
db.session.delete(leave_type)
|
||||
db.session.commit()
|
||||
return {"success": True}
|
||||
|
||||
def delete_holiday(self, holiday_id: int) -> Dict[str, Any]:
|
||||
"""Delete a company holiday."""
|
||||
holiday = CompanyHoliday.query.get(holiday_id)
|
||||
if not holiday:
|
||||
return {"success": False, "message": "Holiday not found"}
|
||||
db.session.delete(holiday)
|
||||
db.session.commit()
|
||||
return {"success": True}
|
||||
|
||||
@@ -87,6 +87,13 @@
|
||||
<button class="btn btn-sm btn-danger" type="submit">{{ _('Close') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% set p_status_val = p.status.value if p.status is defined and p.status.value is defined else (p.status|string) %}
|
||||
{% if p_status_val in ['draft', 'rejected'] and (p.user_id == current_user.id or current_user.is_admin) %}
|
||||
<form method="post" action="{{ url_for('workforce.delete_period', period_id=p.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-sm btn-danger" type="submit">{{ _('Delete') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,6 +187,12 @@
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if r_status in ['draft', 'submitted', 'cancelled'] and (r.user_id == current_user.id or can_approve) %}
|
||||
<form method="post" action="{{ url_for('workforce.delete_time_off_request', request_id=r.id) }}" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-sm btn-danger" type="submit">{{ _('Delete') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -245,6 +258,19 @@
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="is_paid" checked> {{ _('Paid leave') }}</label>
|
||||
<button class="btn btn-secondary" type="submit">{{ _('Add leave type') }}</button>
|
||||
</form>
|
||||
<div class="mt-3 space-y-1">
|
||||
{% for lt in leave_types %}
|
||||
<div class="flex justify-between items-center gap-2 text-sm">
|
||||
<span>{{ lt.name }} ({{ lt.code }}){% if not lt.enabled %} <span class="text-text-muted-light dark:text-text-muted-dark">— {{ _('disabled') }}</span>{% endif %}</span>
|
||||
<form method="post" action="{{ url_for('workforce.delete_leave_type', leave_type_id=lt.id) }}" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-sm btn-danger" type="submit">{{ _('Delete') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('No leave types configured.') }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card p-4 space-y-2">
|
||||
@@ -257,9 +283,15 @@
|
||||
<input type="text" name="region" class="form-input" placeholder="{{ _('Region (optional)') }}">
|
||||
<button class="btn btn-secondary" type="submit">{{ _('Add holiday') }}</button>
|
||||
</form>
|
||||
<div class="max-h-40 overflow-auto text-sm">
|
||||
<div class="max-h-40 overflow-auto text-sm space-y-1">
|
||||
{% for h in holidays %}
|
||||
<div>{{ h.start_date }} - {{ h.end_date }}: {{ h.name }}{% if h.region %} ({{ h.region }}){% endif %}</div>
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<span>{{ h.start_date }} - {{ h.end_date }}: {{ h.name }}{% if h.region %} ({{ h.region }}){% endif %}</span>
|
||||
<form method="post" action="{{ url_for('workforce.delete_holiday', holiday_id=h.id) }}" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-sm btn-danger" type="submit">{{ _('Delete') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>{{ _('No holidays configured.') }}</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -234,6 +234,10 @@ class ApiClient {
|
||||
return await this.client.post(`/api/v1/timesheet-periods/${periodId}/reject`, data);
|
||||
}
|
||||
|
||||
async deleteTimesheetPeriod(periodId) {
|
||||
return await this.client.delete(`/api/v1/timesheet-periods/${periodId}`);
|
||||
}
|
||||
|
||||
async getLeaveTypes() {
|
||||
return await this.client.get('/api/v1/time-off/leave-types');
|
||||
}
|
||||
@@ -275,6 +279,10 @@ class ApiClient {
|
||||
if (comment) data.comment = comment;
|
||||
return await this.client.post(`/api/v1/time-off/requests/${requestId}/reject`, data);
|
||||
}
|
||||
|
||||
async deleteTimeOffRequest(requestId) {
|
||||
return await this.client.delete(`/api/v1/time-off/requests/${requestId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other files
|
||||
|
||||
@@ -81,11 +81,12 @@ async function loadCurrentUserProfile() {
|
||||
const role = String(user.role || '').toLowerCase();
|
||||
const roleCanApprove = ['admin', 'owner', 'manager', 'approver'].includes(role);
|
||||
state.currentUserProfile = {
|
||||
id: user.id,
|
||||
is_admin: Boolean(user.is_admin),
|
||||
can_approve: Boolean(user.is_admin) || roleCanApprove,
|
||||
};
|
||||
} catch (_) {
|
||||
state.currentUserProfile = { is_admin: false, can_approve: false };
|
||||
state.currentUserProfile = { id: null, is_admin: false, can_approve: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,6 +948,9 @@ function renderPeriods() {
|
||||
${(String(period.status || '').toLowerCase() === 'submitted' && state.currentUserProfile.can_approve)
|
||||
? `<button class="btn btn-sm btn-danger" onclick="reviewTimesheetPeriodAction(${period.id}, false)">Reject</button>`
|
||||
: ''}
|
||||
${['draft', 'rejected'].includes(String(period.status || '').toLowerCase())
|
||||
? `<button class="btn btn-sm btn-danger" onclick="deleteTimesheetPeriodAction(${period.id})">Delete</button>`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -1007,6 +1011,9 @@ function renderTimeOffRequests() {
|
||||
<div class="entry-time">${status}</div>
|
||||
${canReview ? `<button class="btn btn-sm btn-primary" onclick="reviewTimeOffRequestAction(${req.id}, true)">Approve</button>` : ''}
|
||||
${canReview ? `<button class="btn btn-sm btn-danger" onclick="reviewTimeOffRequestAction(${req.id}, false)">Reject</button>` : ''}
|
||||
${['draft', 'submitted', 'cancelled'].includes(String(status).toLowerCase()) && (req.user_id === state.currentUserProfile.id || state.currentUserProfile.can_approve)
|
||||
? `<button class="btn btn-sm btn-danger" onclick="deleteTimeOffRequestAction(${req.id})">Delete</button>`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1152,6 +1159,18 @@ async function reviewTimesheetPeriodAction(periodId, approve) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTimesheetPeriodAction(periodId) {
|
||||
if (!state.apiClient) return;
|
||||
if (!confirm('Are you sure you want to delete this timesheet period?')) return;
|
||||
try {
|
||||
await state.apiClient.deleteTimesheetPeriod(periodId);
|
||||
showSuccess('Timesheet period deleted');
|
||||
await loadWorkforce();
|
||||
} catch (error) {
|
||||
showError('Failed to delete period: ' + (error.response?.data?.error || error.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function showCreateTimeOffDialog() {
|
||||
if (!state.apiClient) return;
|
||||
|
||||
@@ -1348,6 +1367,18 @@ async function reviewTimeOffRequestAction(requestId, approve) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTimeOffRequestAction(requestId) {
|
||||
if (!state.apiClient) return;
|
||||
if (!confirm('Are you sure you want to delete this time-off request?')) return;
|
||||
try {
|
||||
await state.apiClient.deleteTimeOffRequest(requestId);
|
||||
showSuccess('Time-off request deleted');
|
||||
await loadWorkforce();
|
||||
} catch (error) {
|
||||
showError('Failed to delete time-off request: ' + (error.response?.data?.error || error.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
// Load current settings
|
||||
const serverUrl = await storeGet('server_url') || '';
|
||||
|
||||
@@ -86,13 +86,15 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
```
|
||||
|
||||
#### `write:time_entries`
|
||||
**Grants**: Create, update, and delete time entries; control timer
|
||||
**Grants**: Create, update, and delete time entries; control timer; timesheet periods and time-off requests
|
||||
**Endpoints**:
|
||||
- `POST /api/v1/time-entries` - Create time entry
|
||||
- `PUT /api/v1/time-entries/{id}` - Update time entry
|
||||
- `DELETE /api/v1/time-entries/{id}` - Delete time entry
|
||||
- `POST /api/v1/timer/start` - Start timer
|
||||
- `POST /api/v1/timer/stop` - Stop timer
|
||||
- `DELETE /api/v1/timesheet-periods/{id}` - Delete timesheet period (draft/rejected only; owner or admin)
|
||||
- `DELETE /api/v1/time-off/requests/{id}` - Delete time-off request (draft/submitted/cancelled; owner or approver)
|
||||
|
||||
**Use Cases**:
|
||||
- Time tracking integrations
|
||||
@@ -199,9 +201,11 @@ curl -X POST https://your-domain.com/api/v1/clients \
|
||||
### Reports
|
||||
|
||||
#### `read:reports`
|
||||
**Grants**: Access reporting and analytics endpoints
|
||||
**Grants**: Access reporting and analytics endpoints; read leave types and holidays
|
||||
**Endpoints**:
|
||||
- `GET /api/v1/reports/summary` - Get summary reports
|
||||
- `GET /api/v1/time-off/leave-types` - List leave types
|
||||
- `GET /api/v1/time-off/holidays` - List company holidays
|
||||
|
||||
**Use Cases**:
|
||||
- Business intelligence tools
|
||||
@@ -219,6 +223,16 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
"https://your-domain.com/api/v1/reports/summary?start_date=2024-01-01&end_date=2024-01-31"
|
||||
```
|
||||
|
||||
#### `write:reports`
|
||||
**Grants**: Create and delete leave types and company holidays (workforce admin)
|
||||
**Endpoints**:
|
||||
- `POST /api/v1/time-off/leave-types` - Create leave type (admin only)
|
||||
- `DELETE /api/v1/time-off/leave-types/{id}` - Delete leave type (admin only; blocked if it has time-off requests)
|
||||
- `POST /api/v1/time-off/holidays` - Create company holiday (admin only)
|
||||
- `DELETE /api/v1/time-off/holidays/{id}` - Delete company holiday (admin only)
|
||||
|
||||
**Permissions**: Admin only for these endpoints.
|
||||
|
||||
---
|
||||
|
||||
### Users
|
||||
|
||||
@@ -115,10 +115,11 @@ TimeTracker/
|
||||
Timesheet periods, policies, and time-off tracking for payroll and compliance:
|
||||
|
||||
- **Models**: `TimesheetPeriod`, `TimesheetPolicy`, `TimeOff` (in `app/models/`)
|
||||
- **Routes**: `workforce` blueprint — dashboard, period close, policies, time-off
|
||||
- **Services**: `workforce_governance_service.py` — period close, policy checks, time-off logic
|
||||
- **Templates**: `app/templates/workforce/` (e.g. dashboard)
|
||||
- **Routes**: `workforce` blueprint — dashboard, period close, policies, time-off, **delete** (periods, time-off requests, leave types, holidays)
|
||||
- **Services**: `workforce_governance_service.py` — period close, policy checks, time-off logic, **delete** (period, leave request, leave type, holiday)
|
||||
- **Templates**: `app/templates/workforce/` (e.g. dashboard, with delete buttons where allowed)
|
||||
- **Migration**: `132_add_timesheet_governance_and_time_off.py`
|
||||
- **Docs**: [Workforce delete feature](../features/WORKFORCE_DELETE.md) (Issue #562)
|
||||
|
||||
## ✅ Task Management Feature
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Workforce Tab: Delete Entries
|
||||
|
||||
This feature adds the ability to delete timesheet periods, time-off requests, leave types, and company holidays from the Workforce tab (web, desktop, and mobile). It addresses [Issue #562](https://github.com/DRYTRIX/TimeTracker/issues/562).
|
||||
|
||||
## What Can Be Deleted
|
||||
|
||||
| Entity | Who can delete | When |
|
||||
|--------|----------------|------|
|
||||
| **Timesheet period** | Owner or admin | Only when status is **draft** or **rejected** |
|
||||
| **Time-off request** | Owner or approver/admin | Only when status is **draft**, **submitted**, or **cancelled** |
|
||||
| **Leave type** | Admin only | Only if no time-off request uses this leave type |
|
||||
| **Company holiday** | Admin only | Always (no dependencies) |
|
||||
|
||||
Submitted, approved, closed, or rejected records that affect audit or reporting cannot be deleted.
|
||||
|
||||
## Web (Workforce dashboard)
|
||||
|
||||
- **Timesheet periods:** Each draft or rejected period has a **Delete** button (owner or admin).
|
||||
- **Time-off requests:** Each draft, submitted, or cancelled request has a **Delete** button (owner or approver).
|
||||
- **Leave types:** In the admin section, each leave type is listed with a **Delete** button. Delete is blocked with an error if the leave type has any time-off requests.
|
||||
- **Company holidays:** Each holiday in the list has a **Delete** button (admin only).
|
||||
|
||||
All delete actions use POST forms with CSRF protection and redirect back to the dashboard after success or error.
|
||||
|
||||
## API v1 (Desktop & mobile)
|
||||
|
||||
Delete is exposed as HTTP `DELETE`:
|
||||
|
||||
| Endpoint | Scope | Notes |
|
||||
|----------|--------|--------|
|
||||
| `DELETE /api/v1/timesheet-periods/{id}` | `write:time_entries` | Owner or admin; period must be draft or rejected |
|
||||
| `DELETE /api/v1/time-off/requests/{id}` | `write:time_entries` | Owner or approver; request must be draft, submitted, or cancelled |
|
||||
| `DELETE /api/v1/time-off/leave-types/{id}` | `write:reports` | Admin only; returns 400 if leave type has requests |
|
||||
| `DELETE /api/v1/time-off/holidays/{id}` | `write:reports` | Admin only |
|
||||
|
||||
Success: `200` with JSON `{ "message": "..." }`.
|
||||
Failure: `400` with `{ "error": "..." }` or `403` for permission errors.
|
||||
|
||||
## Desktop app
|
||||
|
||||
- **Timesheet periods:** Delete button on each draft or rejected period; confirmation dialog then refresh.
|
||||
- **Time-off requests:** Delete button on each draft, submitted, or cancelled request (own requests or when user can approve); confirmation then refresh.
|
||||
|
||||
See `desktop/src/renderer/js/api/client.js` (`deleteTimesheetPeriod`, `deleteTimeOffRequest`) and `desktop/src/renderer/js/app.js` (workforce render and handlers).
|
||||
|
||||
## Mobile app
|
||||
|
||||
- **Timesheet periods:** Popup menu on each period includes **Delete** when status is draft or rejected.
|
||||
- **Time-off requests:** Popup menu includes **Delete** when status is draft, submitted, or cancelled and the user is owner or approver.
|
||||
|
||||
See `mobile/lib/data/api/api_client.dart` and `mobile/lib/presentation/screens/finance_workforce_screen.dart`.
|
||||
|
||||
## Backend
|
||||
|
||||
- **Service:** `app/services/workforce_governance_service.py`
|
||||
- `delete_period(period_id, actor_id)`
|
||||
- `delete_leave_request(request_id, actor_id, actor_can_approve=False)`
|
||||
- `delete_leave_type(leave_type_id)`
|
||||
- `delete_holiday(holiday_id)`
|
||||
- **Web routes:** `app/routes/workforce.py` — POST routes for each delete, CSRF and permissions.
|
||||
- **API routes:** `app/routes/api_v1.py` — DELETE endpoints with token scopes and admin checks where required.
|
||||
|
||||
## Risks and notes
|
||||
|
||||
- Deleting a leave type that has time-off requests is prevented; the API and web UI return a clear error.
|
||||
- Only draft or rejected periods can be deleted to keep audit history for submitted/approved/closed periods.
|
||||
- Only draft, submitted, or cancelled time-off requests can be deleted; approved or rejected ones are kept for reporting.
|
||||
@@ -319,6 +319,11 @@ class ApiClient {
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> deleteTimesheetPeriod(int periodId) async {
|
||||
final response = await _dio.delete('/api/v1/timesheet-periods/$periodId');
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getLeaveTypes() async {
|
||||
final response = await _dio.get('/api/v1/time-off/leave-types');
|
||||
return response.data as Map<String, dynamic>;
|
||||
@@ -377,4 +382,9 @@ class ApiClient {
|
||||
final response = await _dio.post('/api/v1/time-off/requests/$requestId/reject', data: data);
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> deleteTimeOffRequest(int requestId) async {
|
||||
final response = await _dio.delete('/api/v1/time-off/requests/$requestId');
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class _FinanceWorkforceScreenState extends ConsumerState<FinanceWorkforceScreen>
|
||||
final TextEditingController _expenseFilterController = TextEditingController();
|
||||
final TextEditingController _timeOffFilterController = TextEditingController();
|
||||
bool _canApprove = false;
|
||||
int? _currentUserId;
|
||||
String _invoiceFilter = '';
|
||||
String _expenseFilter = '';
|
||||
String _timeOffFilter = '';
|
||||
@@ -76,6 +77,7 @@ class _FinanceWorkforceScreenState extends ConsumerState<FinanceWorkforceScreen>
|
||||
final roleCanApprove = role == 'admin' || role == 'owner' || role == 'manager' || role == 'approver';
|
||||
if (mounted) {
|
||||
_canApprove = (user['is_admin'] == true) || roleCanApprove;
|
||||
_currentUserId = (user['id'] as num?)?.toInt();
|
||||
}
|
||||
|
||||
final invoiceTotalPages = ((invoicesRes['pagination'] ?? const {})['pages'] as num?)?.toInt() ?? 1;
|
||||
@@ -189,6 +191,24 @@ class _FinanceWorkforceScreenState extends ConsumerState<FinanceWorkforceScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deletePeriod(int periodId) async {
|
||||
try {
|
||||
final client = await ref.read(apiClientProvider.future);
|
||||
if (client == null) return;
|
||||
await client.deleteTimesheetPeriod(periodId);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Timesheet period deleted')),
|
||||
);
|
||||
await _refresh();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Delete failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createExpense({
|
||||
required String title,
|
||||
required String category,
|
||||
@@ -563,6 +583,24 @@ class _FinanceWorkforceScreenState extends ConsumerState<FinanceWorkforceScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteTimeOffRequest(int requestId) async {
|
||||
try {
|
||||
final client = await ref.read(apiClientProvider.future);
|
||||
if (client == null) return;
|
||||
await client.deleteTimeOffRequest(requestId);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Time-off request deleted')),
|
||||
);
|
||||
await _refresh();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Delete failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openCreateTimeOffDialog(_FinanceWorkforceData data) async {
|
||||
if (data.leaveTypes.isEmpty) {
|
||||
if (!mounted) return;
|
||||
@@ -946,7 +984,12 @@ class _FinanceWorkforceScreenState extends ConsumerState<FinanceWorkforceScreen>
|
||||
final start = (req['start_date'] ?? '').toString();
|
||||
final end = (req['end_date'] ?? '').toString();
|
||||
final leaveType = (req['leave_type_name'] ?? 'Leave').toString();
|
||||
final reqUserId = (req['user_id'] as num?)?.toInt();
|
||||
final isSubmitted = status.toLowerCase() == 'submitted';
|
||||
final canDelete = requestId != null &&
|
||||
['draft', 'submitted', 'cancelled'].contains(status.toLowerCase()) &&
|
||||
(reqUserId == _currentUserId || _canApprove);
|
||||
final showMenu = (isSubmitted && _canApprove) || canDelete;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
@@ -956,19 +999,28 @@ class _FinanceWorkforceScreenState extends ConsumerState<FinanceWorkforceScreen>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(status),
|
||||
if (isSubmitted && requestId != null && _canApprove)
|
||||
if (showMenu && requestId != null)
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) async {
|
||||
if (value == 'approve') {
|
||||
await _reviewTimeOffRequest(requestId: requestId, approve: true);
|
||||
} else if (value == 'reject') {
|
||||
await _reviewTimeOffRequest(requestId: requestId, approve: false);
|
||||
} else if (value == 'delete') {
|
||||
await _deleteTimeOffRequest(requestId);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'approve', child: Text('Approve')),
|
||||
PopupMenuItem(value: 'reject', child: Text('Reject')),
|
||||
],
|
||||
itemBuilder: (context) {
|
||||
final items = <PopupMenuItem<String>>[];
|
||||
if (isSubmitted && _canApprove) {
|
||||
items.add(const PopupMenuItem(value: 'approve', child: Text('Approve')));
|
||||
items.add(const PopupMenuItem(value: 'reject', child: Text('Reject')));
|
||||
}
|
||||
if (canDelete) {
|
||||
items.add(const PopupMenuItem(value: 'delete', child: Text('Delete')));
|
||||
}
|
||||
return items;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1034,6 +1086,8 @@ class _FinanceWorkforceScreenState extends ConsumerState<FinanceWorkforceScreen>
|
||||
final periodId = period['id'] as int?;
|
||||
final canSubmit = status.toLowerCase() == 'draft' && periodId != null;
|
||||
final canReview = _canApprove && status.toLowerCase() == 'submitted' && periodId != null;
|
||||
final canDelete = periodId != null &&
|
||||
['draft', 'rejected'].contains(status.toLowerCase());
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
@@ -1044,19 +1098,28 @@ class _FinanceWorkforceScreenState extends ConsumerState<FinanceWorkforceScreen>
|
||||
onPressed: () => _submitPeriod(periodId),
|
||||
child: const Text('Submit'),
|
||||
)
|
||||
: (canReview
|
||||
: (canReview || canDelete
|
||||
? PopupMenuButton<String>(
|
||||
onSelected: (value) async {
|
||||
if (value == 'approve') {
|
||||
await _reviewPeriod(periodId: periodId, approve: true);
|
||||
await _reviewPeriod(periodId: periodId!, approve: true);
|
||||
} else if (value == 'reject') {
|
||||
await _reviewPeriod(periodId: periodId, approve: false);
|
||||
await _reviewPeriod(periodId: periodId!, approve: false);
|
||||
} else if (value == 'delete') {
|
||||
await _deletePeriod(periodId!);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'approve', child: Text('Approve')),
|
||||
PopupMenuItem(value: 'reject', child: Text('Reject')),
|
||||
],
|
||||
itemBuilder: (context) {
|
||||
final items = <PopupMenuItem<String>>[];
|
||||
if (canReview) {
|
||||
items.add(const PopupMenuItem(value: 'approve', child: Text('Approve')));
|
||||
items.add(const PopupMenuItem(value: 'reject', child: Text('Reject')));
|
||||
}
|
||||
if (canDelete) {
|
||||
items.add(const PopupMenuItem(value: 'delete', child: Text('Delete')));
|
||||
}
|
||||
return items;
|
||||
},
|
||||
)
|
||||
: null),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user