Merge pull request #15 from nullmedium/feature-task-management
Feature task management
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -146,3 +146,7 @@ node_modules/
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# User uploaded content
|
||||
static/uploads/avatars/*
|
||||
!static/uploads/avatars/.gitkeep
|
||||
@@ -17,6 +17,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
# Create www-data user and log directory
|
||||
RUN groupadd -r www-data && useradd -r -g www-data www-data || true
|
||||
RUN mkdir -p /var/log/uwsgi && chown -R www-data:www-data /var/log/uwsgi
|
||||
@@ -35,6 +38,9 @@ COPY . .
|
||||
# Create the SQLite database directory with proper permissions
|
||||
RUN mkdir -p /app/instance && chmod 777 /app/instance
|
||||
|
||||
# Create uploads directory with proper permissions
|
||||
RUN mkdir -p /app/static/uploads/avatars && chmod -R 777 /app/static/uploads
|
||||
|
||||
VOLUME /data
|
||||
RUN mkdir /data && chmod 777 /data
|
||||
|
||||
|
||||
@@ -145,3 +145,64 @@ def format_team_data(entries, granularity='daily'):
|
||||
})
|
||||
|
||||
return {'team_data': team_data}
|
||||
|
||||
|
||||
def format_burndown_data(tasks, start_date, end_date):
|
||||
"""Format data for burndown chart visualization."""
|
||||
from datetime import datetime, timedelta
|
||||
from models import Task, TaskStatus
|
||||
|
||||
if not tasks:
|
||||
return {'burndown': {'dates': [], 'remaining': [], 'ideal': []}}
|
||||
|
||||
# Convert string dates to datetime objects if needed
|
||||
if isinstance(start_date, str):
|
||||
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
if isinstance(end_date, str):
|
||||
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
|
||||
# Generate date range
|
||||
current_date = start_date
|
||||
dates = []
|
||||
while current_date <= end_date:
|
||||
dates.append(current_date.strftime('%Y-%m-%d'))
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
total_tasks = len(tasks)
|
||||
if total_tasks == 0:
|
||||
return {'burndown': {'dates': dates, 'remaining': [0] * len(dates), 'ideal': [0] * len(dates)}}
|
||||
|
||||
# Calculate ideal burndown (linear decrease from total to 0)
|
||||
total_days = len(dates)
|
||||
ideal_burndown = []
|
||||
for i in range(total_days):
|
||||
remaining_ideal = total_tasks - (total_tasks * i / (total_days - 1)) if total_days > 1 else 0
|
||||
ideal_burndown.append(max(0, round(remaining_ideal, 1)))
|
||||
|
||||
# Calculate actual remaining tasks for each date
|
||||
actual_remaining = []
|
||||
for date_str in dates:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
|
||||
# Count tasks not completed by this date
|
||||
remaining_count = 0
|
||||
for task in tasks:
|
||||
# Task is remaining if:
|
||||
# 1. It's not completed, OR
|
||||
# 2. It was completed after this date
|
||||
if task.status != TaskStatus.COMPLETED:
|
||||
remaining_count += 1
|
||||
elif task.completed_date and task.completed_date > date_obj:
|
||||
remaining_count += 1
|
||||
|
||||
actual_remaining.append(remaining_count)
|
||||
|
||||
return {
|
||||
'burndown': {
|
||||
'dates': dates,
|
||||
'remaining': actual_remaining,
|
||||
'ideal': ideal_burndown,
|
||||
'total_tasks': total_tasks,
|
||||
'tasks_completed': total_tasks - (actual_remaining[-1] if actual_remaining else total_tasks)
|
||||
}
|
||||
}
|
||||
621
migrate_db.py
621
migrate_db.py
@@ -14,8 +14,10 @@ from datetime import datetime
|
||||
try:
|
||||
from app import app, db
|
||||
from models import (User, TimeEntry, WorkConfig, SystemSettings, Team, Role, Project,
|
||||
Company, CompanyWorkConfig, UserPreferences, WorkRegion, AccountType,
|
||||
ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent)
|
||||
Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType,
|
||||
ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, Announcement, SystemEvent,
|
||||
WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility,
|
||||
BrandingSettings)
|
||||
from werkzeug.security import generate_password_hash
|
||||
FLASK_AVAILABLE = True
|
||||
except ImportError:
|
||||
@@ -72,6 +74,12 @@ def run_all_migrations(db_path=None):
|
||||
migrate_work_config_data(db_path)
|
||||
migrate_task_system(db_path)
|
||||
migrate_system_events(db_path)
|
||||
migrate_dashboard_system(db_path)
|
||||
migrate_comment_system(db_path)
|
||||
|
||||
# Run PostgreSQL-specific migrations if applicable
|
||||
if FLASK_AVAILABLE:
|
||||
migrate_postgresql_schema()
|
||||
|
||||
if FLASK_AVAILABLE:
|
||||
with app.app_context():
|
||||
@@ -168,7 +176,8 @@ def run_basic_migrations(db_path):
|
||||
('business_name', "ALTER TABLE user ADD COLUMN business_name VARCHAR(100)"),
|
||||
('company_id', "ALTER TABLE user ADD COLUMN company_id INTEGER"),
|
||||
('two_factor_enabled', "ALTER TABLE user ADD COLUMN two_factor_enabled BOOLEAN DEFAULT 0"),
|
||||
('two_factor_secret', "ALTER TABLE user ADD COLUMN two_factor_secret VARCHAR(32)")
|
||||
('two_factor_secret', "ALTER TABLE user ADD COLUMN two_factor_secret VARCHAR(32)"),
|
||||
('avatar_url', "ALTER TABLE user ADD COLUMN avatar_url VARCHAR(255)")
|
||||
]
|
||||
|
||||
for column_name, sql_command in user_migrations:
|
||||
@@ -297,6 +306,47 @@ def create_missing_tables(cursor):
|
||||
)
|
||||
""")
|
||||
|
||||
# Company Settings table
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='company_settings'")
|
||||
if not cursor.fetchone():
|
||||
print("Creating company_settings table...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE company_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL,
|
||||
default_comment_visibility VARCHAR(20) DEFAULT 'Company',
|
||||
allow_team_visibility_comments BOOLEAN DEFAULT 1,
|
||||
require_task_assignment BOOLEAN DEFAULT 0,
|
||||
allow_task_creation_by_members BOOLEAN DEFAULT 1,
|
||||
restrict_project_access_by_team BOOLEAN DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_id INTEGER,
|
||||
FOREIGN KEY (company_id) REFERENCES company (id),
|
||||
FOREIGN KEY (created_by_id) REFERENCES user (id),
|
||||
UNIQUE(company_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Branding Settings table
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='branding_settings'")
|
||||
if not cursor.fetchone():
|
||||
print("Creating branding_settings table...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE branding_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
app_name VARCHAR(100) NOT NULL DEFAULT 'Time Tracker',
|
||||
logo_filename VARCHAR(255),
|
||||
logo_alt_text VARCHAR(255) DEFAULT 'Logo',
|
||||
favicon_filename VARCHAR(255),
|
||||
primary_color VARCHAR(7) DEFAULT '#007bff',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by_id INTEGER,
|
||||
FOREIGN KEY (updated_by_id) REFERENCES user (id)
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def migrate_to_company_model(db_path):
|
||||
"""Migrate to company-based multi-tenancy model."""
|
||||
@@ -454,6 +504,7 @@ def migrate_user_roles(cursor):
|
||||
business_name VARCHAR(100),
|
||||
two_factor_enabled BOOLEAN DEFAULT 0,
|
||||
two_factor_secret VARCHAR(32),
|
||||
avatar_url VARCHAR(255),
|
||||
FOREIGN KEY (company_id) REFERENCES company (id),
|
||||
FOREIGN KEY (team_id) REFERENCES team (id)
|
||||
)
|
||||
@@ -479,7 +530,7 @@ def migrate_user_roles(cursor):
|
||||
WHEN account_type IN (?, ?) THEN account_type
|
||||
ELSE ?
|
||||
END as account_type,
|
||||
business_name, two_factor_enabled, two_factor_secret
|
||||
business_name, two_factor_enabled, two_factor_secret, avatar_url
|
||||
FROM user
|
||||
""", (default_company_id, Role.TEAM_MEMBER.value, Role.TEAM_LEADER.value, Role.SUPERVISOR.value,
|
||||
Role.ADMIN.value, Role.SYSTEM_ADMIN.value, Role.TEAM_MEMBER.value,
|
||||
@@ -602,24 +653,61 @@ def migrate_task_system(db_path):
|
||||
cursor.execute("""
|
||||
CREATE TABLE task (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_number VARCHAR(20) NOT NULL UNIQUE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(50) DEFAULT 'Not Started',
|
||||
priority VARCHAR(50) DEFAULT 'Medium',
|
||||
estimated_hours FLOAT,
|
||||
project_id INTEGER NOT NULL,
|
||||
sprint_id INTEGER,
|
||||
assigned_to_id INTEGER,
|
||||
start_date DATE,
|
||||
due_date DATE,
|
||||
completed_date DATE,
|
||||
archived_date DATE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (project_id) REFERENCES project (id),
|
||||
FOREIGN KEY (sprint_id) REFERENCES sprint (id),
|
||||
FOREIGN KEY (assigned_to_id) REFERENCES user (id),
|
||||
FOREIGN KEY (created_by_id) REFERENCES user (id)
|
||||
)
|
||||
""")
|
||||
else:
|
||||
# Add missing columns to existing task table
|
||||
cursor.execute("PRAGMA table_info(task)")
|
||||
task_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
task_migrations = [
|
||||
('task_number', "ALTER TABLE task ADD COLUMN task_number VARCHAR(20)"),
|
||||
('sprint_id', "ALTER TABLE task ADD COLUMN sprint_id INTEGER"),
|
||||
('archived_date', "ALTER TABLE task ADD COLUMN archived_date DATE")
|
||||
]
|
||||
|
||||
for column_name, sql_command in task_migrations:
|
||||
if column_name not in task_columns:
|
||||
print(f"Adding {column_name} column to task table...")
|
||||
cursor.execute(sql_command)
|
||||
|
||||
# Add unique constraint for task_number if it was just added
|
||||
if 'task_number' not in task_columns:
|
||||
print("Adding unique constraint for task_number...")
|
||||
# For SQLite, we need to recreate the table to add unique constraint
|
||||
cursor.execute("CREATE UNIQUE INDEX idx_task_number ON task(task_number)")
|
||||
|
||||
# Generate task numbers for existing tasks that don't have them
|
||||
print("Generating task numbers for existing tasks...")
|
||||
cursor.execute("SELECT id FROM task WHERE task_number IS NULL ORDER BY id")
|
||||
tasks_without_numbers = cursor.fetchall()
|
||||
|
||||
for i, (task_id,) in enumerate(tasks_without_numbers, 1):
|
||||
task_number = f"TSK-{i:03d}"
|
||||
cursor.execute("UPDATE task SET task_number = ? WHERE id = ?", (task_number, task_id))
|
||||
|
||||
if tasks_without_numbers:
|
||||
print(f"Generated {len(tasks_without_numbers)} task numbers")
|
||||
|
||||
# Check if sub_task table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sub_task'")
|
||||
@@ -641,12 +729,65 @@ def migrate_task_system(db_path):
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES task (id),
|
||||
FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (assigned_to_id) REFERENCES user (id),
|
||||
FOREIGN KEY (created_by_id) REFERENCES user (id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create index for better performance
|
||||
print("Creating index on sub_task.task_id...")
|
||||
cursor.execute("CREATE INDEX idx_subtask_task_id ON sub_task(task_id)")
|
||||
else:
|
||||
# Check if the index exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_subtask_task_id'")
|
||||
if not cursor.fetchone():
|
||||
print("Creating missing index on sub_task.task_id...")
|
||||
cursor.execute("CREATE INDEX idx_subtask_task_id ON sub_task(task_id)")
|
||||
|
||||
# Check if task_dependency table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='task_dependency'")
|
||||
if not cursor.fetchone():
|
||||
print("Creating task_dependency table...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE task_dependency (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
blocked_task_id INTEGER NOT NULL,
|
||||
blocking_task_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (blocked_task_id) REFERENCES task (id),
|
||||
FOREIGN KEY (blocking_task_id) REFERENCES task (id),
|
||||
UNIQUE(blocked_task_id, blocking_task_id),
|
||||
CHECK (blocked_task_id != blocking_task_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Check if sprint table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sprint'")
|
||||
if not cursor.fetchone():
|
||||
print("Creating sprint table...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE sprint (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(50) DEFAULT 'PLANNING',
|
||||
goal TEXT,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
capacity_hours INTEGER,
|
||||
project_id INTEGER,
|
||||
company_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (project_id) REFERENCES project (id),
|
||||
FOREIGN KEY (company_id) REFERENCES company (id),
|
||||
FOREIGN KEY (created_by_id) REFERENCES user (id),
|
||||
UNIQUE(company_id, name)
|
||||
)
|
||||
""")
|
||||
|
||||
# Add category_id to project table if it doesn't exist
|
||||
cursor.execute("PRAGMA table_info(project)")
|
||||
project_columns = [column[1] for column in cursor.fetchall()]
|
||||
@@ -873,6 +1014,7 @@ def create_all_tables(cursor):
|
||||
business_name VARCHAR(100),
|
||||
two_factor_enabled BOOLEAN DEFAULT 0,
|
||||
two_factor_secret VARCHAR(32),
|
||||
avatar_url VARCHAR(255),
|
||||
FOREIGN KEY (company_id) REFERENCES company (id),
|
||||
FOREIGN KEY (team_id) REFERENCES team (id)
|
||||
)
|
||||
@@ -882,6 +1024,464 @@ def create_all_tables(cursor):
|
||||
print("All tables created")
|
||||
|
||||
|
||||
def migrate_postgresql_schema():
|
||||
"""Migrate PostgreSQL schema for archive functionality."""
|
||||
if not FLASK_AVAILABLE:
|
||||
print("Skipping PostgreSQL migration - Flask not available")
|
||||
return
|
||||
|
||||
try:
|
||||
import psycopg2
|
||||
from sqlalchemy import text
|
||||
|
||||
with app.app_context():
|
||||
# Check if we're using PostgreSQL
|
||||
database_url = app.config['SQLALCHEMY_DATABASE_URI']
|
||||
if not ('postgresql://' in database_url or 'postgres://' in database_url):
|
||||
print("Not using PostgreSQL - skipping PostgreSQL migration")
|
||||
return
|
||||
|
||||
print("Running PostgreSQL schema migrations...")
|
||||
|
||||
# Check if archived_date column exists
|
||||
result = db.session.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'task' AND column_name = 'archived_date'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Adding archived_date column to task table...")
|
||||
db.session.execute(text("ALTER TABLE task ADD COLUMN archived_date DATE"))
|
||||
db.session.commit()
|
||||
|
||||
# Check if ARCHIVED status exists in enum
|
||||
result = db.session.execute(text("""
|
||||
SELECT enumlabel
|
||||
FROM pg_enum
|
||||
WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'taskstatus')
|
||||
AND enumlabel = 'ARCHIVED'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Adding ARCHIVED status to TaskStatus enum...")
|
||||
db.session.execute(text("ALTER TYPE taskstatus ADD VALUE 'ARCHIVED'"))
|
||||
db.session.commit()
|
||||
|
||||
# Check if task_number column exists
|
||||
result = db.session.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'task' AND column_name = 'task_number'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Adding task_number column to task table...")
|
||||
db.session.execute(text("ALTER TABLE task ADD COLUMN task_number VARCHAR(20) UNIQUE"))
|
||||
|
||||
# Generate task numbers for existing tasks
|
||||
print("Generating task numbers for existing tasks...")
|
||||
result = db.session.execute(text("SELECT id FROM task WHERE task_number IS NULL ORDER BY id"))
|
||||
tasks_without_numbers = result.fetchall()
|
||||
|
||||
for i, (task_id,) in enumerate(tasks_without_numbers, 1):
|
||||
task_number = f"TSK-{i:03d}"
|
||||
db.session.execute(text("UPDATE task SET task_number = :task_number WHERE id = :task_id"),
|
||||
{"task_number": task_number, "task_id": task_id})
|
||||
|
||||
db.session.commit()
|
||||
if tasks_without_numbers:
|
||||
print(f"Generated {len(tasks_without_numbers)} task numbers")
|
||||
|
||||
# Check if sprint_id column exists
|
||||
result = db.session.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'task' AND column_name = 'sprint_id'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Adding sprint_id column to task table...")
|
||||
db.session.execute(text("ALTER TABLE task ADD COLUMN sprint_id INTEGER"))
|
||||
db.session.execute(text("ALTER TABLE task ADD CONSTRAINT fk_task_sprint FOREIGN KEY (sprint_id) REFERENCES sprint (id)"))
|
||||
db.session.commit()
|
||||
|
||||
# Check if task_dependency table exists
|
||||
result = db.session.execute(text("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'task_dependency'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Creating task_dependency table...")
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE task_dependency (
|
||||
id SERIAL PRIMARY KEY,
|
||||
blocked_task_id INTEGER NOT NULL,
|
||||
blocking_task_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (blocked_task_id) REFERENCES task (id),
|
||||
FOREIGN KEY (blocking_task_id) REFERENCES task (id),
|
||||
UNIQUE(blocked_task_id, blocking_task_id),
|
||||
CHECK (blocked_task_id <> blocking_task_id)
|
||||
)
|
||||
"""))
|
||||
db.session.commit()
|
||||
|
||||
# Check if sub_task table exists
|
||||
result = db.session.execute(text("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'sub_task'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Creating sub_task table...")
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE sub_task (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
status taskstatus DEFAULT 'NOT_STARTED',
|
||||
priority taskpriority DEFAULT 'MEDIUM',
|
||||
estimated_hours FLOAT,
|
||||
task_id INTEGER NOT NULL,
|
||||
assigned_to_id INTEGER,
|
||||
start_date DATE,
|
||||
due_date DATE,
|
||||
completed_date DATE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (assigned_to_id) REFERENCES "user" (id),
|
||||
FOREIGN KEY (created_by_id) REFERENCES "user" (id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# Create index for better performance
|
||||
db.session.execute(text("CREATE INDEX idx_subtask_task_id ON sub_task(task_id)"))
|
||||
db.session.commit()
|
||||
|
||||
# Check if avatar_url column exists in user table
|
||||
result = db.session.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'user' AND column_name = 'avatar_url'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Adding avatar_url column to user table...")
|
||||
db.session.execute(text('ALTER TABLE "user" ADD COLUMN avatar_url VARCHAR(255)'))
|
||||
db.session.commit()
|
||||
|
||||
# Check if comment table exists
|
||||
result = db.session.execute(text("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'comment'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Creating comment table...")
|
||||
|
||||
# Create comment visibility enum type if it doesn't exist
|
||||
db.session.execute(text("""
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE commentvisibility AS ENUM ('TEAM', 'COMPANY');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
"""))
|
||||
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE comment (
|
||||
id SERIAL PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
task_id INTEGER NOT NULL,
|
||||
parent_comment_id INTEGER,
|
||||
visibility commentvisibility DEFAULT 'COMPANY',
|
||||
is_edited BOOLEAN DEFAULT FALSE,
|
||||
edited_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_comment_id) REFERENCES comment (id),
|
||||
FOREIGN KEY (created_by_id) REFERENCES "user" (id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# Create indexes for better performance
|
||||
db.session.execute(text("CREATE INDEX idx_comment_task ON comment(task_id)"))
|
||||
db.session.execute(text("CREATE INDEX idx_comment_parent ON comment(parent_comment_id)"))
|
||||
db.session.execute(text("CREATE INDEX idx_comment_created_by ON comment(created_by_id)"))
|
||||
db.session.execute(text("CREATE INDEX idx_comment_created_at ON comment(created_at DESC)"))
|
||||
db.session.commit()
|
||||
|
||||
# Check if branding_settings table exists
|
||||
result = db.session.execute(text("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'branding_settings'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Creating branding_settings table...")
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE branding_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
app_name VARCHAR(100) NOT NULL DEFAULT 'Time Tracker',
|
||||
logo_filename VARCHAR(255),
|
||||
logo_alt_text VARCHAR(255) DEFAULT 'Logo',
|
||||
favicon_filename VARCHAR(255),
|
||||
primary_color VARCHAR(7) DEFAULT '#007bff',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by_id INTEGER,
|
||||
FOREIGN KEY (updated_by_id) REFERENCES "user" (id)
|
||||
)
|
||||
"""))
|
||||
db.session.commit()
|
||||
|
||||
# Check if company_settings table exists
|
||||
result = db.session.execute(text("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'company_settings'
|
||||
"""))
|
||||
|
||||
if not result.fetchone():
|
||||
print("Creating company_settings table...")
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE company_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_id INTEGER NOT NULL,
|
||||
default_comment_visibility commentvisibility DEFAULT 'COMPANY',
|
||||
allow_team_visibility_comments BOOLEAN DEFAULT TRUE,
|
||||
require_task_assignment BOOLEAN DEFAULT FALSE,
|
||||
allow_task_creation_by_members BOOLEAN DEFAULT TRUE,
|
||||
restrict_project_access_by_team BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_id INTEGER,
|
||||
FOREIGN KEY (company_id) REFERENCES company (id),
|
||||
FOREIGN KEY (created_by_id) REFERENCES "user" (id),
|
||||
UNIQUE(company_id)
|
||||
)
|
||||
"""))
|
||||
db.session.commit()
|
||||
|
||||
print("PostgreSQL schema migration completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during PostgreSQL migration: {e}")
|
||||
if FLASK_AVAILABLE:
|
||||
db.session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def migrate_dashboard_system(db_file=None):
|
||||
"""Migrate to add Dashboard widget system."""
|
||||
db_path = get_db_path(db_file)
|
||||
|
||||
print(f"Migrating Dashboard system in {db_path}...")
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database file {db_path} does not exist. Run basic migration first.")
|
||||
return False
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check if user_dashboard table already exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_dashboard'")
|
||||
if cursor.fetchone():
|
||||
print("Dashboard tables already exist. Skipping migration.")
|
||||
return True
|
||||
|
||||
print("Creating Dashboard system tables...")
|
||||
|
||||
# Create user_dashboard table
|
||||
cursor.execute("""
|
||||
CREATE TABLE user_dashboard (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name VARCHAR(100) DEFAULT 'My Dashboard',
|
||||
is_default BOOLEAN DEFAULT 1,
|
||||
layout_config TEXT,
|
||||
grid_columns INTEGER DEFAULT 6,
|
||||
theme VARCHAR(20) DEFAULT 'light',
|
||||
auto_refresh INTEGER DEFAULT 300,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES user (id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create dashboard_widget table
|
||||
cursor.execute("""
|
||||
CREATE TABLE dashboard_widget (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dashboard_id INTEGER NOT NULL,
|
||||
widget_type VARCHAR(50) NOT NULL,
|
||||
grid_x INTEGER NOT NULL DEFAULT 0,
|
||||
grid_y INTEGER NOT NULL DEFAULT 0,
|
||||
grid_width INTEGER NOT NULL DEFAULT 1,
|
||||
grid_height INTEGER NOT NULL DEFAULT 1,
|
||||
title VARCHAR(100),
|
||||
config TEXT,
|
||||
refresh_interval INTEGER DEFAULT 60,
|
||||
is_visible BOOLEAN DEFAULT 1,
|
||||
is_minimized BOOLEAN DEFAULT 0,
|
||||
z_index INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (dashboard_id) REFERENCES user_dashboard (id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create widget_template table
|
||||
cursor.execute("""
|
||||
CREATE TABLE widget_template (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
widget_type VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
icon VARCHAR(50),
|
||||
default_width INTEGER DEFAULT 1,
|
||||
default_height INTEGER DEFAULT 1,
|
||||
default_config TEXT,
|
||||
required_role VARCHAR(50) DEFAULT 'Team Member',
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
category VARCHAR(50) DEFAULT 'General',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for better performance
|
||||
cursor.execute("CREATE INDEX idx_user_dashboard_user ON user_dashboard(user_id)")
|
||||
cursor.execute("CREATE INDEX idx_user_dashboard_default ON user_dashboard(user_id, is_default)")
|
||||
cursor.execute("CREATE INDEX idx_dashboard_widget_dashboard ON dashboard_widget(dashboard_id)")
|
||||
cursor.execute("CREATE INDEX idx_dashboard_widget_type ON dashboard_widget(widget_type)")
|
||||
cursor.execute("CREATE INDEX idx_widget_template_type ON widget_template(widget_type)")
|
||||
cursor.execute("CREATE INDEX idx_widget_template_category ON widget_template(category)")
|
||||
|
||||
# Insert default widget templates
|
||||
default_templates = [
|
||||
# Time Tracking Widgets
|
||||
('current_timer', 'Current Timer', 'Shows active time tracking session', '⏲️', 2, 1, '{}', 'Team Member', 'Time'),
|
||||
('daily_summary', 'Daily Summary', 'Today\'s time tracking summary', '📊', 2, 1, '{}', 'Team Member', 'Time'),
|
||||
('weekly_chart', 'Weekly Chart', 'Weekly time distribution chart', '📈', 3, 2, '{}', 'Team Member', 'Time'),
|
||||
('break_reminder', 'Break Reminder', 'Reminds when breaks are due', '☕', 1, 1, '{}', 'Team Member', 'Time'),
|
||||
|
||||
# Project Management Widgets
|
||||
('active_projects', 'Active Projects', 'List of current active projects', '📁', 2, 2, '{}', 'Team Member', 'Projects'),
|
||||
('project_progress', 'Project Progress', 'Visual progress of projects', '🎯', 2, 1, '{}', 'Team Member', 'Projects'),
|
||||
('project_activity', 'Recent Activity', 'Recent project activities', '🔄', 2, 1, '{}', 'Team Member', 'Projects'),
|
||||
('project_deadlines', 'Upcoming Deadlines', 'Projects with approaching deadlines', '⚠️', 2, 1, '{}', 'Team Member', 'Projects'),
|
||||
|
||||
# Task Management Widgets
|
||||
('assigned_tasks', 'My Tasks', 'Tasks assigned to me', '✅', 2, 2, '{}', 'Team Member', 'Tasks'),
|
||||
('task_priority', 'Priority Matrix', 'Tasks organized by priority', '🔥', 2, 2, '{}', 'Team Member', 'Tasks'),
|
||||
('task_trends', 'Task Trends', 'Task completion trends', '📉', 2, 1, '{}', 'Team Member', 'Tasks'),
|
||||
|
||||
# Analytics Widgets
|
||||
('productivity_metrics', 'Productivity', 'Personal productivity metrics', '⚡', 1, 1, '{}', 'Team Member', 'Analytics'),
|
||||
('time_distribution', 'Time Distribution', 'How time is distributed', '🥧', 2, 2, '{}', 'Team Member', 'Analytics'),
|
||||
('goal_progress', 'Goals', 'Progress towards goals', '🎯', 1, 1, '{}', 'Team Member', 'Analytics'),
|
||||
('performance_comparison', 'Performance', 'Performance comparison over time', '📊', 2, 1, '{}', 'Team Member', 'Analytics'),
|
||||
|
||||
# Team Widgets (Role-based)
|
||||
('team_overview', 'Team Overview', 'Overview of team performance', '👥', 3, 2, '{}', 'Team Leader', 'Team'),
|
||||
('resource_allocation', 'Resources', 'Team resource allocation', '📊', 2, 2, '{}', 'Administrator', 'Team'),
|
||||
('team_performance', 'Team Performance', 'Team performance metrics', '📈', 3, 1, '{}', 'Supervisor', 'Team'),
|
||||
('company_metrics', 'Company Metrics', 'Company-wide metrics', '🏢', 3, 2, '{}', 'System Administrator', 'Team'),
|
||||
|
||||
# Quick Action Widgets
|
||||
('quick_timer', 'Quick Timer', 'Quick time tracking controls', '▶️', 1, 1, '{}', 'Team Member', 'Actions'),
|
||||
('favorite_projects', 'Favorites', 'Quick access to favorite projects', '⭐', 1, 2, '{}', 'Team Member', 'Actions'),
|
||||
('recent_actions', 'Recent Actions', 'Recently performed actions', '🕒', 2, 1, '{}', 'Team Member', 'Actions'),
|
||||
('shortcuts_panel', 'Shortcuts', 'Quick action shortcuts', '🚀', 1, 1, '{}', 'Team Member', 'Actions'),
|
||||
]
|
||||
|
||||
cursor.executemany("""
|
||||
INSERT INTO widget_template
|
||||
(widget_type, name, description, icon, default_width, default_height, default_config, required_role, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", default_templates)
|
||||
|
||||
conn.commit()
|
||||
print("Dashboard system migration completed successfully!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during Dashboard system migration: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def migrate_comment_system(db_file=None):
|
||||
"""Migrate to add Comment system for tasks."""
|
||||
db_path = get_db_path(db_file)
|
||||
|
||||
print(f"Migrating Comment system in {db_path}...")
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database file {db_path} does not exist. Run basic migration first.")
|
||||
return False
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check if comment table already exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='comment'")
|
||||
if cursor.fetchone():
|
||||
print("Comment table already exists. Skipping migration.")
|
||||
return True
|
||||
|
||||
print("Creating Comment system table...")
|
||||
|
||||
# Create comment table
|
||||
cursor.execute("""
|
||||
CREATE TABLE comment (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL,
|
||||
task_id INTEGER NOT NULL,
|
||||
parent_comment_id INTEGER,
|
||||
visibility VARCHAR(20) DEFAULT 'Company',
|
||||
is_edited BOOLEAN DEFAULT 0,
|
||||
edited_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_comment_id) REFERENCES comment (id),
|
||||
FOREIGN KEY (created_by_id) REFERENCES user (id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for better performance
|
||||
cursor.execute("CREATE INDEX idx_comment_task ON comment(task_id)")
|
||||
cursor.execute("CREATE INDEX idx_comment_parent ON comment(parent_comment_id)")
|
||||
cursor.execute("CREATE INDEX idx_comment_created_by ON comment(created_by_id)")
|
||||
cursor.execute("CREATE INDEX idx_comment_created_at ON comment(created_at DESC)")
|
||||
|
||||
conn.commit()
|
||||
print("Comment system migration completed successfully!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during Comment system migration: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function with command line interface."""
|
||||
parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool')
|
||||
@@ -898,6 +1498,10 @@ def main():
|
||||
help='Run only basic table migrations')
|
||||
parser.add_argument('--system-events', '-s', action='store_true',
|
||||
help='Run only system events migration')
|
||||
parser.add_argument('--dashboard', '--dash', action='store_true',
|
||||
help='Run only dashboard system migration')
|
||||
parser.add_argument('--postgresql', '--pg', action='store_true',
|
||||
help='Run only PostgreSQL schema migration')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -930,6 +1534,13 @@ def main():
|
||||
elif args.system_events:
|
||||
migrate_system_events(db_path)
|
||||
|
||||
|
||||
elif args.dashboard:
|
||||
migrate_dashboard_system(db_path)
|
||||
|
||||
elif args.postgresql:
|
||||
migrate_postgresql_schema()
|
||||
|
||||
else:
|
||||
# Default: run all migrations
|
||||
run_all_migrations(db_path)
|
||||
|
||||
526
models.py
526
models.py
@@ -129,7 +129,7 @@ class Project(db.Model):
|
||||
class User(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), nullable=False)
|
||||
email = db.Column(db.String(120), nullable=False)
|
||||
email = db.Column(db.String(120), nullable=True)
|
||||
password_hash = db.Column(db.String(128))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
@@ -162,6 +162,9 @@ class User(db.Model):
|
||||
two_factor_enabled = db.Column(db.Boolean, default=False)
|
||||
two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret
|
||||
|
||||
# Avatar field
|
||||
avatar_url = db.Column(db.String(255), nullable=True) # URL to user's avatar image
|
||||
|
||||
# Relationships
|
||||
time_entries = db.relationship('TimeEntry', backref='user', lazy=True)
|
||||
work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False)
|
||||
@@ -193,15 +196,17 @@ class User(db.Model):
|
||||
self.two_factor_secret = pyotp.random_base32()
|
||||
return self.two_factor_secret
|
||||
|
||||
def get_2fa_uri(self):
|
||||
def get_2fa_uri(self, issuer_name=None):
|
||||
"""Get the provisioning URI for QR code generation"""
|
||||
if not self.two_factor_secret:
|
||||
return None
|
||||
import pyotp
|
||||
totp = pyotp.TOTP(self.two_factor_secret)
|
||||
if issuer_name is None:
|
||||
issuer_name = "Time Tracker" # Default fallback
|
||||
return totp.provisioning_uri(
|
||||
name=self.email,
|
||||
issuer_name="TimeTrack"
|
||||
issuer_name=issuer_name
|
||||
)
|
||||
|
||||
def verify_2fa_token(self, token, allow_setup=False):
|
||||
@@ -215,6 +220,42 @@ class User(db.Model):
|
||||
totp = pyotp.TOTP(self.two_factor_secret)
|
||||
return totp.verify(token, valid_window=1) # Allow 1 window tolerance
|
||||
|
||||
def get_avatar_url(self, size=40):
|
||||
"""Get user's avatar URL or generate a default one"""
|
||||
if self.avatar_url:
|
||||
return self.avatar_url
|
||||
|
||||
# Generate a default avatar using DiceBear Avatars (similar to GitHub's identicons)
|
||||
# Using initials style for a clean, professional look
|
||||
import hashlib
|
||||
|
||||
# Create a hash from username for consistent colors
|
||||
hash_input = f"{self.username}_{self.id}".encode('utf-8')
|
||||
hash_hex = hashlib.md5(hash_input).hexdigest()
|
||||
|
||||
# Use DiceBear API for avatar generation
|
||||
# For initials style, we need to provide the actual initials
|
||||
initials = self.get_initials()
|
||||
|
||||
# Generate avatar URL with initials
|
||||
# Using a color based on the hash for consistency
|
||||
bg_colors = ['0ea5e9', '8b5cf6', 'ec4899', 'f59e0b', '10b981', 'ef4444', '3b82f6', '6366f1']
|
||||
color_index = int(hash_hex[:2], 16) % len(bg_colors)
|
||||
bg_color = bg_colors[color_index]
|
||||
|
||||
avatar_url = f"https://api.dicebear.com/7.x/initials/svg?seed={initials}&size={size}&backgroundColor={bg_color}&fontSize=50"
|
||||
|
||||
return avatar_url
|
||||
|
||||
def get_initials(self):
|
||||
"""Get user initials for avatar display"""
|
||||
parts = self.username.split()
|
||||
if len(parts) >= 2:
|
||||
return f"{parts[0][0]}{parts[-1][0]}".upper()
|
||||
elif self.username:
|
||||
return self.username[:2].upper()
|
||||
return "??"
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
@@ -228,6 +269,43 @@ class SystemSettings(db.Model):
|
||||
def __repr__(self):
|
||||
return f'<SystemSettings {self.key}={self.value}>'
|
||||
|
||||
class BrandingSettings(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
app_name = db.Column(db.String(100), nullable=False, default='Time Tracker')
|
||||
logo_filename = db.Column(db.String(255), nullable=True) # Filename of uploaded logo
|
||||
logo_alt_text = db.Column(db.String(255), nullable=True, default='Logo')
|
||||
favicon_filename = db.Column(db.String(255), nullable=True) # Filename of uploaded favicon
|
||||
primary_color = db.Column(db.String(7), nullable=True, default='#007bff') # Hex color
|
||||
|
||||
# Imprint/Legal page settings
|
||||
imprint_enabled = db.Column(db.Boolean, default=False) # Enable/disable imprint page
|
||||
imprint_title = db.Column(db.String(200), nullable=True, default='Imprint') # Page title
|
||||
imprint_content = db.Column(db.Text, nullable=True) # HTML content for imprint page
|
||||
|
||||
# Meta fields
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
updated_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
updated_by = db.relationship('User', foreign_keys=[updated_by_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<BrandingSettings {self.app_name}>'
|
||||
|
||||
@staticmethod
|
||||
def get_current():
|
||||
"""Get current branding settings or create defaults"""
|
||||
settings = BrandingSettings.query.first()
|
||||
if not settings:
|
||||
settings = BrandingSettings(
|
||||
app_name='Time Tracker',
|
||||
logo_alt_text='Application Logo'
|
||||
)
|
||||
db.session.add(settings)
|
||||
db.session.commit()
|
||||
return settings
|
||||
|
||||
class TimeEntry(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
arrival_time = db.Column(db.DateTime, nullable=False)
|
||||
@@ -362,6 +440,42 @@ class CompanyWorkConfig(db.Model):
|
||||
}
|
||||
return presets.get(region, presets[WorkRegion.GERMANY])
|
||||
|
||||
# Comment visibility enumeration
|
||||
class CommentVisibility(enum.Enum):
|
||||
TEAM = "Team" # Only visible to team members
|
||||
COMPANY = "Company" # Visible to all company members
|
||||
|
||||
# Company Settings (General company preferences)
|
||||
class CompanySettings(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
|
||||
# Comment settings
|
||||
default_comment_visibility = db.Column(db.Enum(CommentVisibility), default=CommentVisibility.COMPANY)
|
||||
allow_team_visibility_comments = db.Column(db.Boolean, default=True) # Allow users to set comments as team-only
|
||||
|
||||
# Task settings
|
||||
require_task_assignment = db.Column(db.Boolean, default=False) # Tasks must be assigned before work can begin
|
||||
allow_task_creation_by_members = db.Column(db.Boolean, default=True) # Team members can create tasks
|
||||
|
||||
# Project settings
|
||||
restrict_project_access_by_team = db.Column(db.Boolean, default=False) # Only team members can access team projects
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
company = db.relationship('Company', backref=db.backref('settings', uselist=False))
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||
|
||||
# Unique constraint - one settings per company
|
||||
__table_args__ = (db.UniqueConstraint('company_id', name='uq_company_settings'),)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CompanySettings {self.company.name}>'
|
||||
|
||||
# User Preferences (User-configurable display settings)
|
||||
class UserPreferences(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -421,6 +535,7 @@ class TaskStatus(enum.Enum):
|
||||
IN_PROGRESS = "In Progress"
|
||||
ON_HOLD = "On Hold"
|
||||
COMPLETED = "Completed"
|
||||
ARCHIVED = "Archived"
|
||||
CANCELLED = "Cancelled"
|
||||
|
||||
# Task priority enumeration
|
||||
@@ -433,6 +548,7 @@ class TaskPriority(enum.Enum):
|
||||
# Task model for project breakdown
|
||||
class Task(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
task_number = db.Column(db.String(20), nullable=False, unique=True) # e.g., "TSK-001", "TSK-002"
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
@@ -444,6 +560,9 @@ class Task(db.Model):
|
||||
# Project association
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
||||
|
||||
# Sprint association (optional)
|
||||
sprint_id = db.Column(db.Integer, db.ForeignKey('sprint.id'), nullable=True)
|
||||
|
||||
# Task assignment
|
||||
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
|
||||
@@ -451,6 +570,7 @@ class Task(db.Model):
|
||||
start_date = db.Column(db.Date, nullable=True)
|
||||
due_date = db.Column(db.Date, nullable=True)
|
||||
completed_date = db.Column(db.Date, nullable=True)
|
||||
archived_date = db.Column(db.Date, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
@@ -485,6 +605,68 @@ class Task(db.Model):
|
||||
"""Check if a user can access this task"""
|
||||
return self.project.is_user_allowed(user)
|
||||
|
||||
@classmethod
|
||||
def generate_task_number(cls, company_id):
|
||||
"""Generate next task number for the company"""
|
||||
# Get the highest task number for this company
|
||||
last_task = cls.query.join(Project).filter(
|
||||
Project.company_id == company_id,
|
||||
cls.task_number.like('TSK-%')
|
||||
).order_by(cls.task_number.desc()).first()
|
||||
|
||||
if last_task and last_task.task_number:
|
||||
try:
|
||||
# Extract number from TSK-XXX format
|
||||
last_num = int(last_task.task_number.split('-')[1])
|
||||
return f"TSK-{last_num + 1:03d}"
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
return "TSK-001"
|
||||
|
||||
@property
|
||||
def blocked_by_tasks(self):
|
||||
"""Get tasks that are blocking this task"""
|
||||
return [dep.blocking_task for dep in self.blocked_by_dependencies]
|
||||
|
||||
@property
|
||||
def blocking_tasks(self):
|
||||
"""Get tasks that this task is blocking"""
|
||||
return [dep.blocked_task for dep in self.blocking_dependencies]
|
||||
|
||||
# Task Dependencies model for tracking blocking relationships
|
||||
class TaskDependency(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# The task that is blocked (cannot start until blocking task is done)
|
||||
blocked_task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||
|
||||
# The task that is blocking (must be completed before blocked task can start)
|
||||
blocking_task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||
|
||||
# Dependency type (for future extension)
|
||||
dependency_type = db.Column(db.String(50), default='blocks', nullable=False) # 'blocks', 'subtask', etc.
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
blocked_task = db.relationship('Task', foreign_keys=[blocked_task_id],
|
||||
backref=db.backref('blocked_by_dependencies', cascade='all, delete-orphan'))
|
||||
blocking_task = db.relationship('Task', foreign_keys=[blocking_task_id],
|
||||
backref=db.backref('blocking_dependencies', cascade='all, delete-orphan'))
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||
|
||||
# Ensure a task doesn't block itself and prevent duplicate dependencies
|
||||
__table_args__ = (
|
||||
db.CheckConstraint('blocked_task_id != blocking_task_id', name='no_self_blocking'),
|
||||
db.UniqueConstraint('blocked_task_id', 'blocking_task_id', name='unique_dependency'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<TaskDependency {self.blocking_task_id} blocks {self.blocked_task_id}>'
|
||||
|
||||
# SubTask model for task breakdown
|
||||
class SubTask(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -529,6 +711,72 @@ class SubTask(db.Model):
|
||||
"""Check if a user can access this subtask"""
|
||||
return self.parent_task.can_user_access(user)
|
||||
|
||||
# Comment model for task discussions
|
||||
class Comment(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
|
||||
# Task association
|
||||
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||
|
||||
# Parent comment for thread support
|
||||
parent_comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=True)
|
||||
|
||||
# Visibility setting
|
||||
visibility = db.Column(db.Enum(CommentVisibility), default=CommentVisibility.COMPANY)
|
||||
|
||||
# Edit tracking
|
||||
is_edited = db.Column(db.Boolean, default=False)
|
||||
edited_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
task = db.relationship('Task', backref=db.backref('comments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='comments')
|
||||
replies = db.relationship('Comment', backref=db.backref('parent_comment', remote_side=[id]))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Comment {self.id} on Task {self.task_id}>'
|
||||
|
||||
def can_user_view(self, user):
|
||||
"""Check if a user can view this comment based on visibility settings"""
|
||||
# First check if user can access the task
|
||||
if not self.task.can_user_access(user):
|
||||
return False
|
||||
|
||||
# Then check visibility settings
|
||||
if self.visibility == CommentVisibility.TEAM:
|
||||
# Check if user is in the same team as the task's project
|
||||
if self.task.project.team_id:
|
||||
return user.team_id == self.task.project.team_id
|
||||
# If no team assigned to project, fall back to company visibility
|
||||
return user.company_id == self.task.project.company_id
|
||||
elif self.visibility == CommentVisibility.COMPANY:
|
||||
# Check if user is in the same company
|
||||
return user.company_id == self.task.project.company_id
|
||||
|
||||
return False
|
||||
|
||||
def can_user_edit(self, user):
|
||||
"""Check if a user can edit this comment"""
|
||||
# Only the comment creator can edit their own comments
|
||||
return user.id == self.created_by_id
|
||||
|
||||
def can_user_delete(self, user):
|
||||
"""Check if a user can delete this comment"""
|
||||
# Comment creator can delete their own comments
|
||||
if user.id == self.created_by_id:
|
||||
return True
|
||||
|
||||
# Admins and supervisors can delete any comment in their company
|
||||
if user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
||||
return user.company_id == self.task.project.company_id
|
||||
|
||||
return False
|
||||
|
||||
# Announcement model for system-wide announcements
|
||||
class Announcement(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -722,3 +970,275 @@ class SystemEvent(db.Model):
|
||||
'last_error': last_error,
|
||||
'health_status': 'healthy' if recent_errors == 0 else 'issues' if recent_errors < 5 else 'critical'
|
||||
}
|
||||
|
||||
|
||||
# Sprint Management System
|
||||
class SprintStatus(enum.Enum):
|
||||
PLANNING = "Planning"
|
||||
ACTIVE = "Active"
|
||||
COMPLETED = "Completed"
|
||||
CANCELLED = "Cancelled"
|
||||
|
||||
class Sprint(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Sprint status
|
||||
status = db.Column(db.Enum(SprintStatus), nullable=False, default=SprintStatus.PLANNING)
|
||||
|
||||
# Company association - sprints are company-scoped
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
|
||||
# Optional project association - can be project-specific or company-wide
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
|
||||
|
||||
# Sprint timeline
|
||||
start_date = db.Column(db.Date, nullable=False)
|
||||
end_date = db.Column(db.Date, nullable=False)
|
||||
|
||||
# Sprint goals and metrics
|
||||
goal = db.Column(db.Text, nullable=True) # Sprint goal description
|
||||
capacity_hours = db.Column(db.Integer, nullable=True) # Planned capacity in hours
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
company = db.relationship('Company', backref='sprints')
|
||||
project = db.relationship('Project', backref='sprints')
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||
tasks = db.relationship('Task', backref='sprint', lazy=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Sprint {self.name}>'
|
||||
|
||||
@property
|
||||
def is_current(self):
|
||||
"""Check if this sprint is currently active"""
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
return (self.status == SprintStatus.ACTIVE and
|
||||
self.start_date <= today <= self.end_date)
|
||||
|
||||
@property
|
||||
def duration_days(self):
|
||||
"""Get sprint duration in days"""
|
||||
return (self.end_date - self.start_date).days + 1
|
||||
|
||||
@property
|
||||
def days_remaining(self):
|
||||
"""Get remaining days in sprint"""
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
if self.end_date < today:
|
||||
return 0
|
||||
elif self.start_date > today:
|
||||
return self.duration_days
|
||||
else:
|
||||
return (self.end_date - today).days + 1
|
||||
|
||||
@property
|
||||
def progress_percentage(self):
|
||||
"""Calculate sprint progress percentage based on dates"""
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
|
||||
if today < self.start_date:
|
||||
return 0
|
||||
elif today > self.end_date:
|
||||
return 100
|
||||
else:
|
||||
total_days = self.duration_days
|
||||
elapsed_days = (today - self.start_date).days + 1
|
||||
return min(100, int((elapsed_days / total_days) * 100))
|
||||
|
||||
def get_task_summary(self):
|
||||
"""Get summary of tasks in this sprint"""
|
||||
total_tasks = len(self.tasks)
|
||||
completed_tasks = len([t for t in self.tasks if t.status == TaskStatus.COMPLETED])
|
||||
in_progress_tasks = len([t for t in self.tasks if t.status == TaskStatus.IN_PROGRESS])
|
||||
|
||||
return {
|
||||
'total': total_tasks,
|
||||
'completed': completed_tasks,
|
||||
'in_progress': in_progress_tasks,
|
||||
'not_started': total_tasks - completed_tasks - in_progress_tasks,
|
||||
'completion_percentage': int((completed_tasks / total_tasks) * 100) if total_tasks > 0 else 0
|
||||
}
|
||||
|
||||
def can_user_access(self, user):
|
||||
"""Check if user can access this sprint"""
|
||||
# Must be in same company
|
||||
if self.company_id != user.company_id:
|
||||
return False
|
||||
|
||||
# If sprint is project-specific, check project access
|
||||
if self.project_id:
|
||||
return self.project.is_user_allowed(user)
|
||||
|
||||
# Company-wide sprints are accessible to all company members
|
||||
return True
|
||||
|
||||
# Dashboard Widget System
|
||||
class WidgetType(enum.Enum):
|
||||
# Time Tracking Widgets
|
||||
CURRENT_TIMER = "current_timer"
|
||||
DAILY_SUMMARY = "daily_summary"
|
||||
WEEKLY_CHART = "weekly_chart"
|
||||
BREAK_REMINDER = "break_reminder"
|
||||
|
||||
# Project Management Widgets
|
||||
ACTIVE_PROJECTS = "active_projects"
|
||||
PROJECT_PROGRESS = "project_progress"
|
||||
PROJECT_ACTIVITY = "project_activity"
|
||||
PROJECT_DEADLINES = "project_deadlines"
|
||||
|
||||
# Task Management Widgets
|
||||
ASSIGNED_TASKS = "assigned_tasks"
|
||||
TASK_PRIORITY = "task_priority"
|
||||
TASK_TRENDS = "task_trends"
|
||||
|
||||
# Analytics Widgets
|
||||
PRODUCTIVITY_METRICS = "productivity_metrics"
|
||||
TIME_DISTRIBUTION = "time_distribution"
|
||||
GOAL_PROGRESS = "goal_progress"
|
||||
PERFORMANCE_COMPARISON = "performance_comparison"
|
||||
|
||||
# Team Widgets (Role-based)
|
||||
TEAM_OVERVIEW = "team_overview"
|
||||
RESOURCE_ALLOCATION = "resource_allocation"
|
||||
TEAM_PERFORMANCE = "team_performance"
|
||||
COMPANY_METRICS = "company_metrics"
|
||||
|
||||
# Quick Action Widgets
|
||||
QUICK_TIMER = "quick_timer"
|
||||
FAVORITE_PROJECTS = "favorite_projects"
|
||||
RECENT_ACTIONS = "recent_actions"
|
||||
SHORTCUTS_PANEL = "shortcuts_panel"
|
||||
|
||||
class WidgetSize(enum.Enum):
|
||||
SMALL = "1x1" # 1 grid unit
|
||||
MEDIUM = "2x1" # 2 grid units wide, 1 high
|
||||
LARGE = "2x2" # 2x2 grid units
|
||||
WIDE = "3x1" # 3 grid units wide, 1 high
|
||||
TALL = "1x2" # 1 grid unit wide, 2 high
|
||||
EXTRA_LARGE = "3x2" # 3x2 grid units
|
||||
|
||||
class UserDashboard(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
name = db.Column(db.String(100), default='My Dashboard')
|
||||
is_default = db.Column(db.Boolean, default=True)
|
||||
layout_config = db.Column(db.Text) # JSON string for grid layout configuration
|
||||
|
||||
# Dashboard settings
|
||||
grid_columns = db.Column(db.Integer, default=6) # Number of grid columns
|
||||
theme = db.Column(db.String(20), default='light') # light, dark, auto
|
||||
auto_refresh = db.Column(db.Integer, default=300) # Auto-refresh interval in seconds
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user = db.relationship('User', backref='dashboards')
|
||||
widgets = db.relationship('DashboardWidget', backref='dashboard', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
# Unique constraint - one default dashboard per user
|
||||
__table_args__ = (db.Index('idx_user_default_dashboard', 'user_id', 'is_default'),)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<UserDashboard {self.name} (User: {self.user.username})>'
|
||||
|
||||
class DashboardWidget(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
dashboard_id = db.Column(db.Integer, db.ForeignKey('user_dashboard.id'), nullable=False)
|
||||
widget_type = db.Column(db.Enum(WidgetType), nullable=False)
|
||||
|
||||
# Grid position and size
|
||||
grid_x = db.Column(db.Integer, nullable=False, default=0) # X position in grid
|
||||
grid_y = db.Column(db.Integer, nullable=False, default=0) # Y position in grid
|
||||
grid_width = db.Column(db.Integer, nullable=False, default=1) # Width in grid units
|
||||
grid_height = db.Column(db.Integer, nullable=False, default=1) # Height in grid units
|
||||
|
||||
# Widget configuration
|
||||
title = db.Column(db.String(100)) # Custom widget title
|
||||
config = db.Column(db.Text) # JSON string for widget-specific configuration
|
||||
refresh_interval = db.Column(db.Integer, default=60) # Refresh interval in seconds
|
||||
|
||||
# Widget state
|
||||
is_visible = db.Column(db.Boolean, default=True)
|
||||
is_minimized = db.Column(db.Boolean, default=False)
|
||||
z_index = db.Column(db.Integer, default=1) # Stacking order
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<DashboardWidget {self.widget_type.value} ({self.grid_width}x{self.grid_height})>'
|
||||
|
||||
@property
|
||||
def config_dict(self):
|
||||
"""Parse widget configuration JSON"""
|
||||
if self.config:
|
||||
import json
|
||||
try:
|
||||
return json.loads(self.config)
|
||||
except:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
@config_dict.setter
|
||||
def config_dict(self, value):
|
||||
"""Set widget configuration as JSON"""
|
||||
import json
|
||||
self.config = json.dumps(value) if value else None
|
||||
|
||||
class WidgetTemplate(db.Model):
|
||||
"""Pre-defined widget templates for easy dashboard setup"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
widget_type = db.Column(db.Enum(WidgetType), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50)) # Icon name or emoji
|
||||
|
||||
# Default configuration
|
||||
default_width = db.Column(db.Integer, default=1)
|
||||
default_height = db.Column(db.Integer, default=1)
|
||||
default_config = db.Column(db.Text) # JSON string for default widget configuration
|
||||
|
||||
# Access control
|
||||
required_role = db.Column(db.Enum(Role), default=Role.TEAM_MEMBER)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
|
||||
# Categories for organization
|
||||
category = db.Column(db.String(50), default='General') # Time, Projects, Tasks, Analytics, Team, Actions
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<WidgetTemplate {self.name} ({self.widget_type.value})>'
|
||||
|
||||
def can_user_access(self, user):
|
||||
"""Check if user has required role to use this widget"""
|
||||
if not self.is_active:
|
||||
return False
|
||||
|
||||
# Define role hierarchy
|
||||
role_hierarchy = {
|
||||
Role.TEAM_MEMBER: 1,
|
||||
Role.TEAM_LEADER: 2,
|
||||
Role.SUPERVISOR: 3,
|
||||
Role.ADMIN: 4,
|
||||
Role.SYSTEM_ADMIN: 5
|
||||
}
|
||||
|
||||
user_level = role_hierarchy.get(user.role, 0)
|
||||
required_level = role_hierarchy.get(self.required_role, 0)
|
||||
|
||||
return user_level >= required_level
|
||||
89
password_utils.py
Normal file
89
password_utils.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Password validation utilities for TimeTrack"""
|
||||
import re
|
||||
|
||||
class PasswordValidator:
|
||||
"""Password strength validator with configurable rules"""
|
||||
|
||||
def __init__(self):
|
||||
self.min_length = 8
|
||||
self.require_uppercase = True
|
||||
self.require_lowercase = True
|
||||
self.require_numbers = True
|
||||
self.require_special_chars = True
|
||||
self.special_chars = r'!@#$%^&*()_+\-=\[\]{}|;:,.<>?'
|
||||
|
||||
def validate(self, password):
|
||||
"""
|
||||
Validate a password against the configured rules.
|
||||
Returns a tuple (is_valid, list_of_errors)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check minimum length
|
||||
if len(password) < self.min_length:
|
||||
errors.append(f'Password must be at least {self.min_length} characters long')
|
||||
|
||||
# Check for uppercase letter
|
||||
if self.require_uppercase and not re.search(r'[A-Z]', password):
|
||||
errors.append('Password must contain at least one uppercase letter')
|
||||
|
||||
# Check for lowercase letter
|
||||
if self.require_lowercase and not re.search(r'[a-z]', password):
|
||||
errors.append('Password must contain at least one lowercase letter')
|
||||
|
||||
# Check for number
|
||||
if self.require_numbers and not re.search(r'\d', password):
|
||||
errors.append('Password must contain at least one number')
|
||||
|
||||
# Check for special character
|
||||
if self.require_special_chars and not re.search(f'[{re.escape(self.special_chars)}]', password):
|
||||
errors.append('Password must contain at least one special character')
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def get_strength_score(self, password):
|
||||
"""
|
||||
Calculate a strength score for the password (0-100).
|
||||
This matches the JavaScript implementation.
|
||||
"""
|
||||
score = 0
|
||||
|
||||
# Base scoring
|
||||
if len(password) >= self.min_length:
|
||||
score += 20
|
||||
|
||||
if re.search(r'[A-Z]', password):
|
||||
score += 20
|
||||
|
||||
if re.search(r'[a-z]', password):
|
||||
score += 20
|
||||
|
||||
if re.search(r'\d', password):
|
||||
score += 20
|
||||
|
||||
if re.search(f'[{re.escape(self.special_chars)}]', password):
|
||||
score += 20
|
||||
|
||||
# Bonus points for extra length
|
||||
if len(password) >= 12:
|
||||
score = min(100, score + 10)
|
||||
if len(password) >= 16:
|
||||
score = min(100, score + 10)
|
||||
|
||||
return score
|
||||
|
||||
def get_requirements_text(self):
|
||||
"""Get a user-friendly text describing password requirements"""
|
||||
requirements = []
|
||||
requirements.append(f'At least {self.min_length} characters')
|
||||
|
||||
if self.require_uppercase:
|
||||
requirements.append('One uppercase letter')
|
||||
if self.require_lowercase:
|
||||
requirements.append('One lowercase letter')
|
||||
if self.require_numbers:
|
||||
requirements.append('One number')
|
||||
if self.require_special_chars:
|
||||
requirements.append('One special character (!@#$%^&*()_+-=[]{}|;:,.<>?)')
|
||||
|
||||
return requirements
|
||||
581
static/css/auth.css
Normal file
581
static/css/auth.css
Normal file
@@ -0,0 +1,581 @@
|
||||
/* Modern Authentication Pages Styles */
|
||||
|
||||
/* Auth page background */
|
||||
body.auth-page {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto; /* Allow vertical scrolling */
|
||||
display: flex;
|
||||
align-items: flex-start; /* Align to top */
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Add min-height to html to ensure full coverage */
|
||||
html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Animated background shapes */
|
||||
body.auth-page::before,
|
||||
body.auth-page::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
animation: float 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
body.auth-page::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
top: -150px;
|
||||
right: -150px;
|
||||
}
|
||||
|
||||
body.auth-page::after {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
bottom: -250px;
|
||||
left: -250px;
|
||||
animation-delay: 10s;
|
||||
}
|
||||
|
||||
/* Auth container */
|
||||
.auth-container {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
animation: slideUp 0.6s ease-out;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 2rem auto 4rem; /* Top and bottom margin */
|
||||
}
|
||||
|
||||
/* Logo/Brand section */
|
||||
.auth-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-brand h1 {
|
||||
color: #333;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-brand p {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Form styles */
|
||||
.auth-form {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 2px solid #e1e8ed;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
background-color: #f8f9fa;
|
||||
line-height: 1.5;
|
||||
height: 48px; /* Fixed height for consistent alignment */
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
/* Floating label effect */
|
||||
.form-group.floating-label {
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-group.floating-label label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 1rem;
|
||||
transform: translateY(-50%);
|
||||
transition: all 0.3s ease;
|
||||
pointer-events: none;
|
||||
background: white;
|
||||
padding: 0 0.5rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.form-group.floating-label .form-control:focus ~ label,
|
||||
.form-group.floating-label .form-control:not(:placeholder-shown) ~ label {
|
||||
top: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* Registration options */
|
||||
.registration-options {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.registration-options .alert {
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.registration-options h5 {
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.registration-options p {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.registration-options .btn-outline-primary {
|
||||
border: 2px solid #667eea;
|
||||
color: #667eea;
|
||||
border-radius: 25px;
|
||||
padding: 0.5rem 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.registration-options .btn-outline-primary:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
/* Submit button */
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Ripple effect for button */
|
||||
.btn-primary::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.btn-primary:active::after {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
/* Auth links */
|
||||
.auth-links {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.auth-links p {
|
||||
color: #666;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-links a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.auth-links a:hover {
|
||||
color: #764ba2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Verification notice */
|
||||
.verification-notice {
|
||||
background: #f0f4ff;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.verification-notice p {
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Alert messages */
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1.5rem;
|
||||
border: none;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #efe;
|
||||
color: #3c3;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #eef;
|
||||
color: #33c;
|
||||
}
|
||||
|
||||
/* Password strength indicator integration */
|
||||
.password-strength-container {
|
||||
margin-top: 0.75rem;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.password-strength-indicator {
|
||||
height: 6px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.password-strength-bar {
|
||||
height: 100%;
|
||||
transition: all 0.4s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Icon inputs */
|
||||
.input-icon {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-icon i {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
color: #999;
|
||||
font-size: 1.2rem;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input-icon .form-control {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
.input-icon label {
|
||||
position: absolute;
|
||||
left: 3rem; /* Start after the icon */
|
||||
top: 0.875rem;
|
||||
color: #999;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.input-icon .form-control:focus ~ label,
|
||||
.input-icon .form-control:not(:placeholder-shown) ~ label {
|
||||
top: -0.5rem;
|
||||
left: 2.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* Checkbox styling */
|
||||
.form-check {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-check-input {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
cursor: pointer;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Company code input special styling */
|
||||
.company-code-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.company-code-group::before {
|
||||
content: '🏢';
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 2.5rem; /* Position below the label */
|
||||
font-size: 1.5rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.company-code-group .form-control {
|
||||
padding-left: 3.5rem;
|
||||
font-family: inherit;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-30px) rotate(120deg);
|
||||
}
|
||||
66% {
|
||||
transform: translateY(30px) rotate(240deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 568px) {
|
||||
body.auth-page {
|
||||
padding: 1rem;
|
||||
align-items: flex-start; /* Align to top on mobile */
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
padding: 2rem 1.5rem;
|
||||
margin: 1rem auto;
|
||||
max-height: none; /* Remove height limit on mobile */
|
||||
}
|
||||
|
||||
.auth-brand h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
body.auth-page::before,
|
||||
body.auth-page::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.btn-primary.loading {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.btn-primary.loading::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-left: -10px;
|
||||
margin-top: -10px;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: spinner 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Social registration options */
|
||||
.social-registration {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.social-divider {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.social-divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #e1e8ed;
|
||||
}
|
||||
|
||||
.social-divider span {
|
||||
background: white;
|
||||
padding: 0 1rem;
|
||||
color: #999;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Progress indicator for multi-step forms */
|
||||
.registration-progress {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.progress-step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-step::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: -50%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #e1e8ed;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.progress-step:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.progress-step.active::after {
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
.progress-step-number {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: #e1e8ed;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.progress-step.active .progress-step-number {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-step.completed .progress-step-number {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-step-label {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Form helper text alignment */
|
||||
.form-text {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.input-icon .form-text {
|
||||
padding-left: 3rem; /* Align with input content */
|
||||
}
|
||||
|
||||
/* Fix textarea height */
|
||||
textarea.form-control {
|
||||
height: auto;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
/* Better icon vertical centering */
|
||||
.input-icon i {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px; /* Match input height */
|
||||
top: 0;
|
||||
}
|
||||
57
static/css/fonts.css
Normal file
57
static/css/fonts.css
Normal file
@@ -0,0 +1,57 @@
|
||||
/* Inter Font Self-Hosted */
|
||||
|
||||
/* Inter Light (300) */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('../fonts/inter/Inter-Light.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Inter Regular (400) */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('../fonts/inter/Inter-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Inter Medium (500) */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('../fonts/inter/Inter-Medium.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Inter SemiBold (600) */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('../fonts/inter/Inter-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Inter Bold (700) */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('../fonts/inter/Inter-Bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Optional: Inter Variable Font for modern browsers */
|
||||
@supports (font-variation-settings: normal) {
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('../fonts/inter/InterVariable.woff2') format('woff2-variations');
|
||||
}
|
||||
}
|
||||
509
static/css/splash.css
Normal file
509
static/css/splash.css
Normal file
@@ -0,0 +1,509 @@
|
||||
/* Splash Page Styles */
|
||||
|
||||
/* Reset for splash page */
|
||||
.splash-container {
|
||||
margin: -2rem -2rem 0 -2rem;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.splash-hero {
|
||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
||||
color: white;
|
||||
padding: 6rem 2rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 600px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
letter-spacing: -1px;
|
||||
animation: fadeInUp 1s ease-out;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
margin-bottom: 2.5rem;
|
||||
opacity: 0.9;
|
||||
animation: fadeInUp 1s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
.cta-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
animation: fadeInUp 1s ease-out 0.4s both;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 1rem 2.5rem;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #45a049;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: white;
|
||||
color: #2a5298;
|
||||
}
|
||||
|
||||
/* Floating Clock Animation */
|
||||
.hero-visual {
|
||||
position: absolute;
|
||||
right: 10%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.floating-clock {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.clock-face {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 8px solid white;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hour-hand, .minute-hand, .second-hand {
|
||||
position: absolute;
|
||||
background: white;
|
||||
transform-origin: bottom center;
|
||||
bottom: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.hour-hand {
|
||||
width: 6px;
|
||||
height: 80px;
|
||||
margin-left: -3px;
|
||||
animation: rotate 43200s linear infinite;
|
||||
}
|
||||
|
||||
.minute-hand {
|
||||
width: 4px;
|
||||
height: 100px;
|
||||
margin-left: -2px;
|
||||
animation: rotate 3600s linear infinite;
|
||||
}
|
||||
|
||||
.second-hand {
|
||||
width: 2px;
|
||||
height: 110px;
|
||||
margin-left: -1px;
|
||||
background: #4CAF50;
|
||||
animation: rotate 60s linear infinite;
|
||||
}
|
||||
|
||||
/* Features Grid */
|
||||
.features-grid {
|
||||
padding: 5rem 2rem;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 3rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: white;
|
||||
padding: 2.5rem;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 5px 20px rgba(0,0,0,0.08);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Statistics Section */
|
||||
.statistics {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 5rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
gap: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Add subtle overlay for better text contrast */
|
||||
.statistics::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.statistics .section-title {
|
||||
color: white;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
color: white;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
color: white;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
font-weight: 500;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Testimonials */
|
||||
.testimonials {
|
||||
padding: 5rem 2rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.testimonial-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.testimonial-card {
|
||||
background: #f8f9fa;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stars {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.testimonial-card p {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
margin-bottom: 1.5rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.testimonial-author {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.testimonial-author strong {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.testimonial-author span {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Pricing Section */
|
||||
.pricing {
|
||||
padding: 5rem 2rem;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.pricing-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.pricing-card {
|
||||
background: white;
|
||||
padding: 2.5rem;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
box-shadow: 0 5px 20px rgba(0,0,0,0.08);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.pricing-card.featured {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pricing-card h3 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: #2a5298;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.price span {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pricing-features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
|
||||
.pricing-features li {
|
||||
padding: 0.75rem 0;
|
||||
color: #555;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.pricing-features li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.btn-pricing {
|
||||
display: inline-block;
|
||||
padding: 1rem 2rem;
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-pricing:hover {
|
||||
background: #45a049;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.pricing-card.featured .btn-pricing {
|
||||
background: #2a5298;
|
||||
}
|
||||
|
||||
.pricing-card.featured .btn-pricing:hover {
|
||||
background: #1e3c72;
|
||||
}
|
||||
|
||||
/* Final CTA */
|
||||
.final-cta {
|
||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
||||
color: white;
|
||||
padding: 5rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.final-cta h2 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.final-cta p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary.large {
|
||||
font-size: 1.2rem;
|
||||
padding: 1.25rem 3rem;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-50%) translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.cta-buttons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.hero-visual {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.pricing-card.featured {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ripple Effect */
|
||||
.btn-primary, .btn-secondary, .btn-pricing {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ripple {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: scale(0);
|
||||
animation: ripple-animation 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes ripple-animation {
|
||||
to {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
1345
static/css/style.css
1345
static/css/style.css
File diff suppressed because it is too large
Load Diff
BIN
static/fonts/inter/Inter-Bold.woff2
Normal file
BIN
static/fonts/inter/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
static/fonts/inter/Inter-Light.woff2
Normal file
BIN
static/fonts/inter/Inter-Light.woff2
Normal file
Binary file not shown.
BIN
static/fonts/inter/Inter-Medium.woff2
Normal file
BIN
static/fonts/inter/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
static/fonts/inter/Inter-Regular.woff2
Normal file
BIN
static/fonts/inter/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
static/fonts/inter/Inter-SemiBold.woff2
Normal file
BIN
static/fonts/inter/Inter-SemiBold.woff2
Normal file
Binary file not shown.
BIN
static/fonts/inter/InterVariable.woff2
Normal file
BIN
static/fonts/inter/InterVariable.woff2
Normal file
Binary file not shown.
224
static/js/auth-animations.js
Normal file
224
static/js/auth-animations.js
Normal file
@@ -0,0 +1,224 @@
|
||||
// Authentication Page Animations and Interactions
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add loading state to submit button
|
||||
const form = document.querySelector('.auth-form');
|
||||
const submitBtn = document.querySelector('.btn-primary');
|
||||
|
||||
if (form && submitBtn) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
// Check if form is valid
|
||||
if (form.checkValidity()) {
|
||||
submitBtn.classList.add('loading');
|
||||
submitBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Animate form fields on focus
|
||||
const formInputs = document.querySelectorAll('.form-control');
|
||||
formInputs.forEach(input => {
|
||||
input.addEventListener('focus', function() {
|
||||
this.parentElement.classList.add('focused');
|
||||
});
|
||||
|
||||
input.addEventListener('blur', function() {
|
||||
if (!this.value) {
|
||||
this.parentElement.classList.remove('focused');
|
||||
}
|
||||
});
|
||||
|
||||
// Check if input has value on load (for browser autofill)
|
||||
if (input.value) {
|
||||
input.parentElement.classList.add('focused');
|
||||
}
|
||||
});
|
||||
|
||||
// Company code formatting
|
||||
const companyCodeInput = document.querySelector('#company_code');
|
||||
if (companyCodeInput) {
|
||||
companyCodeInput.addEventListener('input', function(e) {
|
||||
// Convert to uppercase and remove non-alphanumeric characters
|
||||
let value = e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '');
|
||||
|
||||
// Add dashes every 4 characters
|
||||
if (value.length > 4 && !value.includes('-')) {
|
||||
value = value.match(/.{1,4}/g).join('-');
|
||||
}
|
||||
|
||||
e.target.value = value;
|
||||
});
|
||||
}
|
||||
|
||||
// Smooth scroll to alert messages
|
||||
const alerts = document.querySelectorAll('.alert');
|
||||
if (alerts.length > 0) {
|
||||
alerts[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// Add ripple effect to buttons
|
||||
const buttons = document.querySelectorAll('.btn-primary, .btn-outline-primary');
|
||||
buttons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
const ripple = document.createElement('span');
|
||||
const rect = this.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height);
|
||||
const x = e.clientX - rect.left - size / 2;
|
||||
const y = e.clientY - rect.top - size / 2;
|
||||
|
||||
ripple.style.width = ripple.style.height = size + 'px';
|
||||
ripple.style.left = x + 'px';
|
||||
ripple.style.top = y + 'px';
|
||||
ripple.classList.add('ripple');
|
||||
|
||||
// Remove any existing ripples
|
||||
const existingRipple = this.querySelector('.ripple');
|
||||
if (existingRipple) {
|
||||
existingRipple.remove();
|
||||
}
|
||||
|
||||
this.appendChild(ripple);
|
||||
|
||||
setTimeout(() => ripple.remove(), 600);
|
||||
});
|
||||
});
|
||||
|
||||
// Animate registration options
|
||||
const registrationOptions = document.querySelector('.registration-options');
|
||||
if (registrationOptions) {
|
||||
registrationOptions.style.opacity = '0';
|
||||
registrationOptions.style.transform = 'translateY(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
registrationOptions.style.transition = 'all 0.6s ease';
|
||||
registrationOptions.style.opacity = '1';
|
||||
registrationOptions.style.transform = 'translateY(0)';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Password visibility toggle
|
||||
const passwordInputs = document.querySelectorAll('input[type="password"]');
|
||||
passwordInputs.forEach(input => {
|
||||
const wrapper = input.parentElement;
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'password-toggle';
|
||||
toggleBtn.innerHTML = '👁️';
|
||||
toggleBtn.style.cssText = `
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s ease;
|
||||
`;
|
||||
|
||||
toggleBtn.addEventListener('click', function() {
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
this.innerHTML = '🙈';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
this.innerHTML = '👁️';
|
||||
}
|
||||
});
|
||||
|
||||
toggleBtn.addEventListener('mouseenter', function() {
|
||||
this.style.opacity = '1';
|
||||
});
|
||||
|
||||
toggleBtn.addEventListener('mouseleave', function() {
|
||||
this.style.opacity = '0.6';
|
||||
});
|
||||
|
||||
wrapper.style.position = 'relative';
|
||||
wrapper.appendChild(toggleBtn);
|
||||
});
|
||||
|
||||
// Form validation feedback
|
||||
const requiredInputs = document.querySelectorAll('.form-control[required]');
|
||||
requiredInputs.forEach(input => {
|
||||
input.addEventListener('blur', function() {
|
||||
if (this.value.trim() === '') {
|
||||
this.classList.add('invalid');
|
||||
this.classList.remove('valid');
|
||||
} else {
|
||||
this.classList.add('valid');
|
||||
this.classList.remove('invalid');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Email validation
|
||||
const emailInput = document.querySelector('input[type="email"]');
|
||||
if (emailInput) {
|
||||
emailInput.addEventListener('blur', function() {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (this.value && !emailRegex.test(this.value)) {
|
||||
this.classList.add('invalid');
|
||||
this.classList.remove('valid');
|
||||
|
||||
// Add error message if not exists
|
||||
if (!this.parentElement.querySelector('.error-message')) {
|
||||
const errorMsg = document.createElement('small');
|
||||
errorMsg.className = 'error-message';
|
||||
errorMsg.style.color = '#dc3545';
|
||||
errorMsg.textContent = 'Please enter a valid email address';
|
||||
this.parentElement.appendChild(errorMsg);
|
||||
}
|
||||
} else if (this.value) {
|
||||
// Remove error message if exists
|
||||
const errorMsg = this.parentElement.querySelector('.error-message');
|
||||
if (errorMsg) {
|
||||
errorMsg.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Animate auth container on load
|
||||
const authContainer = document.querySelector('.auth-container');
|
||||
if (authContainer) {
|
||||
authContainer.style.opacity = '0';
|
||||
authContainer.style.transform = 'scale(0.95)';
|
||||
|
||||
setTimeout(() => {
|
||||
authContainer.style.transition = 'all 0.5s ease';
|
||||
authContainer.style.opacity = '1';
|
||||
authContainer.style.transform = 'scale(1)';
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Add CSS for valid/invalid states
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.form-control.valid {
|
||||
border-color: #28a745 !important;
|
||||
}
|
||||
|
||||
.form-control.invalid {
|
||||
border-color: #dc3545 !important;
|
||||
}
|
||||
|
||||
.ripple {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
transform: scale(0);
|
||||
animation: ripple-animation 0.6s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes ripple-animation {
|
||||
to {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
221
static/js/password-strength.js
Normal file
221
static/js/password-strength.js
Normal file
@@ -0,0 +1,221 @@
|
||||
// Password Strength Indicator
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Password strength rules
|
||||
const passwordRules = {
|
||||
minLength: 8,
|
||||
requireUppercase: true,
|
||||
requireLowercase: true,
|
||||
requireNumbers: true,
|
||||
requireSpecialChars: true,
|
||||
specialChars: '!@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
};
|
||||
|
||||
// Function to check password strength
|
||||
function checkPasswordStrength(password) {
|
||||
let strength = 0;
|
||||
const feedback = [];
|
||||
|
||||
// Check minimum length
|
||||
if (password.length >= passwordRules.minLength) {
|
||||
strength += 20;
|
||||
} else {
|
||||
feedback.push(`At least ${passwordRules.minLength} characters`);
|
||||
}
|
||||
|
||||
// Check for uppercase letters
|
||||
if (passwordRules.requireUppercase && /[A-Z]/.test(password)) {
|
||||
strength += 20;
|
||||
} else if (passwordRules.requireUppercase) {
|
||||
feedback.push('One uppercase letter');
|
||||
}
|
||||
|
||||
// Check for lowercase letters
|
||||
if (passwordRules.requireLowercase && /[a-z]/.test(password)) {
|
||||
strength += 20;
|
||||
} else if (passwordRules.requireLowercase) {
|
||||
feedback.push('One lowercase letter');
|
||||
}
|
||||
|
||||
// Check for numbers
|
||||
if (passwordRules.requireNumbers && /\d/.test(password)) {
|
||||
strength += 20;
|
||||
} else if (passwordRules.requireNumbers) {
|
||||
feedback.push('One number');
|
||||
}
|
||||
|
||||
// Check for special characters
|
||||
const specialCharRegex = new RegExp(`[${passwordRules.specialChars.replace(/[\[\]\\]/g, '\\$&')}]`);
|
||||
if (passwordRules.requireSpecialChars && specialCharRegex.test(password)) {
|
||||
strength += 20;
|
||||
} else if (passwordRules.requireSpecialChars) {
|
||||
feedback.push('One special character');
|
||||
}
|
||||
|
||||
// Bonus points for length
|
||||
if (password.length >= 12) {
|
||||
strength = Math.min(100, strength + 10);
|
||||
}
|
||||
if (password.length >= 16) {
|
||||
strength = Math.min(100, strength + 10);
|
||||
}
|
||||
|
||||
return {
|
||||
score: strength,
|
||||
feedback: feedback,
|
||||
isValid: strength >= 100
|
||||
};
|
||||
}
|
||||
|
||||
// Function to update the strength indicator UI
|
||||
function updateStrengthIndicator(input, result) {
|
||||
let container = input.parentElement.querySelector('.password-strength-container');
|
||||
|
||||
// Create container if it doesn't exist
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'password-strength-container';
|
||||
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'password-strength-indicator';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'password-strength-bar';
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'password-strength-text';
|
||||
|
||||
const requirements = document.createElement('ul');
|
||||
requirements.className = 'password-requirements';
|
||||
|
||||
indicator.appendChild(bar);
|
||||
container.appendChild(indicator);
|
||||
container.appendChild(text);
|
||||
container.appendChild(requirements);
|
||||
|
||||
input.parentElement.appendChild(container);
|
||||
}
|
||||
|
||||
const bar = container.querySelector('.password-strength-bar');
|
||||
const text = container.querySelector('.password-strength-text');
|
||||
const requirements = container.querySelector('.password-requirements');
|
||||
|
||||
// Update bar width and color
|
||||
bar.style.width = result.score + '%';
|
||||
|
||||
// Remove all strength classes
|
||||
bar.className = 'password-strength-bar';
|
||||
|
||||
// Add appropriate class based on score
|
||||
if (result.score < 40) {
|
||||
bar.classList.add('strength-weak');
|
||||
text.textContent = 'Weak';
|
||||
text.className = 'password-strength-text text-weak';
|
||||
} else if (result.score < 70) {
|
||||
bar.classList.add('strength-fair');
|
||||
text.textContent = 'Fair';
|
||||
text.className = 'password-strength-text text-fair';
|
||||
} else if (result.score < 100) {
|
||||
bar.classList.add('strength-good');
|
||||
text.textContent = 'Good';
|
||||
text.className = 'password-strength-text text-good';
|
||||
} else {
|
||||
bar.classList.add('strength-strong');
|
||||
text.textContent = 'Strong';
|
||||
text.className = 'password-strength-text text-strong';
|
||||
}
|
||||
|
||||
// Update requirements list
|
||||
requirements.innerHTML = '';
|
||||
if (result.feedback.length > 0) {
|
||||
requirements.innerHTML = '<li>' + result.feedback.join('</li><li>') + '</li>';
|
||||
}
|
||||
}
|
||||
|
||||
// Function to check if passwords match
|
||||
function checkPasswordMatch(password, confirmPassword) {
|
||||
const confirmInput = document.querySelector(confirmPassword);
|
||||
if (!confirmInput) return;
|
||||
|
||||
let matchIndicator = confirmInput.parentElement.querySelector('.password-match-indicator');
|
||||
|
||||
if (!matchIndicator) {
|
||||
matchIndicator = document.createElement('div');
|
||||
matchIndicator.className = 'password-match-indicator';
|
||||
confirmInput.parentElement.appendChild(matchIndicator);
|
||||
}
|
||||
|
||||
if (confirmInput.value === '') {
|
||||
matchIndicator.textContent = '';
|
||||
matchIndicator.className = 'password-match-indicator';
|
||||
} else if (password === confirmInput.value) {
|
||||
matchIndicator.textContent = '✓ Passwords match';
|
||||
matchIndicator.className = 'password-match-indicator match';
|
||||
} else {
|
||||
matchIndicator.textContent = '✗ Passwords do not match';
|
||||
matchIndicator.className = 'password-match-indicator no-match';
|
||||
}
|
||||
}
|
||||
|
||||
// Attach to all password inputs
|
||||
const passwordInputs = document.querySelectorAll('input[type="password"][name="password"], input[type="password"][name="new_password"]');
|
||||
|
||||
passwordInputs.forEach(input => {
|
||||
// Show initial requirements when focused
|
||||
input.addEventListener('focus', function() {
|
||||
if (this.value === '') {
|
||||
const result = checkPasswordStrength('');
|
||||
updateStrengthIndicator(this, result);
|
||||
}
|
||||
});
|
||||
|
||||
// Check strength on input
|
||||
input.addEventListener('input', function() {
|
||||
const result = checkPasswordStrength(this.value);
|
||||
updateStrengthIndicator(this, result);
|
||||
|
||||
// Check password match if there's a confirm field
|
||||
const formElement = this.closest('form');
|
||||
const confirmField = formElement.querySelector('input[name="confirm_password"], input[name="confirm_new_password"]');
|
||||
if (confirmField && confirmField.value) {
|
||||
checkPasswordMatch(this.value, '#' + confirmField.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Attach to confirm password inputs
|
||||
const confirmInputs = document.querySelectorAll('input[name="confirm_password"], input[name="confirm_new_password"]');
|
||||
|
||||
confirmInputs.forEach(input => {
|
||||
input.addEventListener('input', function() {
|
||||
const formElement = this.closest('form');
|
||||
const passwordField = formElement.querySelector('input[name="password"], input[name="new_password"]');
|
||||
if (passwordField) {
|
||||
checkPasswordMatch(passwordField.value, '#' + this.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Prevent form submission if password is not strong enough
|
||||
const forms = document.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
const passwordField = this.querySelector('input[name="password"], input[name="new_password"]');
|
||||
if (passwordField && passwordField.value) {
|
||||
const result = checkPasswordStrength(passwordField.value);
|
||||
if (!result.isValid) {
|
||||
e.preventDefault();
|
||||
alert('Please ensure your password meets all the requirements.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check password match
|
||||
const confirmField = this.querySelector('input[name="confirm_password"], input[name="confirm_new_password"]');
|
||||
if (confirmField && confirmField.value !== passwordField.value) {
|
||||
e.preventDefault();
|
||||
alert('Passwords do not match.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
125
static/js/splash.js
Normal file
125
static/js/splash.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// Splash Page Interactive Elements
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Observer options for animations
|
||||
const observerOptions = {
|
||||
threshold: 0.5,
|
||||
rootMargin: '0px'
|
||||
};
|
||||
|
||||
// Parallax effect for hero section
|
||||
const hero = document.querySelector('.splash-hero');
|
||||
if (hero) {
|
||||
window.addEventListener('scroll', () => {
|
||||
const scrolled = window.pageYOffset;
|
||||
const parallaxSpeed = 0.5;
|
||||
hero.style.transform = `translateY(${scrolled * parallaxSpeed}px)`;
|
||||
});
|
||||
}
|
||||
|
||||
// Add hover effects to feature cards
|
||||
const featureCards = document.querySelectorAll('.feature-card');
|
||||
featureCards.forEach(card => {
|
||||
card.addEventListener('mouseenter', function() {
|
||||
this.style.transform = 'translateY(-10px) scale(1.02)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', function() {
|
||||
this.style.transform = 'translateY(0) scale(1)';
|
||||
});
|
||||
});
|
||||
|
||||
// Smooth scroll for anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add stagger animation to feature cards
|
||||
const featureObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry, index) => {
|
||||
if (entry.isIntersecting) {
|
||||
setTimeout(() => {
|
||||
entry.target.style.opacity = '1';
|
||||
entry.target.style.transform = 'translateY(0)';
|
||||
}, index * 100);
|
||||
featureObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
featureCards.forEach(card => {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(20px)';
|
||||
card.style.transition = 'all 0.6s ease';
|
||||
featureObserver.observe(card);
|
||||
});
|
||||
|
||||
// Testimonial cards animation
|
||||
const testimonialCards = document.querySelectorAll('.testimonial-card');
|
||||
const testimonialObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry, index) => {
|
||||
if (entry.isIntersecting) {
|
||||
setTimeout(() => {
|
||||
entry.target.style.opacity = '1';
|
||||
entry.target.style.transform = 'translateX(0)';
|
||||
}, index * 150);
|
||||
testimonialObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
testimonialCards.forEach((card, index) => {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = index % 2 === 0 ? 'translateX(-30px)' : 'translateX(30px)';
|
||||
card.style.transition = 'all 0.8s ease';
|
||||
testimonialObserver.observe(card);
|
||||
});
|
||||
|
||||
// Pricing cards hover effect
|
||||
const pricingCards = document.querySelectorAll('.pricing-card');
|
||||
pricingCards.forEach(card => {
|
||||
card.addEventListener('mouseenter', function() {
|
||||
if (!this.classList.contains('featured')) {
|
||||
this.style.transform = 'translateY(-10px)';
|
||||
this.style.boxShadow = '0 15px 40px rgba(0,0,0,0.12)';
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', function() {
|
||||
if (!this.classList.contains('featured')) {
|
||||
this.style.transform = 'translateY(0)';
|
||||
this.style.boxShadow = '0 5px 20px rgba(0,0,0,0.08)';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add ripple effect to buttons
|
||||
const buttons = document.querySelectorAll('.btn-primary, .btn-secondary, .btn-pricing');
|
||||
buttons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
const ripple = document.createElement('span');
|
||||
const rect = this.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height);
|
||||
const x = e.clientX - rect.left - size / 2;
|
||||
const y = e.clientY - rect.top - size / 2;
|
||||
|
||||
ripple.style.width = ripple.style.height = size + 'px';
|
||||
ripple.style.left = x + 'px';
|
||||
ripple.style.top = y + 'px';
|
||||
ripple.classList.add('ripple');
|
||||
|
||||
this.appendChild(ripple);
|
||||
|
||||
setTimeout(() => ripple.remove(), 600);
|
||||
});
|
||||
});
|
||||
});
|
||||
363
static/js/subtasks.js
Normal file
363
static/js/subtasks.js
Normal file
@@ -0,0 +1,363 @@
|
||||
// Sub-task Management Functions
|
||||
|
||||
// Global variable to track subtasks
|
||||
let currentSubtasks = [];
|
||||
|
||||
// Initialize subtasks when loading a task
|
||||
function initializeSubtasks(taskId) {
|
||||
currentSubtasks = [];
|
||||
const subtasksContainer = document.getElementById('subtasks-container');
|
||||
if (!subtasksContainer) return;
|
||||
|
||||
subtasksContainer.innerHTML = '<div class="loading">Loading subtasks...</div>';
|
||||
|
||||
if (taskId) {
|
||||
// Fetch existing subtasks
|
||||
fetch(`/api/tasks/${taskId}`)
|
||||
.then(response => response.json())
|
||||
.then(task => {
|
||||
if (task.subtasks) {
|
||||
currentSubtasks = task.subtasks;
|
||||
renderSubtasks();
|
||||
} else {
|
||||
subtasksContainer.innerHTML = '<p class="no-subtasks">No subtasks yet</p>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading subtasks:', error);
|
||||
subtasksContainer.innerHTML = '<p class="error">Error loading subtasks</p>';
|
||||
});
|
||||
} else {
|
||||
renderSubtasks();
|
||||
}
|
||||
}
|
||||
|
||||
// Render subtasks in the modal
|
||||
function renderSubtasks() {
|
||||
const container = document.getElementById('subtasks-container');
|
||||
if (!container) return;
|
||||
|
||||
if (currentSubtasks.length === 0) {
|
||||
container.innerHTML = '<p class="no-subtasks">No subtasks yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = currentSubtasks.map((subtask, index) => `
|
||||
<div class="subtask-item" data-subtask-id="${subtask.id || ''}" data-index="${index}">
|
||||
<input type="checkbox"
|
||||
class="subtask-checkbox"
|
||||
${subtask.status === 'COMPLETED' ? 'checked' : ''}
|
||||
onchange="toggleSubtaskStatus(${index})"
|
||||
${subtask.id ? '' : 'disabled'}>
|
||||
<input type="text"
|
||||
class="subtask-name"
|
||||
value="${escapeHtml(subtask.name)}"
|
||||
placeholder="Subtask name"
|
||||
onchange="updateSubtaskName(${index}, this.value)">
|
||||
<select class="subtask-priority" onchange="updateSubtaskPriority(${index}, this.value)">
|
||||
<option value="LOW" ${subtask.priority === 'LOW' ? 'selected' : ''}>Low</option>
|
||||
<option value="MEDIUM" ${subtask.priority === 'MEDIUM' ? 'selected' : ''}>Medium</option>
|
||||
<option value="HIGH" ${subtask.priority === 'HIGH' ? 'selected' : ''}>High</option>
|
||||
<option value="URGENT" ${subtask.priority === 'URGENT' ? 'selected' : ''}>Urgent</option>
|
||||
</select>
|
||||
<select class="subtask-assignee" onchange="updateSubtaskAssignee(${index}, this.value)">
|
||||
<option value="">Unassigned</option>
|
||||
${renderAssigneeOptions(subtask.assigned_to_id)}
|
||||
</select>
|
||||
<button type="button" class="btn btn-danger btn-xs" onclick="removeSubtask(${index})">Remove</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Add a new subtask
|
||||
function addSubtask() {
|
||||
const newSubtask = {
|
||||
name: '',
|
||||
status: 'NOT_STARTED',
|
||||
priority: 'MEDIUM',
|
||||
assigned_to_id: null,
|
||||
isNew: true
|
||||
};
|
||||
|
||||
currentSubtasks.push(newSubtask);
|
||||
renderSubtasks();
|
||||
|
||||
// Focus on the new subtask input
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll('.subtask-name');
|
||||
if (inputs.length > 0) {
|
||||
inputs[inputs.length - 1].focus();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Remove a subtask
|
||||
function removeSubtask(index) {
|
||||
const subtask = currentSubtasks[index];
|
||||
|
||||
if (subtask.id) {
|
||||
// If it has an ID, it exists in the database
|
||||
if (confirm('Are you sure you want to delete this subtask?')) {
|
||||
fetch(`/api/subtasks/${subtask.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
currentSubtasks.splice(index, 1);
|
||||
renderSubtasks();
|
||||
showNotification('Subtask deleted successfully', 'success');
|
||||
} else {
|
||||
throw new Error('Failed to delete subtask');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting subtask:', error);
|
||||
showNotification('Error deleting subtask', 'error');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Just remove from array if not saved yet
|
||||
currentSubtasks.splice(index, 1);
|
||||
renderSubtasks();
|
||||
}
|
||||
}
|
||||
|
||||
// Update subtask name
|
||||
function updateSubtaskName(index, name) {
|
||||
currentSubtasks[index].name = name;
|
||||
}
|
||||
|
||||
// Update subtask priority
|
||||
function updateSubtaskPriority(index, priority) {
|
||||
currentSubtasks[index].priority = priority;
|
||||
}
|
||||
|
||||
// Update subtask assignee
|
||||
function updateSubtaskAssignee(index, assigneeId) {
|
||||
currentSubtasks[index].assigned_to_id = assigneeId || null;
|
||||
}
|
||||
|
||||
// Toggle subtask status
|
||||
function toggleSubtaskStatus(index) {
|
||||
const subtask = currentSubtasks[index];
|
||||
const newStatus = subtask.status === 'COMPLETED' ? 'NOT_STARTED' : 'COMPLETED';
|
||||
|
||||
if (subtask.id) {
|
||||
// Update in database
|
||||
fetch(`/api/subtasks/${subtask.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(updatedSubtask => {
|
||||
currentSubtasks[index] = updatedSubtask;
|
||||
renderSubtasks();
|
||||
updateTaskProgress();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error updating subtask status:', error);
|
||||
showNotification('Error updating subtask status', 'error');
|
||||
renderSubtasks(); // Re-render to revert checkbox
|
||||
});
|
||||
} else {
|
||||
currentSubtasks[index].status = newStatus;
|
||||
}
|
||||
}
|
||||
|
||||
// Save all subtasks for a task
|
||||
function saveSubtasks(taskId) {
|
||||
const promises = [];
|
||||
|
||||
currentSubtasks.forEach(subtask => {
|
||||
if (subtask.isNew && subtask.name.trim()) {
|
||||
// Create new subtask
|
||||
promises.push(
|
||||
fetch('/api/subtasks', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
task_id: taskId,
|
||||
name: subtask.name,
|
||||
priority: subtask.priority,
|
||||
assigned_to_id: subtask.assigned_to_id,
|
||||
status: subtask.status
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
);
|
||||
} else if (subtask.id && !subtask.isNew) {
|
||||
// Update existing subtask
|
||||
promises.push(
|
||||
fetch(`/api/subtasks/${subtask.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: subtask.name,
|
||||
priority: subtask.priority,
|
||||
assigned_to_id: subtask.assigned_to_id,
|
||||
status: subtask.status
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
// Update task progress based on subtasks
|
||||
function updateTaskProgress() {
|
||||
const taskId = document.getElementById('task-id').value;
|
||||
if (!taskId) return;
|
||||
|
||||
// Refresh the task card in the board
|
||||
if (typeof refreshTaskCard === 'function') {
|
||||
refreshTaskCard(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
// Render assignee options
|
||||
function renderAssigneeOptions(selectedId) {
|
||||
// This should be populated from the global team members list
|
||||
const teamMembers = window.teamMembers || [];
|
||||
return teamMembers.map(member =>
|
||||
`<option value="${member.id}" ${member.id == selectedId ? 'selected' : ''}>${escapeHtml(member.username)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Helper function to escape HTML
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Display subtasks in task cards
|
||||
function renderSubtasksInCard(subtasks) {
|
||||
if (!subtasks || subtasks.length === 0) return '';
|
||||
|
||||
const completedCount = subtasks.filter(s => s.status === 'COMPLETED').length;
|
||||
const totalCount = subtasks.length;
|
||||
const percentage = Math.round((completedCount / totalCount) * 100);
|
||||
|
||||
return `
|
||||
<div class="task-subtasks">
|
||||
<div class="subtask-progress">
|
||||
<div class="subtask-progress-bar">
|
||||
<div class="subtask-progress-fill" style="width: ${percentage}%"></div>
|
||||
</div>
|
||||
<span class="subtask-count">${completedCount}/${totalCount} subtasks</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add subtask styles
|
||||
const subtaskStyles = `
|
||||
<style>
|
||||
/* Subtask Styles - Compact */
|
||||
.subtask-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.4rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.subtask-checkbox {
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.subtask-name {
|
||||
flex: 2;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.subtask-priority {
|
||||
flex: 0.8;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.subtask-assignee {
|
||||
flex: 1.2;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.no-subtasks {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Subtask progress in task cards */
|
||||
.task-subtasks {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.subtask-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.subtask-progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #e9ecef;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subtask-progress-fill {
|
||||
height: 100%;
|
||||
background: #28a745;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.subtask-count {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Inject styles when document is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.head.insertAdjacentHTML('beforeend', subtaskStyles);
|
||||
});
|
||||
} else {
|
||||
document.head.insertAdjacentHTML('beforeend', subtaskStyles);
|
||||
}
|
||||
54
static/js/user-dropdown.js
Normal file
54
static/js/user-dropdown.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// User dropdown context menu functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userDropdownToggle = document.getElementById('user-dropdown-toggle');
|
||||
const userDropdownModal = document.getElementById('user-dropdown-modal');
|
||||
|
||||
// Toggle dropdown context menu
|
||||
if (userDropdownToggle && userDropdownModal) {
|
||||
userDropdownToggle.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Toggle active class
|
||||
const isActive = userDropdownModal.classList.contains('active');
|
||||
userDropdownModal.classList.toggle('active');
|
||||
|
||||
// Position the dropdown relative to the toggle button
|
||||
if (!isActive) {
|
||||
const toggleRect = userDropdownToggle.getBoundingClientRect();
|
||||
const sidebarHeader = userDropdownToggle.closest('.sidebar-header');
|
||||
const sidebarHeaderRect = sidebarHeader.getBoundingClientRect();
|
||||
|
||||
// Position relative to sidebar header
|
||||
userDropdownModal.style.position = 'absolute';
|
||||
userDropdownModal.style.top = (toggleRect.bottom - sidebarHeaderRect.top + 5) + 'px';
|
||||
userDropdownModal.style.right = '10px';
|
||||
userDropdownModal.style.left = 'auto';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (userDropdownModal && !userDropdownModal.contains(e.target) && !userDropdownToggle.contains(e.target)) {
|
||||
userDropdownModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdown when pressing Escape
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && userDropdownModal && userDropdownModal.classList.contains('active')) {
|
||||
userDropdownModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdown when clicking on menu items
|
||||
if (userDropdownModal) {
|
||||
const menuLinks = userDropdownModal.querySelectorAll('a');
|
||||
menuLinks.forEach(link => {
|
||||
link.addEventListener('click', function() {
|
||||
userDropdownModal.classList.remove('active');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
0
static/uploads/avatars/.gitkeep
Normal file
0
static/uploads/avatars/.gitkeep
Normal file
@@ -5,7 +5,7 @@
|
||||
<div class="about-content">
|
||||
<div class="intro">
|
||||
<h2>Professional Time Tracking Made Simple</h2>
|
||||
<p>TimeTrack is a comprehensive multi-tenant time management solution designed to help organizations and individuals monitor work hours efficiently. Built with simplicity and accuracy in mind, our platform provides the tools you need to track, manage, and analyze time spent on work activities across multiple companies and teams.</p>
|
||||
<p>{{ g.branding.app_name }} is a comprehensive multi-tenant time management solution designed to help organizations and individuals monitor work hours efficiently. Built with simplicity and accuracy in mind, our platform provides the tools you need to track, manage, and analyze time spent on work activities across multiple companies and teams.</p>
|
||||
</div>
|
||||
|
||||
<div class="features-section">
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<div class="feature-item">
|
||||
<h3>🏢 Multi-Company Support</h3>
|
||||
<p>Enterprise-ready multi-tenant architecture allows multiple companies to operate independently within a single TimeTrack instance, with complete data isolation and security.</p>
|
||||
<p>Enterprise-ready multi-tenant architecture allows multiple companies to operate independently within a single {{ g.branding.app_name }} instance, with complete data isolation and security.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
|
||||
<div class="benefits-section">
|
||||
<h2>Why Choose TimeTrack?</h2>
|
||||
<h2>Why Choose {{ g.branding.app_name }}?</h2>
|
||||
|
||||
<div class="benefits-list">
|
||||
<div class="benefit">
|
||||
@@ -103,7 +103,7 @@
|
||||
|
||||
<div class="technical-section">
|
||||
<h2>Technical Information</h2>
|
||||
<p>TimeTrack is built using modern web technologies including Flask (Python), SQLite database, and responsive HTML/CSS/JavaScript frontend. The application features a REST API architecture, secure session management, and email integration for user verification.</p>
|
||||
<p>{{ g.branding.app_name }} is built using modern web technologies including Flask (Python), SQLite database, and responsive HTML/CSS/JavaScript frontend. The application features a REST API architecture, secure session management, and email integration for user verification.</p>
|
||||
</div>
|
||||
|
||||
<div class="company-setup-section">
|
||||
@@ -111,12 +111,12 @@
|
||||
<div class="setup-options">
|
||||
<div class="setup-option">
|
||||
<h3>🏢 New Company Setup</h3>
|
||||
<p>Setting up TimeTrack for the first time or adding a new company? Create your company profile and administrator account to get started.</p>
|
||||
<p>Setting up {{ g.branding.app_name }} for the first time or adding a new company? Create your company profile and administrator account to get started.</p>
|
||||
</div>
|
||||
|
||||
<div class="setup-option">
|
||||
<h3>👥 Join Existing Company</h3>
|
||||
<p>Already have a company using TimeTrack? Get your company code from your administrator and register to join your organization.</p>
|
||||
<p>Already have a company using {{ g.branding.app_name }}? Get your company code from your administrator and register to join your organization.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<div class="admin-header">
|
||||
<h1>Company Management</h1>
|
||||
<div class="admin-actions">
|
||||
<a href="{{ url_for('setup_company') }}" class="btn btn-success">Create New Company</a>
|
||||
<a href="{{ url_for('edit_company') }}" class="btn btn-primary">Edit Company</a>
|
||||
<a href="{{ url_for('setup_company') }}" class="btn btn-md btn-success">Create New Company</a>
|
||||
<a href="{{ url_for('edit_company') }}" class="btn btn-md btn-primary">Edit Company</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,46 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="timetrack-container">
|
||||
<div class="timetrack-container admin-projects-container">
|
||||
<div class="admin-header">
|
||||
<h2>Project Management</h2>
|
||||
<a href="{{ url_for('create_project') }}" class="btn btn-success">Create New Project</a>
|
||||
<div class="admin-actions">
|
||||
<a href="{{ url_for('create_project') }}" class="btn btn-md btn-success">Create New Project</a>
|
||||
<button id="manage-categories-btn" class="btn btn-md btn-info">Manage Categories</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Categories Section -->
|
||||
<div id="categories-section" class="categories-section" style="display: none;">
|
||||
<div class="section-header">
|
||||
<h3>Project Categories</h3>
|
||||
<button id="create-category-btn" class="btn btn-success btn-sm">Create Category</button>
|
||||
</div>
|
||||
|
||||
<div class="categories-grid">
|
||||
{% for category in categories %}
|
||||
<div class="category-card" data-category-id="{{ category.id }}">
|
||||
<div class="category-header" style="background-color: {{ category.color }}20; border-left: 4px solid {{ category.color }};">
|
||||
<div class="category-title">
|
||||
<span class="category-icon">{{ category.icon or '📁' }}</span>
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
</div>
|
||||
<div class="category-actions">
|
||||
<button class="edit-category-btn btn btn-xs btn-primary" data-id="{{ category.id }}">Edit</button>
|
||||
<button class="delete-category-btn btn btn-xs btn-danger" data-id="{{ category.id }}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="category-body">
|
||||
<p class="category-description">{{ category.description or 'No description' }}</p>
|
||||
<small class="category-projects">{{ category.projects|length }} project(s)</small>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-categories">
|
||||
<p>No categories created yet. <button id="first-category-btn" class="btn btn-link">Create your first category</button>.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if projects %}
|
||||
@@ -14,6 +50,7 @@
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Category</th>
|
||||
<th>Team</th>
|
||||
<th>Status</th>
|
||||
<th>Start Date</th>
|
||||
@@ -28,6 +65,15 @@
|
||||
<tr class="{% if not project.is_active %}inactive-project{% endif %}">
|
||||
<td><strong>{{ project.code }}</strong></td>
|
||||
<td>{{ project.name }}</td>
|
||||
<td>
|
||||
{% if project.category %}
|
||||
<span class="category-badge" style="background-color: {{ project.category.color }}20; color: {{ project.category.color }};">
|
||||
{{ project.category.icon or '📁' }} {{ project.category.name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<em>No category</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if project.team %}
|
||||
{{ project.team.name }}
|
||||
@@ -46,7 +92,8 @@
|
||||
<td>{{ project.time_entries|length }}</td>
|
||||
<td class="actions">
|
||||
<a href="{{ url_for('edit_project', project_id=project.id) }}" class="btn btn-sm btn-primary">Edit</a>
|
||||
{% if g.user.role.name == 'ADMIN' and project.time_entries|length == 0 %}
|
||||
<a href="{{ url_for('manage_project_tasks', project_id=project.id) }}" class="btn btn-sm btn-info">Tasks</a>
|
||||
{% if g.user.role == Role.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-sm btn-danger">Delete</button>
|
||||
@@ -66,9 +113,84 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Override container width for admin projects page */
|
||||
.admin-projects-container {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
padding: 1rem !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.projects-table {
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.projects-table table {
|
||||
width: 100%;
|
||||
min-width: 1200px; /* Ensure table is wide enough for all columns */
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
/* Optimize column widths */
|
||||
.projects-table th:nth-child(1), /* Code */
|
||||
.projects-table td:nth-child(1) {
|
||||
width: 8%;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.projects-table th:nth-child(2), /* Name */
|
||||
.projects-table td:nth-child(2) {
|
||||
width: 20%;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.projects-table th:nth-child(3), /* Category */
|
||||
.projects-table td:nth-child(3) {
|
||||
width: 12%;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.projects-table th:nth-child(4), /* Team */
|
||||
.projects-table td:nth-child(4) {
|
||||
width: 12%;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.projects-table th:nth-child(5), /* Status */
|
||||
.projects-table td:nth-child(5) {
|
||||
width: 8%;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.projects-table th:nth-child(6), /* Start Date */
|
||||
.projects-table td:nth-child(6) {
|
||||
width: 10%;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.projects-table th:nth-child(7), /* End Date */
|
||||
.projects-table td:nth-child(7) {
|
||||
width: 10%;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.projects-table th:nth-child(8), /* Created By */
|
||||
.projects-table td:nth-child(8) {
|
||||
width: 10%;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.projects-table th:nth-child(9), /* Time Entries */
|
||||
.projects-table td:nth-child(9) {
|
||||
width: 8%;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.projects-table th:nth-child(10), /* Actions */
|
||||
.projects-table td:nth-child(10) {
|
||||
width: 12%;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.inactive-project {
|
||||
@@ -94,28 +216,408 @@
|
||||
|
||||
.actions {
|
||||
white-space: nowrap;
|
||||
min-width: 180px; /* Ensure enough space for buttons */
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
.actions .btn {
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.actions .btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
margin-right: 0.5rem;
|
||||
min-width: 60px; /* Consistent button widths */
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
border-color: #bd2130;
|
||||
}
|
||||
/* Button definitions now in main style.css */
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Category Management Styles */
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.categories-section {
|
||||
background: #f8f9fa;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.category-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.category-header {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.category-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.category-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.category-body {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
|
||||
.category-description {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.category-projects {
|
||||
color: #999;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.no-categories {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn.btn-xs {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
/* Consistent button styling across the interface */
|
||||
.btn-info {
|
||||
background-color: #17a2b8;
|
||||
border-color: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
background-color: #138496;
|
||||
border-color: #117a8b;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #218838;
|
||||
border-color: #1e7e34;
|
||||
}
|
||||
|
||||
.section-header .btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.category-actions .btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
/* Make the "Manage Categories" button more prominent */
|
||||
#manage-categories-btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.color-input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-input-group input[type="color"] {
|
||||
width: 50px;
|
||||
height: 38px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-input-group input[type="text"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Responsive improvements */
|
||||
@media (max-width: 768px) {
|
||||
.admin-actions {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.projects-table table {
|
||||
min-width: 800px; /* Smaller minimum on mobile */
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
grid-template-columns: 1fr; /* Single column on mobile */
|
||||
}
|
||||
}
|
||||
|
||||
/* Better spacing for action buttons */
|
||||
.actions form {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions .btn + .btn,
|
||||
.actions .btn + form {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Category Management Modal -->
|
||||
<div id="category-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<h3 id="category-modal-title">Create Category</h3>
|
||||
<form id="category-form">
|
||||
<input type="hidden" id="category-id" name="category_id">
|
||||
<div class="form-group">
|
||||
<label for="category-name">Name:</label>
|
||||
<input type="text" id="category-name" name="name" required maxlength="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category-description">Description:</label>
|
||||
<textarea id="category-description" name="description" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category-color">Color:</label>
|
||||
<div class="color-input-group">
|
||||
<input type="color" id="category-color" name="color" value="#007bff">
|
||||
<input type="text" id="category-color-text" name="color_text" value="#007bff" maxlength="7">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category-icon">Icon (emoji or text):</label>
|
||||
<input type="text" id="category-icon" name="icon" maxlength="10" placeholder="📁">
|
||||
<small>Use emojis or short text like 📁, 🎯, 💼, etc.</small>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Category</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancel-category">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const manageCategoriesBtn = document.getElementById('manage-categories-btn');
|
||||
const categoriesSection = document.getElementById('categories-section');
|
||||
const categoryModal = document.getElementById('category-modal');
|
||||
const categoryForm = document.getElementById('category-form');
|
||||
const createCategoryBtn = document.getElementById('create-category-btn');
|
||||
const firstCategoryBtn = document.getElementById('first-category-btn');
|
||||
const colorInput = document.getElementById('category-color');
|
||||
const colorTextInput = document.getElementById('category-color-text');
|
||||
|
||||
// Toggle categories section
|
||||
manageCategoriesBtn.addEventListener('click', function() {
|
||||
const isVisible = categoriesSection.style.display !== 'none';
|
||||
categoriesSection.style.display = isVisible ? 'none' : 'block';
|
||||
this.textContent = isVisible ? 'Manage Categories' : 'Hide Categories';
|
||||
});
|
||||
|
||||
// Sync color inputs
|
||||
colorInput.addEventListener('change', function() {
|
||||
colorTextInput.value = this.value;
|
||||
});
|
||||
|
||||
colorTextInput.addEventListener('change', function() {
|
||||
if (/^#[0-9A-F]{6}$/i.test(this.value)) {
|
||||
colorInput.value = this.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Open create category modal
|
||||
function openCategoryModal(categoryData = null) {
|
||||
const isEdit = categoryData !== null;
|
||||
document.getElementById('category-modal-title').textContent = isEdit ? 'Edit Category' : 'Create Category';
|
||||
|
||||
if (isEdit) {
|
||||
document.getElementById('category-id').value = categoryData.id;
|
||||
document.getElementById('category-name').value = categoryData.name;
|
||||
document.getElementById('category-description').value = categoryData.description || '';
|
||||
document.getElementById('category-color').value = categoryData.color;
|
||||
document.getElementById('category-color-text').value = categoryData.color;
|
||||
document.getElementById('category-icon').value = categoryData.icon || '';
|
||||
} else {
|
||||
categoryForm.reset();
|
||||
document.getElementById('category-id').value = '';
|
||||
document.getElementById('category-color').value = '#007bff';
|
||||
document.getElementById('category-color-text').value = '#007bff';
|
||||
}
|
||||
|
||||
categoryModal.style.display = 'block';
|
||||
}
|
||||
|
||||
createCategoryBtn?.addEventListener('click', () => openCategoryModal());
|
||||
firstCategoryBtn?.addEventListener('click', () => openCategoryModal());
|
||||
|
||||
// Edit category buttons
|
||||
document.querySelectorAll('.edit-category-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const categoryId = this.getAttribute('data-id');
|
||||
// Get category data from the card
|
||||
const card = this.closest('.category-card');
|
||||
const categoryData = {
|
||||
id: categoryId,
|
||||
name: card.querySelector('.category-name').textContent,
|
||||
description: card.querySelector('.category-description').textContent,
|
||||
color: card.querySelector('.category-header').style.borderLeftColor || '#007bff',
|
||||
icon: card.querySelector('.category-icon').textContent
|
||||
};
|
||||
openCategoryModal(categoryData);
|
||||
});
|
||||
});
|
||||
|
||||
// Delete category buttons
|
||||
document.querySelectorAll('.delete-category-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const categoryId = this.getAttribute('data-id');
|
||||
if (confirm('Are you sure you want to delete this category? Projects using this category will be unassigned.')) {
|
||||
fetch(`/api/admin/categories/${categoryId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while deleting the category');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Close modal
|
||||
document.querySelector('#category-modal .close').addEventListener('click', function() {
|
||||
categoryModal.style.display = 'none';
|
||||
});
|
||||
|
||||
document.getElementById('cancel-category').addEventListener('click', function() {
|
||||
categoryModal.style.display = 'none';
|
||||
});
|
||||
|
||||
// Submit category form
|
||||
categoryForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const categoryId = formData.get('category_id');
|
||||
const isEdit = categoryId !== '';
|
||||
|
||||
const url = isEdit ? `/api/admin/categories/${categoryId}` : '/api/admin/categories';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: formData.get('name'),
|
||||
description: formData.get('description'),
|
||||
color: formData.get('color'),
|
||||
icon: formData.get('icon')
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
categoryModal.style.display = 'none';
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while saving the category');
|
||||
});
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target === categoryModal) {
|
||||
categoryModal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,10 +1,10 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="admin-container">
|
||||
<div class="admin-header">
|
||||
<h1>Team Management</h1>
|
||||
<a href="{{ url_for('create_team') }}" class="btn btn-success">Create New Team</a>
|
||||
<a href="{{ url_for('create_team') }}" class="btn btn-md btn-success">Create New Team</a>
|
||||
</div>
|
||||
|
||||
{% if teams %}
|
||||
@@ -25,8 +25,8 @@
|
||||
<td>{{ team.description }}</td>
|
||||
<td>{{ team.users|length }}</td>
|
||||
<td>{{ team.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('manage_team', team_id=team.id) }}" class="button btn btn-sm btn-info">Manage</a>
|
||||
<td class="actions">
|
||||
<a href="{{ url_for('manage_team', team_id=team.id) }}" class="button btn btn-sm btn-primary">Manage</a>
|
||||
<form method="POST" action="{{ url_for('delete_team', team_id=team.id) }}" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this team?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="admin-container">
|
||||
<div class="admin-header">
|
||||
<h1>User Management</h1>
|
||||
<a href="{{ url_for('create_user') }}" class="btn btn-success">Create New User</a>
|
||||
<a href="{{ url_for('create_user') }}" class="btn btn-md btn-success">Create New User</a>
|
||||
</div>
|
||||
|
||||
<div class="user-list">
|
||||
|
||||
@@ -283,36 +283,7 @@
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
margin-right: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
/* Make preset cards clickable */
|
||||
.preset-card {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="timetrack-container">
|
||||
<div class="analytics-header">
|
||||
<div class="page-container timetrack-container">
|
||||
<div class="page-header analytics-header">
|
||||
<h2>📊 Time Analytics</h2>
|
||||
<div class="mode-switcher">
|
||||
<button class="mode-btn {% if mode == 'personal' %}active{% endif %}"
|
||||
onclick="switchMode('personal')">Personal</button>
|
||||
{% if g.user.team_id and g.user.role.value in ['Team Leader', 'Supervisor', 'Administrator'] %}
|
||||
{% if g.user.team_id and g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN] %}
|
||||
<button class="mode-btn {% if mode == 'team' %}active{% endif %}"
|
||||
onclick="switchMode('team')">Team</button>
|
||||
{% endif %}
|
||||
@@ -106,6 +106,7 @@
|
||||
<select id="chart-type">
|
||||
<option value="timeSeries">Time Series</option>
|
||||
<option value="projectDistribution">Project Distribution</option>
|
||||
<option value="burndown">Burndown Chart</option>
|
||||
</select>
|
||||
<div class="export-buttons">
|
||||
<button class="btn btn-secondary" onclick="exportChart('png')">Export PNG</button>
|
||||
@@ -268,7 +269,12 @@ class TimeAnalyticsController {
|
||||
const chartTypeSelect = document.getElementById('chart-type');
|
||||
if (chartTypeSelect) {
|
||||
chartTypeSelect.addEventListener('change', () => {
|
||||
// For burndown chart, we need to reload data from the server
|
||||
if (chartTypeSelect.value === 'burndown') {
|
||||
this.loadData();
|
||||
} else {
|
||||
this.updateChart();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -309,6 +315,12 @@ class TimeAnalyticsController {
|
||||
params.append('project_id', this.state.selectedProject);
|
||||
}
|
||||
|
||||
// Add chart_type parameter for graph view
|
||||
if (this.state.activeView === 'graph') {
|
||||
const chartType = document.getElementById('chart-type')?.value || 'timeSeries';
|
||||
params.append('chart_type', chartType);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/analytics/data?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -355,13 +367,9 @@ class TimeAnalyticsController {
|
||||
|
||||
this.state.activeView = viewType;
|
||||
|
||||
// Load data for new view if needed
|
||||
if (this.state.data) {
|
||||
this.refreshCurrentView();
|
||||
} else {
|
||||
// Always load new data since different views need different data structures
|
||||
this.loadData();
|
||||
}
|
||||
}
|
||||
|
||||
updateTableView() {
|
||||
const tbody = document.getElementById('entries-tbody');
|
||||
@@ -400,12 +408,30 @@ class TimeAnalyticsController {
|
||||
const data = this.state.data;
|
||||
if (!data) return;
|
||||
|
||||
// Update stats
|
||||
const chartType = document.getElementById('chart-type').value;
|
||||
|
||||
// Update stats based on chart type
|
||||
if (chartType === 'burndown' && data.burndown) {
|
||||
document.getElementById('total-hours').textContent = data.burndown.total_tasks || '0';
|
||||
document.getElementById('total-days').textContent = data.burndown.dates?.length || '0';
|
||||
document.getElementById('avg-hours').textContent = data.burndown.tasks_completed || '0';
|
||||
|
||||
// Update stat labels for burndown
|
||||
document.querySelector('.stat-card:nth-child(1) h4').textContent = 'Total Tasks';
|
||||
document.querySelector('.stat-card:nth-child(2) h4').textContent = 'Timeline Days';
|
||||
document.querySelector('.stat-card:nth-child(3) h4').textContent = 'Completed Tasks';
|
||||
} else {
|
||||
document.getElementById('total-hours').textContent = data.totalHours?.toFixed(1) || '0';
|
||||
document.getElementById('total-days').textContent = data.totalDays || '0';
|
||||
document.getElementById('avg-hours').textContent =
|
||||
data.totalDays > 0 ? (data.totalHours / data.totalDays).toFixed(1) : '0';
|
||||
|
||||
// Restore original stat labels
|
||||
document.querySelector('.stat-card:nth-child(1) h4').textContent = 'Total Hours';
|
||||
document.querySelector('.stat-card:nth-child(2) h4').textContent = 'Total Days';
|
||||
document.querySelector('.stat-card:nth-child(3) h4').textContent = 'Average Hours/Day';
|
||||
}
|
||||
|
||||
this.updateChart();
|
||||
}
|
||||
|
||||
@@ -487,6 +513,68 @@ class TimeAnalyticsController {
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (chartType === 'burndown') {
|
||||
this.charts.main = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.burndown?.dates || [],
|
||||
datasets: [{
|
||||
label: 'Remaining Tasks',
|
||||
data: data.burndown?.remaining || [],
|
||||
borderColor: '#FF5722',
|
||||
backgroundColor: 'rgba(255, 87, 34, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.1,
|
||||
pointBackgroundColor: '#FF5722',
|
||||
pointBorderColor: '#FF5722',
|
||||
pointRadius: 4
|
||||
}, {
|
||||
label: 'Ideal Burndown',
|
||||
data: data.burndown?.ideal || [],
|
||||
borderColor: '#4CAF50',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
tension: 0,
|
||||
pointRadius: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Project Burndown Chart'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Remaining Tasks'
|
||||
},
|
||||
ticks: {
|
||||
stepSize: 1
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Date'
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,7 +646,9 @@ function exportChart(format) {
|
||||
} else if (format === 'pdf') {
|
||||
// Get chart title for PDF
|
||||
const chartType = document.getElementById('chart-type').value;
|
||||
const title = chartType === 'timeSeries' ? 'Daily Hours Worked' : 'Time Distribution by Project';
|
||||
const title = chartType === 'timeSeries' ? 'Daily Hours Worked' :
|
||||
chartType === 'projectDistribution' ? 'Time Distribution by Project' :
|
||||
'Project Burndown Chart';
|
||||
|
||||
// Create PDF using jsPDF
|
||||
const { jsPDF } = window.jspdf;
|
||||
|
||||
243
templates/confirm_company_deletion.html
Normal file
243
templates/confirm_company_deletion.html
Normal file
@@ -0,0 +1,243 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
<div class="header-section">
|
||||
<h1>⚠️ Confirm Company Deletion</h1>
|
||||
<p class="subtitle">Critical Action Required - Review All Data Before Proceeding</p>
|
||||
<a href="{{ url_for('admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('system_admin_users') }}"
|
||||
class="btn btn-md btn-secondary">← Back to User Management</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger">
|
||||
<h3>Critical Warning!</h3>
|
||||
<p>You are about to delete user <strong>{{ user.username }}</strong> who is the last administrator/supervisor in company <strong>{{ company.name }}</strong>.</p>
|
||||
<p><strong>This action will permanently delete the entire company and ALL associated data.</strong></p>
|
||||
<p>This action cannot be undone!</p>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<h2>The following data will be permanently deleted:</h2>
|
||||
|
||||
<!-- Company Information -->
|
||||
<div class="table-section">
|
||||
<h3>🏢 Company Information</h3>
|
||||
<table class="data-table">
|
||||
<tr>
|
||||
<th>Company Name:</th>
|
||||
<td>{{ company.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Company Slug:</th>
|
||||
<td>{{ company.slug }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Created:</th>
|
||||
<td>{{ company.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description:</th>
|
||||
<td>{{ company.description or 'None' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Users -->
|
||||
{% if users %}
|
||||
<div class="table-section">
|
||||
<h3>👥 Users ({{ users|length }})</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Team</th>
|
||||
<th>Joined</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr {% if u.id == user.id %}class="highlight-row"{% endif %}>
|
||||
<td>
|
||||
{{ u.username }}
|
||||
{% if u.id == user.id %}<span class="status-badge status-warning">Target User</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ u.email }}</td>
|
||||
<td>{{ u.role.value }}</td>
|
||||
<td>{{ u.team.name if u.team else 'None' }}</td>
|
||||
<td>{{ u.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<span class="status-badge {% if u.is_blocked %}status-blocked{% else %}status-active{% endif %}">
|
||||
{% if u.is_blocked %}Blocked{% else %}Active{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Teams -->
|
||||
{% if teams %}
|
||||
<div class="table-section">
|
||||
<h3>🏭 Teams ({{ teams|length }})</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Team Name</th>
|
||||
<th>Description</th>
|
||||
<th>Members</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for team in teams %}
|
||||
<tr>
|
||||
<td>{{ team.name }}</td>
|
||||
<td>{{ team.description or 'None' }}</td>
|
||||
<td>{{ team.users|length }}</td>
|
||||
<td>{{ team.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Projects -->
|
||||
{% if projects %}
|
||||
<div class="table-section">
|
||||
<h3>📝 Projects ({{ projects|length }})</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project Code</th>
|
||||
<th>Project Name</th>
|
||||
<th>Team</th>
|
||||
<th>Tasks</th>
|
||||
<th>Time Entries</th>
|
||||
<th>Created By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr>
|
||||
<td>{{ project.code }}</td>
|
||||
<td>{{ project.name }}</td>
|
||||
<td>{{ project.team.name if project.team else 'None' }}</td>
|
||||
<td>{{ project.tasks|length }}</td>
|
||||
<td>{{ project.time_entries|length }}</td>
|
||||
<td>{{ project.created_by.username }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tasks -->
|
||||
{% if tasks %}
|
||||
<div class="table-section">
|
||||
<h3>✅ Tasks ({{ tasks|length }})</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task Name</th>
|
||||
<th>Project</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Assigned To</th>
|
||||
<th>Subtasks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr>
|
||||
<td>{{ task.name }}</td>
|
||||
<td>{{ task.project.code }}</td>
|
||||
<td>{{ task.status.value }}</td>
|
||||
<td>{{ task.priority.value }}</td>
|
||||
<td>{{ task.assigned_to.username if task.assigned_to else 'None' }}</td>
|
||||
<td>{{ task.subtasks|length }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Time Entries -->
|
||||
{% if time_entries_count > 0 %}
|
||||
<div class="table-section">
|
||||
<h3>⏱️ Time Entries ({{ time_entries_count }})</h3>
|
||||
<div class="info-card">
|
||||
<p>{{ time_entries_count }} time tracking entries will be permanently deleted.</p>
|
||||
<p><strong>Total Hours Tracked:</strong> {{ total_hours_tracked }} hours</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Categories -->
|
||||
{% if categories %}
|
||||
<div class="table-section">
|
||||
<h3>🏷️ Project Categories ({{ categories|length }})</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category Name</th>
|
||||
<th>Projects</th>
|
||||
<th>Created By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for category in categories %}
|
||||
<tr>
|
||||
<td>{{ category.name }}</td>
|
||||
<td>{{ category.projects|length }}</td>
|
||||
<td>{{ category.created_by.username }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Form -->
|
||||
<div class="form-section">
|
||||
<div class="alert alert-danger">
|
||||
<h3>Final Confirmation Required</h3>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('confirm_company_deletion', user_id=user.id) }}" class="user-form">
|
||||
<div class="form-group">
|
||||
<label for="company_name_confirm">
|
||||
To confirm deletion, please type the company name: <strong>{{ company.name }}</strong>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="company_name_confirm"
|
||||
name="company_name_confirm" required
|
||||
placeholder="Enter company name exactly as shown above">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="understand_deletion" name="understand_deletion" required>
|
||||
I understand that this action will permanently delete the company and ALL associated data, and this cannot be undone.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<a href="{{ url_for('admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('system_admin_users') }}"
|
||||
class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
Delete Company and All Data
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -40,6 +40,19 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="category_id">Project Category</label>
|
||||
<select id="category_id" name="category_id">
|
||||
<option value="">No Category</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}"
|
||||
{% if request.form.category_id and request.form.category_id|int == category.id %}selected{% endif %}>
|
||||
{{ category.icon or '📁' }} {{ category.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
@@ -112,15 +125,7 @@
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
border-color: #545b62;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
#code {
|
||||
text-transform: uppercase;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,21 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="category_id">Project Category</label>
|
||||
<select id="category_id" name="category_id">
|
||||
<option value="">No Category</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}"
|
||||
{% if (request.form.category_id and request.form.category_id|int == category.id) or (not request.form.category_id and project.category_id == category.id) %}selected{% endif %}>
|
||||
{{ category.icon or '📁' }} {{ category.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="is_active"
|
||||
@@ -174,15 +189,7 @@
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
border-color: #545b62;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
#code {
|
||||
text-transform: uppercase;
|
||||
|
||||
99
templates/imprint.html
Normal file
99
templates/imprint.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="imprint-page">
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
<div class="imprint-content">
|
||||
{{ content|safe }}
|
||||
</div>
|
||||
|
||||
<div class="imprint-footer">
|
||||
<a href="{{ url_for('home') }}" class="btn btn-secondary">← Back to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.imprint-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.imprint-page h1 {
|
||||
margin-bottom: 2rem;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.imprint-content {
|
||||
margin-bottom: 3rem;
|
||||
line-height: 1.8;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.imprint-content h2 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.imprint-content h3 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.imprint-content p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.imprint-content a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.imprint-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.imprint-content ul,
|
||||
.imprint-content ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.imprint-content li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.imprint-content strong {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.imprint-footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.imprint-page {
|
||||
padding: 1rem;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.imprint-page h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,14 +1,147 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero">
|
||||
<h1>Welcome to TimeTrack</h1>
|
||||
<p>Track your work hours easily and efficiently</p>
|
||||
</div>
|
||||
|
||||
{% if not g.user %}
|
||||
|
||||
Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('register') }}">register</a> to access your dashboard.
|
||||
<!-- Decadent Splash Page -->
|
||||
<div class="splash-container">
|
||||
<!-- Hero Section -->
|
||||
<section class="splash-hero">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">Transform Your Productivity</h1>
|
||||
<p class="hero-subtitle">Experience the future of time management with {{ g.branding.app_name if g.branding else 'TimeTrack' }}'s intelligent tracking system</p>
|
||||
<div class="cta-buttons">
|
||||
<a href="{{ url_for('register') }}" class="btn-primary">Get Started Free</a>
|
||||
<a href="{{ url_for('login') }}" class="btn-secondary">Sign In</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-visual">
|
||||
<div class="floating-clock">
|
||||
<div class="clock-face">
|
||||
<div class="hour-hand"></div>
|
||||
<div class="minute-hand"></div>
|
||||
<div class="second-hand"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<section class="features-grid">
|
||||
<h2 class="section-title">Powerful Features for Modern Teams</h2>
|
||||
<div class="feature-cards">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<h3>Lightning Fast</h3>
|
||||
<p>Start tracking in seconds with our intuitive one-click interface</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h3>Advanced Analytics</h3>
|
||||
<p>Gain insights with comprehensive reports and visual dashboards</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🏃♂️</div>
|
||||
<h3>Sprint Management</h3>
|
||||
<p>Organize work into sprints with agile project tracking</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">👥</div>
|
||||
<h3>Team Collaboration</h3>
|
||||
<p>Manage teams, projects, and resources all in one place</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔒</div>
|
||||
<h3>Enterprise Security</h3>
|
||||
<p>Bank-level encryption with role-based access control</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌐</div>
|
||||
<h3>Multi-Company Support</h3>
|
||||
<p>Perfect for agencies managing multiple client accounts</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Why Choose Section -->
|
||||
<section class="statistics">
|
||||
<h2 class="section-title">Why Choose {{ g.branding.app_name if g.branding else 'TimeTrack' }}?</h2>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">100%</div>
|
||||
<div class="stat-label">Free & Open Source</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">∞</div>
|
||||
<div class="stat-label">Unlimited Tracking</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">0</div>
|
||||
<div class="stat-label">Hidden Fees</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">24/7</div>
|
||||
<div class="stat-label">Always Available</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Getting Started Section -->
|
||||
<section class="testimonials">
|
||||
<h2 class="section-title">Get Started in Minutes</h2>
|
||||
<div class="testimonial-grid">
|
||||
<div class="testimonial-card">
|
||||
<div class="feature-icon">1️⃣</div>
|
||||
<h3>Sign Up</h3>
|
||||
<p>Create your free account in seconds. No credit card required.</p>
|
||||
</div>
|
||||
<div class="testimonial-card">
|
||||
<div class="feature-icon">2️⃣</div>
|
||||
<h3>Set Up Your Workspace</h3>
|
||||
<p>Add your company, teams, and projects to organize your time tracking.</p>
|
||||
</div>
|
||||
<div class="testimonial-card">
|
||||
<div class="feature-icon">3️⃣</div>
|
||||
<h3>Start Tracking</h3>
|
||||
<p>Click "Arrive" to start tracking, "Leave" when done. It's that simple!</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Open Source Section -->
|
||||
<section class="pricing">
|
||||
<h2 class="section-title">Forever Free, Forever Open</h2>
|
||||
<div class="pricing-cards">
|
||||
<div class="pricing-card featured">
|
||||
<div class="badge">100% Free</div>
|
||||
<h3>{{ g.branding.app_name if g.branding else 'TimeTrack' }} Community</h3>
|
||||
<div class="price">$0<span>/forever</span></div>
|
||||
<ul class="pricing-features">
|
||||
<li>✓ Unlimited users</li>
|
||||
<li>✓ All features included</li>
|
||||
<li>✓ Time tracking & analytics</li>
|
||||
<li>✓ Sprint management</li>
|
||||
<li>✓ Team collaboration</li>
|
||||
<li>✓ Project management</li>
|
||||
<li>✓ Self-hosted option</li>
|
||||
<li>✓ No restrictions</li>
|
||||
</ul>
|
||||
<a href="{{ url_for('register') }}" class="btn-pricing">Get Started Free</a>
|
||||
</div>
|
||||
</div>
|
||||
<p style="text-align: center; margin-top: 2rem; color: #666;">
|
||||
The software {{ g.branding.app_name if g.branding else 'TimeTrack' }} runs is open source software.<br />
|
||||
Host it yourself or use our free hosted version.<br />
|
||||
The source is available on GitHub:
|
||||
<a href="https://github.com/nullmedium/TimeTrack" target="_blank">https://github.com/nullmedium/TimeTrack</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Final CTA -->
|
||||
<section class="final-cta">
|
||||
<h2>Ready to Take Control of Your Time?</h2>
|
||||
<p>Start tracking your time effectively today - no strings attached</p>
|
||||
<a href="{{ url_for('register') }}" class="btn-primary large">Create Free Account</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
@@ -115,27 +248,6 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<h3>Easy Time Tracking</h3>
|
||||
<p>Simply click "Arrive" when you start working and "Leave" when you're done.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>Break Management</h3>
|
||||
<p>Use the Pause button when taking breaks. Your break time is tracked separately.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>Detailed History</h3>
|
||||
<p>View your complete work history with precise timestamps and durations.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>Simple Interface</h3>
|
||||
<p>No complicated setup or configuration needed. Start tracking right away!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Entry Modal -->
|
||||
<div id="edit-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
@@ -578,4 +690,6 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -3,17 +3,69 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }} - TimeTrack{% if g.company %} - {{ g.company.name }}{% endif %}</title>
|
||||
<title>{{ title }} - {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% if g.company %} - {{ g.company.name }}{% endif %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
{% if not g.user %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/splash.css') }}">
|
||||
{% endif %}
|
||||
{% if g.branding and g.branding.favicon_filename %}
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='uploads/branding/' + g.branding.favicon_filename) }}">
|
||||
{% endif %}
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: {{ g.branding.primary_color if g.branding else '#007bff' }};
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: {{ (g.branding.primary_color if g.branding else '#007bff') + 'dd' }};
|
||||
border-color: {{ (g.branding.primary_color if g.branding else '#007bff') + 'dd' }};
|
||||
}
|
||||
.nav-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
a:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.mobile-logo {
|
||||
max-height: 30px;
|
||||
max-width: 150px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.sidebar-logo {
|
||||
max-height: 32px;
|
||||
max-width: 160px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.mobile-nav-brand a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.sidebar-header h2 a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body{% if g.user %} class="has-user"{% endif %}>
|
||||
<!-- Mobile header -->
|
||||
{% if g.user %}
|
||||
<header class="mobile-header">
|
||||
<div class="mobile-nav-brand">
|
||||
<a href="{{ url_for('home') }}">TimeTrack</a>
|
||||
{% if g.company %}
|
||||
<small class="company-name">{{ g.company.name }}</small>
|
||||
<a href="{{ url_for('home') }}">
|
||||
{% if g.branding and g.branding.logo_filename %}
|
||||
<img src="{{ url_for('static', filename='uploads/branding/' + g.branding.logo_filename) }}"
|
||||
alt="{{ g.branding.logo_alt_text }}"
|
||||
class="mobile-logo">
|
||||
{% else %}
|
||||
{{ g.branding.app_name if g.branding else 'TimeTrack' }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<button class="mobile-nav-toggle" id="mobile-nav-toggle">
|
||||
<span></span>
|
||||
@@ -21,33 +73,70 @@
|
||||
<span></span>
|
||||
</button>
|
||||
</header>
|
||||
{% endif %}
|
||||
|
||||
<!-- Sidebar navigation -->
|
||||
{% if g.user %}
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2><a href="{{ url_for('home') }}">TimeTrack</a></h2>
|
||||
{% if g.company %}
|
||||
<div class="company-info">
|
||||
<small class="text-muted">{{ g.company.name }}</small>
|
||||
</div>
|
||||
<!-- <h2>
|
||||
<a href="{{ url_for('home') }}">
|
||||
{% if g.branding and g.branding.logo_filename %}
|
||||
<img src="{{ url_for('static', filename='uploads/branding/' + g.branding.logo_filename) }}"
|
||||
alt="{{ g.branding.logo_alt_text }}"
|
||||
class="sidebar-logo">
|
||||
{% else %}
|
||||
{{ g.branding.app_name if g.branding else 'TimeTrack' }}
|
||||
{% endif %}
|
||||
</a>
|
||||
-->
|
||||
</h2>
|
||||
<button class="sidebar-toggle" id="sidebar-toggle">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
{% if g.user %}
|
||||
<!-- User Account Menu -->
|
||||
<a href="#" class="user-dropdown-toggle" id="user-dropdown-toggle" data-tooltip="{{ g.user.username }}">
|
||||
<img src="{{ g.user.get_avatar_url(32) }}" alt="{{ g.user.username }}" class="user-avatar">
|
||||
<span class="nav-text">{{ g.user.username }}<span class="dropdown-arrow">▼</span></span>
|
||||
</a>
|
||||
|
||||
<!-- User Dropdown Context Menu -->
|
||||
<div class="user-dropdown-modal" id="user-dropdown-modal">
|
||||
<div class="user-dropdown-header">
|
||||
<img src="{{ g.user.get_avatar_url(64) }}" alt="{{ g.user.username }}" class="user-avatar-large">
|
||||
<h3>{{ g.user.username }}</h3>
|
||||
<div class="user-info">
|
||||
{% if g.user.email %}
|
||||
{{ g.user.email }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-dropdown-menu">
|
||||
<ul>
|
||||
<li><a href="{{ url_for('profile') }}"><i class="nav-icon">👤</i>Profile</a></li>
|
||||
<li><a href="{{ url_for('config') }}"><i class="nav-icon">⚙️</i>Settings</a></li>
|
||||
<li class="user-dropdown-divider"></li>
|
||||
<li><a href="{{ url_for('logout') }}"><i class="nav-icon">🚪</i>Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<ul>
|
||||
{% if g.user %}
|
||||
<li><a href="{{ url_for('home') }}" data-tooltip="Home"><i class="nav-icon">🏠</i><span class="nav-text">Home</span></a></li>
|
||||
<li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📊</i><span class="nav-text">Dashboard</span></a></li>
|
||||
<li><a href="{{ url_for('unified_task_management') }}" data-tooltip="Task Management"><i class="nav-icon">📋</i><span class="nav-text">Task Management</span></a></li>
|
||||
<li><a href="{{ url_for('sprint_management') }}" data-tooltip="Sprint Management"><i class="nav-icon">🏃♂️</i><span class="nav-text">Sprints</span></a></li>
|
||||
<li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</span></a></li>
|
||||
|
||||
<!-- Role-based menu items -->
|
||||
{% if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN %}
|
||||
<li class="nav-divider">Admin</li>
|
||||
<li><a href="{{ url_for('profile') }}" data-tooltip="Profile"><i class="nav-icon">👤</i><span class="nav-text">Profile</span></a></li>
|
||||
<li><a href="{{ url_for('config') }}" data-tooltip="Config"><i class="nav-icon">⚙️</i><span class="nav-text">Config</span></a></li>
|
||||
<li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📈</i><span class="nav-text">Dashboard</span></a></li>
|
||||
<li><a href="{{ url_for('admin_company') }}" data-tooltip="Company"><i class="nav-icon">🏢</i><span class="nav-text">Company</span></a></li>
|
||||
<li><a href="{{ url_for('admin_users') }}" data-tooltip="Manage Users"><i class="nav-icon">👥</i><span class="nav-text">Manage Users</span></a></li>
|
||||
@@ -61,21 +150,12 @@
|
||||
<li><a href="{{ url_for('system_admin_announcements') }}" data-tooltip="Announcements"><i class="nav-icon">📢</i><span class="nav-text">Announcements</span></a></li>
|
||||
{% endif %}
|
||||
{% elif g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
|
||||
<li class="nav-divider">{{ g.user.username }}</li>
|
||||
<li><a href="{{ url_for('profile') }}" data-tooltip="Profile"><i class="nav-icon">👤</i><span class="nav-text">Profile</span></a></li>
|
||||
<li><a href="{{ url_for('config') }}" data-tooltip="Config"><i class="nav-icon">⚙️</i><span class="nav-text">Config</span></a></li>
|
||||
<li class="nav-divider">Team</li>
|
||||
<li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📈</i><span class="nav-text">Dashboard</span></a></li>
|
||||
{% if g.user.role == Role.SUPERVISOR %}
|
||||
<li><a href="{{ url_for('admin_projects') }}" data-tooltip="Manage Projects"><i class="nav-icon">📝</i><span class="nav-text">Manage Projects</span></a></li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="nav-divider">{{ g.user.username }}</li>
|
||||
<li><a href="{{ url_for('profile') }}" data-tooltip="Profile"><i class="nav-icon">👤</i><span class="nav-text">Profile</span></a></li>
|
||||
<li><a href="{{ url_for('config') }}" data-tooltip="Config"><i class="nav-icon">⚙️</i><span class="nav-text">Config</span></a></li>
|
||||
{% endif %}
|
||||
|
||||
<li class="nav-divider"></li>
|
||||
<li><a href="{{ url_for('logout') }}" data-tooltip="Logout"><i class="nav-icon">🚪</i><span class="nav-text">Logout</span></a></li>
|
||||
{% else %}
|
||||
<li><a href="{{ url_for('about') }}" data-tooltip="About"><i class="nav-icon">ℹ️</i><span class="nav-text">About</span></a></li>
|
||||
<li><a href="{{ url_for('login') }}" data-tooltip="Login"><i class="nav-icon">🔑</i><span class="nav-text">Login</span></a></li>
|
||||
@@ -84,9 +164,12 @@
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
{% endif %}
|
||||
|
||||
<!-- Mobile overlay -->
|
||||
{% if g.user %}
|
||||
<div class="mobile-overlay" id="mobile-overlay"></div>
|
||||
{% endif %}
|
||||
|
||||
<main class="main-content">
|
||||
<!-- System Announcements -->
|
||||
@@ -123,15 +206,79 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Email Nag Screens -->
|
||||
{% if g.show_email_nag %}
|
||||
<div class="email-nag-banner">
|
||||
<div class="email-nag-content">
|
||||
<span class="email-nag-icon">📧</span>
|
||||
<span class="email-nag-text">
|
||||
<strong>Add your email address</strong> to enable account recovery and receive important notifications.
|
||||
</span>
|
||||
<a href="{{ url_for('profile') }}" class="btn btn-sm btn-primary">Add Email</a>
|
||||
<button class="email-nag-dismiss" onclick="dismissEmailNag()" title="Dismiss for this session">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% elif g.show_email_verification_nag %}
|
||||
<div class="email-nag-banner email-verify">
|
||||
<div class="email-nag-content">
|
||||
<span class="email-nag-icon">✉️</span>
|
||||
<span class="email-nag-text">
|
||||
<strong>Please verify your email address</strong> to ensure you can recover your account if needed.
|
||||
</span>
|
||||
<a href="{{ url_for('profile') }}" class="btn btn-sm btn-warning">Verify Email</a>
|
||||
<button class="email-nag-dismiss" onclick="dismissEmailNag()" title="Dismiss for this session">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© {{ current_year }} TimeTrack. All rights reserved.</p>
|
||||
<footer class="site-footer">
|
||||
<div class="footer-content">
|
||||
<div class="footer-info">
|
||||
<p>© {{ current_year }} {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% if g.company %} - {{ g.company.name }}{% endif %}. All rights reserved.</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="{{ url_for('about') }}">About</a>
|
||||
{% if g.branding and g.branding.imprint_enabled %}
|
||||
<span class="footer-separator">•</span>
|
||||
<a href="{{ url_for('imprint') }}">{{ g.branding.imprint_title or 'Imprint' }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/sidebar.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
|
||||
{% if g.user %}
|
||||
<script src="{{ url_for('static', filename='js/user-dropdown.js') }}"></script>
|
||||
<script>
|
||||
function dismissEmailNag() {
|
||||
// Hide the banner with animation
|
||||
const banner = document.querySelector('.email-nag-banner');
|
||||
if (banner) {
|
||||
banner.style.animation = 'slideUp 0.3s ease-out';
|
||||
setTimeout(() => {
|
||||
banner.style.display = 'none';
|
||||
}, 300);
|
||||
}
|
||||
// Store in session storage to not show again this session
|
||||
sessionStorage.setItem('emailNagDismissed', 'true');
|
||||
}
|
||||
|
||||
// Check if already dismissed this session
|
||||
if (sessionStorage.getItem('emailNagDismissed') === 'true') {
|
||||
const banner = document.querySelector('.email-nag-banner');
|
||||
if (banner) {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% else %}
|
||||
<script src="{{ url_for('static', filename='js/splash.js') }}"></script>
|
||||
{% endif %}
|
||||
|
||||
<!-- Custom Tracking Script -->
|
||||
{% if tracking_script_enabled and tracking_script_code %}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - {{ g.branding.app_name if g.branding else 'TimeTrack' }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<div class="auth-container">
|
||||
<h1>Login to TimeTrack</h1>
|
||||
<div class="auth-brand">
|
||||
<h1>Welcome Back</h1>
|
||||
<p>Sign in to {{ g.branding.app_name if g.branding else 'TimeTrack' }}</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
@@ -13,31 +24,42 @@
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('login') }}" class="auth-form">
|
||||
<div class="form-group">
|
||||
<div class="form-group input-icon">
|
||||
<i>👤</i>
|
||||
<input type="text" id="username" name="username" class="form-control" placeholder="Enter your username" required autofocus>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group input-icon">
|
||||
<i>🔒</i>
|
||||
<input type="password" id="password" name="password" class="form-control" placeholder="Enter your password" required>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" name="remember"> Remember me
|
||||
<span class="checkmark"></span>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="remember" name="remember">
|
||||
<label class="form-check-label" for="remember">
|
||||
Keep me signed in
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
<button type="submit" class="btn btn-primary">Sign In</button>
|
||||
</div>
|
||||
|
||||
<div class="social-divider">
|
||||
<span>New to {{ g.branding.app_name if g.branding else 'TimeTrack' }}?</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-links">
|
||||
<p>Don't have an account?</p>
|
||||
<p><a href="{{ url_for('register') }}">Register with Company Code</a> | <a href="{{ url_for('register_freelancer') }}">Register as Freelancer</a></p>
|
||||
<p>
|
||||
<a href="{{ url_for('register') }}" style="margin-right: 1rem;">Join a Company</a>
|
||||
<a href="{{ url_for('register_freelancer') }}">Start Freelancing</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/auth-animations.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
783
templates/manage_project_tasks.html
Normal file
783
templates/manage_project_tasks.html
Normal file
@@ -0,0 +1,783 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-container timetrack-container">
|
||||
<div class="page-header task-header">
|
||||
<div class="project-info">
|
||||
<h2>Tasks for Project: {{ project.code }} - {{ project.name }}</h2>
|
||||
<p class="project-description">{{ project.description or 'No description available' }}</p>
|
||||
{% if project.category %}
|
||||
<span class="category-badge" style="background-color: {{ project.category.color }}20; color: {{ project.category.color }};">
|
||||
{{ project.category.icon or '📁' }} {{ project.category.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="page-actions task-actions">
|
||||
<button id="create-task-btn" class="btn btn-success">Create New Task</button>
|
||||
<a href="{{ url_for('admin_projects') }}" class="btn btn-secondary">Back to Projects</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Statistics -->
|
||||
<div class="task-stats">
|
||||
<div class="stat-card">
|
||||
<h3>{{ tasks|length }}</h3>
|
||||
<p>Total Tasks</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{ tasks|selectattr('status.value', 'equalto', 'Completed')|list|length }}</h3>
|
||||
<p>Completed</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{ tasks|selectattr('status.value', 'equalto', 'In Progress')|list|length }}</h3>
|
||||
<p>In Progress</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{ tasks|selectattr('status.value', 'equalto', 'Not Started')|list|length }}</h3>
|
||||
<p>Not Started</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task List -->
|
||||
{% if tasks %}
|
||||
<div class="tasks-section">
|
||||
<h3>Project Tasks</h3>
|
||||
<div class="tasks-container">
|
||||
{% for task in tasks %}
|
||||
<div class="task-card" data-task-id="{{ task.id }}">
|
||||
<div class="task-header">
|
||||
<div class="task-title-area">
|
||||
<h4 class="task-name">{{ task.name }}</h4>
|
||||
<div class="task-meta">
|
||||
<span class="status-badge status-{{ task.status.value.lower().replace(' ', '-') }}">
|
||||
{{ task.status.value }}
|
||||
</span>
|
||||
<span class="priority-badge priority-{{ task.priority.value.lower() }}">
|
||||
{{ task.priority.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<button class="btn btn-xs btn-primary edit-task-btn" data-id="{{ task.id }}">Edit</button>
|
||||
<button class="btn btn-xs btn-info add-subtask-btn" data-id="{{ task.id }}">Add Subtask</button>
|
||||
<button class="btn btn-xs btn-danger delete-task-btn" data-id="{{ task.id }}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-body">
|
||||
<p class="task-description">{{ task.description or 'No description' }}</p>
|
||||
|
||||
<div class="task-details">
|
||||
<div class="task-detail">
|
||||
<strong>Assigned to:</strong>
|
||||
{% if task.assigned_to %}
|
||||
{{ task.assigned_to.username }}
|
||||
{% else %}
|
||||
<em>Unassigned</em>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="task-detail">
|
||||
<strong>Due Date:</strong>
|
||||
{% if task.due_date %}
|
||||
{{ task.due_date.strftime('%Y-%m-%d') }}
|
||||
{% else %}
|
||||
<em>No due date</em>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="task-detail">
|
||||
<strong>Estimated Hours:</strong>
|
||||
{% if task.estimated_hours %}
|
||||
{{ task.estimated_hours }}h
|
||||
{% else %}
|
||||
<em>Not estimated</em>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="task-detail">
|
||||
<strong>Time Logged:</strong>
|
||||
{{ (task.total_time_logged / 3600)|round(1) }}h
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-section">
|
||||
<div class="progress-info">
|
||||
<span>Progress: {{ task.progress_percentage }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {{ task.progress_percentage }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtasks -->
|
||||
{% if task.subtasks %}
|
||||
<div class="subtasks-section">
|
||||
<h5>Subtasks ({{ task.subtasks|length }})</h5>
|
||||
<div class="subtasks-list">
|
||||
{% for subtask in task.subtasks %}
|
||||
<div class="subtask-item" data-subtask-id="{{ subtask.id }}">
|
||||
<div class="subtask-content">
|
||||
<span class="subtask-name">{{ subtask.name }}</span>
|
||||
<span class="status-badge status-{{ subtask.status.value.lower().replace(' ', '-') }}">
|
||||
{{ subtask.status.value }}
|
||||
</span>
|
||||
{% if subtask.assigned_to %}
|
||||
<span class="subtask-assignee">{{ subtask.assigned_to.username }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="subtask-actions">
|
||||
<button class="btn btn-xs btn-primary edit-subtask-btn" data-id="{{ subtask.id }}">Edit</button>
|
||||
<button class="btn btn-xs btn-danger delete-subtask-btn" data-id="{{ subtask.id }}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-tasks">
|
||||
<div class="no-tasks-content">
|
||||
<h3>No Tasks Created Yet</h3>
|
||||
<p>Start organizing your project by creating tasks.</p>
|
||||
<button id="create-first-task-btn" class="btn btn-primary">Create Your First Task</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Task Creation/Edit Modal -->
|
||||
<div id="task-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<h3 id="task-modal-title">Create Task</h3>
|
||||
<form id="task-form">
|
||||
<input type="hidden" id="task-id" name="task_id">
|
||||
<input type="hidden" id="project-id" name="project_id" value="{{ project.id }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="task-name">Task Name *</label>
|
||||
<input type="text" id="task-name" name="name" required maxlength="200">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="task-description">Description</label>
|
||||
<textarea id="task-description" name="description" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="task-status">Status</label>
|
||||
<select id="task-status" name="status">
|
||||
<option value="Not Started">Not Started</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="On Hold">On Hold</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="task-priority">Priority</label>
|
||||
<select id="task-priority" name="priority">
|
||||
<option value="Low">Low</option>
|
||||
<option value="Medium" selected>Medium</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="task-assigned-to">Assigned To</label>
|
||||
<select id="task-assigned-to" name="assigned_to_id">
|
||||
<option value="">Unassigned</option>
|
||||
{% for user in team_members %}
|
||||
<option value="{{ user.id }}">{{ user.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="task-estimated-hours">Estimated Hours</label>
|
||||
<input type="number" id="task-estimated-hours" name="estimated_hours" step="0.5" min="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="task-start-date">Start Date</label>
|
||||
<input type="date" id="task-start-date" name="start_date">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="task-due-date">Due Date</label>
|
||||
<input type="date" id="task-due-date" name="due_date">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Task</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancel-task">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtask Creation/Edit Modal -->
|
||||
<div id="subtask-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<h3 id="subtask-modal-title">Create Subtask</h3>
|
||||
<form id="subtask-form">
|
||||
<input type="hidden" id="subtask-id" name="subtask_id">
|
||||
<input type="hidden" id="parent-task-id" name="task_id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subtask-name">Subtask Name *</label>
|
||||
<input type="text" id="subtask-name" name="name" required maxlength="200">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subtask-description">Description</label>
|
||||
<textarea id="subtask-description" name="description" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subtask-status">Status</label>
|
||||
<select id="subtask-status" name="status">
|
||||
<option value="Not Started">Not Started</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="On Hold">On Hold</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subtask-priority">Priority</label>
|
||||
<select id="subtask-priority" name="priority">
|
||||
<option value="Low">Low</option>
|
||||
<option value="Medium" selected>Medium</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subtask-assigned-to">Assigned To</label>
|
||||
<select id="subtask-assigned-to" name="assigned_to_id">
|
||||
<option value="">Unassigned</option>
|
||||
{% for user in team_members %}
|
||||
<option value="{{ user.id }}">{{ user.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subtask-estimated-hours">Estimated Hours</label>
|
||||
<input type="number" id="subtask-estimated-hours" name="estimated_hours" step="0.5" min="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subtask-start-date">Start Date</label>
|
||||
<input type="date" id="subtask-start-date" name="start_date">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subtask-due-date">Due Date</label>
|
||||
<input type="date" id="subtask-due-date" name="due_date">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Subtask</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancel-subtask">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.project-info h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.project-description {
|
||||
color: #666;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 2rem;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.stat-card p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tasks-container {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.task-header {
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.task-title-area {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.task-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.task-detail {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #4CAF50;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-not-started { background: #f8d7da; color: #721c24; }
|
||||
.status-in-progress { background: #d1ecf1; color: #0c5460; }
|
||||
.status-on-hold { background: #fff3cd; color: #856404; }
|
||||
.status-completed { background: #d4edda; color: #155724; }
|
||||
.status-cancelled { background: #f8d7da; color: #721c24; }
|
||||
|
||||
.priority-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.priority-low { background: #e2e3e5; color: #383d41; }
|
||||
.priority-medium { background: #b8daff; color: #004085; }
|
||||
.priority-high { background: #ffeaa7; color: #856404; }
|
||||
.priority-urgent { background: #f5c6cb; color: #721c24; }
|
||||
|
||||
.subtasks-section {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.subtasks-section h5 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.subtasks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.subtask-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.subtask-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subtask-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.subtask-assignee {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.subtask-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.no-tasks {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.no-tasks-content {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.no-tasks h3 {
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.no-tasks p {
|
||||
color: #999;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.task-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const taskModal = document.getElementById('task-modal');
|
||||
const subtaskModal = document.getElementById('subtask-modal');
|
||||
const taskForm = document.getElementById('task-form');
|
||||
const subtaskForm = document.getElementById('subtask-form');
|
||||
|
||||
// Task Modal Functions
|
||||
function openTaskModal(taskData = null) {
|
||||
const isEdit = taskData !== null;
|
||||
document.getElementById('task-modal-title').textContent = isEdit ? 'Edit Task' : 'Create Task';
|
||||
|
||||
if (isEdit) {
|
||||
document.getElementById('task-id').value = taskData.id;
|
||||
document.getElementById('task-name').value = taskData.name;
|
||||
document.getElementById('task-description').value = taskData.description || '';
|
||||
document.getElementById('task-status').value = taskData.status;
|
||||
document.getElementById('task-priority').value = taskData.priority;
|
||||
document.getElementById('task-assigned-to').value = taskData.assigned_to_id || '';
|
||||
document.getElementById('task-estimated-hours').value = taskData.estimated_hours || '';
|
||||
document.getElementById('task-start-date').value = taskData.start_date || '';
|
||||
document.getElementById('task-due-date').value = taskData.due_date || '';
|
||||
} else {
|
||||
taskForm.reset();
|
||||
document.getElementById('task-id').value = '';
|
||||
document.getElementById('task-priority').value = 'Medium';
|
||||
}
|
||||
|
||||
taskModal.style.display = 'block';
|
||||
}
|
||||
|
||||
function openSubtaskModal(taskId, subtaskData = null) {
|
||||
const isEdit = subtaskData !== null;
|
||||
document.getElementById('subtask-modal-title').textContent = isEdit ? 'Edit Subtask' : 'Create Subtask';
|
||||
document.getElementById('parent-task-id').value = taskId;
|
||||
|
||||
if (isEdit) {
|
||||
document.getElementById('subtask-id').value = subtaskData.id;
|
||||
document.getElementById('subtask-name').value = subtaskData.name;
|
||||
document.getElementById('subtask-description').value = subtaskData.description || '';
|
||||
document.getElementById('subtask-status').value = subtaskData.status;
|
||||
document.getElementById('subtask-priority').value = subtaskData.priority;
|
||||
document.getElementById('subtask-assigned-to').value = subtaskData.assigned_to_id || '';
|
||||
document.getElementById('subtask-estimated-hours').value = subtaskData.estimated_hours || '';
|
||||
document.getElementById('subtask-start-date').value = subtaskData.start_date || '';
|
||||
document.getElementById('subtask-due-date').value = subtaskData.due_date || '';
|
||||
} else {
|
||||
subtaskForm.reset();
|
||||
document.getElementById('subtask-id').value = '';
|
||||
document.getElementById('subtask-priority').value = 'Medium';
|
||||
}
|
||||
|
||||
subtaskModal.style.display = 'block';
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
document.getElementById('create-task-btn').addEventListener('click', () => openTaskModal());
|
||||
document.getElementById('create-first-task-btn')?.addEventListener('click', () => openTaskModal());
|
||||
|
||||
// Task Actions
|
||||
document.querySelectorAll('.edit-task-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const taskId = this.getAttribute('data-id');
|
||||
// Fetch task data and open modal (implement API call)
|
||||
fetch(`/api/tasks/${taskId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
openTaskModal(data.task);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.add-subtask-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const taskId = this.getAttribute('data-id');
|
||||
openSubtaskModal(taskId);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.delete-task-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const taskId = this.getAttribute('data-id');
|
||||
if (confirm('Are you sure you want to delete this task? All subtasks will also be deleted.')) {
|
||||
fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Subtask Actions
|
||||
document.querySelectorAll('.edit-subtask-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const subtaskId = this.getAttribute('data-id');
|
||||
const taskId = this.closest('.task-card').getAttribute('data-task-id');
|
||||
// Fetch subtask data and open modal
|
||||
fetch(`/api/subtasks/${subtaskId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
openSubtaskModal(taskId, data.subtask);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.delete-subtask-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const subtaskId = this.getAttribute('data-id');
|
||||
if (confirm('Are you sure you want to delete this subtask?')) {
|
||||
fetch(`/api/subtasks/${subtaskId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Form Submissions
|
||||
taskForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const taskId = formData.get('task_id');
|
||||
const isEdit = taskId !== '';
|
||||
|
||||
const url = isEdit ? `/api/tasks/${taskId}` : '/api/tasks';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(Object.fromEntries(formData))
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
taskModal.style.display = 'none';
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
subtaskForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const subtaskId = formData.get('subtask_id');
|
||||
const isEdit = subtaskId !== '';
|
||||
|
||||
const url = isEdit ? `/api/subtasks/${subtaskId}` : '/api/subtasks';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(Object.fromEntries(formData))
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
subtaskModal.style.display = 'none';
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Modal Close Events
|
||||
document.querySelectorAll('.close').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
this.closest('.modal').style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('cancel-task').addEventListener('click', () => {
|
||||
taskModal.style.display = 'none';
|
||||
});
|
||||
|
||||
document.getElementById('cancel-subtask').addEventListener('click', () => {
|
||||
subtaskModal.style.display = 'none';
|
||||
});
|
||||
|
||||
// Close modals when clicking outside
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target === taskModal) {
|
||||
taskModal.style.display = 'none';
|
||||
}
|
||||
if (event.target === subtaskModal) {
|
||||
subtaskModal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -12,47 +12,150 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="profile-info">
|
||||
<p><strong>Username:</strong> {{ user.username }}</p>
|
||||
<p><strong>Account Type:</strong> {{ user.role.value if user.role else 'Team Member' }}</p>
|
||||
<p><strong>Member Since:</strong> {{ user.created_at.strftime('%Y-%m-%d') }}</p>
|
||||
<p><strong>Two-Factor Authentication:</strong>
|
||||
{% if user.two_factor_enabled %}
|
||||
<span class="status enabled">✅ Enabled</span>
|
||||
{% else %}
|
||||
<span class="status disabled">❌ Disabled</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="profile-grid">
|
||||
<!-- Avatar Card -->
|
||||
<div class="profile-card avatar-card">
|
||||
<h3>Profile Picture</h3>
|
||||
<div class="avatar-section">
|
||||
<img src="{{ user.get_avatar_url(128) }}" alt="{{ user.username }}" class="profile-avatar" id="avatar-preview">
|
||||
<div class="avatar-info">
|
||||
<p><strong>{{ user.username }}</strong></p>
|
||||
<p class="text-muted">{{ user.role.value if user.role else 'Team Member' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Profile Settings</h2>
|
||||
<div class="avatar-controls">
|
||||
<h4>Change Avatar</h4>
|
||||
<div class="avatar-options">
|
||||
<div class="avatar-option">
|
||||
<input type="radio" id="avatar-default" name="avatar-type" value="default" checked>
|
||||
<label for="avatar-default">Default Avatar</label>
|
||||
</div>
|
||||
<div class="avatar-option">
|
||||
<input type="radio" id="avatar-upload" name="avatar-type" value="upload">
|
||||
<label for="avatar-upload">Upload Image</label>
|
||||
</div>
|
||||
<div class="avatar-option">
|
||||
<input type="radio" id="avatar-url" name="avatar-type" value="url">
|
||||
<label for="avatar-url">Custom URL</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-card">
|
||||
<h3>Basic Information</h3>
|
||||
<form method="POST" action="{{ url_for('profile') }}" class="profile-form">
|
||||
<!-- Default Avatar Options -->
|
||||
<div id="default-avatar-options" class="avatar-option-panel">
|
||||
<p class="help-text">Your default avatar is generated based on your username.</p>
|
||||
<button type="button" class="btn btn-secondary" onclick="resetAvatar()">Use Default Avatar</button>
|
||||
</div>
|
||||
|
||||
<!-- Upload Avatar Options -->
|
||||
<div id="upload-avatar-options" class="avatar-option-panel" style="display: none;">
|
||||
<form method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data" class="avatar-upload-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" class="form-control" value="{{ user.email }}" required>
|
||||
<small>This email address is used for account verification and notifications.</small>
|
||||
<label for="avatar_file" class="file-upload-label">
|
||||
<span class="upload-icon">📁</span>
|
||||
<span class="upload-text">Choose an image file</span>
|
||||
<span class="file-name" id="file-name">No file selected</span>
|
||||
</label>
|
||||
<input type="file" id="avatar_file" name="avatar_file" class="file-input"
|
||||
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp" required>
|
||||
<small>Max file size: 5MB. Supported formats: JPG, PNG, GIF, WebP</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Update Email</button>
|
||||
<div class="upload-preview" id="upload-preview" style="display: none;">
|
||||
<img id="upload-preview-img" src="" alt="Preview">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="upload-btn" disabled>Upload Avatar</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- URL Avatar Options -->
|
||||
<div id="url-avatar-options" class="avatar-option-panel" style="display: none;">
|
||||
<form method="POST" action="{{ url_for('update_avatar') }}" class="avatar-form">
|
||||
<div class="form-group">
|
||||
<label for="avatar_url">Avatar URL</label>
|
||||
<input type="url" id="avatar_url" name="avatar_url" class="form-control"
|
||||
placeholder="https://example.com/avatar.jpg"
|
||||
value="{{ user.avatar_url or '' }}">
|
||||
<small>Enter a direct link to an image (PNG, JPG, GIF)</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Update Avatar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Info Card -->
|
||||
<div class="profile-card">
|
||||
<h3>Account Information</h3>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Username</span>
|
||||
<span class="info-value">{{ user.username }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Email</span>
|
||||
<span class="info-value">
|
||||
{% if user.email %}
|
||||
{{ user.email }}
|
||||
{% if not user.is_verified %}
|
||||
<span class="badge badge-warning">Unverified</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">Not provided</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Role</span>
|
||||
<span class="info-value">{{ user.role.value if user.role else 'Team Member' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Company</span>
|
||||
<span class="info-value">{{ user.company.name if user.company else 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Team</span>
|
||||
<span class="info-value">{{ user.team.name if user.team else 'No Team' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Member Since</span>
|
||||
<span class="info-value">{{ user.created_at.strftime('%B %d, %Y') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Settings Card -->
|
||||
<div class="profile-card">
|
||||
<h3>Email Settings</h3>
|
||||
<form method="POST" action="{{ url_for('profile') }}" class="profile-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" class="form-control" value="{{ user.email or '' }}" placeholder="your@email.com">
|
||||
<small>This email address is used for account recovery and notifications.</small>
|
||||
</div>
|
||||
{% if user.email and not user.is_verified %}
|
||||
<div class="alert alert-warning">
|
||||
<i>⚠️</i> Your email address is not verified.
|
||||
<a href="{{ url_for('profile') }}" class="btn btn-sm btn-warning">Send Verification Email</a>
|
||||
</div>
|
||||
{% elif not user.email %}
|
||||
<div class="alert alert-info">
|
||||
<i>ℹ️</i> Adding an email address enables account recovery and important notifications.
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary">{% if user.email %}Update{% else %}Add{% endif %} Email</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Password Settings Card -->
|
||||
<div class="profile-card">
|
||||
<h3>Change Password</h3>
|
||||
<p>Update your account password to keep your account secure.</p>
|
||||
<form method="POST" action="{{ url_for('profile') }}" class="password-form">
|
||||
<!-- Hidden email field to maintain current email -->
|
||||
<input type="hidden" name="email" value="{{ user.email }}">
|
||||
<input type="hidden" name="email" value="{{ user.email or '' }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="current_password">Current Password</label>
|
||||
<input type="password" id="current_password" name="current_password" class="form-control" required>
|
||||
<small>Enter your current password to verify your identity.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -64,144 +167,267 @@
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Confirm New Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" class="form-control" required>
|
||||
<small>Re-enter your new password to confirm.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-warning">Change Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="security-section">
|
||||
<h2>Security Settings</h2>
|
||||
|
||||
<div class="security-card">
|
||||
<!-- Security Settings Card -->
|
||||
<div class="profile-card security-card">
|
||||
<h3>Two-Factor Authentication</h3>
|
||||
<div class="security-status">
|
||||
{% if user.two_factor_enabled %}
|
||||
<p>Two-factor authentication is <strong>enabled</strong> for your account. This adds an extra layer of security by requiring a code from your authenticator app when logging in.</p>
|
||||
<div class="status-badge enabled">
|
||||
<span class="status-icon">✅</span>
|
||||
<span>Enabled</span>
|
||||
</div>
|
||||
<p>Two-factor authentication adds an extra layer of security to your account.</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('disable_2fa') }}" class="disable-2fa-form" onsubmit="return confirm('Are you sure you want to disable two-factor authentication? This will make your account less secure.');">
|
||||
<form method="POST" action="{{ url_for('disable_2fa') }}" class="disable-2fa-form"
|
||||
onsubmit="return confirm('Are you sure you want to disable two-factor authentication?');">
|
||||
<div class="form-group">
|
||||
<label for="password_disable">Enter your password to disable 2FA:</label>
|
||||
<input type="password" id="password_disable" name="password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
|
||||
<button type="submit" class="btn btn-danger">Disable 2FA</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>Two-factor authentication is <strong>not enabled</strong> for your account. We strongly recommend enabling it to protect your account.</p>
|
||||
<p>With 2FA enabled, you'll need both your password and a code from your phone to log in.</p>
|
||||
|
||||
<a href="{{ url_for('setup_2fa') }}" class="btn btn-success">Enable Two-Factor Authentication</a>
|
||||
<div class="status-badge disabled">
|
||||
<span class="status-icon">❌</span>
|
||||
<span>Disabled</span>
|
||||
</div>
|
||||
<p>Enable two-factor authentication to add an extra layer of security to your account.</p>
|
||||
<a href="{{ url_for('setup_2fa') }}" class="btn btn-success">Enable 2FA</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.status.enabled {
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
.profile-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.status.disabled {
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
.profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.profile-card h3 {
|
||||
color: #007bff;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.profile-card p {
|
||||
color: #6c757d;
|
||||
color: #333;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.security-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
.profile-card h4 {
|
||||
color: #495057;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 1.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.security-card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
/* Avatar Section */
|
||||
.avatar-card {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.security-card h3 {
|
||||
color: #007bff;
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 4px solid #e9ecef;
|
||||
}
|
||||
|
||||
.avatar-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.avatar-info p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.avatar-controls {
|
||||
border-top: 1px solid #e9ecef;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.avatar-options {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.avatar-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.avatar-option input[type="radio"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-option label {
|
||||
cursor: pointer;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.avatar-option-panel {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* File Upload Styles */
|
||||
.file-upload-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.file-upload-label:hover {
|
||||
border-color: #007bff;
|
||||
background: #e7f3ff;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-preview {
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-preview img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
/* Account Info Grid */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Security Status */
|
||||
.security-status {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status-badge.enabled {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.status-badge.disabled {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.disable-2fa-form {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -217,9 +443,8 @@
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
@@ -235,5 +460,146 @@
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.profile-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.avatar-card {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-options {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Avatar type toggle
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const avatarTypeRadios = document.querySelectorAll('input[name="avatar-type"]');
|
||||
const defaultPanel = document.getElementById('default-avatar-options');
|
||||
const uploadPanel = document.getElementById('upload-avatar-options');
|
||||
const urlPanel = document.getElementById('url-avatar-options');
|
||||
const avatarUrlInput = document.getElementById('avatar_url');
|
||||
const avatarPreview = document.getElementById('avatar-preview');
|
||||
const fileInput = document.getElementById('avatar_file');
|
||||
const fileName = document.getElementById('file-name');
|
||||
const uploadPreview = document.getElementById('upload-preview');
|
||||
const uploadPreviewImg = document.getElementById('upload-preview-img');
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
|
||||
avatarTypeRadios.forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
// Hide all panels
|
||||
defaultPanel.style.display = 'none';
|
||||
uploadPanel.style.display = 'none';
|
||||
urlPanel.style.display = 'none';
|
||||
|
||||
// Show selected panel
|
||||
if (this.value === 'default') {
|
||||
defaultPanel.style.display = 'block';
|
||||
} else if (this.value === 'upload') {
|
||||
uploadPanel.style.display = 'block';
|
||||
} else if (this.value === 'url') {
|
||||
urlPanel.style.display = 'block';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// File input handling
|
||||
fileInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
// Update file name display
|
||||
fileName.textContent = file.name;
|
||||
|
||||
// Validate file size (5MB max)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('File size must be less than 5MB');
|
||||
this.value = '';
|
||||
fileName.textContent = 'No file selected';
|
||||
uploadPreview.style.display = 'none';
|
||||
uploadBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('Please select a valid image file (JPG, PNG, GIF, or WebP)');
|
||||
this.value = '';
|
||||
fileName.textContent = 'No file selected';
|
||||
uploadPreview.style.display = 'none';
|
||||
uploadBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Preview the image
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
uploadPreviewImg.src = e.target.result;
|
||||
uploadPreview.style.display = 'block';
|
||||
uploadBtn.disabled = false;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
fileName.textContent = 'No file selected';
|
||||
uploadPreview.style.display = 'none';
|
||||
uploadBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Preview avatar URL
|
||||
avatarUrlInput.addEventListener('input', function() {
|
||||
const url = this.value.trim();
|
||||
if (url && isValidUrl(url)) {
|
||||
// Test if image loads
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
avatarPreview.src = url;
|
||||
};
|
||||
img.onerror = function() {
|
||||
// Keep current avatar if URL is invalid
|
||||
avatarPreview.src = '{{ user.get_avatar_url(128) }}';
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function isValidUrl(string) {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetAvatar() {
|
||||
if (confirm('This will remove your custom avatar and use the default generated avatar. Continue?')) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '{{ url_for("update_avatar") }}';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'avatar_url';
|
||||
input.value = '';
|
||||
|
||||
form.appendChild(input);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,9 +1,19 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Register - {{ g.branding.app_name if g.branding else 'TimeTrack' }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<div class="auth-container">
|
||||
<h1>Register for TimeTrack</h1>
|
||||
<p class="text-muted">Join your company team</p>
|
||||
<div class="auth-brand">
|
||||
<h1>Welcome to {{ g.branding.app_name if g.branding else 'TimeTrack' }}</h1>
|
||||
<p>Join your company team</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
@@ -22,36 +32,47 @@
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('register') }}" class="auth-form">
|
||||
<div class="form-group">
|
||||
<div class="form-group company-code-group">
|
||||
<label for="company_code">Company Code</label>
|
||||
<input type="text" id="company_code" name="company_code" class="form-control" required autofocus
|
||||
placeholder="Enter your company code">
|
||||
<small class="form-text text-muted">Get this code from your company administrator.</small>
|
||||
placeholder="ENTER-CODE">
|
||||
<small class="form-text text-muted">Get this code from your company administrator</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group input-icon">
|
||||
<i>👤</i>
|
||||
<input type="text" id="username" name="username" class="form-control" placeholder="Choose a username" required>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" class="form-control" required>
|
||||
<small class="form-text text-muted">A verification link will be sent to this email address.</small>
|
||||
<div class="form-group input-icon">
|
||||
<i>📧</i>
|
||||
<input type="email" id="email" name="email" class="form-control" placeholder="your@email.com (optional)">
|
||||
<label for="email">Email Address (Optional)</label>
|
||||
<small class="form-text text-muted">Recommended for account recovery and notifications</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group input-icon">
|
||||
<i>🔒</i>
|
||||
<input type="password" id="password" name="password" class="form-control" placeholder="Create a strong password" required>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group input-icon">
|
||||
<i>🔒</i>
|
||||
<input type="password" id="confirm_password" name="confirm_password" class="form-control" placeholder="Confirm your password" required>
|
||||
<label for="confirm_password">Confirm Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="terms" required>
|
||||
<label class="form-check-label" for="terms">
|
||||
I agree to the Terms of Service and Privacy Policy
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Register</button>
|
||||
<button type="submit" class="btn btn-primary">Create Account</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-links">
|
||||
@@ -59,8 +80,12 @@
|
||||
</div>
|
||||
|
||||
<div class="verification-notice">
|
||||
<p>After registration, you'll need to verify your email address before you can log in.</p>
|
||||
<p>💡 You can register without an email, but we recommend adding one later for account recovery.</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/auth-animations.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +1,19 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Freelancer Registration - TimeTrack</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
<div class="auth-container">
|
||||
<h1>Register as Freelancer</h1>
|
||||
<p class="text-muted">Create your independent freelancer account</p>
|
||||
<div class="auth-brand">
|
||||
<h1>Start Freelancing</h1>
|
||||
<p>Create your independent workspace</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
@@ -13,36 +23,64 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="registration-progress">
|
||||
<div class="progress-step active">
|
||||
<div class="progress-step-number">1</div>
|
||||
<div class="progress-step-label">Account Info</div>
|
||||
</div>
|
||||
<div class="progress-step">
|
||||
<div class="progress-step-number">2</div>
|
||||
<div class="progress-step-label">Verify Email</div>
|
||||
</div>
|
||||
<div class="progress-step">
|
||||
<div class="progress-step-number">3</div>
|
||||
<div class="progress-step-label">Start Tracking</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('register_freelancer') }}" class="auth-form">
|
||||
<div class="form-group">
|
||||
<div class="form-group input-icon">
|
||||
<i>👤</i>
|
||||
<input type="text" id="username" name="username" class="form-control" placeholder="Choose your username" required autofocus>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" class="form-control" required>
|
||||
<div class="form-group input-icon">
|
||||
<i>📧</i>
|
||||
<input type="email" id="email" name="email" class="form-control" placeholder="your@email.com (optional)">
|
||||
<label for="email">Email Address (Optional)</label>
|
||||
<small class="form-text text-muted">Recommended for account recovery</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="business_name">Business Name (Optional)</label>
|
||||
<div class="form-group input-icon">
|
||||
<i>🏢</i>
|
||||
<input type="text" id="business_name" name="business_name" class="form-control"
|
||||
placeholder="Your business or freelance name">
|
||||
<small class="form-text text-muted">Leave blank to use your username as your workspace name.</small>
|
||||
placeholder="Your Business Name (optional)">
|
||||
<label for="business_name">Business Name</label>
|
||||
<small class="form-text text-muted">Leave blank to use your username as workspace name</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group input-icon">
|
||||
<i>🔒</i>
|
||||
<input type="password" id="password" name="password" class="form-control" placeholder="Create a strong password" required>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group input-icon">
|
||||
<i>🔒</i>
|
||||
<input type="password" id="confirm_password" name="confirm_password" class="form-control" placeholder="Confirm your password" required>
|
||||
<label for="confirm_password">Confirm Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="terms" required>
|
||||
<label class="form-check-label" for="terms">
|
||||
I agree to the Terms of Service and Privacy Policy
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Create Freelancer Account</button>
|
||||
<button type="submit" class="btn btn-primary">Create My Workspace</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-links">
|
||||
@@ -60,6 +98,20 @@
|
||||
<li>No company code required</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="social-divider">
|
||||
<span>Why choose TimeTrack?</span>
|
||||
</div>
|
||||
|
||||
<div class="freelancer-benefits" style="text-align: center; margin-top: 1.5rem;">
|
||||
<p style="color: #666; font-size: 0.9rem;">
|
||||
✓ Free forever ✓ No credit card required ✓ Unlimited projects
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/auth-animations.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="manual-entry">
|
||||
<h3>Can't scan? Enter this code manually:</h3>
|
||||
<div class="secret-code">{{ secret }}</div>
|
||||
<p><small>Account: {{ g.user.email }}<br>Issuer: TimeTrack</small></p>
|
||||
<p><small>Account: {{ g.user.email }}<br>Issuer: {{ g.branding.app_name }}</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,34 +175,7 @@
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
.security-notice {
|
||||
background: #fff3cd;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="admin-container">
|
||||
<h1>
|
||||
{% if is_initial_setup %}
|
||||
Welcome to TimeTrack
|
||||
Welcome to {{ g.branding.app_name }}
|
||||
{% else %}
|
||||
Create New Company
|
||||
{% endif %}
|
||||
@@ -14,12 +14,12 @@
|
||||
{% if is_initial_setup %}
|
||||
<div class="info-message">
|
||||
<h3>🎉 Let's Get Started!</h3>
|
||||
<p>Set up your company and create the first administrator account to begin using TimeTrack.</p>
|
||||
<p>Set up your company and create the first administrator account to begin using {{ g.branding.app_name }}.</p>
|
||||
</div>
|
||||
{% elif is_super_admin %}
|
||||
<div class="info-message">
|
||||
<h3>🏢 New Company Setup</h3>
|
||||
<p>Create a new company with its own administrator. This will be a separate organization within TimeTrack.</p>
|
||||
<p>Create a new company with its own administrator. This will be a separate organization within {{ g.branding.app_name }}.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="error-message">
|
||||
|
||||
912
templates/sprint_management.html
Normal file
912
templates/sprint_management.html
Normal file
@@ -0,0 +1,912 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="management-container sprint-management-container">
|
||||
<!-- Header Section -->
|
||||
<div class="management-header sprint-header">
|
||||
<h1>🏃♂️ Sprint Management</h1>
|
||||
<div class="management-controls sprint-controls">
|
||||
<!-- View Switcher -->
|
||||
<div class="view-switcher">
|
||||
<button class="view-btn active" data-view="active">Active Sprints</button>
|
||||
<button class="view-btn" data-view="all">All Sprints</button>
|
||||
<button class="view-btn" data-view="planning">Planning</button>
|
||||
<button class="view-btn" data-view="completed">Completed</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="management-actions sprint-actions">
|
||||
<button id="add-sprint-btn" class="btn btn-primary">+ New Sprint</button>
|
||||
<button id="refresh-sprints" class="btn btn-secondary">🔄 Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sprint Statistics -->
|
||||
<div class="management-stats sprint-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="total-sprints">0</div>
|
||||
<div class="stat-label">Total Sprints</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="active-sprints">0</div>
|
||||
<div class="stat-label">Active</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="completed-sprints">0</div>
|
||||
<div class="stat-label">Completed</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="total-tasks">0</div>
|
||||
<div class="stat-label">Total Tasks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sprint Grid -->
|
||||
<div class="management-grid sprint-grid" id="sprint-grid">
|
||||
<!-- Sprint cards will be populated here -->
|
||||
</div>
|
||||
|
||||
<!-- Loading and Error States -->
|
||||
<div id="loading-indicator" class="loading-spinner" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading sprints...</p>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="error-alert" style="display: none;">
|
||||
<p>Failed to load sprints. Please try again.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sprint Modal -->
|
||||
<div id="sprint-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content large">
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title">Sprint Details</h2>
|
||||
<span class="close" onclick="closeSprintModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="sprint-form">
|
||||
<input type="hidden" id="sprint-id">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sprint-name">Sprint Name *</label>
|
||||
<input type="text" id="sprint-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sprint-status">Status</label>
|
||||
<select id="sprint-status">
|
||||
<option value="PLANNING">Planning</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sprint-description">Description</label>
|
||||
<textarea id="sprint-description" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sprint-project">Project (Optional)</label>
|
||||
<select id="sprint-project">
|
||||
<option value="">Company-wide Sprint</option>
|
||||
{% for project in available_projects %}
|
||||
<option value="{{ project.id }}">{{ project.code }} - {{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sprint-capacity">Capacity (Hours)</label>
|
||||
<input type="number" id="sprint-capacity" min="0" step="1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sprint-start-date">Start Date *</label>
|
||||
<div class="hybrid-date-input">
|
||||
<input type="date" id="sprint-start-date-native" class="date-input-native" required>
|
||||
<input type="text" id="sprint-start-date" class="date-input-formatted" required placeholder="{{ "YYYY-MM-DD" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "ISO" else "MM/DD/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "US" else "DD/MM/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") in ["EU", "UK"] else "Mon, Dec 25, 2024" }}">
|
||||
<button type="button" class="calendar-picker-btn" onclick="openCalendarPicker('sprint-start-date')" title="Open calendar">📅</button>
|
||||
</div>
|
||||
<div class="date-error" id="sprint-start-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sprint-end-date">End Date *</label>
|
||||
<div class="hybrid-date-input">
|
||||
<input type="date" id="sprint-end-date-native" class="date-input-native" required>
|
||||
<input type="text" id="sprint-end-date" class="date-input-formatted" required placeholder="{{ "YYYY-MM-DD" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "ISO" else "MM/DD/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "US" else "DD/MM/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") in ["EU", "UK"] else "Mon, Dec 25, 2024" }}">
|
||||
<button type="button" class="calendar-picker-btn" onclick="openCalendarPicker('sprint-end-date')" title="Open calendar">📅</button>
|
||||
</div>
|
||||
<div class="date-error" id="sprint-end-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sprint-goal">Sprint Goal</label>
|
||||
<textarea id="sprint-goal" rows="3" placeholder="What is the main objective of this sprint?"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSprintModal()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveSprint()">Save Sprint</button>
|
||||
<button type="button" class="btn btn-danger" onclick="deleteSprint()" id="delete-sprint-btn" style="display: none;">Delete Sprint</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Styles -->
|
||||
<style>
|
||||
.sprint-management-container {
|
||||
padding: 1rem;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sprint-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sprint-header h1 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.sprint-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.view-switcher {
|
||||
display: flex;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.view-btn:hover:not(.active) {
|
||||
background: #e9ecef;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.sprint-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sprint-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.sprint-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
|
||||
.sprint-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sprint-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.sprint-dates {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sprint-progress {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.sprint-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metric {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.metric-number {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.sprint-goal {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Hybrid Date Input Styles */
|
||||
.hybrid-date-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.hybrid-date-input.compact {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.date-input-native {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: calc(100% - 35px); /* Leave space for calendar button */
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.date-input-formatted {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.calendar-picker-btn {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
z-index: 3;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-picker-btn:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.calendar-picker-btn.compact {
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hybrid-date-input.compact .date-input-formatted {
|
||||
padding: 0.375rem;
|
||||
font-size: 12px;
|
||||
width: 100px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sprint-metrics {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// User preferences for date formatting
|
||||
const USER_DATE_FORMAT = '{{ g.user.preferences.date_format if g.user.preferences else "ISO" }}';
|
||||
|
||||
// Date formatting utility function
|
||||
function formatUserDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
switch (USER_DATE_FORMAT) {
|
||||
case 'US':
|
||||
return date.toLocaleDateString('en-US'); // MM/DD/YYYY
|
||||
case 'EU':
|
||||
case 'UK':
|
||||
return date.toLocaleDateString('en-GB'); // DD/MM/YYYY
|
||||
case 'Readable':
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}); // Mon, Dec 25, 2024
|
||||
case 'ISO':
|
||||
default:
|
||||
return date.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
}
|
||||
}
|
||||
|
||||
// Date input formatting function - formats ISO date for user input
|
||||
function formatDateForInput(isoDateString) {
|
||||
if (!isoDateString) return '';
|
||||
|
||||
const date = new Date(isoDateString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
return formatUserDate(isoDateString);
|
||||
}
|
||||
|
||||
// Date parsing function - converts user-formatted date to ISO format
|
||||
function parseUserDate(dateString) {
|
||||
if (!dateString || dateString.trim() === '') return null;
|
||||
|
||||
const trimmed = dateString.trim();
|
||||
let date;
|
||||
|
||||
switch (USER_DATE_FORMAT) {
|
||||
case 'US': // MM/DD/YYYY
|
||||
const usParts = trimmed.split('/');
|
||||
if (usParts.length === 3) {
|
||||
const month = parseInt(usParts[0], 10);
|
||||
const day = parseInt(usParts[1], 10);
|
||||
const year = parseInt(usParts[2], 10);
|
||||
if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year > 1900) {
|
||||
date = new Date(year, month - 1, day);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EU':
|
||||
case 'UK': // DD/MM/YYYY
|
||||
const euParts = trimmed.split('/');
|
||||
if (euParts.length === 3) {
|
||||
const day = parseInt(euParts[0], 10);
|
||||
const month = parseInt(euParts[1], 10);
|
||||
const year = parseInt(euParts[2], 10);
|
||||
if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year > 1900) {
|
||||
date = new Date(year, month - 1, day);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Readable': // Mon, Dec 25, 2024
|
||||
date = new Date(trimmed);
|
||||
break;
|
||||
|
||||
case 'ISO': // YYYY-MM-DD
|
||||
default:
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
date = new Date(trimmed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!date || isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Date validation function
|
||||
function validateDateInput(inputElement, errorElement) {
|
||||
const value = inputElement.value.trim();
|
||||
if (!value) {
|
||||
errorElement.style.display = 'none';
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsedDate = parseUserDate(value);
|
||||
if (!parsedDate) {
|
||||
let expectedFormat;
|
||||
switch (USER_DATE_FORMAT) {
|
||||
case 'US': expectedFormat = 'MM/DD/YYYY'; break;
|
||||
case 'EU':
|
||||
case 'UK': expectedFormat = 'DD/MM/YYYY'; break;
|
||||
case 'Readable': expectedFormat = 'Mon, Dec 25, 2024'; break;
|
||||
case 'ISO':
|
||||
default: expectedFormat = 'YYYY-MM-DD'; break;
|
||||
}
|
||||
errorElement.textContent = `Invalid date format. Expected: ${expectedFormat}`;
|
||||
errorElement.style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
|
||||
errorElement.style.display = 'none';
|
||||
return true;
|
||||
}
|
||||
|
||||
// Date range validation function
|
||||
function validateDateRange(startElement, endElement, startErrorElement, endErrorElement) {
|
||||
const startValid = validateDateInput(startElement, startErrorElement);
|
||||
const endValid = validateDateInput(endElement, endErrorElement);
|
||||
|
||||
if (!startValid || !endValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startDate = parseUserDate(startElement.value);
|
||||
const endDate = parseUserDate(endElement.value);
|
||||
|
||||
if (startDate && endDate) {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
if (start >= end) {
|
||||
endErrorElement.textContent = 'End date must be after start date';
|
||||
endErrorElement.style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hybrid Date Input Functions
|
||||
function setupHybridDateInput(inputId) {
|
||||
const formattedInput = document.getElementById(inputId);
|
||||
const nativeInput = document.getElementById(inputId + '-native');
|
||||
|
||||
if (!formattedInput || !nativeInput) return;
|
||||
|
||||
// Sync from native input to formatted input
|
||||
nativeInput.addEventListener('change', function() {
|
||||
if (this.value) {
|
||||
formattedInput.value = formatDateForInput(this.value);
|
||||
// Trigger change event on formatted input
|
||||
formattedInput.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
|
||||
// Sync from formatted input to native input
|
||||
formattedInput.addEventListener('change', function() {
|
||||
const isoDate = parseUserDate(this.value);
|
||||
if (isoDate) {
|
||||
nativeInput.value = isoDate;
|
||||
} else {
|
||||
nativeInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Clear both inputs when formatted input is cleared
|
||||
formattedInput.addEventListener('input', function() {
|
||||
if (this.value === '') {
|
||||
nativeInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openCalendarPicker(inputId) {
|
||||
const nativeInput = document.getElementById(inputId + '-native');
|
||||
if (nativeInput) {
|
||||
// Try multiple methods to open the date picker
|
||||
nativeInput.focus();
|
||||
|
||||
// For modern browsers
|
||||
if (nativeInput.showPicker) {
|
||||
try {
|
||||
nativeInput.showPicker();
|
||||
} catch (e) {
|
||||
// Fallback to click if showPicker fails
|
||||
nativeInput.click();
|
||||
}
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
nativeInput.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sprint Management Controller
|
||||
class SprintManager {
|
||||
constructor() {
|
||||
this.sprints = [];
|
||||
this.currentView = 'active';
|
||||
this.currentSprint = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.setupEventListeners();
|
||||
await this.loadSprints();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// View switcher
|
||||
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
this.switchView(e.target.dataset.view);
|
||||
});
|
||||
});
|
||||
|
||||
// Actions
|
||||
document.getElementById('add-sprint-btn').addEventListener('click', () => {
|
||||
this.openSprintModal();
|
||||
});
|
||||
|
||||
document.getElementById('refresh-sprints').addEventListener('click', () => {
|
||||
this.loadSprints();
|
||||
});
|
||||
|
||||
// Date validation
|
||||
document.getElementById('sprint-start-date').addEventListener('blur', () => {
|
||||
const startInput = document.getElementById('sprint-start-date');
|
||||
const endInput = document.getElementById('sprint-end-date');
|
||||
const startError = document.getElementById('sprint-start-date-error');
|
||||
const endError = document.getElementById('sprint-end-date-error');
|
||||
validateDateRange(startInput, endInput, startError, endError);
|
||||
});
|
||||
|
||||
document.getElementById('sprint-end-date').addEventListener('blur', () => {
|
||||
const startInput = document.getElementById('sprint-start-date');
|
||||
const endInput = document.getElementById('sprint-end-date');
|
||||
const startError = document.getElementById('sprint-start-date-error');
|
||||
const endError = document.getElementById('sprint-end-date-error');
|
||||
validateDateRange(startInput, endInput, startError, endError);
|
||||
});
|
||||
|
||||
// Setup hybrid date inputs
|
||||
setupHybridDateInput('sprint-start-date');
|
||||
setupHybridDateInput('sprint-end-date');
|
||||
|
||||
// Modal close handlers
|
||||
document.querySelectorAll('.close').forEach(closeBtn => {
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.target.closest('.modal').style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// Click outside modal to close
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal')) {
|
||||
e.target.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
switchView(view) {
|
||||
// Update button states
|
||||
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-view="${view}"]`).classList.add('active');
|
||||
|
||||
this.currentView = view;
|
||||
this.renderSprints();
|
||||
}
|
||||
|
||||
async loadSprints() {
|
||||
document.getElementById('loading-indicator').style.display = 'flex';
|
||||
document.getElementById('error-message').style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sprints');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.sprints = data.sprints;
|
||||
this.renderSprints();
|
||||
this.updateStatistics();
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to load sprints');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading sprints:', error);
|
||||
document.getElementById('error-message').style.display = 'block';
|
||||
} finally {
|
||||
document.getElementById('loading-indicator').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
renderSprints() {
|
||||
const grid = document.getElementById('sprint-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
const filteredSprints = this.getFilteredSprints();
|
||||
|
||||
if (filteredSprints.length === 0) {
|
||||
grid.innerHTML = '<div class="empty-state">No sprints found for the selected view.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
filteredSprints.forEach(sprint => {
|
||||
const sprintCard = this.createSprintCard(sprint);
|
||||
grid.appendChild(sprintCard);
|
||||
});
|
||||
}
|
||||
|
||||
getFilteredSprints() {
|
||||
return this.sprints.filter(sprint => {
|
||||
switch (this.currentView) {
|
||||
case 'active':
|
||||
return sprint.status === 'ACTIVE';
|
||||
case 'planning':
|
||||
return sprint.status === 'PLANNING';
|
||||
case 'completed':
|
||||
return sprint.status === 'COMPLETED';
|
||||
case 'all':
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createSprintCard(sprint) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'management-card sprint-card';
|
||||
card.addEventListener('click', () => this.openSprintModal(sprint));
|
||||
|
||||
const startDate = new Date(sprint.start_date);
|
||||
const endDate = new Date(sprint.end_date);
|
||||
const today = new Date();
|
||||
|
||||
// Calculate progress
|
||||
let progressPercentage = 0;
|
||||
if (today >= startDate && today <= endDate) {
|
||||
const totalDays = (endDate - startDate) / (1000 * 60 * 60 * 24);
|
||||
const elapsedDays = (today - startDate) / (1000 * 60 * 60 * 24);
|
||||
progressPercentage = Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100));
|
||||
} else if (today > endDate) {
|
||||
progressPercentage = 100;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="sprint-card-header">
|
||||
<h3 class="sprint-name">${sprint.name}</h3>
|
||||
<span class="sprint-status ${sprint.status}">${sprint.status}</span>
|
||||
</div>
|
||||
|
||||
<div class="sprint-dates">
|
||||
📅 ${formatUserDate(sprint.start_date)} - ${formatUserDate(sprint.end_date)}
|
||||
${sprint.days_remaining > 0 ? `(${sprint.days_remaining} days left)` : ''}
|
||||
</div>
|
||||
|
||||
<div class="sprint-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progressPercentage}%"></div>
|
||||
</div>
|
||||
<div class="progress-text">${Math.round(progressPercentage)}% complete</div>
|
||||
</div>
|
||||
|
||||
<div class="sprint-metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-number">${sprint.task_summary.total}</div>
|
||||
<div class="metric-label">Total</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-number">${sprint.task_summary.completed}</div>
|
||||
<div class="metric-label">Done</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-number">${sprint.task_summary.in_progress}</div>
|
||||
<div class="metric-label">Active</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-number">${sprint.task_summary.not_started}</div>
|
||||
<div class="metric-label">Todo</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${sprint.goal ? `<div class="sprint-goal">"${sprint.goal}"</div>` : ''}
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
updateStatistics() {
|
||||
const totalSprints = this.sprints.length;
|
||||
const activeSprints = this.sprints.filter(s => s.status === 'ACTIVE').length;
|
||||
const completedSprints = this.sprints.filter(s => s.status === 'COMPLETED').length;
|
||||
const totalTasks = this.sprints.reduce((sum, s) => sum + s.task_summary.total, 0);
|
||||
|
||||
document.getElementById('total-sprints').textContent = totalSprints;
|
||||
document.getElementById('active-sprints').textContent = activeSprints;
|
||||
document.getElementById('completed-sprints').textContent = completedSprints;
|
||||
document.getElementById('total-tasks').textContent = totalTasks;
|
||||
}
|
||||
|
||||
openSprintModal(sprint = null) {
|
||||
this.currentSprint = sprint;
|
||||
const modal = document.getElementById('sprint-modal');
|
||||
|
||||
if (sprint) {
|
||||
document.getElementById('modal-title').textContent = 'Edit Sprint';
|
||||
document.getElementById('sprint-id').value = sprint.id;
|
||||
document.getElementById('sprint-name').value = sprint.name;
|
||||
document.getElementById('sprint-description').value = sprint.description || '';
|
||||
document.getElementById('sprint-status').value = sprint.status;
|
||||
document.getElementById('sprint-project').value = sprint.project_id || '';
|
||||
document.getElementById('sprint-capacity').value = sprint.capacity_hours || '';
|
||||
document.getElementById('sprint-start-date').value = formatDateForInput(sprint.start_date);
|
||||
document.getElementById('sprint-end-date').value = formatDateForInput(sprint.end_date);
|
||||
document.getElementById('sprint-goal').value = sprint.goal || '';
|
||||
document.getElementById('delete-sprint-btn').style.display = 'inline-block';
|
||||
} else {
|
||||
document.getElementById('modal-title').textContent = 'Create New Sprint';
|
||||
document.getElementById('sprint-form').reset();
|
||||
document.getElementById('sprint-id').value = '';
|
||||
|
||||
// Set default dates (next 2 weeks)
|
||||
const today = new Date();
|
||||
const twoWeeksLater = new Date(today.getTime() + 14 * 24 * 60 * 60 * 1000);
|
||||
document.getElementById('sprint-start-date').value = formatDateForInput(today.toISOString().split('T')[0]);
|
||||
document.getElementById('sprint-end-date').value = formatDateForInput(twoWeeksLater.toISOString().split('T')[0]);
|
||||
|
||||
document.getElementById('delete-sprint-btn').style.display = 'none';
|
||||
}
|
||||
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
|
||||
async saveSprint() {
|
||||
// Validate date inputs before saving
|
||||
const startInput = document.getElementById('sprint-start-date');
|
||||
const endInput = document.getElementById('sprint-end-date');
|
||||
const startError = document.getElementById('sprint-start-date-error');
|
||||
const endError = document.getElementById('sprint-end-date-error');
|
||||
|
||||
if (!validateDateRange(startInput, endInput, startError, endError)) {
|
||||
if (startError.style.display !== 'none') {
|
||||
startInput.focus();
|
||||
} else {
|
||||
endInput.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sprintData = {
|
||||
name: document.getElementById('sprint-name').value,
|
||||
description: document.getElementById('sprint-description').value,
|
||||
status: document.getElementById('sprint-status').value,
|
||||
project_id: document.getElementById('sprint-project').value || null,
|
||||
capacity_hours: document.getElementById('sprint-capacity').value || null,
|
||||
start_date: parseUserDate(document.getElementById('sprint-start-date').value),
|
||||
end_date: parseUserDate(document.getElementById('sprint-end-date').value),
|
||||
goal: document.getElementById('sprint-goal').value || null
|
||||
};
|
||||
|
||||
const sprintId = document.getElementById('sprint-id').value;
|
||||
const isEdit = sprintId !== '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/sprints${isEdit ? `/${sprintId}` : ''}`, {
|
||||
method: isEdit ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(sprintData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
closeSprintModal();
|
||||
await this.loadSprints();
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to save sprint');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving sprint:', error);
|
||||
alert('Failed to save sprint: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSprint() {
|
||||
if (!this.currentSprint) return;
|
||||
|
||||
if (confirm(`Are you sure you want to delete sprint "${this.currentSprint.name}"? This will also remove the sprint assignment from all tasks.`)) {
|
||||
try {
|
||||
const response = await fetch(`/api/sprints/${this.currentSprint.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
closeSprintModal();
|
||||
await this.loadSprints();
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to delete sprint');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting sprint:', error);
|
||||
alert('Failed to delete sprint: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global functions
|
||||
let sprintManager;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
sprintManager = new SprintManager();
|
||||
sprintManager.init();
|
||||
});
|
||||
|
||||
function closeSprintModal() {
|
||||
document.getElementById('sprint-modal').style.display = 'none';
|
||||
sprintManager.currentSprint = null;
|
||||
}
|
||||
|
||||
function saveSprint() {
|
||||
sprintManager.saveSprint();
|
||||
}
|
||||
|
||||
function deleteSprint() {
|
||||
sprintManager.deleteSprint();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -326,35 +326,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="content-header">
|
||||
<div class="header-row">
|
||||
<h1>System Announcements</h1>
|
||||
<a href="{{ url_for('system_admin_announcement_new') }}" class="btn btn-primary">
|
||||
<a href="{{ url_for('system_admin_announcement_new') }}" class="btn btn-md btn-primary">
|
||||
<i class="icon">➕</i> New Announcement
|
||||
</a>
|
||||
</div>
|
||||
|
||||
434
templates/system_admin_branding.html
Normal file
434
templates/system_admin_branding.html
Normal file
@@ -0,0 +1,434 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="management-container">
|
||||
<div class="management-header">
|
||||
<h1>🎨 Branding Settings</h1>
|
||||
<div class="management-actions">
|
||||
<a href="{{ url_for('system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="subtitle">Customize the appearance and branding of {{ branding.app_name }}</p>
|
||||
|
||||
<!-- Current Branding Preview -->
|
||||
<div class="management-section">
|
||||
<h2>👁️ Current Branding Preview</h2>
|
||||
<div class="management-card branding-preview-card">
|
||||
<div class="card-header">
|
||||
<h3>Live Preview</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preview-demo">
|
||||
<div class="demo-header">
|
||||
{% if branding.logo_filename %}
|
||||
<img src="{{ url_for('static', filename='uploads/branding/' + branding.logo_filename) }}"
|
||||
alt="{{ branding.logo_alt_text }}"
|
||||
class="demo-logo">
|
||||
{% else %}
|
||||
<span class="demo-text-logo">{{ branding.app_name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="demo-content">
|
||||
<p>Welcome to {{ branding.app_name }}</p>
|
||||
<button class="btn btn-primary" style="background-color: {{ branding.primary_color }}; border-color: {{ branding.primary_color }};">
|
||||
Sample Button
|
||||
</button>
|
||||
<a href="#" style="color: {{ branding.primary_color }};">Sample Link</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Branding Settings Form -->
|
||||
<div class="management-section">
|
||||
<h2>🔧 Branding Configuration</h2>
|
||||
<div class="management-card">
|
||||
<form method="POST" enctype="multipart/form-data" class="settings-form">
|
||||
<!-- Application Name -->
|
||||
<div class="form-section">
|
||||
<h3>📝 Basic Information</h3>
|
||||
<div class="form-group">
|
||||
<label for="app_name">Application Name</label>
|
||||
<input type="text" id="app_name" name="app_name"
|
||||
value="{{ branding.app_name }}"
|
||||
class="form-control"
|
||||
placeholder="TimeTrack"
|
||||
required>
|
||||
<small class="form-text text-muted">
|
||||
This name will appear in the title, navigation, and throughout the interface.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="logo_alt_text">Logo Alternative Text</label>
|
||||
<input type="text" id="logo_alt_text" name="logo_alt_text"
|
||||
value="{{ branding.logo_alt_text }}"
|
||||
class="form-control"
|
||||
placeholder="Company Logo">
|
||||
<small class="form-text text-muted">
|
||||
Text displayed when the logo cannot be loaded (accessibility).
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual Assets -->
|
||||
<div class="form-section">
|
||||
<h3>🖼️ Visual Assets</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-6">
|
||||
<label for="logo_file">Logo Image</label>
|
||||
<input type="file" id="logo_file" name="logo_file"
|
||||
accept="image/*"
|
||||
class="form-control-file">
|
||||
{% if branding.logo_filename %}
|
||||
<div class="current-asset">
|
||||
<img src="{{ url_for('static', filename='uploads/branding/' + branding.logo_filename) }}"
|
||||
alt="{{ branding.logo_alt_text }}"
|
||||
class="current-logo">
|
||||
<span class="asset-label">Current logo</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="form-text text-muted">
|
||||
PNG, JPG, GIF, SVG. Recommended: 200x50px
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group col-md-6">
|
||||
<label for="favicon_file">Favicon</label>
|
||||
<input type="file" id="favicon_file" name="favicon_file"
|
||||
accept="image/*"
|
||||
class="form-control-file">
|
||||
{% if branding.favicon_filename %}
|
||||
<div class="current-asset">
|
||||
<img src="{{ url_for('static', filename='uploads/branding/' + branding.favicon_filename) }}"
|
||||
alt="Current favicon"
|
||||
class="current-favicon">
|
||||
<span class="asset-label">Current favicon</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="form-text text-muted">
|
||||
ICO, PNG. Recommended: 16x16px or 32x32px
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Settings -->
|
||||
<div class="form-section">
|
||||
<h3>🎨 Theme Settings</h3>
|
||||
<div class="form-group">
|
||||
<label for="primary_color">Primary Color</label>
|
||||
<div class="color-picker-wrapper">
|
||||
<input type="color" id="primary_color" name="primary_color"
|
||||
value="{{ branding.primary_color }}"
|
||||
class="form-control color-picker">
|
||||
<input type="text" value="{{ branding.primary_color }}"
|
||||
class="form-control color-value"
|
||||
id="primary_color_text"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
placeholder="#007bff">
|
||||
</div>
|
||||
<small class="form-text text-muted">
|
||||
This color will be used for buttons, links, and other UI elements.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Imprint/Legal Page -->
|
||||
<div class="form-section">
|
||||
<h3>⚖️ Imprint / Legal Page</h3>
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" name="imprint_enabled" id="imprint_enabled"
|
||||
{% if branding.imprint_enabled %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
<span class="toggle-text">Enable Imprint Page</span>
|
||||
</label>
|
||||
<small class="form-text text-muted">
|
||||
When enabled, an "Imprint" link will appear in the footer linking to your custom legal page.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="imprint-settings" id="imprint-settings" style="{% if not branding.imprint_enabled %}display: none;{% endif %}">
|
||||
<div class="form-group">
|
||||
<label for="imprint_title">Page Title</label>
|
||||
<input type="text" id="imprint_title" name="imprint_title"
|
||||
value="{{ branding.imprint_title or 'Imprint' }}"
|
||||
class="form-control"
|
||||
placeholder="Imprint">
|
||||
<small class="form-text text-muted">
|
||||
The title that will be displayed on the imprint page.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="imprint_content">Page Content (HTML supported)</label>
|
||||
<textarea id="imprint_content" name="imprint_content"
|
||||
class="form-control content-editor"
|
||||
rows="15"
|
||||
placeholder="Enter your imprint/legal information here...">{{ branding.imprint_content or '' }}</textarea>
|
||||
<small class="form-text text-muted">
|
||||
You can use HTML to format your content. Common tags: <h2>, <h3>, <p>, <strong>, <br>, <a href="">
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 Save Branding Settings</button>
|
||||
<a href="{{ url_for('system_admin_dashboard') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Branding-specific styles */
|
||||
.branding-preview-card {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.preview-demo {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.demo-logo {
|
||||
max-height: 50px;
|
||||
max-width: 200px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.demo-text-logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.demo-content p {
|
||||
color: #6c757d;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.demo-content .btn {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
/* Current assets display */
|
||||
.current-asset {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.current-logo {
|
||||
max-height: 40px;
|
||||
max-width: 120px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.current-favicon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.asset-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Color picker styling */
|
||||
.color-picker-wrapper {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 60px;
|
||||
height: 38px;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-value {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Form sections */
|
||||
.form-section {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.form-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: #495057;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* File input styling */
|
||||
.form-control-file {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0;
|
||||
}
|
||||
|
||||
/* Form row for two-column layout */
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-right: -0.5rem;
|
||||
margin-left: -0.5rem;
|
||||
}
|
||||
|
||||
.form-row > .col-md-6 {
|
||||
flex: 0 0 50%;
|
||||
max-width: 50%;
|
||||
padding-right: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row > .col-md-6 {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sync color inputs */
|
||||
|
||||
/* Toggle label styling - ensuring proper alignment */
|
||||
.form-group .toggle-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toggle-label input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
background: #ccc;
|
||||
border-radius: 24px;
|
||||
transition: background 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.toggle-label input[type="checkbox"]:checked + .toggle-slider {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.toggle-label input[type="checkbox"]:checked + .toggle-slider::before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Content editor styling */
|
||||
.content-editor {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.imprint-settings {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Sync color picker with text input
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const colorPicker = document.getElementById('primary_color');
|
||||
const colorText = document.getElementById('primary_color_text');
|
||||
|
||||
colorPicker.addEventListener('input', function() {
|
||||
colorText.value = this.value;
|
||||
});
|
||||
|
||||
colorText.addEventListener('input', function() {
|
||||
if (this.value.match(/^#[0-9A-Fa-f]{6}$/)) {
|
||||
colorPicker.value = this.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle imprint settings visibility
|
||||
const imprintEnabled = document.getElementById('imprint_enabled');
|
||||
const imprintSettings = document.getElementById('imprint-settings');
|
||||
|
||||
imprintEnabled.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
imprintSettings.style.display = 'block';
|
||||
} else {
|
||||
imprintSettings.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="header-section">
|
||||
<h1>🏢 System Admin - All Companies</h1>
|
||||
<p class="subtitle">Manage companies across the entire system</p>
|
||||
<a href="{{ url_for('system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
|
||||
<a href="{{ url_for('system_admin_dashboard') }}" class="btn btn-md btn-secondary">← Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<!-- Companies Table -->
|
||||
@@ -221,39 +221,7 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
.pagination-section {
|
||||
margin: 2rem 0;
|
||||
|
||||
@@ -536,30 +536,7 @@
|
||||
color: #6a1b99;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
|
||||
@@ -183,7 +183,10 @@
|
||||
<a href="{{ url_for('system_admin_settings') }}" class="btn btn-primary">
|
||||
⚙️ System Settings
|
||||
</a>
|
||||
<a href="{{ url_for('system_admin_health') }}" class="btn btn-warning">
|
||||
<a href="{{ url_for('system_admin_branding') }}" class="btn btn-primary">
|
||||
🎨 Branding Settings
|
||||
</a>
|
||||
<a href="{{ url_for('system_admin_health') }}" class="btn btn-primary">
|
||||
🏥 System Health
|
||||
</a>
|
||||
</div>
|
||||
@@ -327,30 +330,7 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
.subtitle {
|
||||
color: #6c757d;
|
||||
|
||||
@@ -308,43 +308,7 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
.danger-zone {
|
||||
margin-left: auto;
|
||||
|
||||
@@ -506,27 +506,7 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.health-cards {
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
<div class="info-grid">
|
||||
<div class="info-card">
|
||||
<h4>Application Version</h4>
|
||||
<p>TimeTrack v1.0</p>
|
||||
<p>{{ g.branding.app_name }} v1.0</p>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<h4>Database</h4>
|
||||
@@ -500,52 +500,7 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.setting-group {
|
||||
|
||||
@@ -390,30 +390,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="header-section">
|
||||
<h1>👥 System Admin - All Users</h1>
|
||||
<p class="subtitle">Manage users across all companies</p>
|
||||
<a href="{{ url_for('system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
|
||||
<a href="{{ url_for('system_admin_dashboard') }}" class="btn btn-md btn-secondary">← Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<!-- Filter Options -->
|
||||
@@ -328,37 +328,7 @@
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
.pagination-section {
|
||||
margin-top: 2rem;
|
||||
|
||||
740
templates/task_modal.html
Normal file
740
templates/task_modal.html
Normal file
@@ -0,0 +1,740 @@
|
||||
<!-- Task Detail Modal -->
|
||||
<div id="task-modal" class="modal task-modal" style="display: none;">
|
||||
<div class="modal-content task-modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title">Task Details</h2>
|
||||
<span class="close" onclick="closeTaskModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="task-form">
|
||||
<input type="hidden" id="task-id">
|
||||
|
||||
<!-- Basic Information -->
|
||||
<div class="form-section">
|
||||
<h3>📝 Basic Information</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="task-name">Task Name *</label>
|
||||
<input type="text" id="task-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="task-priority">Priority</label>
|
||||
<select id="task-priority">
|
||||
<option value="LOW">Low</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
<option value="HIGH">High</option>
|
||||
<option value="URGENT">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="task-description">Description</label>
|
||||
<textarea id="task-description" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="task-status">Status</label>
|
||||
<select id="task-status">
|
||||
<option value="NOT_STARTED">Not Started</option>
|
||||
<option value="IN_PROGRESS">In Progress</option>
|
||||
<option value="ON_HOLD">On Hold</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="ARCHIVED">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignment & Planning -->
|
||||
<div class="form-section">
|
||||
<h3>👥 Assignment & Planning</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="task-project">Project</label>
|
||||
<select id="task-project">
|
||||
<option value="">Select 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="task-assignee">Assigned To</label>
|
||||
<select id="task-assignee">
|
||||
<option value="">Unassigned</option>
|
||||
{% for user in team_members %}
|
||||
<option value="{{ user.id }}">{{ user.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="task-sprint">Sprint</label>
|
||||
<select id="task-sprint">
|
||||
<option value="">No Sprint</option>
|
||||
<!-- Sprint options will be populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="task-estimated-hours">Estimated Hours</label>
|
||||
<input type="number" id="task-estimated-hours" min="0" step="0.5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="task-due-date">Due Date</label>
|
||||
<div class="hybrid-date-input">
|
||||
<input type="date" id="task-due-date-native" class="date-input-native">
|
||||
<input type="text" id="task-due-date" class="date-input-formatted" placeholder="{{ "YYYY-MM-DD" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "ISO" else "MM/DD/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") == "US" else "DD/MM/YYYY" if (g.user.preferences.date_format if g.user.preferences else "ISO") in ["EU", "UK"] else "Mon, Dec 25, 2024" }}">
|
||||
<button type="button" class="calendar-picker-btn" onclick="openCalendarPicker('task-due-date')" title="Open calendar">📅</button>
|
||||
</div>
|
||||
<div class="date-error" id="task-due-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dependencies -->
|
||||
<div class="form-section">
|
||||
<h3>🔗 Dependencies</h3>
|
||||
<div class="dependencies-grid">
|
||||
<!-- Blocked By -->
|
||||
<div class="dependency-column">
|
||||
<h4>🚫 Blocked By</h4>
|
||||
<p class="dependency-help">Tasks that must be completed before this task can start</p>
|
||||
<div id="blocked-by-container" class="dependency-list">
|
||||
<!-- Blocked by tasks will be populated here -->
|
||||
</div>
|
||||
<div class="add-dependency-form">
|
||||
<input type="text" id="blocked-by-input" placeholder="TSK-001" class="dependency-input">
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="addBlockedBy()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocks -->
|
||||
<div class="dependency-column">
|
||||
<h4>🔒 Blocks</h4>
|
||||
<p class="dependency-help">Tasks that cannot start until this task is completed</p>
|
||||
<div id="blocks-container" class="dependency-list">
|
||||
<!-- Blocks tasks will be populated here -->
|
||||
</div>
|
||||
<div class="add-dependency-form">
|
||||
<input type="text" id="blocks-input" placeholder="TSK-002" class="dependency-input">
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="addBlocks()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtasks -->
|
||||
<div class="form-section">
|
||||
<h3>📋 Subtasks</h3>
|
||||
<div id="subtasks-container">
|
||||
<!-- Subtasks will be populated here -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="addSubtask()">+ Add Subtask</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Comments Section (outside form) -->
|
||||
<div class="form-section" id="comments-section" style="display: none;">
|
||||
<h3>💬 Comments</h3>
|
||||
<div id="comments-container">
|
||||
<!-- Comments will be populated here -->
|
||||
</div>
|
||||
<div class="comment-form">
|
||||
<textarea id="new-comment" placeholder="Add a comment..." rows="2"></textarea>
|
||||
<div class="comment-form-actions">
|
||||
<select id="comment-visibility" class="comment-visibility-select" style="display: none;">
|
||||
<option value="COMPANY">🏢 Company</option>
|
||||
<option value="TEAM">👥 Team Only</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="addComment()">Post Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeTaskModal()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTask()">Save Task</button>
|
||||
<button type="button" class="btn btn-danger" onclick="deleteTask()" id="delete-task-btn" style="display: none;">Delete Task</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Modal JavaScript Functions -->
|
||||
<script>
|
||||
// Hybrid Date Input Functions for task modal
|
||||
function setupHybridDateInput(inputId) {
|
||||
const formattedInput = document.getElementById(inputId);
|
||||
const nativeInput = document.getElementById(inputId + '-native');
|
||||
|
||||
if (!formattedInput || !nativeInput) return;
|
||||
|
||||
// Sync from native input to formatted input
|
||||
nativeInput.addEventListener('change', function() {
|
||||
if (this.value) {
|
||||
formattedInput.value = formatDateForInput(this.value);
|
||||
// Trigger change event on formatted input
|
||||
formattedInput.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
|
||||
// Sync from formatted input to native input
|
||||
formattedInput.addEventListener('change', function() {
|
||||
const isoDate = parseUserDate(this.value);
|
||||
if (isoDate) {
|
||||
nativeInput.value = isoDate;
|
||||
} else {
|
||||
nativeInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Clear both inputs when formatted input is cleared
|
||||
formattedInput.addEventListener('input', function() {
|
||||
if (this.value === '') {
|
||||
nativeInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openCalendarPicker(inputId) {
|
||||
const nativeInput = document.getElementById(inputId + '-native');
|
||||
if (nativeInput) {
|
||||
// Try multiple methods to open the date picker
|
||||
nativeInput.focus();
|
||||
|
||||
// For modern browsers
|
||||
if (nativeInput.showPicker) {
|
||||
try {
|
||||
nativeInput.showPicker();
|
||||
} catch (e) {
|
||||
// Fallback to click if showPicker fails
|
||||
nativeInput.click();
|
||||
}
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
nativeInput.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize hybrid date inputs when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Setup hybrid date inputs for task modal
|
||||
setupHybridDateInput('task-due-date');
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Task Modal Styles -->
|
||||
<style>
|
||||
/* Task Modal Specific Styles - Compact Design */
|
||||
.task-modal .modal-content {
|
||||
width: 95%;
|
||||
max-width: 900px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.task-modal .modal-header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.task-modal .modal-header h2 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-modal-content .modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.form-section:last-of-type {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: #495057;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 2px solid #f8f9fa;
|
||||
}
|
||||
|
||||
/* Dependencies Grid - Compact */
|
||||
.dependencies-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dependency-column {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.dependency-column h4 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: #495057;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dependency-help {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
margin: 0 0 0.5rem 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.dependency-list {
|
||||
min-height: 40px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dependency-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.dependency-task-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dependency-task-number {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #007bff;
|
||||
background: #e3f2fd;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dependency-task-title {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dependency-remove-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: background-color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dependency-remove-btn:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.add-dependency-form {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.dependency-input {
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.dependency-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.dependency-input::placeholder {
|
||||
color: #adb5bd;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Subtasks Section - Compact */
|
||||
.subtask-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.3rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.subtask-item input[type="text"] {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.subtask-item button {
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Hybrid Date Input Styles */
|
||||
.hybrid-date-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.date-input-native {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: calc(100% - 35px);
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.date-input-formatted {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.calendar-picker-btn {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
z-index: 3;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-picker-btn:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
/* Compact Form Styles */
|
||||
.form-group {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #80bdff;
|
||||
box-shadow: 0 0 0 0.1rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Compact button styles */
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.task-modal .modal-content {
|
||||
width: 98%;
|
||||
margin: 1% auto;
|
||||
max-height: 98vh;
|
||||
}
|
||||
|
||||
.dependencies-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dependency-task-title {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.task-modal-content .modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dependency-item {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.dependency-task-number {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Comments Section Styles */
|
||||
#comments-container {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.no-comments {
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.comment-edited {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-author-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-author-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.comment-author-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.comment-visibility-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: #e9ecef;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.comment-visibility-badge.team {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-action {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.comment-action:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.comment-form {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
#new-comment {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.comment-form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-visibility-select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Edit comment form */
|
||||
.comment-edit-form {
|
||||
margin-top: 0.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comment-edit-textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-edit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Reply form */
|
||||
.comment-reply-form {
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 2rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comment-replies {
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.comment-reply {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.3rem;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
</style>
|
||||
2463
templates/unified_task_management.html
Normal file
2463
templates/unified_task_management.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -102,24 +102,7 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 2rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
/* Button styles now centralized in main style.css */
|
||||
|
||||
.help-section {
|
||||
text-align: center;
|
||||
|
||||
Reference in New Issue
Block a user