Implement comprehensive project time logging feature
Add complete project management system with role-based access control: **Core Features:** - Project creation and management for Admins/Supervisors - Time tracking with optional project selection and notes - Project-based filtering and reporting in history - Enhanced export functionality with project data - Team-specific project assignments **Database Changes:** - New Project model with full relationships - Enhanced TimeEntry model with project_id and notes - Updated migration scripts with rollback support - Sample project creation for testing **User Interface:** - Project management templates (create, edit, list) - Enhanced time tracking with project dropdown - Project filtering in history page - Updated navigation for role-based access - Modern styling with hover effects and responsive design **API Enhancements:** - Project validation and access control - Updated arrive endpoint with project support - Enhanced export functions with project data - Role-based route protection **Migration Support:** - Comprehensive migration scripts (migrate_projects.py) - Updated main migration script (migrate_db.py) - Detailed migration documentation - Rollback functionality for safe deployment **Role-Based Access:** - Admins: Full project CRUD operations - Supervisors: Project creation and management - Team Leaders: View team hours with projects - Team Members: Select projects when tracking time 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
174
MIGRATION_PROJECTS.md
Normal file
174
MIGRATION_PROJECTS.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Project Time Logging Migration Guide
|
||||||
|
|
||||||
|
This document explains how to migrate your TimeTrack database to support the new Project Time Logging feature.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Project Time Logging feature adds the ability to:
|
||||||
|
- Track time against specific projects
|
||||||
|
- Manage projects with role-based access control
|
||||||
|
- Filter and report on project-based time entries
|
||||||
|
- Export data with project information
|
||||||
|
|
||||||
|
## Database Changes
|
||||||
|
|
||||||
|
### New Tables
|
||||||
|
- **`project`**: Stores project information including name, code, description, team assignment, and dates
|
||||||
|
|
||||||
|
### Modified Tables
|
||||||
|
- **`time_entry`**: Added `project_id` (foreign key) and `notes` (text) columns
|
||||||
|
- **Existing data**: All existing time entries remain unchanged and will show as "No project assigned"
|
||||||
|
|
||||||
|
## Migration Options
|
||||||
|
|
||||||
|
### Option 1: Run Main Migration Script (Recommended)
|
||||||
|
The main migration script has been updated to include project functionality:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python migrate_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Create the project table
|
||||||
|
- Add project_id and notes columns to time_entry
|
||||||
|
- Create 3 sample projects (if no admin user exists)
|
||||||
|
- Maintain all existing data
|
||||||
|
|
||||||
|
### Option 2: Run Project-Specific Migration
|
||||||
|
For existing installations, you can run the project-specific migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python migrate_projects.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Manual Migration
|
||||||
|
If you prefer to handle the migration manually, execute these SQL commands:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Create project table
|
||||||
|
CREATE TABLE project (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by_id INTEGER NOT NULL,
|
||||||
|
team_id INTEGER,
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
FOREIGN KEY (created_by_id) REFERENCES user (id),
|
||||||
|
FOREIGN KEY (team_id) REFERENCES team (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add columns to time_entry table
|
||||||
|
ALTER TABLE time_entry ADD COLUMN project_id INTEGER;
|
||||||
|
ALTER TABLE time_entry ADD COLUMN notes TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sample Projects
|
||||||
|
|
||||||
|
The migration creates these sample projects (if admin user exists):
|
||||||
|
|
||||||
|
1. **ADMIN001** - General Administration
|
||||||
|
2. **DEV001** - Development Project
|
||||||
|
3. **SUPPORT001** - Customer Support
|
||||||
|
|
||||||
|
These can be modified or deleted after migration.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
To rollback the project functionality (removes projects but keeps time entry columns):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python migrate_projects.py rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Due to SQLite limitations, the `project_id` and `notes` columns cannot be removed from the `time_entry` table during rollback.
|
||||||
|
|
||||||
|
## Post-Migration Steps
|
||||||
|
|
||||||
|
1. **Verify Migration**: Check that the migration completed successfully
|
||||||
|
2. **Create Projects**: Admin/Supervisor users can create projects via the web interface
|
||||||
|
3. **Assign Teams**: Optionally assign projects to specific teams
|
||||||
|
4. **User Training**: Inform users about the new project selection feature
|
||||||
|
|
||||||
|
## Migration Verification
|
||||||
|
|
||||||
|
After running the migration, verify it worked by:
|
||||||
|
|
||||||
|
1. **Check Tables**:
|
||||||
|
```sql
|
||||||
|
.tables -- Should show 'project' table
|
||||||
|
.schema project -- Verify project table structure
|
||||||
|
.schema time_entry -- Verify project_id and notes columns
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check Web Interface**:
|
||||||
|
- Admin/Supervisor users should see "Manage Projects" in their dropdown menu
|
||||||
|
- Time tracking interface should show project selection dropdown
|
||||||
|
- History page should have project filtering
|
||||||
|
|
||||||
|
3. **Check Sample Projects**:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM project; -- Should show 3 sample projects
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Migration Fails
|
||||||
|
- Ensure no active connections to the database
|
||||||
|
- Check file permissions
|
||||||
|
- Verify admin user exists in the database
|
||||||
|
|
||||||
|
### Missing Navigation Links
|
||||||
|
- Clear browser cache
|
||||||
|
- Verify user has Admin or Supervisor role
|
||||||
|
- Check that the templates have been updated
|
||||||
|
|
||||||
|
### Project Selection Not Available
|
||||||
|
- Verify migration completed successfully
|
||||||
|
- Check that active projects exist in the database
|
||||||
|
- Ensure user has permission to access projects
|
||||||
|
|
||||||
|
## Feature Access
|
||||||
|
|
||||||
|
### Admin Users
|
||||||
|
- Create, edit, delete, and manage all projects
|
||||||
|
- Access project management interface
|
||||||
|
- View all project reports
|
||||||
|
|
||||||
|
### Supervisor Users
|
||||||
|
- Create, edit, and manage projects
|
||||||
|
- Access project management interface
|
||||||
|
- View project reports
|
||||||
|
|
||||||
|
### Team Leader Users
|
||||||
|
- View team hours with project breakdown
|
||||||
|
- No project creation/management access
|
||||||
|
|
||||||
|
### Team Member Users
|
||||||
|
- Select projects when tracking time
|
||||||
|
- View personal history with project filtering
|
||||||
|
- No project management access
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
The migration affects these files:
|
||||||
|
- `migrate_db.py` - Updated main migration script
|
||||||
|
- `migrate_projects.py` - New project-specific migration
|
||||||
|
- `models.py` - Added Project model and updated TimeEntry
|
||||||
|
- `app.py` - Added project routes and updated existing routes
|
||||||
|
- Templates - Updated with project functionality
|
||||||
|
- `static/js/script.js` - Updated time tracking JavaScript
|
||||||
|
|
||||||
|
## Backup Recommendation
|
||||||
|
|
||||||
|
Before running any migration, it's recommended to backup your database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp timetrack.db timetrack.db.backup
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows you to restore the original database if needed.
|
||||||
221
app.py
221
app.py
@@ -1,5 +1,5 @@
|
|||||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file
|
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file
|
||||||
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role
|
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
import os
|
import os
|
||||||
@@ -154,7 +154,17 @@ def home():
|
|||||||
TimeEntry.arrival_time <= datetime.combine(today, time.max)
|
TimeEntry.arrival_time <= datetime.combine(today, time.max)
|
||||||
).order_by(TimeEntry.arrival_time.desc()).all()
|
).order_by(TimeEntry.arrival_time.desc()).all()
|
||||||
|
|
||||||
return render_template('index.html', title='Home', active_entry=active_entry, history=history)
|
# Get available projects for this user
|
||||||
|
available_projects = []
|
||||||
|
all_projects = Project.query.filter_by(is_active=True).all()
|
||||||
|
for project in all_projects:
|
||||||
|
if project.is_user_allowed(g.user):
|
||||||
|
available_projects.append(project)
|
||||||
|
|
||||||
|
return render_template('index.html', title='Home',
|
||||||
|
active_entry=active_entry,
|
||||||
|
history=history,
|
||||||
|
available_projects=available_projects)
|
||||||
else:
|
else:
|
||||||
return render_template('index.html', title='Home')
|
return render_template('index.html', title='Home')
|
||||||
|
|
||||||
@@ -683,14 +693,35 @@ def timetrack():
|
|||||||
@app.route('/api/arrive', methods=['POST'])
|
@app.route('/api/arrive', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def arrive():
|
def arrive():
|
||||||
|
# Get project and notes from request
|
||||||
|
project_id = request.json.get('project_id') if request.json else None
|
||||||
|
notes = request.json.get('notes') if request.json else None
|
||||||
|
|
||||||
|
# Validate project access if project is specified
|
||||||
|
if project_id:
|
||||||
|
project = Project.query.get(project_id)
|
||||||
|
if not project or not project.is_user_allowed(g.user):
|
||||||
|
return jsonify({'error': 'Invalid or unauthorized project'}), 403
|
||||||
|
|
||||||
# Create a new time entry with arrival time for the current user
|
# Create a new time entry with arrival time for the current user
|
||||||
new_entry = TimeEntry(user_id=session['user_id'], arrival_time=datetime.now())
|
new_entry = TimeEntry(
|
||||||
|
user_id=g.user.id,
|
||||||
|
arrival_time=datetime.now(),
|
||||||
|
project_id=int(project_id) if project_id else None,
|
||||||
|
notes=notes
|
||||||
|
)
|
||||||
db.session.add(new_entry)
|
db.session.add(new_entry)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'id': new_entry.id,
|
'id': new_entry.id,
|
||||||
'arrival_time': new_entry.arrival_time.strftime('%Y-%m-%d %H:%M:%S')
|
'arrival_time': new_entry.arrival_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'project': {
|
||||||
|
'id': new_entry.project.id,
|
||||||
|
'code': new_entry.project.code,
|
||||||
|
'name': new_entry.project.name
|
||||||
|
} if new_entry.project else None,
|
||||||
|
'notes': new_entry.notes
|
||||||
})
|
})
|
||||||
|
|
||||||
@app.route('/api/leave/<int:entry_id>', methods=['POST'])
|
@app.route('/api/leave/<int:entry_id>', methods=['POST'])
|
||||||
@@ -900,10 +931,38 @@ def team_hours():
|
|||||||
@app.route('/history')
|
@app.route('/history')
|
||||||
@login_required
|
@login_required
|
||||||
def history():
|
def history():
|
||||||
# Get all time entries for the current user, ordered by most recent first
|
# Get project filter from query parameters
|
||||||
all_entries = TimeEntry.query.filter_by(user_id=session['user_id']).order_by(TimeEntry.arrival_time.desc()).all()
|
project_filter = request.args.get('project_id')
|
||||||
|
|
||||||
return render_template('history.html', title='Time Entry History', entries=all_entries)
|
# Base query for user's time entries
|
||||||
|
query = TimeEntry.query.filter_by(user_id=g.user.id)
|
||||||
|
|
||||||
|
# Apply project filter if specified
|
||||||
|
if project_filter:
|
||||||
|
if project_filter == 'none':
|
||||||
|
# Show entries with no project assigned
|
||||||
|
query = query.filter(TimeEntry.project_id.is_(None))
|
||||||
|
else:
|
||||||
|
# Show entries for specific project
|
||||||
|
try:
|
||||||
|
project_id = int(project_filter)
|
||||||
|
query = query.filter_by(project_id=project_id)
|
||||||
|
except ValueError:
|
||||||
|
# Invalid project ID, ignore filter
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get filtered entries ordered by most recent first
|
||||||
|
all_entries = query.order_by(TimeEntry.arrival_time.desc()).all()
|
||||||
|
|
||||||
|
# Get available projects for the filter dropdown
|
||||||
|
available_projects = []
|
||||||
|
all_projects = Project.query.filter_by(is_active=True).all()
|
||||||
|
for project in all_projects:
|
||||||
|
if project.is_user_allowed(g.user):
|
||||||
|
available_projects.append(project)
|
||||||
|
|
||||||
|
return render_template('history.html', title='Time Entry History',
|
||||||
|
entries=all_entries, available_projects=available_projects)
|
||||||
|
|
||||||
def calculate_work_duration(arrival_time, departure_time, total_break_duration):
|
def calculate_work_duration(arrival_time, departure_time, total_break_duration):
|
||||||
"""
|
"""
|
||||||
@@ -1187,6 +1246,149 @@ def manage_team(team_id):
|
|||||||
available_users=available_users
|
available_users=available_users
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Project Management Routes
|
||||||
|
@app.route('/admin/projects')
|
||||||
|
@role_required(Role.SUPERVISOR) # Supervisors and Admins can manage projects
|
||||||
|
def admin_projects():
|
||||||
|
projects = Project.query.order_by(Project.created_at.desc()).all()
|
||||||
|
return render_template('admin_projects.html', title='Project Management', projects=projects)
|
||||||
|
|
||||||
|
@app.route('/admin/projects/create', methods=['GET', 'POST'])
|
||||||
|
@role_required(Role.SUPERVISOR)
|
||||||
|
def create_project():
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form.get('name')
|
||||||
|
description = request.form.get('description')
|
||||||
|
code = request.form.get('code')
|
||||||
|
team_id = request.form.get('team_id') or None
|
||||||
|
start_date_str = request.form.get('start_date')
|
||||||
|
end_date_str = request.form.get('end_date')
|
||||||
|
|
||||||
|
# Validate input
|
||||||
|
error = None
|
||||||
|
if not name:
|
||||||
|
error = 'Project name is required'
|
||||||
|
elif not code:
|
||||||
|
error = 'Project code is required'
|
||||||
|
elif Project.query.filter_by(code=code).first():
|
||||||
|
error = 'Project code already exists'
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
start_date = None
|
||||||
|
end_date = None
|
||||||
|
if start_date_str:
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
error = 'Invalid start date format'
|
||||||
|
|
||||||
|
if end_date_str:
|
||||||
|
try:
|
||||||
|
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
error = 'Invalid end date format'
|
||||||
|
|
||||||
|
if start_date and end_date and start_date > end_date:
|
||||||
|
error = 'Start date cannot be after end date'
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
project = Project(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
code=code.upper(),
|
||||||
|
team_id=int(team_id) if team_id else None,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
created_by_id=g.user.id
|
||||||
|
)
|
||||||
|
db.session.add(project)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Project "{name}" created successfully!', 'success')
|
||||||
|
return redirect(url_for('admin_projects'))
|
||||||
|
else:
|
||||||
|
flash(error, 'error')
|
||||||
|
|
||||||
|
# Get available teams for the form
|
||||||
|
teams = Team.query.order_by(Team.name).all()
|
||||||
|
return render_template('create_project.html', title='Create Project', teams=teams)
|
||||||
|
|
||||||
|
@app.route('/admin/projects/edit/<int:project_id>', methods=['GET', 'POST'])
|
||||||
|
@role_required(Role.SUPERVISOR)
|
||||||
|
def edit_project(project_id):
|
||||||
|
project = Project.query.get_or_404(project_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form.get('name')
|
||||||
|
description = request.form.get('description')
|
||||||
|
code = request.form.get('code')
|
||||||
|
team_id = request.form.get('team_id') or None
|
||||||
|
is_active = request.form.get('is_active') == 'on'
|
||||||
|
start_date_str = request.form.get('start_date')
|
||||||
|
end_date_str = request.form.get('end_date')
|
||||||
|
|
||||||
|
# Validate input
|
||||||
|
error = None
|
||||||
|
if not name:
|
||||||
|
error = 'Project name is required'
|
||||||
|
elif not code:
|
||||||
|
error = 'Project code is required'
|
||||||
|
elif code != project.code and Project.query.filter_by(code=code).first():
|
||||||
|
error = 'Project code already exists'
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
start_date = None
|
||||||
|
end_date = None
|
||||||
|
if start_date_str:
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
error = 'Invalid start date format'
|
||||||
|
|
||||||
|
if end_date_str:
|
||||||
|
try:
|
||||||
|
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
error = 'Invalid end date format'
|
||||||
|
|
||||||
|
if start_date and end_date and start_date > end_date:
|
||||||
|
error = 'Start date cannot be after end date'
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
project.name = name
|
||||||
|
project.description = description
|
||||||
|
project.code = code.upper()
|
||||||
|
project.team_id = int(team_id) if team_id else None
|
||||||
|
project.is_active = is_active
|
||||||
|
project.start_date = start_date
|
||||||
|
project.end_date = end_date
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Project "{name}" updated successfully!', 'success')
|
||||||
|
return redirect(url_for('admin_projects'))
|
||||||
|
else:
|
||||||
|
flash(error, 'error')
|
||||||
|
|
||||||
|
# Get available teams for the form
|
||||||
|
teams = Team.query.order_by(Team.name).all()
|
||||||
|
return render_template('edit_project.html', title='Edit Project', project=project, teams=teams)
|
||||||
|
|
||||||
|
@app.route('/admin/projects/delete/<int:project_id>', methods=['POST'])
|
||||||
|
@role_required(Role.ADMIN) # Only admins can delete projects
|
||||||
|
def delete_project(project_id):
|
||||||
|
project = Project.query.get_or_404(project_id)
|
||||||
|
|
||||||
|
# Check if there are time entries associated with this project
|
||||||
|
time_entries_count = TimeEntry.query.filter_by(project_id=project_id).count()
|
||||||
|
|
||||||
|
if time_entries_count > 0:
|
||||||
|
flash(f'Cannot delete project "{project.name}" - it has {time_entries_count} time entries associated with it. Deactivate the project instead.', 'error')
|
||||||
|
else:
|
||||||
|
project_name = project.name
|
||||||
|
db.session.delete(project)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Project "{project_name}" deleted successfully!', 'success')
|
||||||
|
|
||||||
|
return redirect(url_for('admin_projects'))
|
||||||
|
|
||||||
@app.route('/api/team/hours_data', methods=['GET'])
|
@app.route('/api/team/hours_data', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
@role_required(Role.TEAM_LEADER) # Only team leaders and above can access
|
@role_required(Role.TEAM_LEADER) # Only team leaders and above can access
|
||||||
@@ -1345,12 +1547,15 @@ def prepare_export_data(entries):
|
|||||||
for entry in entries:
|
for entry in entries:
|
||||||
row = {
|
row = {
|
||||||
'Date': entry.arrival_time.strftime('%Y-%m-%d'),
|
'Date': entry.arrival_time.strftime('%Y-%m-%d'),
|
||||||
|
'Project Code': entry.project.code if entry.project else '',
|
||||||
|
'Project Name': entry.project.name if entry.project else '',
|
||||||
'Arrival Time': entry.arrival_time.strftime('%H:%M:%S'),
|
'Arrival Time': entry.arrival_time.strftime('%H:%M:%S'),
|
||||||
'Departure Time': entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active',
|
'Departure Time': entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active',
|
||||||
'Work Duration (HH:MM:SS)': format_duration(entry.duration) if entry.duration is not None else 'In progress',
|
'Work Duration (HH:MM:SS)': format_duration(entry.duration) if entry.duration is not None else 'In progress',
|
||||||
'Break Duration (HH:MM:SS)': format_duration(entry.total_break_duration),
|
'Break Duration (HH:MM:SS)': format_duration(entry.total_break_duration),
|
||||||
'Work Duration (seconds)': entry.duration if entry.duration is not None else 0,
|
'Work Duration (seconds)': entry.duration if entry.duration is not None else 0,
|
||||||
'Break Duration (seconds)': entry.total_break_duration if entry.total_break_duration is not None else 0
|
'Break Duration (seconds)': entry.total_break_duration if entry.total_break_duration is not None else 0,
|
||||||
|
'Notes': entry.notes if entry.notes else ''
|
||||||
}
|
}
|
||||||
data.append(row)
|
data.append(row)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from app import app, db
|
from app import app, db
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
import os
|
||||||
from models import User, TimeEntry, WorkConfig, SystemSettings, Team, Role
|
from models import User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -153,6 +153,40 @@ def migrate_database():
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Check if the project table exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='project'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("Creating project table...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE project (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by_id INTEGER NOT NULL,
|
||||||
|
team_id INTEGER,
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
FOREIGN KEY (created_by_id) REFERENCES user (id),
|
||||||
|
FOREIGN KEY (team_id) REFERENCES team (id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Add project-related columns to time_entry table
|
||||||
|
cursor.execute("PRAGMA table_info(time_entry)")
|
||||||
|
time_entry_columns = [column[1] for column in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'project_id' not in time_entry_columns:
|
||||||
|
print("Adding project_id column to time_entry...")
|
||||||
|
cursor.execute("ALTER TABLE time_entry ADD COLUMN project_id INTEGER")
|
||||||
|
|
||||||
|
if 'notes' not in time_entry_columns:
|
||||||
|
print("Adding notes column to time_entry...")
|
||||||
|
cursor.execute("ALTER TABLE time_entry ADD COLUMN notes TEXT")
|
||||||
|
|
||||||
# Commit changes and close connection
|
# Commit changes and close connection
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -230,6 +264,44 @@ def migrate_database():
|
|||||||
print(f"Marked {len(existing_users)} existing users as verified")
|
print(f"Marked {len(existing_users)} existing users as verified")
|
||||||
print(f"Updated {updated_count} users with default role and 2FA settings")
|
print(f"Updated {updated_count} users with default role and 2FA settings")
|
||||||
|
|
||||||
|
# Create sample projects if none exist
|
||||||
|
existing_projects = Project.query.count()
|
||||||
|
if existing_projects == 0 and admin:
|
||||||
|
sample_projects = [
|
||||||
|
{
|
||||||
|
'name': 'General Administration',
|
||||||
|
'code': 'ADMIN001',
|
||||||
|
'description': 'General administrative tasks and meetings',
|
||||||
|
'team_id': None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Development Project',
|
||||||
|
'code': 'DEV001',
|
||||||
|
'description': 'Software development and maintenance tasks',
|
||||||
|
'team_id': None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Customer Support',
|
||||||
|
'code': 'SUPPORT001',
|
||||||
|
'description': 'Customer service and technical support activities',
|
||||||
|
'team_id': None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
for proj_data in sample_projects:
|
||||||
|
project = Project(
|
||||||
|
name=proj_data['name'],
|
||||||
|
code=proj_data['code'],
|
||||||
|
description=proj_data['description'],
|
||||||
|
team_id=proj_data['team_id'],
|
||||||
|
created_by_id=admin.id,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.session.add(project)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Created {len(sample_projects)} sample projects")
|
||||||
|
|
||||||
def init_system_settings():
|
def init_system_settings():
|
||||||
"""Initialize system settings with default values if they don't exist"""
|
"""Initialize system settings with default values if they don't exist"""
|
||||||
# Check if registration_enabled setting exists
|
# Check if registration_enabled setting exists
|
||||||
|
|||||||
184
migrate_projects.py
Normal file
184
migrate_projects.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
from app import app, db
|
||||||
|
from models import User, TimeEntry, Project, Team, Role
|
||||||
|
from sqlalchemy import text
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def migrate_projects():
|
||||||
|
"""Migration script to add project time logging functionality"""
|
||||||
|
with app.app_context():
|
||||||
|
logger.info("Starting migration for project time logging...")
|
||||||
|
|
||||||
|
# Check if the project table exists
|
||||||
|
try:
|
||||||
|
# Create the project table if it doesn't exist
|
||||||
|
db.engine.execute(text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS project (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by_id INTEGER NOT NULL,
|
||||||
|
team_id INTEGER,
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
FOREIGN KEY (created_by_id) REFERENCES user (id),
|
||||||
|
FOREIGN KEY (team_id) REFERENCES team (id)
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
logger.info("Project table created or already exists")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating project table: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if the time_entry table has the project-related columns
|
||||||
|
try:
|
||||||
|
# Check if project_id and notes columns exist in time_entry
|
||||||
|
result = db.engine.execute(text("PRAGMA table_info(time_entry)"))
|
||||||
|
columns = [row[1] for row in result]
|
||||||
|
|
||||||
|
if 'project_id' not in columns:
|
||||||
|
db.engine.execute(text("ALTER TABLE time_entry ADD COLUMN project_id INTEGER REFERENCES project(id)"))
|
||||||
|
logger.info("Added project_id column to time_entry table")
|
||||||
|
|
||||||
|
if 'notes' not in columns:
|
||||||
|
db.engine.execute(text("ALTER TABLE time_entry ADD COLUMN notes TEXT"))
|
||||||
|
logger.info("Added notes column to time_entry table")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating time_entry table: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create some default projects for demonstration
|
||||||
|
try:
|
||||||
|
# Check if any projects exist
|
||||||
|
existing_projects = Project.query.count()
|
||||||
|
if existing_projects == 0:
|
||||||
|
# Find an admin or supervisor user to be the creator
|
||||||
|
admin_user = User.query.filter_by(is_admin=True).first()
|
||||||
|
if not admin_user:
|
||||||
|
admin_user = User.query.filter(User.role.in_([Role.ADMIN, Role.SUPERVISOR])).first()
|
||||||
|
|
||||||
|
if admin_user:
|
||||||
|
# Create some sample projects
|
||||||
|
sample_projects = [
|
||||||
|
{
|
||||||
|
'name': 'General Administration',
|
||||||
|
'code': 'ADMIN001',
|
||||||
|
'description': 'General administrative tasks and meetings',
|
||||||
|
'team_id': None, # Available to all teams
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Development Project',
|
||||||
|
'code': 'DEV001',
|
||||||
|
'description': 'Software development and maintenance tasks',
|
||||||
|
'team_id': None, # Available to all teams
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Customer Support',
|
||||||
|
'code': 'SUPPORT001',
|
||||||
|
'description': 'Customer service and technical support activities',
|
||||||
|
'team_id': None, # Available to all teams
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
for proj_data in sample_projects:
|
||||||
|
project = Project(
|
||||||
|
name=proj_data['name'],
|
||||||
|
code=proj_data['code'],
|
||||||
|
description=proj_data['description'],
|
||||||
|
team_id=proj_data['team_id'],
|
||||||
|
created_by_id=admin_user.id,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.session.add(project)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(f"Created {len(sample_projects)} sample projects")
|
||||||
|
else:
|
||||||
|
logger.warning("No admin or supervisor user found to create sample projects")
|
||||||
|
else:
|
||||||
|
logger.info(f"Found {existing_projects} existing projects, skipping sample creation")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating sample projects: {e}")
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
# Update database schema to match the current models
|
||||||
|
try:
|
||||||
|
db.create_all()
|
||||||
|
logger.info("Database schema updated successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating database schema: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Verify the migration
|
||||||
|
try:
|
||||||
|
# Check if we can query the new tables and columns
|
||||||
|
project_count = Project.query.count()
|
||||||
|
logger.info(f"Project table accessible with {project_count} projects")
|
||||||
|
|
||||||
|
# Check if time_entry has the new columns
|
||||||
|
result = db.engine.execute(text("PRAGMA table_info(time_entry)"))
|
||||||
|
columns = [row[1] for row in result]
|
||||||
|
|
||||||
|
required_columns = ['project_id', 'notes']
|
||||||
|
missing_columns = [col for col in required_columns if col not in columns]
|
||||||
|
|
||||||
|
if missing_columns:
|
||||||
|
logger.error(f"Missing columns in time_entry: {missing_columns}")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
logger.info("All required columns present in time_entry table")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error verifying migration: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Project time logging migration completed successfully!")
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PROJECT TIME LOGGING FEATURE ENABLED")
|
||||||
|
print("="*60)
|
||||||
|
print("✅ Project management interface available for Admins/Supervisors")
|
||||||
|
print("✅ Time tracking with optional project selection")
|
||||||
|
print("✅ Project-based reporting and filtering")
|
||||||
|
print("✅ Enhanced export functionality with project data")
|
||||||
|
print("\nAccess project management via:")
|
||||||
|
print("- Admin dropdown → Manage Projects")
|
||||||
|
print("- Supervisor dropdown → Manage Projects")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
def rollback_projects():
|
||||||
|
"""Rollback migration (removes project functionality)"""
|
||||||
|
with app.app_context():
|
||||||
|
logger.warning("Rolling back project time logging migration...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Drop the project table
|
||||||
|
db.engine.execute(text("DROP TABLE IF EXISTS project"))
|
||||||
|
logger.info("Dropped project table")
|
||||||
|
|
||||||
|
# Note: SQLite doesn't support dropping columns, so we can't remove
|
||||||
|
# project_id and notes columns from time_entry table
|
||||||
|
logger.warning("Note: project_id and notes columns in time_entry table cannot be removed due to SQLite limitations")
|
||||||
|
logger.warning("These columns will remain but will not be used")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during rollback: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Project time logging rollback completed")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "rollback":
|
||||||
|
rollback_projects()
|
||||||
|
else:
|
||||||
|
migrate_projects()
|
||||||
52
models.py
52
models.py
@@ -26,6 +26,49 @@ class Team(db.Model):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Team {self.name}>'
|
return f'<Team {self.name}>'
|
||||||
|
|
||||||
|
class Project(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
code = db.Column(db.String(20), unique=True, nullable=False) # Project code (e.g., PRJ001)
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# Foreign key to user who created the project (Admin/Supervisor)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Optional team assignment - if set, only team members can log time to this project
|
||||||
|
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True)
|
||||||
|
|
||||||
|
# Project dates
|
||||||
|
start_date = db.Column(db.Date, nullable=True)
|
||||||
|
end_date = db.Column(db.Date, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='created_projects')
|
||||||
|
team = db.relationship('Team', backref='projects')
|
||||||
|
time_entries = db.relationship('TimeEntry', backref='project', lazy=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Project {self.code}: {self.name}>'
|
||||||
|
|
||||||
|
def is_user_allowed(self, user):
|
||||||
|
"""Check if a user is allowed to log time to this project"""
|
||||||
|
if not self.is_active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Admins and Supervisors can log time to any project
|
||||||
|
if user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If project is team-specific, only team members can log time
|
||||||
|
if self.team_id:
|
||||||
|
return user.team_id == self.team_id
|
||||||
|
|
||||||
|
# If no team restriction, any user can log time
|
||||||
|
return True
|
||||||
|
|
||||||
# Update User model to include role and team relationship
|
# Update User model to include role and team relationship
|
||||||
class User(db.Model):
|
class User(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@@ -127,8 +170,15 @@ class TimeEntry(db.Model):
|
|||||||
total_break_duration = db.Column(db.Integer, default=0) # Total break duration in seconds
|
total_break_duration = db.Column(db.Integer, default=0) # Total break duration in seconds
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||||
|
|
||||||
|
# Project association - nullable for backward compatibility
|
||||||
|
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
|
||||||
|
|
||||||
|
# Optional notes/description for the time entry
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<TimeEntry {self.id}: {self.arrival_time} - {self.departure_time}>'
|
project_info = f" (Project: {self.project.code})" if self.project else ""
|
||||||
|
return f'<TimeEntry {self.id}: {self.arrival_time} - {self.departure_time}{project_info}>'
|
||||||
|
|
||||||
class WorkConfig(db.Model):
|
class WorkConfig(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|||||||
@@ -58,20 +58,43 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Handle arrive button click
|
// Handle arrive button click
|
||||||
if (arriveBtn) {
|
if (arriveBtn) {
|
||||||
arriveBtn.addEventListener('click', function() {
|
arriveBtn.addEventListener('click', function() {
|
||||||
|
// Get project and notes from the form
|
||||||
|
const projectSelect = document.getElementById('project-select');
|
||||||
|
const notesTextarea = document.getElementById('work-notes');
|
||||||
|
|
||||||
|
const projectId = projectSelect ? projectSelect.value : null;
|
||||||
|
const notes = notesTextarea ? notesTextarea.value.trim() : null;
|
||||||
|
|
||||||
|
const requestData = {};
|
||||||
|
if (projectId) {
|
||||||
|
requestData.project_id = projectId;
|
||||||
|
}
|
||||||
|
if (notes) {
|
||||||
|
requestData.notes = notes;
|
||||||
|
}
|
||||||
|
|
||||||
fetch('/api/arrive', {
|
fetch('/api/arrive', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
},
|
||||||
|
body: JSON.stringify(requestData)
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.json().then(data => {
|
||||||
|
throw new Error(data.error || 'Failed to record arrival time');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Reload the page to show the active timer
|
// Reload the page to show the active timer
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
alert('Failed to record arrival time. Please try again.');
|
alert('Failed to record arrival time: ' + error.message);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
127
templates/admin_projects.html
Normal file
127
templates/admin_projects.html
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="timetrack-container">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h2>Project Management</h2>
|
||||||
|
<a href="{{ url_for('create_project') }}" class="btn">Create New Project</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if projects %}
|
||||||
|
<div class="projects-table">
|
||||||
|
<table class="time-history">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Code</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Team</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Start Date</th>
|
||||||
|
<th>End Date</th>
|
||||||
|
<th>Created By</th>
|
||||||
|
<th>Time Entries</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for project in projects %}
|
||||||
|
<tr class="{% if not project.is_active %}inactive-project{% endif %}">
|
||||||
|
<td><strong>{{ project.code }}</strong></td>
|
||||||
|
<td>{{ project.name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if project.team %}
|
||||||
|
{{ project.team.name }}
|
||||||
|
{% else %}
|
||||||
|
<em>All Teams</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge {% if project.is_active %}active{% else %}inactive{% endif %}">
|
||||||
|
{{ 'Active' if project.is_active else 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ project.start_date.strftime('%Y-%m-%d') if project.start_date else '-' }}</td>
|
||||||
|
<td>{{ project.end_date.strftime('%Y-%m-%d') if project.end_date else '-' }}</td>
|
||||||
|
<td>{{ project.created_by.username }}</td>
|
||||||
|
<td>{{ project.time_entries|length }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<a href="{{ url_for('edit_project', project_id=project.id) }}" class="btn btn-small">Edit</a>
|
||||||
|
{% if g.user.role.name == 'ADMIN' and project.time_entries|length == 0 %}
|
||||||
|
<form method="POST" action="{{ url_for('delete_project', project_id=project.id) }}" style="display: inline;"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete this project?')">
|
||||||
|
<button type="submit" class="btn btn-small btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="no-data">
|
||||||
|
<p>No projects found. <a href="{{ url_for('create_project') }}">Create your first project</a>.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive-project {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.active {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.inactive {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #dc3545;
|
||||||
|
border-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
border-color: #bd2130;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
136
templates/create_project.html
Normal file
136
templates/create_project.html
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="timetrack-container">
|
||||||
|
<h2>Create New Project</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('create_project') }}" class="project-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Project Name *</label>
|
||||||
|
<input type="text" id="name" name="name" required
|
||||||
|
value="{{ request.form.name if request.form.name else '' }}"
|
||||||
|
placeholder="Enter project name">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="code">Project Code *</label>
|
||||||
|
<input type="text" id="code" name="code" required
|
||||||
|
value="{{ request.form.code if request.form.code else '' }}"
|
||||||
|
placeholder="e.g., PRJ001" maxlength="20" style="text-transform: uppercase;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" name="description" rows="3"
|
||||||
|
placeholder="Enter project description">{{ request.form.description if request.form.description else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="team_id">Assigned Team</label>
|
||||||
|
<select id="team_id" name="team_id">
|
||||||
|
<option value="">All Teams</option>
|
||||||
|
{% for team in teams %}
|
||||||
|
<option value="{{ team.id }}"
|
||||||
|
{% if request.form.team_id and request.form.team_id|int == team.id %}selected{% endif %}>
|
||||||
|
{{ team.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="start_date">Start Date</label>
|
||||||
|
<input type="date" id="start_date" name="start_date"
|
||||||
|
value="{{ request.form.start_date if request.form.start_date else '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="end_date">End Date</label>
|
||||||
|
<input type="date" id="end_date" name="end_date"
|
||||||
|
value="{{ request.form.end_date if request.form.end_date else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn">Create Project</button>
|
||||||
|
<a href="{{ url_for('admin_projects') }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.project-form {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
border-color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
border-color: #545b62;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Auto-uppercase project code
|
||||||
|
document.getElementById('code').addEventListener('input', function(e) {
|
||||||
|
e.target.value = e.target.value.toUpperCase();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
198
templates/edit_project.html
Normal file
198
templates/edit_project.html
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="timetrack-container">
|
||||||
|
<h2>Edit Project: {{ project.name }}</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('edit_project', project_id=project.id) }}" class="project-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Project Name *</label>
|
||||||
|
<input type="text" id="name" name="name" required
|
||||||
|
value="{{ request.form.name if request.form.name else project.name }}"
|
||||||
|
placeholder="Enter project name">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="code">Project Code *</label>
|
||||||
|
<input type="text" id="code" name="code" required
|
||||||
|
value="{{ request.form.code if request.form.code else project.code }}"
|
||||||
|
placeholder="e.g., PRJ001" maxlength="20" style="text-transform: uppercase;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" name="description" rows="3"
|
||||||
|
placeholder="Enter project description">{{ request.form.description if request.form.description else (project.description or '') }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="team_id">Assigned Team</label>
|
||||||
|
<select id="team_id" name="team_id">
|
||||||
|
<option value="">All Teams</option>
|
||||||
|
{% for team in teams %}
|
||||||
|
<option value="{{ team.id }}"
|
||||||
|
{% if (request.form.team_id and request.form.team_id|int == team.id) or (not request.form.team_id and project.team_id == team.id) %}selected{% endif %}>
|
||||||
|
{{ team.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="is_active"
|
||||||
|
{% if (request.form.is_active and request.form.is_active == 'on') or (not request.form and project.is_active) %}checked{% endif %}>
|
||||||
|
Active Project
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="start_date">Start Date</label>
|
||||||
|
<input type="date" id="start_date" name="start_date"
|
||||||
|
value="{{ request.form.start_date if request.form.start_date else (project.start_date.strftime('%Y-%m-%d') if project.start_date else '') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="end_date">End Date</label>
|
||||||
|
<input type="date" id="end_date" name="end_date"
|
||||||
|
value="{{ request.form.end_date if request.form.end_date else (project.end_date.strftime('%Y-%m-%d') if project.end_date else '') }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-info">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Created by:</span>
|
||||||
|
<span class="info-value">{{ project.created_by.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Created on:</span>
|
||||||
|
<span class="info-value">{{ project.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Time entries:</span>
|
||||||
|
<span class="info-value">{{ project.time_entries|length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn">Update Project</button>
|
||||||
|
<a href="{{ url_for('admin_projects') }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.project-form {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
border-color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
border-color: #545b62;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Auto-uppercase project code
|
||||||
|
document.getElementById('code').addEventListener('input', function(e) {
|
||||||
|
e.target.value = e.target.value.toUpperCase();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -7,16 +7,40 @@
|
|||||||
<a href="{{ url_for('export') }}" class="btn">Export Data</a>
|
<a href="{{ url_for('export') }}" class="btn">Export Data</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Filter -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<form method="GET" action="{{ url_for('history') }}" class="filter-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="project-filter">Filter by Project:</label>
|
||||||
|
<select id="project-filter" name="project_id" onchange="this.form.submit()">
|
||||||
|
<option value="">All Projects</option>
|
||||||
|
{% for project in available_projects %}
|
||||||
|
<option value="{{ project.id }}"
|
||||||
|
{% if request.args.get('project_id') and request.args.get('project_id')|int == project.id %}selected{% endif %}>
|
||||||
|
{{ project.code }} - {{ project.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
<option value="none"
|
||||||
|
{% if request.args.get('project_id') == 'none' %}selected{% endif %}>
|
||||||
|
No Project Assigned
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="history-section">
|
<div class="history-section">
|
||||||
{% if entries %}
|
{% if entries %}
|
||||||
<table class="time-history">
|
<table class="time-history">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
|
<th>Project</th>
|
||||||
<th>Arrival</th>
|
<th>Arrival</th>
|
||||||
<th>Departure</th>
|
<th>Departure</th>
|
||||||
<th>Work Duration</th>
|
<th>Work Duration</th>
|
||||||
<th>Break Duration</th>
|
<th>Break Duration</th>
|
||||||
|
<th>Notes</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -24,10 +48,25 @@
|
|||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
<tr data-entry-id="{{ entry.id }}">
|
<tr data-entry-id="{{ entry.id }}">
|
||||||
<td>{{ entry.arrival_time.strftime('%Y-%m-%d') }}</td>
|
<td>{{ entry.arrival_time.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.project %}
|
||||||
|
<span class="project-tag">{{ entry.project.code }}</span>
|
||||||
|
<small>{{ entry.project.name }}</small>
|
||||||
|
{% else %}
|
||||||
|
<em>No project</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ entry.arrival_time.strftime('%H:%M:%S') }}</td>
|
<td>{{ entry.arrival_time.strftime('%H:%M:%S') }}</td>
|
||||||
<td>{{ entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active' }}</td>
|
<td>{{ entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active' }}</td>
|
||||||
<td>{{ '%d:%02d:%02d'|format(entry.duration//3600, (entry.duration%3600)//60, entry.duration%60) if entry.duration is not none else 'In progress' }}</td>
|
<td>{{ '%d:%02d:%02d'|format(entry.duration//3600, (entry.duration%3600)//60, entry.duration%60) if entry.duration is not none else 'In progress' }}</td>
|
||||||
<td>{{ '%d:%02d:%02d'|format(entry.total_break_duration//3600, (entry.total_break_duration%3600)//60, entry.total_break_duration%60) if entry.total_break_duration is not none else '00:00:00' }}</td>
|
<td>{{ '%d:%02d:%02d'|format(entry.total_break_duration//3600, (entry.total_break_duration%3600)//60, entry.total_break_duration%60) if entry.total_break_duration is not none else '00:00:00' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.notes %}
|
||||||
|
<span class="notes-preview" title="{{ entry.notes }}">{{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}</span>
|
||||||
|
{% else %}
|
||||||
|
<em>-</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="edit-entry-btn" data-id="{{ entry.id }}">Edit</button>
|
<button class="edit-entry-btn" data-id="{{ entry.id }}">Edit</button>
|
||||||
<button class="delete-entry-btn" data-id="{{ entry.id }}">Delete</button>
|
<button class="delete-entry-btn" data-id="{{ entry.id }}">Delete</button>
|
||||||
@@ -234,4 +273,70 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
.filter-section {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form .form-group {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tag {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tag + small {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-preview {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-history td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-history .project-tag + small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -20,6 +20,9 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
|
|||||||
<div class="active-timer">
|
<div class="active-timer">
|
||||||
<h3>Currently Working</h3>
|
<h3>Currently Working</h3>
|
||||||
<p>Started at: {{ active_entry.arrival_time.strftime('%Y-%m-%d %H:%M:%S') }}</p>
|
<p>Started at: {{ active_entry.arrival_time.strftime('%Y-%m-%d %H:%M:%S') }}</p>
|
||||||
|
{% if active_entry.project %}
|
||||||
|
<p class="project-info">Project: <strong>{{ active_entry.project.code }} - {{ active_entry.project.name }}</strong></p>
|
||||||
|
{% endif %}
|
||||||
<div id="timer"
|
<div id="timer"
|
||||||
data-start="{{ active_entry.arrival_time.timestamp() }}"
|
data-start="{{ active_entry.arrival_time.timestamp() }}"
|
||||||
data-breaks="{{ active_entry.total_break_duration }}"
|
data-breaks="{{ active_entry.total_break_duration }}"
|
||||||
@@ -43,8 +46,23 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="inactive-timer">
|
<div class="inactive-timer">
|
||||||
<h3>Not Currently Working</h3>
|
<h3>Not Currently Working</h3>
|
||||||
|
<div class="start-work-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="project-select">Select Project (Optional):</label>
|
||||||
|
<select id="project-select" name="project_id">
|
||||||
|
<option value="">No specific project</option>
|
||||||
|
{% for project in available_projects %}
|
||||||
|
<option value="{{ project.id }}">{{ project.code }} - {{ project.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="work-notes">Notes (Optional):</label>
|
||||||
|
<textarea id="work-notes" name="notes" rows="2" placeholder="What are you working on?"></textarea>
|
||||||
|
</div>
|
||||||
<button id="arrive-btn" class="arrive-btn">Arrive</button>
|
<button id="arrive-btn" class="arrive-btn">Arrive</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,6 +73,7 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
|
<th>Project</th>
|
||||||
<th>Arrival</th>
|
<th>Arrival</th>
|
||||||
<th>Departure</th>
|
<th>Departure</th>
|
||||||
<th>Work Duration</th>
|
<th>Work Duration</th>
|
||||||
@@ -66,6 +85,14 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
|
|||||||
{% for entry in history %}
|
{% for entry in history %}
|
||||||
<tr data-entry-id="{{ entry.id }}">
|
<tr data-entry-id="{{ entry.id }}">
|
||||||
<td>{{ entry.arrival_time.strftime('%Y-%m-%d') }}</td>
|
<td>{{ entry.arrival_time.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.project %}
|
||||||
|
<span class="project-tag">{{ entry.project.code }}</span>
|
||||||
|
<small>{{ entry.project.name }}</small>
|
||||||
|
{% else %}
|
||||||
|
<em>No project</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ entry.arrival_time.strftime('%H:%M:%S') }}</td>
|
<td>{{ entry.arrival_time.strftime('%H:%M:%S') }}</td>
|
||||||
<td>{{ entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active' }}</td>
|
<td>{{ entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active' }}</td>
|
||||||
<td>{{ '%d:%02d:%02d'|format(entry.duration//3600, (entry.duration%3600)//60, entry.duration%60) if entry.duration is not none else 'In progress' }}</td>
|
<td>{{ '%d:%02d:%02d'|format(entry.duration//3600, (entry.duration%3600)//60, entry.duration%60) if entry.duration is not none else 'In progress' }}</td>
|
||||||
@@ -310,4 +337,71 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.start-work-form {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-work-form .form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-work-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-work-form select,
|
||||||
|
.start-work-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-work-form select:focus,
|
||||||
|
.start-work-form textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info {
|
||||||
|
color: #4CAF50;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tag {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tag + small {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-history td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-history .project-tag + small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -23,6 +23,14 @@
|
|||||||
<li><a href="{{ url_for('profile') }}">Profile</a></li>
|
<li><a href="{{ url_for('profile') }}">Profile</a></li>
|
||||||
<li><a href="{{ url_for('config') }}">Config</a></li>
|
<li><a href="{{ url_for('config') }}">Config</a></li>
|
||||||
<li><a href="{{ url_for('dashboard') }}">Dashboard</a></li>
|
<li><a href="{{ url_for('dashboard') }}">Dashboard</a></li>
|
||||||
|
<li><a href="{{ url_for('admin_dashboard') }}">Admin Dashboard</a></li>
|
||||||
|
<li><a href="{{ url_for('admin_users') }}">Manage Users</a></li>
|
||||||
|
<li><a href="{{ url_for('admin_teams') }}">Manage Teams</a></li>
|
||||||
|
<li><a href="{{ url_for('admin_projects') }}">Manage Projects</a></li>
|
||||||
|
<li><a href="{{ url_for('admin_settings') }}">System Settings</a></li>
|
||||||
|
{% if g.user.team_id %}
|
||||||
|
<li><a href="{{ url_for('team_hours') }}">Team Hours</a></li>
|
||||||
|
{% endif %}
|
||||||
<li><a href="{{ url_for('logout') }}">Logout</a></li>
|
<li><a href="{{ url_for('logout') }}">Logout</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@@ -34,6 +42,9 @@
|
|||||||
<li><a href="{{ url_for('profile') }}">Profile</a></li>
|
<li><a href="{{ url_for('profile') }}">Profile</a></li>
|
||||||
<li><a href="{{ url_for('config') }}">Config</a></li>
|
<li><a href="{{ url_for('config') }}">Config</a></li>
|
||||||
<li><a href="{{ url_for('dashboard') }}">Dashboard</a></li>
|
<li><a href="{{ url_for('dashboard') }}">Dashboard</a></li>
|
||||||
|
{% if g.user.role == Role.SUPERVISOR %}
|
||||||
|
<li><a href="{{ url_for('admin_projects') }}">Manage Projects</a></li>
|
||||||
|
{% endif %}
|
||||||
{% if g.user.team_id %}
|
{% if g.user.team_id %}
|
||||||
<li><a href="{{ url_for('team_hours') }}">Team Hours</a></li>
|
<li><a href="{{ url_for('team_hours') }}">Team Hours</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user