Merge pull request #3 from nullmedium/feature-project-time-logging
Implement comprehensive project time logging feature
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 models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role
|
||||
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project
|
||||
import logging
|
||||
from datetime import datetime, time, timedelta
|
||||
import os
|
||||
@@ -154,7 +154,17 @@ def home():
|
||||
TimeEntry.arrival_time <= datetime.combine(today, time.max)
|
||||
).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:
|
||||
return render_template('index.html', title='Home')
|
||||
|
||||
@@ -683,14 +693,35 @@ def timetrack():
|
||||
@app.route('/api/arrive', methods=['POST'])
|
||||
@login_required
|
||||
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
|
||||
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.commit()
|
||||
|
||||
return jsonify({
|
||||
'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'])
|
||||
@@ -900,10 +931,38 @@ def team_hours():
|
||||
@app.route('/history')
|
||||
@login_required
|
||||
def history():
|
||||
# Get all time entries for the current user, ordered by most recent first
|
||||
all_entries = TimeEntry.query.filter_by(user_id=session['user_id']).order_by(TimeEntry.arrival_time.desc()).all()
|
||||
# Get project filter from query parameters
|
||||
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):
|
||||
"""
|
||||
@@ -1187,6 +1246,149 @@ def manage_team(team_id):
|
||||
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'])
|
||||
@login_required
|
||||
@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:
|
||||
row = {
|
||||
'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'),
|
||||
'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',
|
||||
'Break Duration (HH:MM:SS)': format_duration(entry.total_break_duration),
|
||||
'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)
|
||||
return data
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from app import app, db
|
||||
import sqlite3
|
||||
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 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
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -230,6 +264,44 @@ def migrate_database():
|
||||
print(f"Marked {len(existing_users)} existing users as verified")
|
||||
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():
|
||||
"""Initialize system settings with default values if they don't exist"""
|
||||
# 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):
|
||||
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
|
||||
class User(db.Model):
|
||||
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
|
||||
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):
|
||||
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):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
@@ -58,20 +58,43 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle arrive button click
|
||||
if (arriveBtn) {
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'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 => {
|
||||
// Reload the page to show the active timer
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(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>
|
||||
</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">
|
||||
{% if entries %}
|
||||
<table class="time-history">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Project</th>
|
||||
<th>Arrival</th>
|
||||
<th>Departure</th>
|
||||
<th>Work Duration</th>
|
||||
<th>Break Duration</th>
|
||||
<th>Notes</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -24,10 +48,25 @@
|
||||
{% for entry in entries %}
|
||||
<tr data-entry-id="{{ entry.id }}">
|
||||
<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.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.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>
|
||||
<button class="edit-entry-btn" data-id="{{ entry.id }}">Edit</button>
|
||||
<button class="delete-entry-btn" data-id="{{ entry.id }}">Delete</button>
|
||||
@@ -234,4 +273,70 @@
|
||||
});
|
||||
});
|
||||
</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 %}
|
||||
@@ -20,6 +20,9 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
|
||||
<div class="active-timer">
|
||||
<h3>Currently Working</h3>
|
||||
<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"
|
||||
data-start="{{ active_entry.arrival_time.timestamp() }}"
|
||||
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 %}
|
||||
<div class="inactive-timer">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -55,6 +73,7 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Project</th>
|
||||
<th>Arrival</th>
|
||||
<th>Departure</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 %}
|
||||
<tr data-entry-id="{{ entry.id }}">
|
||||
<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.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>
|
||||
@@ -310,4 +337,71 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
|
||||
});
|
||||
</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 %}
|
||||
@@ -23,6 +23,14 @@
|
||||
<li><a href="{{ url_for('profile') }}">Profile</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('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>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -34,6 +42,9 @@
|
||||
<li><a href="{{ url_for('profile') }}">Profile</a></li>
|
||||
<li><a href="{{ url_for('config') }}">Config</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 %}
|
||||
<li><a href="{{ url_for('team_hours') }}">Team Hours</a></li>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user