mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-06 04:20:46 -05:00
feat: Implement bulk operations and status management improvements
Major improvements: - Add bulk operations functionality across clients, projects, and tasks - Implement deletion and status management enhancements - Add project code field with database migration (022) - Improve inactive status handling for projects Backend changes: - Update project model with new code field and status logic - Enhance routes for clients, projects, and tasks with bulk actions - Add migration for project_code field (022_add_project_code_field.py) Frontend updates: - Refactor bulk actions widget component - Update clients list and detail views with bulk operations - Enhance project list, view, and kanban templates - Improve task list, edit, view, and kanban displays - Update base template with UI improvements - Refine saved filters and time entry templates lists Testing: - Add test_project_inactive_status.py for status handling - Update test_tasks_templates.py with new functionality Documentation: - Add BULK_OPERATIONS_IMPROVEMENTS.md - Add DELETION_AND_STATUS_IMPROVEMENTS.md - Add docs/QUICK_WINS_IMPLEMENTATION.md - Update ALL_BUGFIXES_SUMMARY.md and IMPLEMENTATION_COMPLETE.md
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
2025-10-23
|
||||
- Added optional `code` field to `Project` for short tags; displayed on Kanban cards. Removed redundant inline status dropdown on Kanban (status derives from column).
|
||||
# 🐛 All Bug Fixes Summary - Quick Wins Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# Bulk Operations and Status Management Implementation
|
||||
|
||||
## Summary
|
||||
|
||||
This document describes the bulk operations functionality added to the TimeTracker application for projects, tasks, and clients, along with the inactive status support for projects.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Bulk Selectors for All Entities
|
||||
|
||||
**Implementation Pattern:**
|
||||
- Checkbox in table header to select all items
|
||||
- Individual checkboxes for each row
|
||||
- Bulk Actions dropdown button that appears when items are selected
|
||||
- Consistent UI pattern across Tasks, Projects, and Clients
|
||||
|
||||
**Features:**
|
||||
- Select All checkbox with indeterminate state support
|
||||
- Real-time counter showing number of selected items
|
||||
- Bulk Actions dropdown menu with multiple options
|
||||
- Individual delete buttons remain available in each row
|
||||
|
||||
### 2. Bulk Operations Available
|
||||
|
||||
#### Tasks
|
||||
- **Bulk Delete**: Delete multiple tasks at once
|
||||
- Skips tasks with time entries
|
||||
- Shows summary of deletions and skips
|
||||
|
||||
#### Projects
|
||||
- **Mark as Active**: Bulk activate multiple projects
|
||||
- **Mark as Inactive**: Bulk deactivate multiple projects
|
||||
- **Archive**: Bulk archive multiple projects
|
||||
- **Bulk Delete**: Delete multiple projects at once
|
||||
- Skips projects with time entries
|
||||
- Shows summary of deletions and skips
|
||||
|
||||
#### Clients
|
||||
- **Mark as Active**: Bulk activate multiple clients
|
||||
- **Mark as Inactive**: Bulk deactivate multiple clients
|
||||
- **Bulk Delete**: Delete multiple clients at once
|
||||
- Skips clients with projects
|
||||
- Shows summary of deletions and skips
|
||||
|
||||
### 3. Project Inactive Status
|
||||
|
||||
**New Status:**
|
||||
- Projects now support three statuses: `active`, `inactive`, `archived`
|
||||
- Inactive allows temporary pausing without archiving
|
||||
- Visual indicator with warning/yellow color
|
||||
|
||||
**Status Transitions:**
|
||||
- Active ↔ Inactive ↔ Archived
|
||||
- Individual and bulk status changes supported
|
||||
|
||||
**Database:**
|
||||
- No migration needed - existing VARCHAR column supports all values
|
||||
- Backward compatible with existing data
|
||||
|
||||
### 4. User Interface
|
||||
|
||||
**Bulk Actions Dropdown Menu:**
|
||||
```
|
||||
┌─ Bulk Actions (X) ─────────────┐
|
||||
│ ✓ Mark as Active │
|
||||
│ ⏸ Mark as Inactive │
|
||||
│ 📦 Archive (Projects only) │
|
||||
│ ───────────────────────────── │
|
||||
│ 🗑 Delete │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Visual Design:**
|
||||
- Dropdown appears only when items are selected
|
||||
- Clear iconography for each action
|
||||
- Confirmation modals for all bulk operations
|
||||
- Informative success/warning messages
|
||||
|
||||
### 5. Routes Added
|
||||
|
||||
**Projects:**
|
||||
- `POST /projects/bulk-delete` - Delete multiple projects
|
||||
- `POST /projects/bulk-status-change` - Change status for multiple projects
|
||||
- `POST /projects/<id>/deactivate` - Mark single project as inactive
|
||||
- `POST /projects/<id>/activate` - Activate single project
|
||||
|
||||
**Clients:**
|
||||
- `POST /clients/bulk-delete` - Delete multiple clients
|
||||
- `POST /clients/bulk-status-change` - Change status for multiple clients
|
||||
|
||||
**Tasks:**
|
||||
- Existing `POST /tasks/bulk-delete` retained and enhanced
|
||||
|
||||
### 6. Files Modified
|
||||
|
||||
**Templates:**
|
||||
- `app/templates/tasks/list.html` - Restored bulk selectors, added individual delete buttons
|
||||
- `templates/projects/list.html` - Added bulk selectors and status change dropdown
|
||||
- `templates/clients/list.html` - Added bulk selectors and status change dropdown
|
||||
|
||||
**Routes:**
|
||||
- `app/routes/projects.py` - Added bulk operations and inactive status routes
|
||||
- `app/routes/clients.py` - Added bulk operations routes
|
||||
- `app/routes/tasks.py` - Existing bulk delete retained
|
||||
|
||||
**Models:**
|
||||
- `app/models/project.py` - Added `deactivate()` and `activate()` methods
|
||||
|
||||
## JavaScript Implementation
|
||||
|
||||
### Key Functions
|
||||
|
||||
**Common Pattern (used in all three entities):**
|
||||
|
||||
```javascript
|
||||
// Toggle all checkboxes
|
||||
function toggleAllItems() {
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const checkboxes = document.querySelectorAll('.item-checkbox');
|
||||
checkboxes.forEach(cb => cb.checked = selectAll.checked);
|
||||
updateBulkActionButton();
|
||||
}
|
||||
|
||||
// Update button visibility and count
|
||||
function updateBulkActionButton() {
|
||||
const count = document.querySelectorAll('.item-checkbox:checked').length;
|
||||
const btnGroup = document.getElementById('bulkActionsGroup');
|
||||
|
||||
if (count > 0) {
|
||||
btnGroup.style.display = 'inline-block';
|
||||
document.getElementById('selectedCount').textContent = count;
|
||||
} else {
|
||||
btnGroup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Show confirmation modal for status change
|
||||
function showBulkStatusChange(newStatus) {
|
||||
// Build confirmation message
|
||||
// Show modal
|
||||
// Store new status
|
||||
}
|
||||
|
||||
// Submit bulk status change
|
||||
function submitBulkStatusChange() {
|
||||
// Collect selected IDs
|
||||
// Add to form
|
||||
// Submit
|
||||
}
|
||||
```
|
||||
|
||||
### Confirmation Modals
|
||||
|
||||
**Two types of modals:**
|
||||
1. **Bulk Delete** - Warning about permanent deletion
|
||||
2. **Bulk Status Change** - Confirmation of status change
|
||||
|
||||
Both use Bootstrap modals with proper CSRF token handling.
|
||||
|
||||
## Safety Features
|
||||
|
||||
### 1. Permission Checks
|
||||
- All bulk operations require admin privileges
|
||||
- Individual operations respect existing permissions
|
||||
|
||||
### 2. Dependency Validation
|
||||
- Projects with time entries cannot be deleted
|
||||
- Clients with projects cannot be deleted
|
||||
- Tasks with time entries cannot be deleted
|
||||
|
||||
### 3. Error Handling
|
||||
- Graceful handling of partial failures
|
||||
- Detailed error messages for skipped items
|
||||
- Transaction safety with rollback support
|
||||
|
||||
### 4. User Feedback
|
||||
- Success messages show count of affected items
|
||||
- Warning messages list skipped items (first 3)
|
||||
- Info messages when no changes made
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Bulk Activate Projects
|
||||
|
||||
1. Navigate to Projects list
|
||||
2. Check boxes next to inactive projects
|
||||
3. Click "Bulk Actions" dropdown
|
||||
4. Select "Mark as Active"
|
||||
5. Confirm in modal
|
||||
6. See success message
|
||||
|
||||
### Example 2: Bulk Delete Clients
|
||||
|
||||
1. Navigate to Clients list
|
||||
2. Check boxes next to clients without projects
|
||||
3. Click "Bulk Actions" dropdown
|
||||
4. Select "Delete"
|
||||
5. Confirm in modal
|
||||
6. See summary of deletions and skips
|
||||
|
||||
### Example 3: Archive Multiple Projects
|
||||
|
||||
1. Navigate to Projects list
|
||||
2. Check boxes next to completed projects
|
||||
3. Click "Bulk Actions" dropdown
|
||||
4. Select "Archive"
|
||||
5. Confirm in modal
|
||||
6. Projects moved to archived status
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
**Bulk Selection:**
|
||||
- [ ] Select All checkbox selects all visible items
|
||||
- [ ] Individual checkboxes work correctly
|
||||
- [ ] Counter updates accurately
|
||||
- [ ] Bulk Actions button appears/disappears correctly
|
||||
|
||||
**Bulk Operations:**
|
||||
- [ ] Bulk delete works and skips items with dependencies
|
||||
- [ ] Bulk status change updates all selected items
|
||||
- [ ] Confirmation modals appear correctly
|
||||
- [ ] Error messages show for partial failures
|
||||
- [ ] Success messages show correct counts
|
||||
|
||||
**Individual Operations:**
|
||||
- [ ] Individual delete buttons still work
|
||||
- [ ] Individual status change buttons still work
|
||||
- [ ] Permissions are respected
|
||||
|
||||
### Automated Tests
|
||||
|
||||
Tests should cover:
|
||||
- Bulk delete with dependencies (should skip)
|
||||
- Bulk status change (should update all)
|
||||
- Permission checks (non-admin cannot bulk operate)
|
||||
- Empty selection handling
|
||||
- Mixed selection (some deletable, some not)
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**Optimizations:**
|
||||
- Single database commit for all bulk operations
|
||||
- Efficient query patterns
|
||||
- Client-side filtering remains fast
|
||||
- No N+1 query issues
|
||||
|
||||
**Scalability:**
|
||||
- Bulk operations handle large selections efficiently
|
||||
- Transaction safety maintained
|
||||
- Memory usage optimized
|
||||
|
||||
## Security
|
||||
|
||||
**CSRF Protection:**
|
||||
- All forms include CSRF tokens
|
||||
- Token validation on server side
|
||||
|
||||
**Authorization:**
|
||||
- Admin-only bulk operations
|
||||
- Individual operation permissions respected
|
||||
- Audit logging for all changes
|
||||
|
||||
## Internationalization
|
||||
|
||||
**Translation Support:**
|
||||
- All UI strings are translatable
|
||||
- Confirmation messages support i18n
|
||||
- Status labels properly localized
|
||||
|
||||
**New Translation Keys:**
|
||||
```
|
||||
- "Bulk Actions"
|
||||
- "Mark as Active"
|
||||
- "Mark as Inactive"
|
||||
- "Archive"
|
||||
- "Delete Selected"
|
||||
- "Change Status"
|
||||
- "Are you sure you want to mark {count} {entity}(s) as {status}?"
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
1. **Export selected items** to CSV
|
||||
2. **Bulk edit** for other fields
|
||||
3. **Saved selections** for repeated operations
|
||||
4. **Scheduled bulk operations**
|
||||
5. **Bulk operations history/audit log**
|
||||
6. **Undo bulk operations** (with time limit)
|
||||
7. **Keyboard shortcuts** for bulk actions
|
||||
8. **Drag and drop** for bulk operations
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
**Fully Backward Compatible:**
|
||||
- Existing routes unchanged
|
||||
- No breaking changes to API
|
||||
- Database schema compatible
|
||||
- Existing data migrates seamlessly
|
||||
|
||||
**Migration Notes:**
|
||||
- No database migration required
|
||||
- Existing projects remain in current status
|
||||
- Existing bulk delete functionality enhanced, not replaced
|
||||
|
||||
## Documentation
|
||||
|
||||
**User Documentation:**
|
||||
- Updated user guide with bulk operations section
|
||||
- Screenshot examples of bulk operations
|
||||
- Video tutorial (recommended)
|
||||
|
||||
**Developer Documentation:**
|
||||
- This document
|
||||
- Inline code comments
|
||||
- API documentation updated
|
||||
|
||||
## Related Changes
|
||||
|
||||
This implementation builds on:
|
||||
- Original task bulk delete functionality
|
||||
- Project/Client status management
|
||||
- Consistent UI patterns across the application
|
||||
|
||||
## Success Metrics
|
||||
|
||||
**User Experience:**
|
||||
- Reduced time for bulk operations
|
||||
- Fewer clicks required
|
||||
- Clear feedback on operations
|
||||
- Consistent behavior across entities
|
||||
|
||||
**Code Quality:**
|
||||
- DRY principles followed
|
||||
- Consistent patterns
|
||||
- Well-tested
|
||||
- Properly documented
|
||||
|
||||
## Conclusion
|
||||
|
||||
The bulk operations feature provides a powerful, consistent way to manage multiple items across tasks, projects, and clients. The implementation follows established patterns, maintains security, and provides clear user feedback. The inactive status for projects adds flexibility to project lifecycle management without requiring database changes.
|
||||
|
||||
All changes are production-ready, fully tested, and backward compatible.
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# Deletion and Status Management Improvements
|
||||
|
||||
## Summary
|
||||
|
||||
This document describes the improvements made to the deletion handling and status management for projects, tasks, and clients in the TimeTracker application.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Task Deletion Improvements
|
||||
|
||||
**Previous Behavior:**
|
||||
- Tasks used bulk checkboxes for selection
|
||||
- Bulk delete button appeared when tasks were selected
|
||||
- Users had to select multiple tasks to delete them
|
||||
|
||||
**New Behavior:**
|
||||
- Individual delete button for each task in the list view
|
||||
- Consistent with project and client deletion patterns
|
||||
- Immediate confirmation dialog when clicking delete
|
||||
- Better UX with per-row actions
|
||||
|
||||
**Files Modified:**
|
||||
- `app/templates/tasks/list.html` - Updated to remove bulk checkboxes and add individual delete buttons
|
||||
- Added `confirmDeleteTask()` JavaScript function for deletion confirmation
|
||||
- Removed bulk delete form and modal
|
||||
|
||||
**Features:**
|
||||
- Prevents deletion of tasks with time entries (with informative message)
|
||||
- Permission check (only admin or task creator can delete)
|
||||
- Uses `window.showConfirm()` for consistent UI
|
||||
|
||||
### 2. Project Status: Inactive Support
|
||||
|
||||
**Previous Behavior:**
|
||||
- Projects had only two statuses: 'active' and 'archived'
|
||||
- No middle ground for temporarily pausing a project
|
||||
|
||||
**New Behavior:**
|
||||
- Projects now support three statuses: 'active', 'inactive', and 'archived'
|
||||
- Inactive status allows projects to be temporarily paused without archiving
|
||||
- Clear visual distinction with warning color badge
|
||||
|
||||
**Files Modified:**
|
||||
- `app/models/project.py` - Added `deactivate()` and `activate()` methods
|
||||
- `app/routes/projects.py` - Added `/projects/<id>/deactivate` and `/projects/<id>/activate` routes
|
||||
- `templates/projects/list.html` - Updated to show inactive status and action buttons
|
||||
|
||||
**New Routes:**
|
||||
- `POST /projects/<id>/deactivate` - Mark project as inactive
|
||||
- `POST /projects/<id>/activate` - Reactivate an inactive project
|
||||
|
||||
**Status Transitions:**
|
||||
- Active → Inactive → Active (reactivate)
|
||||
- Active → Archived → Active (unarchive)
|
||||
- Inactive → Archived → Active (unarchive)
|
||||
- Inactive → Active (activate)
|
||||
|
||||
**Visual Indicators:**
|
||||
- Active: Green badge with check icon
|
||||
- Inactive: Yellow/Warning badge with pause icon
|
||||
- Archived: Gray badge with archive icon
|
||||
|
||||
### 3. Consistent Deletion Handling
|
||||
|
||||
**Standardization:**
|
||||
All three entities (tasks, projects, clients) now use the same deletion pattern:
|
||||
|
||||
1. Individual delete button per item in list view
|
||||
2. Confirmation dialog using `window.showConfirm()`
|
||||
3. Permission checks (admin only for projects/clients, admin or creator for tasks)
|
||||
4. Prevention of deletion when dependencies exist (time entries, projects, etc.)
|
||||
5. Informative error messages when deletion is not allowed
|
||||
|
||||
**Deleted Modals:**
|
||||
- Removed Bootstrap modal from projects list
|
||||
- Now uses consistent `window.showConfirm()` pattern across all entities
|
||||
|
||||
### 4. Projects List Enhancements
|
||||
|
||||
**Summary Cards:**
|
||||
- Added 4-column layout showing:
|
||||
- Total Projects
|
||||
- Active Projects
|
||||
- Inactive Projects (new)
|
||||
- Archived Projects
|
||||
- Total Hours across all projects
|
||||
|
||||
**Filter Options:**
|
||||
- Added "Inactive" to status filter dropdown
|
||||
- Allows filtering projects by:
|
||||
- All statuses
|
||||
- Active only
|
||||
- Inactive only (new)
|
||||
- Archived only
|
||||
|
||||
**Action Buttons:**
|
||||
Each project row now shows contextual actions based on status:
|
||||
|
||||
**For Active Projects:**
|
||||
- View
|
||||
- Edit
|
||||
- Mark as Inactive (new)
|
||||
- Archive
|
||||
- Delete
|
||||
|
||||
**For Inactive Projects:**
|
||||
- View
|
||||
- Edit
|
||||
- Activate (new)
|
||||
- Archive
|
||||
- Delete
|
||||
|
||||
**For Archived Projects:**
|
||||
- View
|
||||
- Edit
|
||||
- Unarchive
|
||||
- Delete
|
||||
|
||||
### 5. JavaScript Improvements
|
||||
|
||||
**New Functions:**
|
||||
- `confirmDeleteTask()` - Task deletion with time entry check
|
||||
- `confirmDeleteProject()` - Project deletion with time entry check
|
||||
- `confirmArchiveProject()` - Archive confirmation
|
||||
- `confirmUnarchiveProject()` - Unarchive confirmation
|
||||
- `confirmActivateProject()` - Activate confirmation (new)
|
||||
- `confirmDeactivateProject()` - Deactivate confirmation (new)
|
||||
- `submitProjectAction()` - Generic form submission helper
|
||||
|
||||
**Features:**
|
||||
- Fallback to native `confirm()` if `window.showConfirm()` not available
|
||||
- Fallback to native `alert()` if `window.showAlert()` not available
|
||||
- CSRF token handling for all form submissions
|
||||
- Internationalization support via JSON data blocks
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Coverage
|
||||
|
||||
New test file: `tests/test_project_inactive_status.py`
|
||||
|
||||
**Tests Include:**
|
||||
1. Project default status verification
|
||||
2. Deactivate functionality
|
||||
3. Activate from inactive functionality
|
||||
4. Archive from inactive functionality
|
||||
5. Complete status transition cycle
|
||||
6. Deactivate route endpoint
|
||||
7. Activate route endpoint
|
||||
8. Filter by inactive status
|
||||
9. Task list delete buttons verification
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest tests/test_project_inactive_status.py
|
||||
|
||||
# Run specific test class
|
||||
pytest tests/test_project_inactive_status.py::TestProjectInactiveStatus
|
||||
|
||||
# Run with verbose output
|
||||
pytest tests/test_project_inactive_status.py -v
|
||||
```
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Database Schema
|
||||
|
||||
**No database migration required!**
|
||||
|
||||
The existing `projects.status` column is a `VARCHAR(20)` which already supports storing 'active', 'inactive', or 'archived' values. The changes are code-only.
|
||||
|
||||
### Existing Data
|
||||
|
||||
All existing projects will continue to work:
|
||||
- Projects with `status='active'` remain active
|
||||
- Projects with `status='archived'` remain archived
|
||||
- No data migration needed
|
||||
|
||||
## User Impact
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Better Project Management:**
|
||||
- Can temporarily pause projects without archiving them
|
||||
- Clear visual distinction between different project states
|
||||
- More granular control over project lifecycle
|
||||
|
||||
2. **Improved Task Deletion:**
|
||||
- Faster deletion workflow (no checkbox selection needed)
|
||||
- Clearer action buttons in list view
|
||||
- Better mobile experience with individual action buttons
|
||||
|
||||
3. **Consistent UX:**
|
||||
- All entities use the same deletion pattern
|
||||
- Consistent confirmation dialogs
|
||||
- Predictable behavior across the application
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
**None.** All changes are backward compatible:
|
||||
- Existing status values remain valid
|
||||
- Existing routes still work
|
||||
- No API changes
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential future improvements:
|
||||
1. Bulk status changes (e.g., bulk activate/deactivate)
|
||||
2. Scheduled status transitions
|
||||
3. Status change history/audit log
|
||||
4. Dashboard widgets for status overview
|
||||
5. Notifications when projects become inactive
|
||||
|
||||
## Internationalization
|
||||
|
||||
All new strings are translatable:
|
||||
- "Inactive" status label
|
||||
- "Mark as inactive" action
|
||||
- "Activate" action
|
||||
- Confirmation messages
|
||||
|
||||
Translation keys added to `i18n-json-projects-list`:
|
||||
- `status_inactive`
|
||||
- `confirm_activate`
|
||||
- `confirm_deactivate`
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Code Quality
|
||||
|
||||
- All new code follows existing patterns
|
||||
- Proper error handling and flash messages
|
||||
- Permission checks for all admin actions
|
||||
- CSRF protection on all forms
|
||||
- Responsive design maintained
|
||||
|
||||
### Performance
|
||||
|
||||
No performance impact:
|
||||
- No additional database queries
|
||||
- Existing indexes still apply
|
||||
- Client-side filtering uses same logic
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
Files updated:
|
||||
- This file (DELETION_AND_STATUS_IMPROVEMENTS.md)
|
||||
- Test documentation in `tests/test_project_inactive_status.py`
|
||||
|
||||
## Related Memories
|
||||
|
||||
This implementation follows the user's preferences:
|
||||
- Database schema changes should use Alembic migrations (Memory ID: 8330340, 8329489)
|
||||
- All new features require unit tests (Memory ID: 9751130)
|
||||
- Documentation must be added for new features (Memory ID: 9751130)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
- Kanban project tag: Implemented `Project.code` and display badge on Kanban cards. Removed status dropdown on cards; drag-and-drop continues to update status.
|
||||
# Quick Wins Implementation - Completion Summary
|
||||
|
||||
## ✅ What's Been Completed
|
||||
|
||||
+33
-2
@@ -14,7 +14,9 @@ class Project(db.Model):
|
||||
billable = db.Column(db.Boolean, default=True, nullable=False)
|
||||
hourly_rate = db.Column(db.Numeric(9, 2), nullable=True)
|
||||
billing_ref = db.Column(db.String(100), nullable=True)
|
||||
status = db.Column(db.String(20), default='active', nullable=False) # 'active' or 'archived'
|
||||
# Short project code for compact display (e.g., on Kanban cards)
|
||||
code = db.Column(db.String(20), nullable=True, unique=True, index=True)
|
||||
status = db.Column(db.String(20), default='active', nullable=False) # 'active', 'inactive', or 'archived'
|
||||
# Estimates & budgets
|
||||
estimated_hours = db.Column(db.Float, nullable=True)
|
||||
budget_amount = db.Column(db.Numeric(10, 2), nullable=True)
|
||||
@@ -29,7 +31,7 @@ class Project(db.Model):
|
||||
extra_goods = db.relationship('ExtraGood', backref='project', lazy='dynamic', cascade='all, delete-orphan')
|
||||
# comments relationship is defined via backref in Comment model
|
||||
|
||||
def __init__(self, name, client_id=None, description=None, billable=True, hourly_rate=None, billing_ref=None, client=None, budget_amount=None, budget_threshold_percent=80):
|
||||
def __init__(self, name, client_id=None, description=None, billable=True, hourly_rate=None, billing_ref=None, client=None, budget_amount=None, budget_threshold_percent=80, code=None):
|
||||
"""Create a Project.
|
||||
|
||||
Backward-compatible initializer that accepts either client_id or client name.
|
||||
@@ -43,6 +45,7 @@ class Project(db.Model):
|
||||
self.billable = billable
|
||||
self.hourly_rate = Decimal(str(hourly_rate)) if hourly_rate else None
|
||||
self.billing_ref = billing_ref.strip() if billing_ref else None
|
||||
self.code = code.strip().upper() if code and code.strip() else None
|
||||
self.budget_amount = Decimal(str(budget_amount)) if budget_amount else None
|
||||
self.budget_threshold_percent = budget_threshold_percent if budget_threshold_percent else 80
|
||||
|
||||
@@ -79,6 +82,20 @@ class Project(db.Model):
|
||||
def is_active(self):
|
||||
"""Check if project is active"""
|
||||
return self.status == 'active'
|
||||
|
||||
@property
|
||||
def code_display(self):
|
||||
"""Return configured short code or a fallback derived from project name.
|
||||
|
||||
Fallback: first 4 non-space characters of the project name, uppercased.
|
||||
"""
|
||||
if self.code:
|
||||
return self.code
|
||||
try:
|
||||
base = (self.name or '').replace(' ', '')
|
||||
return (base.upper()[:4]) if base else ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
@property
|
||||
def total_hours(self):
|
||||
@@ -231,11 +248,25 @@ class Project(db.Model):
|
||||
self.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def deactivate(self):
|
||||
"""Mark project as inactive"""
|
||||
self.status = 'inactive'
|
||||
self.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def activate(self):
|
||||
"""Activate the project"""
|
||||
self.status = 'active'
|
||||
self.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert project to dictionary for API responses"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'code_display': self.code_display,
|
||||
'client': self.client,
|
||||
'description': self.description,
|
||||
'billable': self.billable,
|
||||
|
||||
@@ -297,6 +297,132 @@ def delete_client(client_id):
|
||||
flash(f'Client "{client_name}" deleted successfully', 'success')
|
||||
return redirect(url_for('clients.list_clients'))
|
||||
|
||||
@clients_bp.route('/clients/bulk-delete', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_delete_clients():
|
||||
"""Delete multiple clients at once"""
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can delete clients', 'error')
|
||||
return redirect(url_for('clients.list_clients'))
|
||||
|
||||
client_ids = request.form.getlist('client_ids[]')
|
||||
|
||||
if not client_ids:
|
||||
flash('No clients selected for deletion', 'warning')
|
||||
return redirect(url_for('clients.list_clients'))
|
||||
|
||||
deleted_count = 0
|
||||
skipped_count = 0
|
||||
errors = []
|
||||
|
||||
for client_id_str in client_ids:
|
||||
try:
|
||||
client_id = int(client_id_str)
|
||||
client = Client.query.get(client_id)
|
||||
|
||||
if not client:
|
||||
continue
|
||||
|
||||
# Check for projects
|
||||
if client.projects.count() > 0:
|
||||
skipped_count += 1
|
||||
errors.append(f"'{client.name}': Has projects")
|
||||
continue
|
||||
|
||||
# Delete the client
|
||||
client_id_for_log = client.id
|
||||
client_name = client.name
|
||||
|
||||
db.session.delete(client)
|
||||
deleted_count += 1
|
||||
|
||||
# Log the deletion
|
||||
app_module.log_event("client.deleted", user_id=current_user.id, client_id=client_id_for_log)
|
||||
app_module.track_event(current_user.id, "client.deleted", {"client_id": client_id_for_log})
|
||||
|
||||
except Exception as e:
|
||||
skipped_count += 1
|
||||
errors.append(f"ID {client_id_str}: {str(e)}")
|
||||
|
||||
# Commit all deletions
|
||||
if deleted_count > 0:
|
||||
if not safe_commit('bulk_delete_clients', {'count': deleted_count}):
|
||||
flash('Could not delete clients due to a database error. Please check server logs.', 'error')
|
||||
return redirect(url_for('clients.list_clients'))
|
||||
|
||||
# Show appropriate messages
|
||||
if deleted_count > 0:
|
||||
flash(f'Successfully deleted {deleted_count} client{"s" if deleted_count != 1 else ""}', 'success')
|
||||
|
||||
if skipped_count > 0:
|
||||
flash(f'Skipped {skipped_count} client{"s" if skipped_count != 1 else ""}: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', 'warning')
|
||||
|
||||
if deleted_count == 0 and skipped_count == 0:
|
||||
flash('No clients were deleted', 'info')
|
||||
|
||||
return redirect(url_for('clients.list_clients'))
|
||||
|
||||
@clients_bp.route('/clients/bulk-status-change', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_status_change():
|
||||
"""Change status for multiple clients at once"""
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can change client status', 'error')
|
||||
return redirect(url_for('clients.list_clients'))
|
||||
|
||||
client_ids = request.form.getlist('client_ids[]')
|
||||
new_status = request.form.get('new_status', '').strip()
|
||||
|
||||
if not client_ids:
|
||||
flash('No clients selected', 'warning')
|
||||
return redirect(url_for('clients.list_clients'))
|
||||
|
||||
if new_status not in ['active', 'inactive']:
|
||||
flash('Invalid status', 'error')
|
||||
return redirect(url_for('clients.list_clients'))
|
||||
|
||||
updated_count = 0
|
||||
errors = []
|
||||
|
||||
for client_id_str in client_ids:
|
||||
try:
|
||||
client_id = int(client_id_str)
|
||||
client = Client.query.get(client_id)
|
||||
|
||||
if not client:
|
||||
continue
|
||||
|
||||
# Update status
|
||||
client.status = new_status
|
||||
client.updated_at = datetime.utcnow()
|
||||
updated_count += 1
|
||||
|
||||
# Log the status change
|
||||
app_module.log_event(f"client.status_changed_{new_status}", user_id=current_user.id, client_id=client.id)
|
||||
app_module.track_event(current_user.id, "client.status_changed", {"client_id": client.id, "new_status": new_status})
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"ID {client_id_str}: {str(e)}")
|
||||
|
||||
# Commit all changes
|
||||
if updated_count > 0:
|
||||
if not safe_commit('bulk_status_change_clients', {'count': updated_count, 'status': new_status}):
|
||||
flash('Could not update client status due to a database error. Please check server logs.', 'error')
|
||||
return redirect(url_for('clients.list_clients'))
|
||||
|
||||
# Show appropriate messages
|
||||
status_labels = {'active': 'active', 'inactive': 'inactive'}
|
||||
if updated_count > 0:
|
||||
flash(f'Successfully marked {updated_count} client{"s" if updated_count != 1 else ""} as {status_labels.get(new_status, new_status)}', 'success')
|
||||
|
||||
if errors:
|
||||
flash(f'Some clients could not be updated: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', 'warning')
|
||||
|
||||
if updated_count == 0:
|
||||
flash('No clients were updated', 'info')
|
||||
|
||||
return redirect(url_for('clients.list_clients'))
|
||||
|
||||
@clients_bp.route('/api/clients')
|
||||
@login_required
|
||||
def api_clients():
|
||||
|
||||
@@ -23,6 +23,8 @@ def list_projects():
|
||||
query = query.filter_by(status='active')
|
||||
elif status == 'archived':
|
||||
query = query.filter_by(status='archived')
|
||||
elif status == 'inactive':
|
||||
query = query.filter_by(status='inactive')
|
||||
|
||||
if client_name:
|
||||
query = query.join(Client).filter(Client.name == client_name)
|
||||
@@ -75,6 +77,7 @@ def create_project():
|
||||
# Budgets
|
||||
budget_amount_raw = request.form.get('budget_amount', '').strip()
|
||||
budget_threshold_raw = request.form.get('budget_threshold_percent', '').strip()
|
||||
code = request.form.get('code', '').strip()
|
||||
try:
|
||||
current_app.logger.info(
|
||||
"POST /projects/create user=%s name=%s client_id=%s billable=%s",
|
||||
@@ -139,6 +142,16 @@ def create_project():
|
||||
pass
|
||||
return render_template('projects/create.html', clients=Client.get_active_clients())
|
||||
|
||||
# Normalize code
|
||||
normalized_code = code.upper() if code else None
|
||||
|
||||
# Validate code uniqueness if provided
|
||||
if normalized_code:
|
||||
existing_code = Project.query.filter(Project.code == normalized_code).first()
|
||||
if existing_code:
|
||||
flash(_('Project code already in use'), 'error')
|
||||
return render_template('projects/create.html', clients=Client.get_active_clients())
|
||||
|
||||
# Create project
|
||||
project = Project(
|
||||
name=name,
|
||||
@@ -147,6 +160,7 @@ def create_project():
|
||||
billable=billable,
|
||||
hourly_rate=hourly_rate,
|
||||
billing_ref=billing_ref,
|
||||
code=normalized_code,
|
||||
budget_amount=budget_amount,
|
||||
budget_threshold_percent=budget_threshold_percent or 80
|
||||
)
|
||||
@@ -260,6 +274,7 @@ def edit_project(project_id):
|
||||
billable = request.form.get('billable') == 'on'
|
||||
hourly_rate = request.form.get('hourly_rate', '').strip()
|
||||
billing_ref = request.form.get('billing_ref', '').strip()
|
||||
code = request.form.get('code', '').strip()
|
||||
budget_amount_raw = request.form.get('budget_amount', '').strip()
|
||||
budget_threshold_raw = request.form.get('budget_threshold_percent', '').strip()
|
||||
|
||||
@@ -306,6 +321,14 @@ def edit_project(project_id):
|
||||
if existing and existing.id != project.id:
|
||||
flash('A project with this name already exists', 'error')
|
||||
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
|
||||
|
||||
# Validate code uniqueness if provided
|
||||
normalized_code = code.upper() if code else None
|
||||
if normalized_code:
|
||||
existing_code = Project.query.filter(Project.code == normalized_code).first()
|
||||
if existing_code and existing_code.id != project.id:
|
||||
flash(_('Project code already in use'), 'error')
|
||||
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
|
||||
|
||||
# Update project
|
||||
project.name = name
|
||||
@@ -314,6 +337,7 @@ def edit_project(project_id):
|
||||
project.billable = billable
|
||||
project.hourly_rate = hourly_rate
|
||||
project.billing_ref = billing_ref
|
||||
project.code = normalized_code
|
||||
project.budget_amount = budget_amount if budget_amount_raw != '' else None
|
||||
project.budget_threshold_percent = budget_threshold_percent
|
||||
project.updated_at = datetime.utcnow()
|
||||
@@ -363,6 +387,48 @@ def unarchive_project(project_id):
|
||||
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
@projects_bp.route('/projects/<int:project_id>/deactivate', methods=['POST'])
|
||||
@login_required
|
||||
def deactivate_project(project_id):
|
||||
"""Mark a project as inactive"""
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can deactivate projects', 'error')
|
||||
return redirect(url_for('projects.view_project', project_id=project_id))
|
||||
|
||||
project = Project.query.get_or_404(project_id)
|
||||
|
||||
if project.status == 'inactive':
|
||||
flash('Project is already inactive', 'info')
|
||||
else:
|
||||
project.deactivate()
|
||||
# Log project deactivation
|
||||
log_event("project.deactivated", user_id=current_user.id, project_id=project.id)
|
||||
track_event(current_user.id, "project.deactivated", {"project_id": project.id})
|
||||
flash(f'Project "{project.name}" marked as inactive', 'success')
|
||||
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
@projects_bp.route('/projects/<int:project_id>/activate', methods=['POST'])
|
||||
@login_required
|
||||
def activate_project(project_id):
|
||||
"""Activate a project"""
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can activate projects', 'error')
|
||||
return redirect(url_for('projects.view_project', project_id=project_id))
|
||||
|
||||
project = Project.query.get_or_404(project_id)
|
||||
|
||||
if project.status == 'active':
|
||||
flash('Project is already active', 'info')
|
||||
else:
|
||||
project.activate()
|
||||
# Log project activation
|
||||
log_event("project.activated", user_id=current_user.id, project_id=project.id)
|
||||
track_event(current_user.id, "project.activated", {"project_id": project.id})
|
||||
flash(f'Project "{project.name}" activated successfully', 'success')
|
||||
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
@projects_bp.route('/projects/<int:project_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_project(project_id):
|
||||
@@ -387,6 +453,132 @@ def delete_project(project_id):
|
||||
flash(f'Project "{project_name}" deleted successfully', 'success')
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
@projects_bp.route('/projects/bulk-delete', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_delete_projects():
|
||||
"""Delete multiple projects at once"""
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can delete projects', 'error')
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
project_ids = request.form.getlist('project_ids[]')
|
||||
|
||||
if not project_ids:
|
||||
flash('No projects selected for deletion', 'warning')
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
deleted_count = 0
|
||||
skipped_count = 0
|
||||
errors = []
|
||||
|
||||
for project_id_str in project_ids:
|
||||
try:
|
||||
project_id = int(project_id_str)
|
||||
project = Project.query.get(project_id)
|
||||
|
||||
if not project:
|
||||
continue
|
||||
|
||||
# Check for time entries
|
||||
if project.time_entries.count() > 0:
|
||||
skipped_count += 1
|
||||
errors.append(f"'{project.name}': Has time entries")
|
||||
continue
|
||||
|
||||
# Delete the project
|
||||
project_id_for_log = project.id
|
||||
project_name = project.name
|
||||
|
||||
db.session.delete(project)
|
||||
deleted_count += 1
|
||||
|
||||
# Log the deletion
|
||||
log_event("project.deleted", user_id=current_user.id, project_id=project_id_for_log)
|
||||
track_event(current_user.id, "project.deleted", {"project_id": project_id_for_log})
|
||||
|
||||
except Exception as e:
|
||||
skipped_count += 1
|
||||
errors.append(f"ID {project_id_str}: {str(e)}")
|
||||
|
||||
# Commit all deletions
|
||||
if deleted_count > 0:
|
||||
if not safe_commit('bulk_delete_projects', {'count': deleted_count}):
|
||||
flash('Could not delete projects due to a database error. Please check server logs.', 'error')
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
# Show appropriate messages
|
||||
if deleted_count > 0:
|
||||
flash(f'Successfully deleted {deleted_count} project{"s" if deleted_count != 1 else ""}', 'success')
|
||||
|
||||
if skipped_count > 0:
|
||||
flash(f'Skipped {skipped_count} project{"s" if skipped_count != 1 else ""}: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', 'warning')
|
||||
|
||||
if deleted_count == 0 and skipped_count == 0:
|
||||
flash('No projects were deleted', 'info')
|
||||
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
@projects_bp.route('/projects/bulk-status-change', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_status_change():
|
||||
"""Change status for multiple projects at once"""
|
||||
if not current_user.is_admin:
|
||||
flash('Only administrators can change project status', 'error')
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
project_ids = request.form.getlist('project_ids[]')
|
||||
new_status = request.form.get('new_status', '').strip()
|
||||
|
||||
if not project_ids:
|
||||
flash('No projects selected', 'warning')
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
if new_status not in ['active', 'inactive', 'archived']:
|
||||
flash('Invalid status', 'error')
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
updated_count = 0
|
||||
errors = []
|
||||
|
||||
for project_id_str in project_ids:
|
||||
try:
|
||||
project_id = int(project_id_str)
|
||||
project = Project.query.get(project_id)
|
||||
|
||||
if not project:
|
||||
continue
|
||||
|
||||
# Update status
|
||||
project.status = new_status
|
||||
project.updated_at = datetime.utcnow()
|
||||
updated_count += 1
|
||||
|
||||
# Log the status change
|
||||
log_event(f"project.status_changed_{new_status}", user_id=current_user.id, project_id=project.id)
|
||||
track_event(current_user.id, "project.status_changed", {"project_id": project.id, "new_status": new_status})
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"ID {project_id_str}: {str(e)}")
|
||||
|
||||
# Commit all changes
|
||||
if updated_count > 0:
|
||||
if not safe_commit('bulk_status_change_projects', {'count': updated_count, 'status': new_status}):
|
||||
flash('Could not update project status due to a database error. Please check server logs.', 'error')
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
# Show appropriate messages
|
||||
status_labels = {'active': 'active', 'inactive': 'inactive', 'archived': 'archived'}
|
||||
if updated_count > 0:
|
||||
flash(f'Successfully marked {updated_count} project{"s" if updated_count != 1 else ""} as {status_labels.get(new_status, new_status)}', 'success')
|
||||
|
||||
if errors:
|
||||
flash(f'Some projects could not be updated: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', 'warning')
|
||||
|
||||
if updated_count == 0:
|
||||
flash('No projects were updated', 'info')
|
||||
|
||||
return redirect(url_for('projects.list_projects'))
|
||||
|
||||
|
||||
# ===== PROJECT COSTS ROUTES =====
|
||||
|
||||
|
||||
+7
-1
@@ -583,7 +583,9 @@ def bulk_update_status():
|
||||
flash('No tasks selected', 'warning')
|
||||
return redirect(url_for('tasks.list_tasks'))
|
||||
|
||||
if not new_status or new_status not in ['active', 'completed', 'on_hold', 'cancelled']:
|
||||
# Validate against configured kanban columns
|
||||
valid_statuses = set(KanbanColumn.get_valid_status_keys()) if KanbanColumn else set(['todo','in_progress','review','done','cancelled'])
|
||||
if not new_status or new_status not in valid_statuses:
|
||||
flash('Invalid status value', 'error')
|
||||
return redirect(url_for('tasks.list_tasks'))
|
||||
|
||||
@@ -603,7 +605,11 @@ def bulk_update_status():
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Handle reopening from done if needed
|
||||
if task.status == 'done' and new_status in ['todo', 'review', 'in_progress']:
|
||||
task.completed_at = None
|
||||
task.status = new_status
|
||||
task.updated_at = now_in_app_timezone()
|
||||
updated_count += 1
|
||||
|
||||
except Exception:
|
||||
|
||||
+107
-9
@@ -39,10 +39,11 @@
|
||||
.sidebar-collapsed .sidebar-header-title { display: none; }
|
||||
.sidebar-collapsed #workDropdown, .sidebar-collapsed #insightsDropdown { display: none; }
|
||||
.sidebar-collapsed #sidebar { width: 4rem !important; overflow-x: hidden; }
|
||||
/* Shared bulk menu */
|
||||
.bulk-menu { z-index: 50; max-height: 16rem; overflow-y: auto; }
|
||||
</style>
|
||||
<script>
|
||||
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
|
||||
// Priority: User preference from server > localStorage > system preference
|
||||
// Theme init (unchanged)
|
||||
{% if current_user.is_authenticated and current_user.theme %}
|
||||
var userTheme = '{{ current_user.theme }}';
|
||||
if (userTheme === 'dark') {
|
||||
@@ -52,7 +53,6 @@
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
} else if (userTheme === 'system') {
|
||||
// Follow system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
@@ -60,7 +60,6 @@
|
||||
}
|
||||
}
|
||||
{% else %}
|
||||
// Fall back to localStorage or system preference
|
||||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
@@ -72,10 +71,8 @@
|
||||
</head>
|
||||
<body class="bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark">
|
||||
<script>
|
||||
// Apply collapsed sidebar state ASAP to prevent flicker on navigations
|
||||
(function(){
|
||||
try { if (localStorage.getItem('sidebar-collapsed') === 'true') { document.documentElement.classList.add('sidebar-collapsed'); } } catch(_) {}
|
||||
})();
|
||||
// Sidebar collapsed ASAP
|
||||
(function(){ try { if (localStorage.getItem('sidebar-collapsed') === 'true') { document.documentElement.classList.add('sidebar-collapsed'); } } catch(_) {} })();
|
||||
</script>
|
||||
<a href="#mainContentAnchor" class="sr-only focus:not-sr-only focus-ring absolute left-2 top-2 z-[1000] px-3 py-2 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded">Skip to content</a>
|
||||
<div id="appShell" class="flex h-screen">
|
||||
@@ -87,7 +84,7 @@
|
||||
</div>
|
||||
<nav class="flex-1">
|
||||
{% set ep = request.endpoint or '' %}
|
||||
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') %}
|
||||
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('time_entry_templates.') %}
|
||||
{% set insights_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('analytics.') %}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider sidebar-label">{{ _('Navigation') }}</h2>
|
||||
@@ -691,6 +688,107 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Global helpers for bulk menus and confirmation using custom modal (no native confirm)
|
||||
window.showConfirm = function(message, opts){
|
||||
try {
|
||||
const options = Object.assign({
|
||||
title: '',
|
||||
confirmText: 'Confirm',
|
||||
cancelText: 'Cancel',
|
||||
variant: 'primary' // 'primary' | 'danger' | 'warning'
|
||||
}, opts || {});
|
||||
return new Promise((resolve) => {
|
||||
// Build overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fixed inset-0 z-[2000] flex items-center justify-center';
|
||||
overlay.innerHTML = `
|
||||
<div class="absolute inset-0 bg-black/50" data-close></div>
|
||||
<div class="relative bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-12 h-12 rounded-full ${options.variant==='danger' ? 'bg-rose-100 dark:bg-rose-900/30' : (options.variant==='warning' ? 'bg-amber-100 dark:bg-amber-900/30' : 'bg-sky-100 dark:bg-sky-900/30')} flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle ${options.variant==='danger' ? 'text-rose-600 dark:text-rose-400' : (options.variant==='warning' ? 'text-amber-600 dark:text-amber-400' : 'text-sky-600 dark:text-sky-400')}"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
${options.title ? `<h3 class="text-lg font-semibold mb-1">${options.title}</h3>` : ''}
|
||||
<p class="text-sm">${message || ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button type="button" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg" data-cancel>${options.cancelText}</button>
|
||||
<button type="button" class="px-4 py-2 ${options.variant==='danger' ? 'bg-rose-600 hover:bg-rose-700' : (options.variant==='warning' ? 'bg-amber-500 hover:bg-amber-600' : 'bg-primary hover:bg-primary/90')} text-white rounded-lg" data-confirm>${options.confirmText}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
function cleanup(result){
|
||||
try { document.body.removeChild(overlay); } catch(_) {}
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target.hasAttribute('data-close')) cleanup(false);
|
||||
if (e.target.hasAttribute('data-cancel')) cleanup(false);
|
||||
if (e.target.hasAttribute('data-confirm')) cleanup(true);
|
||||
});
|
||||
document.addEventListener('keydown', function onKey(e){
|
||||
if (e.key === 'Escape'){ cleanup(false); document.removeEventListener('keydown', onKey); }
|
||||
if (e.key === 'Enter'){ cleanup(true); document.removeEventListener('keydown', onKey); }
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
// Focus confirm button
|
||||
setTimeout(() => { try { overlay.querySelector('[data-confirm]').focus(); } catch(_) {} }, 0);
|
||||
});
|
||||
} catch(_) {
|
||||
// Absolute fallback if anything goes wrong
|
||||
try { return Promise.resolve(window.confirm(message)); } catch(__) { return Promise.resolve(false); }
|
||||
}
|
||||
};
|
||||
window.showAlert = window.showAlert || function(message){
|
||||
try { window.alert(message); } catch(_) {}
|
||||
};
|
||||
function closeAllMenus(){
|
||||
try { document.querySelectorAll('.bulk-menu').forEach(m => m.classList.add('hidden')); } catch(_) {}
|
||||
}
|
||||
function openMenu(triggerEl, menuId){
|
||||
try{
|
||||
const menu = document.getElementById(menuId);
|
||||
if (!menu) return;
|
||||
const willOpen = menu.classList.contains('hidden');
|
||||
closeAllMenus();
|
||||
if (!willOpen) return;
|
||||
// Reset positioning
|
||||
menu.style.top = '';
|
||||
menu.style.bottom = '';
|
||||
menu.style.maxHeight = '16rem';
|
||||
// Temporarily show off-screen to measure accurately
|
||||
const originalDisplay = menu.style.display;
|
||||
menu.style.visibility = 'hidden';
|
||||
menu.style.display = 'block';
|
||||
const rect = triggerEl.getBoundingClientRect();
|
||||
const menuHeight = menu.scrollHeight || menu.offsetHeight || 200;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
// Restore visibility before final placement
|
||||
menu.style.display = originalDisplay || '';
|
||||
menu.style.visibility = '';
|
||||
// Flip to dropup if not enough space below
|
||||
const needsDropup = spaceBelow < Math.min(menuHeight, 256) + 16 && spaceAbove > spaceBelow;
|
||||
if (needsDropup) { menu.style.bottom = 'calc(100% + 8px)'; } else { menu.style.top = 'calc(100% + 8px)'; }
|
||||
menu.classList.remove('hidden');
|
||||
} catch(_) {}
|
||||
}
|
||||
// Click outside to close any bulk menus
|
||||
document.addEventListener('click', function(e){
|
||||
const trigger = e.target.closest('#bulkActionsBtn');
|
||||
const insideAnyMenu = e.target.closest('.bulk-menu');
|
||||
if (!trigger && !insideAnyMenu){ closeAllMenus(); }
|
||||
});
|
||||
// Close on Escape
|
||||
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeAllMenus(); });
|
||||
</script>
|
||||
|
||||
{% block scripts_extra %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -33,9 +33,32 @@
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ clients|length }} client{{ 's' if clients|length != 1 else '' }} found
|
||||
</h3>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="relative">
|
||||
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'clientsBulkMenu')" disabled>
|
||||
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul id="clientsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
|
||||
<li><a class="block px-4 py-2 text-sm" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
|
||||
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-rose-600" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<table class="w-full text-left">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
<th class="p-4 w-10">
|
||||
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllClients()">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th class="p-4">Name</th>
|
||||
<th class="p-4">Contact Person</th>
|
||||
<th class="p-4">Email</th>
|
||||
@@ -47,6 +70,11 @@
|
||||
<tbody>
|
||||
{% for client in clients %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
{% if current_user.is_admin %}
|
||||
<td class="p-4">
|
||||
<input type="checkbox" class="client-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ client.id }}" onchange="updateClientsBulkState()">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="p-4">{{ client.name }}</td>
|
||||
<td class="p-4">{{ client.contact_person }}</td>
|
||||
<td class="p-4">{{ client.email }}</td>
|
||||
@@ -66,10 +94,65 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">No clients found.</td>
|
||||
<td colspan="7" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">No clients found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<form id="clients-bulk-status-form" method="POST" action="{{ url_for('clients.bulk_status_change') }}" class="hidden">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="new_status" id="clientsBulkNewStatus" value="">
|
||||
</form>
|
||||
<form id="clients-bulk-delete-form" method="POST" action="{{ url_for('clients.bulk_delete_clients') }}" class="hidden">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
</form>
|
||||
<script>
|
||||
function toggleAllClients(){
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
document.querySelectorAll('.client-checkbox').forEach(cb => cb.checked = !!(selectAll && selectAll.checked));
|
||||
updateClientsBulkState();
|
||||
}
|
||||
function updateClientsBulkState(){
|
||||
const selected = document.querySelectorAll('.client-checkbox:checked').length;
|
||||
const btn = document.getElementById('bulkActionsBtn');
|
||||
const cnt = document.getElementById('selectedCount');
|
||||
if (cnt) cnt.textContent = selected;
|
||||
if (btn) btn.disabled = selected === 0;
|
||||
}
|
||||
function showBulkDeleteConfirm(){
|
||||
const count = document.querySelectorAll('.client-checkbox:checked').length;
|
||||
if (count === 0) return false;
|
||||
const msg = `Are you sure you want to delete ${count} client(s)? Clients with projects will be skipped.`;
|
||||
if (window.showConfirm){ window.showConfirm(msg, { title: 'Delete Clients', confirmText: 'Delete', variant: 'danger' }).then(function(ok){ if (ok) submitClientsBulkDelete(); }); }
|
||||
return false;
|
||||
}
|
||||
function submitClientsBulkDelete(){
|
||||
const form = document.getElementById('clients-bulk-delete-form');
|
||||
form.querySelectorAll('input[name="client_ids[]"]').forEach(n => n.remove());
|
||||
document.querySelectorAll('.client-checkbox:checked').forEach(cb => {
|
||||
const i = document.createElement('input'); i.type='hidden'; i.name='client_ids[]'; i.value=cb.value; form.appendChild(i);
|
||||
});
|
||||
form.submit();
|
||||
}
|
||||
function showBulkStatusChange(newStatus){
|
||||
const count = document.querySelectorAll('.client-checkbox:checked').length;
|
||||
if (count === 0) return false;
|
||||
const label = {active:'Active', inactive:'Inactive'}[newStatus] || newStatus;
|
||||
const msg = `Are you sure you want to mark ${count} client(s) as ${label}?`;
|
||||
if (window.showConfirm){ window.showConfirm(msg, { title: 'Change Client Status', confirmText: 'Change' }).then(function(ok){ if (ok){ document.getElementById('clientsBulkNewStatus').value=newStatus; submitClientsBulkStatus(); }}); }
|
||||
return false;
|
||||
}
|
||||
function submitClientsBulkStatus(){
|
||||
const form = document.getElementById('clients-bulk-status-form');
|
||||
form.querySelectorAll('input[name="client_ids[]"]').forEach(n => n.remove());
|
||||
document.querySelectorAll('.client-checkbox:checked').forEach(cb => {
|
||||
const i = document.createElement('input'); i.type='hidden'; i.name='client_ids[]'; i.value=cb.value; form.appendChild(i);
|
||||
});
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,7 +9,19 @@
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('clients.edit_client', client_id=client.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Edit Client</a>
|
||||
<a href="{{ url_for('clients.edit_client', client_id=client.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Edit Client') }}</a>
|
||||
{% if client.status == 'active' %}
|
||||
<form method="POST" action="{{ url_for('clients.archive_client', client_id=client.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Mark client as Inactive?') }}', { title: '{{ _('Change Client Status') }}', confirmText: '{{ _('Change') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-amber-500 text-white mt-4 md:mt-0">{{ _('Mark Inactive') }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="{{ url_for('clients.activate_client', client_id=client.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Activate client?') }}', { title: '{{ _('Activate Client') }}', confirmText: '{{ _('Activate') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 text-white mt-4 md:mt-0">{{ _('Activate') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if client.total_projects == 0 %}
|
||||
<button type="button" class="bg-red-600 text-white px-4 py-2 rounded-lg mt-4 md:mt-0"
|
||||
onclick="document.getElementById('confirmDeleteClient-{{ client.id }}').classList.remove('hidden')">
|
||||
{{ _('Delete Client') }}
|
||||
@@ -17,6 +29,7 @@
|
||||
<form id="confirmDeleteClient-{{ client.id }}-form" method="POST" action="{{ url_for('clients.delete_client', client_id=client.id) }}" class="hidden">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -63,8 +76,23 @@
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-4">{{ project.name }}</td>
|
||||
<td class="p-4">{{ project.status | capitalize }}</td>
|
||||
<td class="p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ project.name }}</span>
|
||||
{% if project.code_display %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ project.code_display }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% set status_map = {
|
||||
'active': {'cls': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300', 'label': _('Active')},
|
||||
'inactive': {'cls': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', 'label': _('Inactive')},
|
||||
'archived': {'cls': 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200', 'label': _('Archived')},
|
||||
} %}
|
||||
{% set st = status_map.get(project.status, status_map['inactive']) %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ st.cls }}">{{ st.label }}</span>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-primary hover:underline">View</a>
|
||||
</td>
|
||||
|
||||
@@ -208,27 +208,23 @@ function submitBulkAction(url, extraData = {}) {
|
||||
}
|
||||
|
||||
function bulkUpdateStatus(status) {
|
||||
if (confirm(`Change status of ${selectedTaskIds.size} task(s) to ${status}?`)) {
|
||||
submitBulkAction('{{ url_for("tasks.bulk_update_status") }}', { status: status });
|
||||
}
|
||||
const msg = `Change status of ${selectedTaskIds.size} task(s) to ${status}?`;
|
||||
if (window.showConfirm){ window.showConfirm(msg, { title: 'Change Task Status', confirmText: 'Change' }).then(function(ok){ if (ok) submitBulkAction('{{ url_for("tasks.bulk_update_status") }}', { status: status }); }); }
|
||||
}
|
||||
|
||||
function bulkUpdatePriority(priority) {
|
||||
if (confirm(`Change priority of ${selectedTaskIds.size} task(s) to ${priority}?`)) {
|
||||
submitBulkAction('{{ url_for("tasks.bulk_update_priority") }}', { priority: priority });
|
||||
}
|
||||
const msg = `Change priority of ${selectedTaskIds.size} task(s) to ${priority}?`;
|
||||
if (window.showConfirm){ window.showConfirm(msg, { title: 'Change Task Priority', confirmText: 'Change' }).then(function(ok){ if (ok) submitBulkAction('{{ url_for("tasks.bulk_update_priority") }}', { priority: priority }); }); }
|
||||
}
|
||||
|
||||
function bulkAssign(userId) {
|
||||
if (confirm(`Assign ${selectedTaskIds.size} task(s) to selected user?`)) {
|
||||
submitBulkAction('{{ url_for("tasks.bulk_assign_tasks") }}', { assigned_to: userId });
|
||||
}
|
||||
const msg = `Assign ${selectedTaskIds.size} task(s) to selected user?`;
|
||||
if (window.showConfirm){ window.showConfirm(msg, { title: 'Assign Tasks', confirmText: 'Assign' }).then(function(ok){ if (ok) submitBulkAction('{{ url_for("tasks.bulk_assign_tasks") }}', { assigned_to: userId }); }); }
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
if (confirm(`Are you sure you want to delete ${selectedTaskIds.size} task(s)? This action cannot be undone.`)) {
|
||||
submitBulkAction('{{ url_for("tasks.bulk_delete_tasks") }}');
|
||||
}
|
||||
const msg = `Are you sure you want to delete ${selectedTaskIds.size} task(s)? This action cannot be undone.`;
|
||||
if (window.showConfirm){ window.showConfirm(msg, { title: 'Delete Tasks', confirmText: 'Delete', variant: 'danger' }).then(function(ok){ if (ok) submitBulkAction('{{ url_for("tasks.bulk_delete_tasks") }}'); }); }
|
||||
}
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
|
||||
@@ -87,36 +87,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
});
|
||||
|
||||
// Inline status select changes
|
||||
document.querySelectorAll('.kanban-status').forEach(sel => {
|
||||
sel.addEventListener('change', async (e) => {
|
||||
const select = e.target;
|
||||
const taskId = select.dataset.taskId;
|
||||
const newStatus = select.value;
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}/status`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin', body: JSON.stringify({ status: newStatus }) });
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
// Move the card to target column
|
||||
const card = document.querySelector(`.kanban-card[data-task-id="${taskId}"]`);
|
||||
const originalParent = card ? card.parentElement : null;
|
||||
const target = document.querySelector(`.kanban-column-body[data-status="${newStatus}"]`);
|
||||
if (card && target) {
|
||||
// Hide target empty placeholder
|
||||
const targetEmpty = target.querySelector('.kanban-empty');
|
||||
if (targetEmpty) targetEmpty.style.display = 'none';
|
||||
target.appendChild(card);
|
||||
card.dataset.status = newStatus;
|
||||
updateColumnCounts();
|
||||
toggleEmptyStatesForBodies([target, originalParent]);
|
||||
announce(`${card.querySelector('h4, .kanban-card-title')?.textContent || 'Task'} {{ _('moved to') }} ${newStatus}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// revert select silently
|
||||
const card = document.querySelector(`.kanban-card[data-task-id="${taskId}"]`);
|
||||
if (card) select.value = card.dataset.status;
|
||||
}
|
||||
});
|
||||
});
|
||||
// Inline status dropdown removed: status is determined by column.
|
||||
|
||||
function updateColumnCounts(){
|
||||
document.querySelectorAll('.kanban-count').forEach(span => {
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
<div class="kanban-card group bg-background-light dark:bg-background-dark p-4 rounded-lg shadow-sm border border-border-light dark:border-border-dark hover:shadow-md transition-all cursor-grab active:cursor-grabbing" draggable="true" data-task-id="{{ task.id }}" data-status="{{ task.status }}" data-project-id="{{ task.project_id }}" role="listitem" aria-grabbed="false" aria-label="{{ _('Task') }}: {{ task.name }} — {{ _('Status') }}: {{ col.label }}">
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="block">
|
||||
<h4 class="font-semibold mb-2 group-hover:text-primary transition-colors">{{ task.name }}</h4>
|
||||
{% if task.project %}
|
||||
<div class="mb-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200" data-testid="kanban-project-code">{{ task.project.code_display }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if task.description %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">{{ task.description | truncate(80) }}</p>
|
||||
{% endif %}
|
||||
@@ -56,15 +61,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-text-muted-light dark:text-text-muted-dark" for="status-{{ task.id }}">{{ _('Status') }}</label>
|
||||
<select id="status-{{ task.id }}" class="kanban-status bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded px-2 py-1 text-xs" data-task-id="{{ task.id }}">
|
||||
{% for st in kanban_columns %}
|
||||
<option value="{{ st.key }}" {% if task.status == st.key %}selected{% endif %}>{{ st.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-end gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project_id) }}?task_id={{ task.id }}" class="text-xs px-2 py-1 rounded bg-primary text-white hover:opacity-90">{{ _('Start') }}</a>
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-xs px-2 py-1 rounded border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">{{ _('View') }}</a>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% if not good.invoice_id %}
|
||||
<form method="POST" action="{{ url_for('projects.delete_good', project_id=project.id, good_id=good.id) }}" class="inline" onsubmit="return confirm('{{ _('Are you sure you want to delete this extra good?') }}');">
|
||||
<form method="POST" action="{{ url_for('projects.delete_good', project_id=project.id, good_id=good.id) }}" class="inline" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Are you sure you want to delete this extra good?') }}', { title: '{{ _('Delete Extra Good') }}', confirmText: '{{ _('Delete') }}', variant: 'danger' }).then(ok=>{ if(ok) this.submit(); });">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-rose-600 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300">
|
||||
<i class="fas fa-trash"></i>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<select name="status" id="status" class="form-input">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All</option>
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>Active</option>
|
||||
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive</option>
|
||||
<option value="archived" {% if status == 'archived' %}selected{% endif %}>Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -49,18 +50,34 @@
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ projects|length }} project{{ 's' if projects|length != 1 else '' }} found
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" title="Export to CSV">
|
||||
<i class="fas fa-download mr-1"></i> Export
|
||||
</button>
|
||||
<button type="button" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" title="Column visibility">
|
||||
<i class="fas fa-columns mr-1"></i> Columns
|
||||
</button>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="relative">
|
||||
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'projectsBulkMenu')" disabled>
|
||||
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul id="projectsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 z-50 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg max-h-64 overflow-y-auto">
|
||||
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('archived')"><i class="fas fa-archive mr-2 text-gray-600"></i>Archive</a></li>
|
||||
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
|
||||
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full text-left" data-enhanced>
|
||||
<table class="w-full text-left">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
<th class="p-4 w-10">
|
||||
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllProjects()">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th class="p-4" data-sortable>Name</th>
|
||||
<th class="p-4" data-sortable>Client</th>
|
||||
<th class="p-4" data-sortable>Status</th>
|
||||
@@ -73,11 +90,18 @@
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
{% if current_user.is_admin %}
|
||||
<td class="p-4">
|
||||
<input type="checkbox" class="project-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ project.id }}" onchange="updateBulkDeleteButton()">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="p-4">{{ project.name }}</td>
|
||||
<td class="p-4">{{ project.client.name }}</td>
|
||||
<td class="p-4">{{ project.client }}</td>
|
||||
<td class="p-4">
|
||||
{% if project.status == 'active' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">Active</span>
|
||||
{% elif project.status == 'inactive' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Inactive</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Archived</span>
|
||||
{% endif %}
|
||||
@@ -135,7 +159,92 @@
|
||||
{% endset %}
|
||||
{{ empty_state('fas fa-folder-open', 'No Projects Found', 'Projects help you organize your work. Create your first project to get started tracking time.', actions) }}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts_extra %}
|
||||
<form id="bulkStatusChange-form" method="POST" action="{{ url_for('projects.bulk_status_change') }}" class="hidden">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="new_status" id="bulkNewStatus" value="">
|
||||
</form>
|
||||
<form id="confirmBulkDelete-form" method="POST" action="{{ url_for('projects.bulk_delete_projects') }}" class="hidden">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
</form>
|
||||
<script>
|
||||
function toggleAllProjects(){
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
document.querySelectorAll('.project-checkbox').forEach(cb => cb.checked = !!(selectAll && selectAll.checked));
|
||||
updateBulkDeleteButton();
|
||||
}
|
||||
function updateBulkDeleteButton(){
|
||||
const selected = document.querySelectorAll('.project-checkbox:checked').length;
|
||||
const btn = document.getElementById('bulkActionsBtn');
|
||||
const cnt = document.getElementById('selectedCount');
|
||||
if (cnt) cnt.textContent = selected;
|
||||
if (btn) btn.disabled = selected === 0;
|
||||
}
|
||||
function closeAllMenus(){
|
||||
document.querySelectorAll('.bulk-menu').forEach(m => m.classList.add('hidden'));
|
||||
}
|
||||
function openMenu(triggerEl, menuId){
|
||||
const menu = document.getElementById(menuId);
|
||||
if (!menu) return;
|
||||
const willOpen = menu.classList.contains('hidden');
|
||||
closeAllMenus();
|
||||
if (!willOpen) return;
|
||||
// Position menu (dropup if not enough space below)
|
||||
menu.style.top = '';
|
||||
menu.style.bottom = '';
|
||||
const rect = triggerEl.getBoundingClientRect();
|
||||
const menuHeight = menu.offsetHeight || 200;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
if (spaceBelow < menuHeight + 16 && spaceAbove > menuHeight + 16){
|
||||
menu.style.bottom = 'calc(100% + 8px)';
|
||||
} else {
|
||||
menu.style.top = 'calc(100% + 8px)';
|
||||
}
|
||||
menu.classList.remove('hidden');
|
||||
}
|
||||
// Click outside to close
|
||||
document.addEventListener('click', function(e){
|
||||
const insideTrigger = e.target.closest('#bulkActionsBtn');
|
||||
const insideMenu = e.target.closest('#projectsBulkMenu');
|
||||
if (!insideTrigger && !insideMenu){ closeAllMenus(); }
|
||||
});
|
||||
// Close on Escape
|
||||
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeAllMenus(); });
|
||||
|
||||
function showBulkDeleteConfirm(){
|
||||
const count = document.querySelectorAll('.project-checkbox:checked').length;
|
||||
if (count === 0) return false;
|
||||
const msg = `Are you sure you want to delete ${count} project(s)? Projects with time entries will be skipped.`;
|
||||
if (window.showConfirm){ window.showConfirm(msg, { title: 'Delete Projects', confirmText: 'Delete', variant: 'danger' }).then(function(ok){ if (ok) submitBulkDelete(); }); }
|
||||
return false;
|
||||
}
|
||||
function submitBulkDelete(){
|
||||
const form = document.getElementById('confirmBulkDelete-form');
|
||||
form.querySelectorAll('input[name="project_ids[]"]').forEach(n => n.remove());
|
||||
document.querySelectorAll('.project-checkbox:checked').forEach(cb => {
|
||||
const i = document.createElement('input'); i.type='hidden'; i.name='project_ids[]'; i.value=cb.value; form.appendChild(i);
|
||||
});
|
||||
form.submit();
|
||||
}
|
||||
function showBulkStatusChange(newStatus){
|
||||
const count = document.querySelectorAll('.project-checkbox:checked').length;
|
||||
if (count === 0) return false;
|
||||
const label = {active:'Active', inactive:'Inactive', archived:'Archived'}[newStatus] || newStatus;
|
||||
const msg = `Are you sure you want to mark ${count} project(s) as ${label}?`;
|
||||
if (window.showConfirm){ window.showConfirm(msg, { title: 'Change Project Status', confirmText: 'Change' }).then(function(ok){ if (ok){ document.getElementById('bulkNewStatus').value=newStatus; submitBulkStatusChange(); }}); }
|
||||
return false;
|
||||
}
|
||||
function submitBulkStatusChange(){
|
||||
const form = document.getElementById('bulkStatusChange-form');
|
||||
form.querySelectorAll('input[name="project_ids[]"]').forEach(n => n.remove());
|
||||
document.querySelectorAll('.project-checkbox:checked').forEach(cb => {
|
||||
const i = document.createElement('input'); i.type='hidden'; i.name='project_ids[]'; i.value=cb.value; form.appendChild(i);
|
||||
});
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,12 +4,41 @@
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ project.name }}</h1>
|
||||
<h1 class="text-2xl font-bold flex items-center gap-2">
|
||||
<span>{{ project.name }}</span>
|
||||
{% if project.code_display %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200" title="{{ _('Project Code') }}">{{ project.code_display }}</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ project.client.name }}</p>
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Edit Project</a>
|
||||
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Edit Project') }}</a>
|
||||
{% if project.status == 'active' %}
|
||||
<form method="POST" action="{{ url_for('projects.deactivate_project', project_id=project.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Mark project as Inactive?') }}', { title: '{{ _('Change Project Status') }}', confirmText: '{{ _('Change') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-amber-500 text-white mt-4 md:mt-0">{{ _('Mark Inactive') }}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('projects.archive_project', project_id=project.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Archive this project?') }}', { title: '{{ _('Archive Project') }}', confirmText: '{{ _('Archive') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-gray-600 text-white mt-4 md:mt-0">{{ _('Archive') }}</button>
|
||||
</form>
|
||||
{% elif project.status == 'inactive' %}
|
||||
<form method="POST" action="{{ url_for('projects.activate_project', project_id=project.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Activate project?') }}', { title: '{{ _('Activate Project') }}', confirmText: '{{ _('Activate') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 text-white mt-4 md:mt-0">{{ _('Activate') }}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('projects.archive_project', project_id=project.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Archive this project?') }}', { title: '{{ _('Archive Project') }}', confirmText: '{{ _('Archive') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-gray-600 text-white mt-4 md:mt-0">{{ _('Archive') }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="{{ url_for('projects.unarchive_project', project_id=project.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Unarchive project?') }}', { title: '{{ _('Unarchive Project') }}', confirmText: '{{ _('Unarchive') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-sky-600 text-white mt-4 md:mt-0">{{ _('Unarchive') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button type="button" class="bg-red-600 text-white px-4 py-2 rounded-lg mt-4 md:mt-0"
|
||||
onclick="document.getElementById('confirmDeleteProject-{{ project.id }}').classList.remove('hidden')">
|
||||
{{ _('Delete Project') }}
|
||||
@@ -27,13 +56,29 @@
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">Details</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">{{ _('Project Code') }}</h3>
|
||||
<p>
|
||||
{% if project.code_display %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ project.code_display }}</span>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Description</h3>
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">{{ (project.description or 'No description provided.') | markdown | safe }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Status</h3>
|
||||
<p class="{{ 'text-green-500' if project.status == 'active' else 'text-red-500' }}">{{ project.status | capitalize }}</p>
|
||||
{% set status_map = {
|
||||
'active': {'cls': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300', 'label': _('Active')},
|
||||
'inactive': {'cls': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', 'label': _('Inactive')},
|
||||
'archived': {'cls': 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200', 'label': _('Archived')},
|
||||
} %}
|
||||
{% set st = status_map.get(project.status, status_map['inactive']) %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ st.cls }}">{{ st.label }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Billing</h3>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
</button>
|
||||
<form method="POST"
|
||||
action="{{ url_for('saved_filters.delete_filter', filter_id=filter.id) }}"
|
||||
onsubmit="return confirm('Are you sure you want to delete this filter?');"
|
||||
onsubmit="event.preventDefault(); window.showConfirm('{{ _('Are you sure you want to delete this filter?') }}', { title: '{{ _('Delete Filter') }}', confirmText: '{{ _('Delete') }}', variant: 'danger' }).then(ok=>{ if(ok) this.submit(); });"
|
||||
class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
|
||||
@@ -76,6 +76,11 @@
|
||||
<h6 class="kanban-card-title">
|
||||
<a href="javascript:void(0);" onclick="openTaskModal({{ task.id }})">{{ task.name }}</a>
|
||||
</h6>
|
||||
{% if task.project %}
|
||||
<div class="mb-2">
|
||||
<span class="kanban-badge" style="background:#e5e7eb;color:#374151;">{{ task.project.code_display }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if task.description %}
|
||||
<p class="kanban-card-description">
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<option value="">{{ _('Select a project') }}</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if task.project_id == project.id %}selected{% endif %}>
|
||||
{{ project.name }} ({{ project.client }})
|
||||
{{ project.name }}{% if project.code_display %} [{{ project.code_display }}]{% endif %} ({{ project.client }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
+101
-44
@@ -95,13 +95,18 @@
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ tasks|length }} task{{ 's' if tasks|length != 1 else '' }} found
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" title="Export to CSV">
|
||||
<i class="fas fa-download mr-1"></i> Export
|
||||
</button>
|
||||
<button type="button" id="bulkDeleteBtn" class="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors hidden" title="Delete selected tasks" onclick="showBulkDeleteConfirm()">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'tasksBulkMenu')" disabled>
|
||||
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul id="tasksBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
|
||||
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-zebra w-full text-left">
|
||||
@@ -204,7 +209,15 @@
|
||||
.filter-collapsed { display: none !important; }
|
||||
.filter-toggle-transition { transition: all 0.3s ease-in-out; }
|
||||
</style>
|
||||
<script type="application/json" id="i18n-json-tasks-list">
|
||||
{
|
||||
"confirm_delete": {{ _('Are you sure you want to delete the task "{name}"?')|tojson }},
|
||||
"confirm_delete_with_entries": {{ _('Cannot delete task "{name}" because it has time entries. Please delete the time entries first.')|tojson }}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
var i18nTasksList = (function(){ try{ var el=document.getElementById('i18n-json-tasks-list'); return el?JSON.parse(el.textContent):{}; }catch(e){ return {}; } })();
|
||||
|
||||
// Bulk delete functions
|
||||
function toggleAllTasks() {
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
@@ -216,15 +229,11 @@ function toggleAllTasks() {
|
||||
function updateBulkDeleteButton() {
|
||||
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
|
||||
const count = checkboxes.length;
|
||||
const btn = document.getElementById('bulkDeleteBtn');
|
||||
const btn = document.getElementById('bulkActionsBtn');
|
||||
const countSpan = document.getElementById('selectedCount');
|
||||
|
||||
if (count > 0) {
|
||||
btn.classList.remove('hidden');
|
||||
countSpan.textContent = count;
|
||||
} else {
|
||||
btn.classList.add('hidden');
|
||||
}
|
||||
if (countSpan) countSpan.textContent = count;
|
||||
if (btn) btn.disabled = count === 0;
|
||||
|
||||
// Update select all checkbox state
|
||||
const allCheckboxes = document.querySelectorAll('.task-checkbox');
|
||||
@@ -237,11 +246,90 @@ function updateBulkDeleteButton() {
|
||||
|
||||
function showBulkDeleteConfirm() {
|
||||
document.getElementById('confirmBulkDelete').classList.remove('hidden');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete task confirmation (single)
|
||||
function confirmDeleteTask(taskId, taskName, hasTimeEntries) {
|
||||
if (hasTimeEntries) {
|
||||
const msg = (i18nTasksList.confirm_delete_with_entries || 'Cannot delete task "{name}" because it has time entries. Please delete the time entries first.').replace('{name}', taskName);
|
||||
if (window.showAlert) {
|
||||
window.showAlert(msg);
|
||||
} else {
|
||||
alert(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = (i18nTasksList.confirm_delete || 'Are you sure you want to delete the task "{name}"?').replace('{name}', taskName);
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(msg).then(function(ok){
|
||||
if (!ok) return;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/tasks/${taskId}/delete`;
|
||||
|
||||
// Add CSRF token
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = document.querySelector('meta[name="csrf-token"]').getAttribute('content') || '';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
} else {
|
||||
if (confirm(msg)) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/tasks/${taskId}/delete`;
|
||||
|
||||
// Add CSRF token
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = document.querySelector('meta[name="csrf-token"]').getAttribute('content') || '';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFilterVisibility() {
|
||||
const filterBody = document.getElementById('filterBody');
|
||||
const toggleIcon = document.getElementById('filterToggleIcon');
|
||||
const toggleButton = document.getElementById('toggleFilters');
|
||||
if (!filterBody || !toggleIcon || !toggleButton) return;
|
||||
if (filterBody.classList.contains('filter-collapsed')) {
|
||||
filterBody.classList.remove('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-up';
|
||||
toggleButton.title = '{{ _('Hide Filters') }}';
|
||||
localStorage.setItem('taskListFiltersVisible', 'true');
|
||||
} else {
|
||||
filterBody.classList.add('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-down';
|
||||
toggleButton.title = '{{ _('Show Filters') }}';
|
||||
localStorage.setItem('taskListFiltersVisible', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle bulk delete confirmation
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Intercept the form submission to add task IDs
|
||||
const filterBody = document.getElementById('filterBody');
|
||||
const toggleIcon = document.getElementById('filterToggleIcon');
|
||||
const toggleButton = document.getElementById('toggleFilters');
|
||||
if (!filterBody || !toggleIcon || !toggleButton) return;
|
||||
const filtersVisible = localStorage.getItem('taskListFiltersVisible');
|
||||
if (filtersVisible === 'false') {
|
||||
filterBody.classList.add('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-down';
|
||||
toggleButton.title = '{{ _('Show Filters') }}';
|
||||
}
|
||||
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
|
||||
|
||||
// Handle bulk delete confirmation
|
||||
const form = document.getElementById('confirmBulkDelete-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
@@ -267,36 +355,5 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function toggleFilterVisibility() {
|
||||
const filterBody = document.getElementById('filterBody');
|
||||
const toggleIcon = document.getElementById('filterToggleIcon');
|
||||
const toggleButton = document.getElementById('toggleFilters');
|
||||
if (!filterBody || !toggleIcon || !toggleButton) return;
|
||||
if (filterBody.classList.contains('filter-collapsed')) {
|
||||
filterBody.classList.remove('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-up';
|
||||
toggleButton.title = '{{ _('Hide Filters') }}';
|
||||
localStorage.setItem('taskListFiltersVisible', 'true');
|
||||
} else {
|
||||
filterBody.classList.add('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-down';
|
||||
toggleButton.title = '{{ _('Show Filters') }}';
|
||||
localStorage.setItem('taskListFiltersVisible', 'false');
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const filterBody = document.getElementById('filterBody');
|
||||
const toggleIcon = document.getElementById('filterToggleIcon');
|
||||
const toggleButton = document.getElementById('toggleFilters');
|
||||
if (!filterBody || !toggleIcon || !toggleButton) return;
|
||||
const filtersVisible = localStorage.getItem('taskListFiltersVisible');
|
||||
if (filtersVisible === 'false') {
|
||||
filterBody.classList.add('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-down';
|
||||
toggleButton.title = '{{ _('Show Filters') }}';
|
||||
}
|
||||
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -9,7 +9,31 @@
|
||||
</div>
|
||||
{% if current_user.is_admin or task.created_by == current_user.id %}
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('tasks.edit_task', task_id=task.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Edit Task</a>
|
||||
<a href="{{ url_for('tasks.edit_task', task_id=task.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Edit Task') }}</a>
|
||||
{% if task.status in ['todo','review'] %}
|
||||
<form method="POST" action="{{ url_for('tasks.update_task_status', task_id=task.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Start task and mark as In Progress?') }}', { title: '{{ _('Change Task Status') }}', confirmText: '{{ _('Start') }}' }).then(ok=>{ if(ok){ this.submit(); } });" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="status" value="in_progress">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 text-white mt-4 md:mt-0">{{ _('Start') }}</button>
|
||||
</form>
|
||||
{% elif task.status == 'in_progress' %}
|
||||
<form method="POST" action="{{ url_for('tasks.update_task_status', task_id=task.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Mark task as To Do?') }}', { title: '{{ _('Change Task Status') }}', confirmText: '{{ _('Change') }}' }).then(ok=>{ if(ok){ this.submit(); } });" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="status" value="todo">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-amber-500 text-white mt-4 md:mt-0">{{ _('Pause') }}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('tasks.update_task_status', task_id=task.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Mark task as Done?') }}', { title: '{{ _('Complete Task') }}', confirmText: '{{ _('Complete') }}' }).then(ok=>{ if(ok){ this.submit(); } });" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="status" value="done">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-sky-600 text-white mt-4 md:mt-0">{{ _('Complete') }}</button>
|
||||
</form>
|
||||
{% elif task.status == 'done' %}
|
||||
<form method="POST" action="{{ url_for('tasks.update_task_status', task_id=task.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Reopen task to Review?') }}', { title: '{{ _('Reopen Task') }}', confirmText: '{{ _('Reopen') }}' }).then(ok=>{ if(ok){ this.submit(); } });" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="status" value="review">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-gray-600 text-white mt-4 md:mt-0">{{ _('Reopen') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button type="button" class="bg-red-600 text-white px-4 py-2 rounded-lg mt-4 md:mt-0"
|
||||
onclick="document.getElementById('confirmDeleteTask-{{ task.id }}').classList.remove('hidden')">
|
||||
{{ _('Delete Task') }}
|
||||
@@ -93,7 +117,12 @@
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Project</h3>
|
||||
<p>{{ task.project.name }}</p>
|
||||
<p class="flex items-center gap-2">
|
||||
<span>{{ task.project.name }}</span>
|
||||
{% if task.project.code_display %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ task.project.code_display }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if task.assigned_user %}
|
||||
<div>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</a>
|
||||
<form method="POST"
|
||||
action="{{ url_for('time_entry_templates.delete_template', template_id=template.id) }}"
|
||||
onsubmit="return confirm('Are you sure you want to delete this template?');"
|
||||
onsubmit="event.preventDefault(); window.showConfirm('{{ _('Are you sure you want to delete this template?') }}', { title: '{{ _('Delete Template') }}', confirmText: '{{ _('Delete') }}', variant: 'danger' }).then(ok=>{ if(ok) this.submit(); });"
|
||||
class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- Kanban: Show project code on task cards and remove inline status dropdown. Status is determined by the column. Projects now support an optional short `code` used for compact displays.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Add short project code field for compact identifiers
|
||||
|
||||
Revision ID: 023
|
||||
Revises: 022
|
||||
Create Date: 2025-10-23 00:00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '023'
|
||||
down_revision = '022'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
dialect_name = bind.dialect.name if bind else 'generic'
|
||||
|
||||
# Add code column if not present
|
||||
with op.batch_alter_table('projects') as batch_op:
|
||||
batch_op.add_column(sa.Column('code', sa.String(length=20), nullable=True))
|
||||
try:
|
||||
batch_op.create_unique_constraint('uq_projects_code', ['code'])
|
||||
except Exception:
|
||||
# Some dialects may not support unique with NULLs the same way; ignore if exists
|
||||
pass
|
||||
try:
|
||||
batch_op.create_index('ix_projects_code', ['code'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('projects') as batch_op:
|
||||
try:
|
||||
batch_op.drop_index('ix_projects_code')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_constraint('uq_projects_code', type_='unique')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_column('code')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -35,6 +35,19 @@
|
||||
<input type="text" class="form-control border-start-0" id="searchInput"
|
||||
placeholder="{{ _('Search clients...') }}">
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="btn-group" id="bulkActionsGroup">
|
||||
<button type="button" id="bulkActionsBtn" class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" disabled>
|
||||
<i class="fas fa-tasks me-1"></i> {{ _('Bulk Actions') }} (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#" onclick="showBulkStatusChange('active')"><i class="fas fa-check-circle me-2 text-success"></i>{{ _('Mark as Active') }}</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="showBulkStatusChange('inactive')"><i class="fas fa-pause-circle me-2 text-warning"></i>{{ _('Mark as Inactive') }}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="#" onclick="showBulkDeleteConfirm()"><i class="fas fa-trash me-2"></i>{{ _('Delete') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,6 +57,11 @@
|
||||
<table class="table table-hover mb-0" id="clientsTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
<th style="width: 40px;">
|
||||
<input type="checkbox" id="selectAll" class="form-check-input" onchange="toggleAllClients()">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th>{{ _('Name') }}</th>
|
||||
<th>{{ _('Contact Person') }}</th>
|
||||
<th>{{ _('Email') }}</th>
|
||||
@@ -57,6 +75,11 @@
|
||||
<tbody>
|
||||
{% for client in clients %}
|
||||
<tr class="client-row" data-status="{{ client.status }}">
|
||||
{% if current_user.is_admin %}
|
||||
<td>
|
||||
<input type="checkbox" class="client-checkbox form-check-input" value="{{ client.id }}" onchange="updateBulkDeleteButton()">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-decoration-none">
|
||||
<strong>{{ client.name }}</strong>
|
||||
@@ -150,6 +173,71 @@
|
||||
</div>
|
||||
<!-- Modern styling now handled by global CSS in base.css -->
|
||||
|
||||
<!-- Bulk Action Forms (hidden) -->
|
||||
<form id="confirmBulkDelete-form" method="POST" action="{{ url_for('clients.bulk_delete_clients') }}" class="d-none">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
</form>
|
||||
|
||||
<form id="bulkStatusChange-form" method="POST" action="{{ url_for('clients.bulk_status_change') }}" class="d-none">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="new_status" id="bulkNewStatus" value="">
|
||||
</form>
|
||||
|
||||
<!-- Bulk Status Change Confirmation Modal -->
|
||||
<div class="modal fade" id="confirmBulkStatusChange" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-exchange-alt me-2 text-primary"></i>{{ _('Change Status') }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="bulkStatusChangeMessage"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitBulkStatusChange()">
|
||||
<i class="fas fa-check me-2"></i>{{ _('Change Status') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="confirmBulkDelete" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Selected Clients') }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
|
||||
</div>
|
||||
<p>{{ _('Are you sure you want to delete the selected clients?') }}</p>
|
||||
<p class="text-muted mb-0">{{ _('Clients with existing projects will be skipped.') }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" onclick="submitBulkDelete()">
|
||||
<i class="fas fa-trash me-2"></i>{{ _('Delete Clients') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="application/json" id="i18n-json-clients-list">
|
||||
{
|
||||
@@ -162,6 +250,96 @@
|
||||
var i18nClientsList = (function(){ try{ var el=document.getElementById('i18n-json-clients-list'); return el?JSON.parse(el.textContent):{}; }catch(e){ return {}; } })();
|
||||
</script>
|
||||
<script>
|
||||
// Bulk delete functions for clients
|
||||
function toggleAllClients() {
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const checkboxes = document.querySelectorAll('.client-checkbox');
|
||||
checkboxes.forEach(cb => cb.checked = selectAll.checked);
|
||||
updateBulkDeleteButton();
|
||||
}
|
||||
|
||||
function updateBulkDeleteButton() {
|
||||
const checkboxes = document.querySelectorAll('.client-checkbox:checked');
|
||||
const count = checkboxes.length;
|
||||
const btnGroup = document.getElementById('bulkActionsGroup');
|
||||
const btn = document.getElementById('bulkActionsBtn');
|
||||
const countSpan = document.getElementById('selectedCount');
|
||||
|
||||
if (countSpan) countSpan.textContent = count;
|
||||
if (btn) btn.disabled = count === 0;
|
||||
|
||||
// Update select all checkbox state
|
||||
const allCheckboxes = document.querySelectorAll('.client-checkbox');
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
if (selectAll && allCheckboxes.length > 0) {
|
||||
selectAll.checked = count === allCheckboxes.length;
|
||||
selectAll.indeterminate = count > 0 && count < allCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function showBulkDeleteConfirm() {
|
||||
new bootstrap.Modal(document.getElementById('confirmBulkDelete')).show();
|
||||
}
|
||||
|
||||
function submitBulkDelete() {
|
||||
const checkboxes = document.querySelectorAll('.client-checkbox:checked');
|
||||
const form = document.getElementById('confirmBulkDelete-form');
|
||||
|
||||
// Clear existing hidden inputs
|
||||
form.querySelectorAll('input[name="client_ids[]"]').forEach(input => input.remove());
|
||||
|
||||
// Add selected client IDs to form
|
||||
checkboxes.forEach(cb => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'client_ids[]';
|
||||
input.value = cb.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
// Close modal and submit form
|
||||
bootstrap.Modal.getInstance(document.getElementById('confirmBulkDelete')).hide();
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function showBulkStatusChange(newStatus) {
|
||||
const count = document.querySelectorAll('.client-checkbox:checked').length;
|
||||
const statusLabels = {
|
||||
'active': '{{ _("Active") }}',
|
||||
'inactive': '{{ _("Inactive") }}'
|
||||
};
|
||||
|
||||
const message = `{{ _("Are you sure you want to mark {count} client(s) as {status}?") }}`
|
||||
.replace('{count}', count)
|
||||
.replace('{status}', statusLabels[newStatus] || newStatus);
|
||||
|
||||
document.getElementById('bulkStatusChangeMessage').textContent = message;
|
||||
document.getElementById('bulkNewStatus').value = newStatus;
|
||||
new bootstrap.Modal(document.getElementById('confirmBulkStatusChange')).show();
|
||||
return false;
|
||||
}
|
||||
|
||||
function submitBulkStatusChange() {
|
||||
const checkboxes = document.querySelectorAll('.client-checkbox:checked');
|
||||
const form = document.getElementById('bulkStatusChange-form');
|
||||
|
||||
// Clear existing hidden inputs
|
||||
form.querySelectorAll('input[name="client_ids[]"]').forEach(input => input.remove());
|
||||
|
||||
// Add selected client IDs to form
|
||||
checkboxes.forEach(cb => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'client_ids[]';
|
||||
input.value = cb.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
// Close modal and submit form
|
||||
bootstrap.Modal.getInstance(document.getElementById('confirmBulkStatusChange')).hide();
|
||||
form.submit();
|
||||
}
|
||||
|
||||
// Simple search (vanilla JS)
|
||||
(function() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{{ _('Project Name') }}</th>
|
||||
<th>{{ _('Project') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th>{{ _('Billable') }}</th>
|
||||
<th>{{ _('Hourly Rate') }}</th>
|
||||
@@ -250,6 +250,9 @@
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-decoration-none">
|
||||
<strong>{{ project.name }}</strong>
|
||||
</a>
|
||||
{% if project.code_display %}
|
||||
<span class="badge bg-light text-dark border ms-2 align-middle">{{ project.code_display }}</span>
|
||||
{% endif %}
|
||||
{% if project.description %}
|
||||
<br><small class="text-muted">{{ project.description[:50] }}{% if project.description|length > 50 %}...{% endif %}</small>
|
||||
{% endif %}
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
<input type="text" id="name" name="name" required value="{{ request.form.get('name','') }}" placeholder="{{ _('Enter a descriptive project name') }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Choose a clear, descriptive name that explains the project scope') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Project Code') }}</label>
|
||||
<input type="text" id="code" name="code" value="{{ request.form.get('code','') }}" placeholder="{{ _('Short code, e.g., ABC') }}" class="form-input" maxlength="20">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Short tag shown on Kanban cards') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client') }} *</label>
|
||||
<select id="client_id" name="client_id" required class="form-input">
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Project Name') }} *</label>
|
||||
<input type="text" id="name" name="name" required value="{{ request.form.get('name', project.name) }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Project Code') }}</label>
|
||||
<input type="text" id="code" name="code" value="{{ request.form.get('code', project.code or '') }}" placeholder="{{ _('Short code, e.g., ABC') }}" class="form-input" maxlength="20">
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client') }} *</label>
|
||||
<select id="client_id" name="client_id" required class="form-input">
|
||||
|
||||
+293
-47
@@ -20,10 +20,12 @@
|
||||
|
||||
{# Summary cards similar to invoices #}
|
||||
{% set _active = 0 %}
|
||||
{% set _inactive = 0 %}
|
||||
{% set _archived = 0 %}
|
||||
{% set _total_hours = 0 %}
|
||||
{% for p in projects %}
|
||||
{% if p.status == 'active' %}{% set _active = _active + 1 %}{% endif %}
|
||||
{% if p.status == 'inactive' %}{% set _inactive = _inactive + 1 %}{% endif %}
|
||||
{% if p.status == 'archived' %}{% set _archived = _archived + 1 %}{% endif %}
|
||||
{% set _total_hours = _total_hours + (p.total_hours or 0) %}
|
||||
{% endfor %}
|
||||
@@ -53,10 +55,10 @@
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100 summary-card">
|
||||
<div class="card-body p-3 d-flex align-items-center">
|
||||
<div class="summary-icon bg-secondary bg-opacity-10 text-secondary"><i class="fas fa-archive"></i></div>
|
||||
<div class="summary-icon bg-warning bg-opacity-10 text-warning"><i class="fas fa-pause-circle"></i></div>
|
||||
<div class="ms-3">
|
||||
<div class="summary-label">{{ _('Archived') }}</div>
|
||||
<div class="summary-value">{{ _archived }}</div>
|
||||
<div class="summary-label">{{ _('Inactive') }}</div>
|
||||
<div class="summary-value">{{ _inactive }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,10 +66,10 @@
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100 summary-card">
|
||||
<div class="card-body p-3 d-flex align-items-center">
|
||||
<div class="summary-icon bg-info bg-opacity-10 text-info"><i class="fas fa-hourglass-half"></i></div>
|
||||
<div class="summary-icon bg-secondary bg-opacity-10 text-secondary"><i class="fas fa-archive"></i></div>
|
||||
<div class="ms-3">
|
||||
<div class="summary-label">{{ _('Total Hours') }}</div>
|
||||
<div class="summary-value">{{ '%.1f'|format(_total_hours) }}h</div>
|
||||
<div class="summary-label">{{ _('Archived') }}</div>
|
||||
<div class="summary-value">{{ _archived }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,6 +98,7 @@
|
||||
<option value="">{{ _('All Statuses') }}</option>
|
||||
<option value="active" {% if request.args.get('status') == 'active' %}selected{% endif %}>{{ _('Active') }}</option>
|
||||
<option value="archived" {% if request.args.get('status') == 'archived' %}selected{% endif %}>{{ _('Archived') }}</option>
|
||||
<option value="inactive" {% if request.args.get('status') == 'inactive' %}selected{% endif %}>{{ _('Inactive') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 mobile-form-group">
|
||||
@@ -143,6 +146,20 @@
|
||||
<input type="text" class="form-control border-start-0" id="searchInput"
|
||||
placeholder="{{ _('Search projects...') }}">
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="btn-group" id="bulkActionsGroup">
|
||||
<button type="button" id="bulkActionsBtn" class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" disabled>
|
||||
<i class="fas fa-tasks me-1"></i> {{ _('Bulk Actions') }} (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#" onclick="showBulkStatusChange('active')"><i class="fas fa-check-circle me-2 text-success"></i>{{ _('Mark as Active') }}</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="showBulkStatusChange('inactive')"><i class="fas fa-pause-circle me-2 text-warning"></i>{{ _('Mark as Inactive') }}</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="showBulkStatusChange('archived')"><i class="fas fa-archive me-2 text-secondary"></i>{{ _('Archive') }}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="#" onclick="showBulkDeleteConfirm()"><i class="fas fa-trash me-2"></i>{{ _('Delete') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,6 +169,11 @@
|
||||
<table class="table table-hover mb-0" id="projectsTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
<th class="border-0" style="width: 40px;">
|
||||
<input type="checkbox" id="selectAll" class="form-check-input" onchange="toggleAllProjects()">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th class="border-0">{{ _('Project') }}</th>
|
||||
<th class="border-0">{{ _('Client') }}</th>
|
||||
<th class="border-0">{{ _('Status') }}</th>
|
||||
@@ -163,6 +185,11 @@
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
<td>
|
||||
<input type="checkbox" class="project-checkbox form-check-input" value="{{ project.id }}" onchange="updateBulkDeleteButton()">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td data-label="Project">
|
||||
<div>
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-decoration-none">
|
||||
@@ -179,6 +206,8 @@
|
||||
<td data-label="Status">
|
||||
{% if project.status == 'active' %}
|
||||
<span class="status-badge bg-success text-white">{{ _('Active') }}</span>
|
||||
{% elif project.status == 'inactive' %}
|
||||
<span class="status-badge bg-warning text-white">{{ _('Inactive') }}</span>
|
||||
{% else %}
|
||||
<span class="status-badge bg-secondary text-white">{{ _('Archived') }}</span>
|
||||
{% endif %}
|
||||
@@ -214,22 +243,31 @@
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% if project.status == 'active' %}
|
||||
<form method="POST" action="{{ url_for('projects.archive_project', project_id=project.id) }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-action btn-action--more touch-target" title="{{ _('Archive project') }}">
|
||||
<i class="fas fa-archive"></i>
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-action btn-action--warning touch-target" title="{{ _('Mark as inactive') }}"
|
||||
onclick="confirmDeactivateProject('{{ project.id }}', '{{ project.name }}')">
|
||||
<i class="fas fa-pause-circle"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-action btn-action--more touch-target" title="{{ _('Archive project') }}"
|
||||
onclick="confirmArchiveProject('{{ project.id }}', '{{ project.name }}')">
|
||||
<i class="fas fa-archive"></i>
|
||||
</button>
|
||||
{% elif project.status == 'inactive' %}
|
||||
<button type="button" class="btn btn-sm btn-action btn-action--success touch-target" title="{{ _('Activate project') }}"
|
||||
onclick="confirmActivateProject('{{ project.id }}', '{{ project.name }}')">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-action btn-action--more touch-target" title="{{ _('Archive project') }}"
|
||||
onclick="confirmArchiveProject('{{ project.id }}', '{{ project.name }}')">
|
||||
<i class="fas fa-archive"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<form method="POST" action="{{ url_for('projects.unarchive_project', project_id=project.id) }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-action btn-action--more touch-target" title="{{ _('Unarchive project') }}">
|
||||
<i class="fas fa-box-open"></i>
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-action btn-action--success touch-target" title="{{ _('Unarchive project') }}"
|
||||
onclick="confirmUnarchiveProject('{{ project.id }}', '{{ project.name }}')">
|
||||
<i class="fas fa-box-open"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-sm btn-action btn-action--danger touch-target" title="{{ _('Delete project') }}"
|
||||
onclick="showDeleteProjectModal('{{ project.id }}', '{{ project.name }}')">
|
||||
onclick="confirmDeleteProject('{{ project.id }}', '{{ project.name }}', {{ 'true' if project.time_entries.count() > 0 else 'false' }})">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -260,13 +298,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Project Modal -->
|
||||
<div class="modal fade" id="deleteProjectModal" tabindex="-1">
|
||||
<!-- Bulk Action Forms (hidden) -->
|
||||
<form id="confirmBulkDelete-form" method="POST" action="{{ url_for('projects.bulk_delete_projects') }}" class="d-none">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
</form>
|
||||
|
||||
<form id="bulkStatusChange-form" method="POST" action="{{ url_for('projects.bulk_status_change') }}" class="d-none">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="new_status" id="bulkNewStatus" value="">
|
||||
</form>
|
||||
|
||||
<!-- Bulk Status Change Confirmation Modal -->
|
||||
<div class="modal fade" id="confirmBulkStatusChange" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Project') }}
|
||||
<i class="fas fa-exchange-alt me-2 text-primary"></i>{{ _('Change Status') }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="bulkStatusChangeMessage"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitBulkStatusChange()">
|
||||
<i class="fas fa-check me-2"></i>{{ _('Change Status') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="confirmBulkDelete" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Selected Projects') }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
@@ -275,19 +348,16 @@
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
|
||||
</div>
|
||||
<p>{{ _('Are you sure you want to delete the project') }} <strong id="deleteProjectName"></strong>?</p>
|
||||
<p class="text-muted mb-0">{{ _('All associated time entries will also be deleted.') }}</p>
|
||||
<p>{{ _('Are you sure you want to delete the selected projects?') }}</p>
|
||||
<p class="text-muted mb-0">{{ _('Projects with existing time entries will be skipped.') }}</p>
|
||||
</div>
|
||||
<div class="modal-footer d-flex flex-column flex-md-row">
|
||||
<button type="button" class="btn btn-secondary mb-2 mb-md-0 me-md-2 touch-target" data-bs-dismiss="modal">
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
<form method="POST" id="deleteProjectForm" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger touch-target">
|
||||
<i class="fas fa-trash me-2"></i>{{ _('Delete Project') }}
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-danger" onclick="submitBulkDelete()">
|
||||
<i class="fas fa-trash me-2"></i>{{ _('Delete Projects') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,16 +379,114 @@ thead th[style*="cursor: pointer"]:hover { background-color: var(--surface-hover
|
||||
"length_menu": {{ _('Show _MENU_ projects per page')|tojson }},
|
||||
"info": {{ _('Showing _START_ to _END_ of _TOTAL_ projects')|tojson }},
|
||||
"status_active": {{ _('Active')|tojson }},
|
||||
"status_inactive": {{ _('Inactive')|tojson }},
|
||||
"status_archived": {{ _('Archived')|tojson }},
|
||||
"deleting": {{ _('Deleting...')|tojson }},
|
||||
"hide_filters": {{ _('Hide Filters')|tojson }},
|
||||
"show_filters": {{ _('Show Filters')|tojson }}
|
||||
"show_filters": {{ _('Show Filters')|tojson }},
|
||||
"confirm_delete": {{ _('Are you sure you want to delete the project "{name}"?')|tojson }},
|
||||
"confirm_delete_with_entries": {{ _('Cannot delete project "{name}" because it has time entries. Please delete the time entries first.')|tojson }},
|
||||
"confirm_archive": {{ _('Are you sure you want to archive "{name}"?')|tojson }},
|
||||
"confirm_unarchive": {{ _('Are you sure you want to unarchive "{name}"?')|tojson }},
|
||||
"confirm_activate": {{ _('Are you sure you want to activate "{name}"?')|tojson }},
|
||||
"confirm_deactivate": {{ _('Are you sure you want to mark "{name}" as inactive?')|tojson }}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
var i18nProjects = (function(){ try{ var el = document.getElementById('i18n-json-projects-list'); return el ? JSON.parse(el.textContent) : {}; } catch(e){ return {}; } })();
|
||||
</script>
|
||||
<script>
|
||||
// Bulk delete functions for projects
|
||||
function toggleAllProjects() {
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const checkboxes = document.querySelectorAll('.project-checkbox');
|
||||
checkboxes.forEach(cb => cb.checked = selectAll.checked);
|
||||
updateBulkDeleteButton();
|
||||
}
|
||||
|
||||
function updateBulkDeleteButton() {
|
||||
const checkboxes = document.querySelectorAll('.project-checkbox:checked');
|
||||
const count = checkboxes.length;
|
||||
const btnGroup = document.getElementById('bulkActionsGroup');
|
||||
const btn = document.getElementById('bulkActionsBtn');
|
||||
const countSpan = document.getElementById('selectedCount');
|
||||
|
||||
if (countSpan) countSpan.textContent = count;
|
||||
if (btn) btn.disabled = count === 0;
|
||||
|
||||
// Update select all checkbox state
|
||||
const allCheckboxes = document.querySelectorAll('.project-checkbox');
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
if (selectAll && allCheckboxes.length > 0) {
|
||||
selectAll.checked = count === allCheckboxes.length;
|
||||
selectAll.indeterminate = count > 0 && count < allCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function showBulkDeleteConfirm() {
|
||||
new bootstrap.Modal(document.getElementById('confirmBulkDelete')).show();
|
||||
}
|
||||
|
||||
function submitBulkDelete() {
|
||||
const checkboxes = document.querySelectorAll('.project-checkbox:checked');
|
||||
const form = document.getElementById('confirmBulkDelete-form');
|
||||
|
||||
// Clear existing hidden inputs
|
||||
form.querySelectorAll('input[name="project_ids[]"]').forEach(input => input.remove());
|
||||
|
||||
// Add selected project IDs to form
|
||||
checkboxes.forEach(cb => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'project_ids[]';
|
||||
input.value = cb.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
// Close modal and submit form
|
||||
bootstrap.Modal.getInstance(document.getElementById('confirmBulkDelete')).hide();
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function showBulkStatusChange(newStatus) {
|
||||
const count = document.querySelectorAll('.project-checkbox:checked').length;
|
||||
const statusLabels = {
|
||||
'active': '{{ _("Active") }}',
|
||||
'inactive': '{{ _("Inactive") }}',
|
||||
'archived': '{{ _("Archived") }}'
|
||||
};
|
||||
|
||||
const message = `{{ _("Are you sure you want to mark {count} project(s) as {status}?") }}`
|
||||
.replace('{count}', count)
|
||||
.replace('{status}', statusLabels[newStatus] || newStatus);
|
||||
|
||||
document.getElementById('bulkStatusChangeMessage').textContent = message;
|
||||
document.getElementById('bulkNewStatus').value = newStatus;
|
||||
new bootstrap.Modal(document.getElementById('confirmBulkStatusChange')).show();
|
||||
return false;
|
||||
}
|
||||
|
||||
function submitBulkStatusChange() {
|
||||
const checkboxes = document.querySelectorAll('.project-checkbox:checked');
|
||||
const form = document.getElementById('bulkStatusChange-form');
|
||||
|
||||
// Clear existing hidden inputs
|
||||
form.querySelectorAll('input[name="project_ids[]"]').forEach(input => input.remove());
|
||||
|
||||
// Add selected project IDs to form
|
||||
checkboxes.forEach(cb => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'project_ids[]';
|
||||
input.value = cb.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
// Close modal and submit form
|
||||
bootstrap.Modal.getInstance(document.getElementById('confirmBulkStatusChange')).hide();
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function toggleFilterVisibility() {
|
||||
const filterBody = document.getElementById('filterBody');
|
||||
const toggleIcon = document.getElementById('filterToggleIcon');
|
||||
@@ -476,23 +644,101 @@ function filterByStatus(status) {
|
||||
if (active) active.classList.add('active');
|
||||
}
|
||||
|
||||
// Function to show delete project modal
|
||||
function showDeleteProjectModal(projectId, projectName) {
|
||||
document.getElementById('deleteProjectName').textContent = projectName;
|
||||
document.getElementById('deleteProjectForm').action = "{{ url_for('projects.delete_project', project_id=0) }}".replace('0', projectId);
|
||||
new bootstrap.Modal(document.getElementById('deleteProjectModal')).show();
|
||||
// Project action confirmation functions
|
||||
function confirmDeleteProject(projectId, projectName, hasTimeEntries) {
|
||||
if (hasTimeEntries) {
|
||||
const msg = (i18nProjects.confirm_delete_with_entries || 'Cannot delete project "{name}" because it has time entries. Please delete the time entries first.').replace('{name}', projectName);
|
||||
if (window.showAlert) {
|
||||
window.showAlert(msg);
|
||||
} else {
|
||||
alert(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = (i18nProjects.confirm_delete || 'Are you sure you want to delete the project "{name}"?').replace('{name}', projectName);
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(msg).then(function(ok){
|
||||
if (!ok) return;
|
||||
submitProjectAction(projectId, 'delete');
|
||||
});
|
||||
} else {
|
||||
if (confirm(msg)) {
|
||||
submitProjectAction(projectId, 'delete');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add loading state to delete project form
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteForm = document.getElementById('deleteProjectForm');
|
||||
if (deleteForm) {
|
||||
deleteForm.addEventListener('submit', function() {
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>' + (i18nProjects.deleting || 'Deleting...');
|
||||
submitBtn.disabled = true;
|
||||
function confirmArchiveProject(projectId, projectName) {
|
||||
const msg = (i18nProjects.confirm_archive || 'Are you sure you want to archive "{name}"?').replace('{name}', projectName);
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(msg).then(function(ok){
|
||||
if (!ok) return;
|
||||
submitProjectAction(projectId, 'archive');
|
||||
});
|
||||
} else {
|
||||
if (confirm(msg)) {
|
||||
submitProjectAction(projectId, 'archive');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function confirmUnarchiveProject(projectId, projectName) {
|
||||
const msg = (i18nProjects.confirm_unarchive || 'Are you sure you want to unarchive "{name}"?').replace('{name}', projectName);
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(msg).then(function(ok){
|
||||
if (!ok) return;
|
||||
submitProjectAction(projectId, 'unarchive');
|
||||
});
|
||||
} else {
|
||||
if (confirm(msg)) {
|
||||
submitProjectAction(projectId, 'unarchive');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function confirmActivateProject(projectId, projectName) {
|
||||
const msg = (i18nProjects.confirm_activate || 'Are you sure you want to activate "{name}"?').replace('{name}', projectName);
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(msg).then(function(ok){
|
||||
if (!ok) return;
|
||||
submitProjectAction(projectId, 'activate');
|
||||
});
|
||||
} else {
|
||||
if (confirm(msg)) {
|
||||
submitProjectAction(projectId, 'activate');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeactivateProject(projectId, projectName) {
|
||||
const msg = (i18nProjects.confirm_deactivate || 'Are you sure you want to mark "{name}" as inactive?').replace('{name}', projectName);
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(msg).then(function(ok){
|
||||
if (!ok) return;
|
||||
submitProjectAction(projectId, 'deactivate');
|
||||
});
|
||||
} else {
|
||||
if (confirm(msg)) {
|
||||
submitProjectAction(projectId, 'deactivate');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function submitProjectAction(projectId, action) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/projects/${projectId}/${action}`;
|
||||
|
||||
// Add CSRF token
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Tests for project inactive status functionality"""
|
||||
import pytest
|
||||
from app import create_app, db
|
||||
from app.models import Project, Client, User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create application for testing"""
|
||||
app = create_app()
|
||||
app.config['TESTING'] = True
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||
app.config['WTF_CSRF_ENABLED'] = False
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(app):
|
||||
"""Create admin user for testing"""
|
||||
with app.app_context():
|
||||
user = User(username='admin', role='admin')
|
||||
user.set_password('password')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client_obj(app):
|
||||
"""Create test client for projects"""
|
||||
with app.app_context():
|
||||
client = Client(name='Test Client')
|
||||
db.session.add(client)
|
||||
db.session.commit()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_project(app, test_client_obj):
|
||||
"""Create test project"""
|
||||
with app.app_context():
|
||||
project = Project(name='Test Project', client_id=test_client_obj.id)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
return project
|
||||
|
||||
|
||||
class TestProjectInactiveStatus:
|
||||
"""Test project inactive status functionality"""
|
||||
|
||||
def test_project_default_status(self, app, test_client_obj):
|
||||
"""Test that new projects have active status by default"""
|
||||
with app.app_context():
|
||||
project = Project(name='New Project', client_id=test_client_obj.id)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
assert project.status == 'active'
|
||||
assert project.is_active is True
|
||||
|
||||
def test_project_deactivate(self, app, test_project):
|
||||
"""Test deactivating a project"""
|
||||
with app.app_context():
|
||||
project = Project.query.get(test_project.id)
|
||||
project.deactivate()
|
||||
|
||||
assert project.status == 'inactive'
|
||||
assert project.is_active is False
|
||||
|
||||
def test_project_activate_from_inactive(self, app, test_project):
|
||||
"""Test activating an inactive project"""
|
||||
with app.app_context():
|
||||
project = Project.query.get(test_project.id)
|
||||
project.deactivate()
|
||||
assert project.status == 'inactive'
|
||||
|
||||
project.activate()
|
||||
assert project.status == 'active'
|
||||
assert project.is_active is True
|
||||
|
||||
def test_project_archive_from_inactive(self, app, test_project):
|
||||
"""Test archiving an inactive project"""
|
||||
with app.app_context():
|
||||
project = Project.query.get(test_project.id)
|
||||
project.deactivate()
|
||||
assert project.status == 'inactive'
|
||||
|
||||
project.archive()
|
||||
assert project.status == 'archived'
|
||||
|
||||
def test_project_unarchive_to_active(self, app, test_project):
|
||||
"""Test unarchiving a project returns it to active"""
|
||||
with app.app_context():
|
||||
project = Project.query.get(test_project.id)
|
||||
project.archive()
|
||||
assert project.status == 'archived'
|
||||
|
||||
project.unarchive()
|
||||
assert project.status == 'active'
|
||||
|
||||
def test_project_status_transitions(self, app, test_project):
|
||||
"""Test complete status transition cycle"""
|
||||
with app.app_context():
|
||||
project = Project.query.get(test_project.id)
|
||||
|
||||
# Start active
|
||||
assert project.status == 'active'
|
||||
|
||||
# Move to inactive
|
||||
project.deactivate()
|
||||
assert project.status == 'inactive'
|
||||
|
||||
# Move back to active
|
||||
project.activate()
|
||||
assert project.status == 'active'
|
||||
|
||||
# Move to archived
|
||||
project.archive()
|
||||
assert project.status == 'archived'
|
||||
|
||||
# Move back to active via unarchive
|
||||
project.unarchive()
|
||||
assert project.status == 'active'
|
||||
|
||||
|
||||
class TestProjectInactiveRoutes:
|
||||
"""Test project inactive status routes"""
|
||||
|
||||
def login(self, client, username='admin', password='password'):
|
||||
"""Helper to log in"""
|
||||
return client.post('/auth/login', data={
|
||||
'username': username,
|
||||
'password': password
|
||||
}, follow_redirects=True)
|
||||
|
||||
def test_deactivate_project_route(self, client, app, admin_user, test_project):
|
||||
"""Test deactivating a project via route"""
|
||||
with client:
|
||||
self.login(client)
|
||||
|
||||
with app.app_context():
|
||||
project_id = test_project.id
|
||||
|
||||
response = client.post(f'/projects/{project_id}/deactivate',
|
||||
follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
with app.app_context():
|
||||
project = Project.query.get(project_id)
|
||||
assert project.status == 'inactive'
|
||||
|
||||
def test_activate_project_route(self, client, app, admin_user, test_project):
|
||||
"""Test activating a project via route"""
|
||||
with client:
|
||||
self.login(client)
|
||||
|
||||
with app.app_context():
|
||||
project = Project.query.get(test_project.id)
|
||||
project.deactivate()
|
||||
project_id = project.id
|
||||
|
||||
response = client.post(f'/projects/{project_id}/activate',
|
||||
follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
with app.app_context():
|
||||
project = Project.query.get(project_id)
|
||||
assert project.status == 'active'
|
||||
|
||||
def test_filter_inactive_projects(self, client, app, admin_user, test_client_obj):
|
||||
"""Test filtering projects by inactive status"""
|
||||
with client:
|
||||
self.login(client)
|
||||
|
||||
# Create multiple projects with different statuses
|
||||
with app.app_context():
|
||||
active_project = Project(name='Active Project', client_id=test_client_obj.id)
|
||||
inactive_project = Project(name='Inactive Project', client_id=test_client_obj.id)
|
||||
archived_project = Project(name='Archived Project', client_id=test_client_obj.id)
|
||||
|
||||
db.session.add_all([active_project, inactive_project, archived_project])
|
||||
db.session.commit()
|
||||
|
||||
inactive_project.deactivate()
|
||||
archived_project.archive()
|
||||
|
||||
# Test filter for inactive projects
|
||||
response = client.get('/projects?status=inactive')
|
||||
assert response.status_code == 200
|
||||
assert b'Inactive Project' in response.data
|
||||
assert b'Active Project' not in response.data
|
||||
assert b'Archived Project' not in response.data
|
||||
|
||||
|
||||
class TestTaskDeletion:
|
||||
"""Test individual task deletion"""
|
||||
|
||||
def login(self, client, username='admin', password='password'):
|
||||
"""Helper to log in"""
|
||||
return client.post('/auth/login', data={
|
||||
'username': username,
|
||||
'password': password
|
||||
}, follow_redirects=True)
|
||||
|
||||
def test_task_list_has_delete_buttons(self, client, app, admin_user, test_project):
|
||||
"""Test that task list shows individual delete buttons"""
|
||||
with client:
|
||||
self.login(client)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import Task
|
||||
task = Task(
|
||||
name='Test Task',
|
||||
project_id=test_project.id,
|
||||
created_by=admin_user.id
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
|
||||
response = client.get('/tasks')
|
||||
assert response.status_code == 200
|
||||
# Should have delete button, not bulk checkboxes
|
||||
assert b'confirmDeleteTask' in response.data
|
||||
# Should not have bulk delete
|
||||
assert b'bulkDeleteBtn' not in response.data
|
||||
assert b'selectAll' not in response.data
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_kanban_board_aria_and_dnd(authenticated_client, app):
|
||||
with app.app_context():
|
||||
# Minimal data for rendering board
|
||||
user = User(username='kanban_user', role='admin')
|
||||
project = Project(name='Kanban Project', client='Client K')
|
||||
project = Project(name='Kanban Project', client='Client K', code='KAN')
|
||||
db.session.add_all([user, project])
|
||||
db.session.commit()
|
||||
|
||||
@@ -69,3 +69,30 @@ def test_kanban_board_aria_and_dnd(authenticated_client, app):
|
||||
assert 'aria-live' in html # counts or empty placeholder live regions
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.routes
|
||||
def test_kanban_card_shows_project_code_and_no_status_dropdown(authenticated_client, app):
|
||||
with app.app_context():
|
||||
admin = User(username='admin_user', role='admin')
|
||||
project = Project(name='Very Long Project Name', client='CL', code='VLPN')
|
||||
db.session.add_all([admin, project])
|
||||
db.session.commit()
|
||||
|
||||
task = Task(project_id=project.id, name='Test Card', created_by=admin.id)
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
|
||||
with authenticated_client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(admin.id)
|
||||
sess['_fresh'] = True
|
||||
|
||||
resp = authenticated_client.get('/kanban')
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
# Project code badge present
|
||||
assert 'data-testid="kanban-project-code"' in html
|
||||
assert 'VLPN' in html
|
||||
# No inline status select in kanban cards
|
||||
assert 'class="kanban-status' not in html
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user