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:
Dries Peeters
2026-03-11 18:44:53 +01:00
parent a70285bfa9
commit daf3236c37
11 changed files with 400 additions and 20 deletions
+52
View File
@@ -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 ====================
+42
View File
@@ -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}
+34 -2
View File
@@ -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 %}
+8
View File
@@ -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
+32 -1
View File
@@ -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') || '';
+16 -2
View File
@@ -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
+4 -3
View File
@@ -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
+67
View File
@@ -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.
+10
View File
@@ -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),
);