Merge pull request #17 from nullmedium/replace-emojis-with-tabler-icons

Replace emojis with tabler icons and refactor views to use a common styling theme.
This commit is contained in:
Jens Luedicke
2025-07-09 10:51:40 +02:00
committed by GitHub
90 changed files with 18063 additions and 7846 deletions

54
app.py
View File

@@ -1,5 +1,5 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file, abort
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings, CompanyInvitation
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role, Project, Company, CompanyWorkConfig, CompanySettings, UserPreferences, WorkRegion, AccountType, ProjectCategory, Task, SubTask, TaskStatus, TaskPriority, TaskDependency, Sprint, SprintStatus, Announcement, SystemEvent, WidgetType, UserDashboard, DashboardWidget, WidgetTemplate, Comment, CommentVisibility, BrandingSettings, CompanyInvitation, Note, NoteFolder, NoteShare
from data_formatting import (
format_duration, prepare_export_data, prepare_team_hours_export_data,
format_table_data, format_graph_data, format_team_data, format_burndown_data
@@ -20,9 +20,10 @@ from password_utils import PasswordValidator
from werkzeug.security import check_password_hash
# Import blueprints
# from routes.notes import notes_bp
# from routes.notes_download import notes_download_bp
# from routes.notes_api import notes_api_bp
from routes.notes import notes_bp
from routes.notes_download import notes_download_bp
from routes.notes_api import notes_api_bp
from routes.notes_public import notes_public_bp
from routes.tasks import tasks_bp, get_filtered_tasks_for_burndown
from routes.tasks_api import tasks_api_bp
from routes.sprints import sprints_bp
@@ -39,6 +40,7 @@ from routes.system_admin import system_admin_bp
from routes.announcements import announcements_bp
from routes.export import export_bp
from routes.export_api import export_api_bp
from routes.organization import organization_bp
# Import auth decorators from routes.auth
from routes.auth import login_required, admin_required, system_admin_required, role_required, company_required
@@ -84,9 +86,10 @@ mail = Mail(app)
db.init_app(app)
# Register blueprints
# app.register_blueprint(notes_bp)
# app.register_blueprint(notes_download_bp)
# app.register_blueprint(notes_api_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(notes_download_bp)
app.register_blueprint(notes_api_bp)
app.register_blueprint(notes_public_bp)
app.register_blueprint(tasks_bp)
app.register_blueprint(tasks_api_bp)
app.register_blueprint(sprints_bp)
@@ -103,6 +106,7 @@ app.register_blueprint(system_admin_bp)
app.register_blueprint(announcements_bp)
app.register_blueprint(export_bp)
app.register_blueprint(export_api_bp)
app.register_blueprint(organization_bp)
# Import and register invitations blueprint
from routes.invitations import invitations_bp
@@ -829,8 +833,10 @@ def verify_email(token):
@role_required(Role.TEAM_MEMBER)
@company_required
def dashboard():
"""User dashboard with configurable widgets."""
return render_template('dashboard.html', title='Dashboard')
"""User dashboard with configurable widgets - DISABLED due to widget issues."""
# Redirect to home page instead of dashboard
flash('Dashboard is temporarily disabled. Redirecting to home page.', 'info')
return redirect(url_for('index'))
@app.route('/profile', methods=['GET', 'POST'])
@@ -2666,6 +2672,36 @@ def search_sprints():
logger.error(f"Error in search_sprints: {str(e)}")
return jsonify({'success': False, 'message': str(e)})
@app.route('/api/render-markdown', methods=['POST'])
@login_required
def render_markdown():
"""Render markdown content to HTML for preview"""
try:
data = request.get_json()
content = data.get('content', '')
if not content:
return jsonify({'html': '<p class="preview-placeholder">Start typing to see the preview...</p>'})
# Parse frontmatter and extract body
from frontmatter_utils import parse_frontmatter
metadata, body = parse_frontmatter(content)
# Render markdown to HTML
try:
import markdown
# Use extensions for better markdown support
html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc', 'tables', 'fenced_code'])
except ImportError:
# Fallback if markdown not installed
html = f'<pre>{body}</pre>'
return jsonify({'html': html})
except Exception as e:
logger.error(f"Error rendering markdown: {str(e)}")
return jsonify({'html': '<p class="error">Error rendering markdown</p>'})
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
app.run(debug=True, host='0.0.0.0', port=port)

View File

@@ -29,18 +29,20 @@ services:
timetrack:
build: .
environment:
FLASK_ENV: ${FLASK_ENV:-production}
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
MAIL_SERVER: ${MAIL_SERVER}
MAIL_PORT: ${MAIL_PORT}
MAIL_USE_TLS: ${MAIL_USE_TLS}
MAIL_USERNAME: ${MAIL_USERNAME}
MAIL_PASSWORD: ${MAIL_PASSWORD}
MAIL_DEFAULT_SENDER: ${MAIL_DEFAULT_SENDER}
ports:
- "${TIMETRACK_PORT:-5000}:5000"
environment:
- DATABASE_URL=${DATABASE_URL}
- POSTGRES_HOST=db
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- FLASK_ENV=${FLASK_ENV}
- SECRET_KEY=${SECRET_KEY}
- MAIL_SERVER=${MAIL_SERVER}
- MAIL_USERNAME=${MAIL_USERNAME}
- MAIL_PASSWORD=${MAIL_PASSWORD}
- MAIL_DEFAULT_SENDER=${MAIL_DEFAULT_SENDER}
depends_on:
db:
condition: service_healthy

70
frontmatter_utils.py Normal file
View File

@@ -0,0 +1,70 @@
import yaml
import re
from datetime import datetime
def parse_frontmatter(content):
"""
Parse YAML frontmatter from markdown content.
Returns a tuple of (metadata dict, content without frontmatter)
"""
if not content or not content.strip().startswith('---'):
return {}, content
# Match frontmatter pattern
pattern = r'^---\s*\n(.*?)\n---\s*\n(.*)$'
match = re.match(pattern, content, re.DOTALL)
if not match:
return {}, content
try:
# Parse YAML frontmatter
metadata = yaml.safe_load(match.group(1)) or {}
content_body = match.group(2)
return metadata, content_body
except yaml.YAMLError:
# If YAML parsing fails, return original content
return {}, content
def create_frontmatter(metadata):
"""
Create YAML frontmatter from metadata dict.
"""
if not metadata:
return ""
# Filter out None values and empty strings
filtered_metadata = {k: v for k, v in metadata.items() if v is not None and v != ''}
if not filtered_metadata:
return ""
return f"---\n{yaml.dump(filtered_metadata, default_flow_style=False, sort_keys=False)}---\n\n"
def update_frontmatter(content, metadata):
"""
Update or add frontmatter to content.
"""
_, body = parse_frontmatter(content)
frontmatter = create_frontmatter(metadata)
return frontmatter + body
def extract_title_from_content(content):
"""
Extract title from content, checking frontmatter first, then first line.
"""
metadata, body = parse_frontmatter(content)
# Check if title is in frontmatter
if metadata.get('title'):
return metadata['title']
# Otherwise extract from first line of body
lines = body.strip().split('\n')
for line in lines:
line = line.strip()
if line:
# Remove markdown headers if present
return re.sub(r'^#+\s*', '', line)
return 'Untitled Note'

View File

@@ -0,0 +1,20 @@
-- Migration to add CASCADE delete to note_link foreign keys
-- This ensures that when a note is deleted, all links to/from it are also deleted
-- For PostgreSQL
-- Drop existing foreign key constraints
ALTER TABLE note_link DROP CONSTRAINT IF EXISTS note_link_source_note_id_fkey;
ALTER TABLE note_link DROP CONSTRAINT IF EXISTS note_link_target_note_id_fkey;
-- Add new foreign key constraints with CASCADE
ALTER TABLE note_link
ADD CONSTRAINT note_link_source_note_id_fkey
FOREIGN KEY (source_note_id)
REFERENCES note(id)
ON DELETE CASCADE;
ALTER TABLE note_link
ADD CONSTRAINT note_link_target_note_id_fkey
FOREIGN KEY (target_note_id)
REFERENCES note(id)
ON DELETE CASCADE;

View File

@@ -0,0 +1,25 @@
-- SQLite migration for cascade delete on note_link
-- SQLite doesn't support ALTER TABLE for foreign keys, so we need to recreate the table
-- Create new table with CASCADE delete
CREATE TABLE note_link_new (
id INTEGER PRIMARY KEY,
source_note_id INTEGER NOT NULL,
target_note_id INTEGER NOT NULL,
link_type VARCHAR(50) DEFAULT 'related',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
FOREIGN KEY (source_note_id) REFERENCES note(id) ON DELETE CASCADE,
FOREIGN KEY (target_note_id) REFERENCES note(id) ON DELETE CASCADE,
FOREIGN KEY (created_by_id) REFERENCES user(id),
UNIQUE(source_note_id, target_note_id)
);
-- Copy data from old table
INSERT INTO note_link_new SELECT * FROM note_link;
-- Drop old table
DROP TABLE note_link;
-- Rename new table
ALTER TABLE note_link_new RENAME TO note_link;

View File

@@ -0,0 +1,5 @@
-- Add folder column to notes table
ALTER TABLE note ADD COLUMN IF NOT EXISTS folder VARCHAR(100);
-- Create an index on folder for faster filtering
CREATE INDEX IF NOT EXISTS idx_note_folder ON note(folder) WHERE folder IS NOT NULL;

View File

@@ -0,0 +1,17 @@
-- Create note_folder table for tracking folders independently of notes
CREATE TABLE IF NOT EXISTS note_folder (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
path VARCHAR(500) NOT NULL,
parent_path VARCHAR(500),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL REFERENCES "user"(id),
company_id INTEGER NOT NULL REFERENCES company(id),
CONSTRAINT uq_folder_path_company UNIQUE (path, company_id)
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_note_folder_company ON note_folder(company_id);
CREATE INDEX IF NOT EXISTS idx_note_folder_parent_path ON note_folder(parent_path);
CREATE INDEX IF NOT EXISTS idx_note_folder_created_by ON note_folder(created_by_id);

View File

@@ -0,0 +1,21 @@
-- Add note_share table for public note sharing functionality
CREATE TABLE IF NOT EXISTS note_share (
id SERIAL PRIMARY KEY,
note_id INTEGER NOT NULL REFERENCES note(id) ON DELETE CASCADE,
token VARCHAR(64) UNIQUE NOT NULL,
expires_at TIMESTAMP,
password_hash VARCHAR(255),
view_count INTEGER DEFAULT 0,
max_views INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL REFERENCES "user"(id),
last_accessed_at TIMESTAMP
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_note_share_token ON note_share(token);
CREATE INDEX IF NOT EXISTS idx_note_share_note_id ON note_share(note_id);
CREATE INDEX IF NOT EXISTS idx_note_share_created_by ON note_share(created_by_id);
-- Add comment
COMMENT ON TABLE note_share IS 'Public sharing links for notes with optional password protection and view limits';

View File

@@ -79,6 +79,8 @@ def run_all_migrations(db_path=None):
migrate_system_events(db_path)
migrate_dashboard_system(db_path)
migrate_comment_system(db_path)
migrate_notes_system(db_path)
update_note_link_cascade(db_path)
# Run PostgreSQL-specific migrations if applicable
if FLASK_AVAILABLE:
@@ -1275,6 +1277,126 @@ def migrate_postgresql_schema():
"""))
db.session.commit()
# Check if note table exists
result = db.session.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'note'
"""))
if result.fetchone():
# Table exists, check for folder column
result = db.session.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'note' AND column_name = 'folder'
"""))
if not result.fetchone():
print("Adding folder column to note table...")
db.session.execute(text("ALTER TABLE note ADD COLUMN folder VARCHAR(100)"))
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder ON note(folder)"))
db.session.commit()
print("Folder column added successfully!")
else:
print("Creating note and note_link tables...")
# Create NoteVisibility enum type
db.session.execute(text("""
DO $$ BEGIN
CREATE TYPE notevisibility AS ENUM ('Private', 'Team', 'Company');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
"""))
db.session.execute(text("""
CREATE TABLE note (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
slug VARCHAR(100) NOT NULL,
visibility notevisibility NOT NULL DEFAULT 'Private',
folder VARCHAR(100),
company_id INTEGER NOT NULL,
created_by_id INTEGER NOT NULL,
project_id INTEGER,
task_id INTEGER,
tags TEXT[],
is_archived BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (company_id) REFERENCES company (id),
FOREIGN KEY (created_by_id) REFERENCES "user" (id),
FOREIGN KEY (project_id) REFERENCES project (id),
FOREIGN KEY (task_id) REFERENCES task (id)
)
"""))
# Create note_link table
db.session.execute(text("""
CREATE TABLE note_link (
source_note_id INTEGER NOT NULL,
target_note_id INTEGER NOT NULL,
link_type VARCHAR(50) DEFAULT 'related',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (source_note_id, target_note_id),
FOREIGN KEY (source_note_id) REFERENCES note (id) ON DELETE CASCADE,
FOREIGN KEY (target_note_id) REFERENCES note (id) ON DELETE CASCADE
)
"""))
# Check if note_folder table exists
result = db.session.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'note_folder'
"""))
if not result.fetchone():
print("Creating note_folder table...")
db.session.execute(text("""
CREATE TABLE note_folder (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
path VARCHAR(500) NOT NULL,
parent_path VARCHAR(500),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
FOREIGN KEY (created_by_id) REFERENCES "user" (id),
FOREIGN KEY (company_id) REFERENCES company (id),
CONSTRAINT uq_folder_path_company UNIQUE (path, company_id)
)
"""))
# Create indexes
db.session.execute(text("CREATE INDEX idx_note_company ON note(company_id)"))
db.session.execute(text("CREATE INDEX idx_note_created_by ON note(created_by_id)"))
db.session.execute(text("CREATE INDEX idx_note_project ON note(project_id)"))
db.session.execute(text("CREATE INDEX idx_note_task ON note(task_id)"))
db.session.execute(text("CREATE INDEX idx_note_slug ON note(company_id, slug)"))
db.session.execute(text("CREATE INDEX idx_note_visibility ON note(visibility)"))
db.session.execute(text("CREATE INDEX idx_note_archived ON note(is_archived)"))
db.session.execute(text("CREATE INDEX idx_note_created_at ON note(created_at DESC)"))
db.session.execute(text("CREATE INDEX idx_note_folder ON note(folder)"))
db.session.execute(text("CREATE INDEX idx_note_link_source ON note_link(source_note_id)"))
db.session.execute(text("CREATE INDEX idx_note_link_target ON note_link(target_note_id)"))
# Create indexes for note_folder if table was created
result = db.session.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'note_folder'
"""))
if result.fetchone():
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder_company ON note_folder(company_id)"))
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder_parent_path ON note_folder(parent_path)"))
db.session.execute(text("CREATE INDEX IF NOT EXISTS idx_note_folder_created_by ON note_folder(created_by_id)"))
db.session.commit()
print("PostgreSQL schema migration completed successfully!")
except Exception as e:
@@ -1485,6 +1607,222 @@ def migrate_comment_system(db_file=None):
conn.close()
def migrate_notes_system(db_file=None):
"""Migrate to add Notes system with markdown support."""
db_path = get_db_path(db_file)
print(f"Migrating Notes 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 note table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='note'")
if cursor.fetchone():
print("Note table already exists. Checking for updates...")
# Check if folder column exists
cursor.execute("PRAGMA table_info(note)")
columns = [column[1] for column in cursor.fetchall()]
if 'folder' not in columns:
print("Adding folder column to note table...")
cursor.execute("ALTER TABLE note ADD COLUMN folder VARCHAR(100)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_note_folder ON note(folder)")
conn.commit()
print("Folder column added successfully!")
# Check if note_folder table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='note_folder'")
if not cursor.fetchone():
print("Creating note_folder table...")
cursor.execute("""
CREATE TABLE note_folder (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
path VARCHAR(500) NOT NULL,
parent_path VARCHAR(500),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
FOREIGN KEY (created_by_id) REFERENCES user(id),
FOREIGN KEY (company_id) REFERENCES company(id),
UNIQUE(path, company_id)
)
""")
# Create indexes for note_folder
cursor.execute("CREATE INDEX idx_note_folder_company ON note_folder(company_id)")
cursor.execute("CREATE INDEX idx_note_folder_parent_path ON note_folder(parent_path)")
cursor.execute("CREATE INDEX idx_note_folder_created_by ON note_folder(created_by_id)")
conn.commit()
print("Note folder table created successfully!")
return True
print("Creating Notes system tables...")
# Create note table
cursor.execute("""
CREATE TABLE note (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
slug VARCHAR(100) NOT NULL,
visibility VARCHAR(20) NOT NULL DEFAULT 'Private',
folder VARCHAR(100),
company_id INTEGER NOT NULL,
created_by_id INTEGER NOT NULL,
project_id INTEGER,
task_id INTEGER,
tags TEXT,
archived BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (company_id) REFERENCES company (id),
FOREIGN KEY (created_by_id) REFERENCES user (id),
FOREIGN KEY (project_id) REFERENCES project (id),
FOREIGN KEY (task_id) REFERENCES task (id)
)
""")
# Create note_link table for linking notes
cursor.execute("""
CREATE TABLE note_link (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_note_id INTEGER NOT NULL,
target_note_id INTEGER NOT NULL,
link_type VARCHAR(50) DEFAULT 'related',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
FOREIGN KEY (source_note_id) REFERENCES note (id) ON DELETE CASCADE,
FOREIGN KEY (target_note_id) REFERENCES note (id) ON DELETE CASCADE,
FOREIGN KEY (created_by_id) REFERENCES user (id),
UNIQUE(source_note_id, target_note_id)
)
""")
# Create indexes for better performance
cursor.execute("CREATE INDEX idx_note_company ON note(company_id)")
cursor.execute("CREATE INDEX idx_note_created_by ON note(created_by_id)")
cursor.execute("CREATE INDEX idx_note_project ON note(project_id)")
cursor.execute("CREATE INDEX idx_note_task ON note(task_id)")
cursor.execute("CREATE INDEX idx_note_slug ON note(company_id, slug)")
cursor.execute("CREATE INDEX idx_note_visibility ON note(visibility)")
cursor.execute("CREATE INDEX idx_note_archived ON note(archived)")
cursor.execute("CREATE INDEX idx_note_created_at ON note(created_at DESC)")
# Create indexes for note links
cursor.execute("CREATE INDEX idx_note_link_source ON note_link(source_note_id)")
cursor.execute("CREATE INDEX idx_note_link_target ON note_link(target_note_id)")
# Create note_folder table
cursor.execute("""
CREATE TABLE note_folder (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
path VARCHAR(500) NOT NULL,
parent_path VARCHAR(500),
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
FOREIGN KEY (created_by_id) REFERENCES user(id),
FOREIGN KEY (company_id) REFERENCES company(id),
UNIQUE(path, company_id)
)
""")
# Create indexes for note_folder
cursor.execute("CREATE INDEX idx_note_folder_company ON note_folder(company_id)")
cursor.execute("CREATE INDEX idx_note_folder_parent_path ON note_folder(parent_path)")
cursor.execute("CREATE INDEX idx_note_folder_created_by ON note_folder(created_by_id)")
conn.commit()
print("Notes system migration completed successfully!")
return True
except Exception as e:
print(f"Error during Notes system migration: {e}")
conn.rollback()
return False
finally:
conn.close()
def update_note_link_cascade(db_path):
"""Update note_link table to ensure CASCADE delete is enabled."""
print("Checking note_link cascade delete constraints...")
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check if note_link table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='note_link'")
if not cursor.fetchone():
print("note_link table does not exist, skipping cascade update")
return
# Check current foreign key constraints
cursor.execute("PRAGMA foreign_key_list(note_link)")
fk_info = cursor.fetchall()
# Check if CASCADE is already set
has_cascade = any('CASCADE' in str(fk) for fk in fk_info)
if not has_cascade:
print("Updating note_link table with CASCADE delete...")
# SQLite doesn't support ALTER TABLE for foreign keys, so recreate the table
cursor.execute("""
CREATE TABLE note_link_temp (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_note_id INTEGER NOT NULL,
target_note_id INTEGER NOT NULL,
link_type VARCHAR(50) DEFAULT 'related',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL,
FOREIGN KEY (source_note_id) REFERENCES note(id) ON DELETE CASCADE,
FOREIGN KEY (target_note_id) REFERENCES note(id) ON DELETE CASCADE,
FOREIGN KEY (created_by_id) REFERENCES user(id),
UNIQUE(source_note_id, target_note_id)
)
""")
# Copy data
cursor.execute("INSERT INTO note_link_temp SELECT * FROM note_link")
# Drop old table and rename new one
cursor.execute("DROP TABLE note_link")
cursor.execute("ALTER TABLE note_link_temp RENAME TO note_link")
# Recreate indexes
cursor.execute("CREATE INDEX idx_note_link_source ON note_link(source_note_id)")
cursor.execute("CREATE INDEX idx_note_link_target ON note_link(target_note_id)")
print("note_link table updated with CASCADE delete")
else:
print("note_link table already has CASCADE delete")
conn.commit()
except Exception as e:
print(f"Error updating note_link cascade: {e}")
if conn:
conn.rollback()
finally:
if conn:
conn.close()
def main():
"""Main function with command line interface."""
parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool')

View File

@@ -17,6 +17,7 @@ MIGRATION_STATE_FILE = '/data/postgres_migrations_state.json'
# List of PostgreSQL migrations in order
POSTGRES_MIGRATIONS = [
'postgres_only_migration.py', # Main migration from commit 4214e88 onward
'add_note_sharing.sql', # Add note sharing functionality
]
@@ -49,12 +50,39 @@ def run_migration(migration_file):
print(f"\n🔄 Running migration: {migration_file}")
try:
# Run the migration script
result = subprocess.run(
[sys.executable, script_path],
capture_output=True,
text=True
)
# Check if it's a SQL file
if migration_file.endswith('.sql'):
# Run SQL file using psql
# Try to parse DATABASE_URL first, fall back to individual env vars
database_url = os.environ.get('DATABASE_URL')
if database_url:
# Parse DATABASE_URL: postgresql://user:password@host:port/dbname
from urllib.parse import urlparse
parsed = urlparse(database_url)
db_host = parsed.hostname or 'db'
db_port = parsed.port or 5432
db_name = parsed.path.lstrip('/') or 'timetrack'
db_user = parsed.username or 'timetrack'
db_password = parsed.password or 'timetrack'
else:
db_host = os.environ.get('POSTGRES_HOST', 'db')
db_name = os.environ.get('POSTGRES_DB', 'timetrack')
db_user = os.environ.get('POSTGRES_USER', 'timetrack')
db_password = os.environ.get('POSTGRES_PASSWORD', 'timetrack')
result = subprocess.run(
['psql', '-h', db_host, '-U', db_user, '-d', db_name, '-f', script_path],
capture_output=True,
text=True,
env={**os.environ, 'PGPASSWORD': db_password}
)
else:
# Run Python migration script
result = subprocess.run(
[sys.executable, script_path],
capture_output=True,
text=True
)
if result.returncode == 0:
print(f"{migration_file} completed successfully")

View File

@@ -26,6 +26,8 @@ from .announcement import Announcement
from .dashboard import DashboardWidget, WidgetTemplate
from .work_config import WorkConfig
from .invitation import CompanyInvitation
from .note import Note, NoteVisibility, NoteLink, NoteFolder
from .note_share import NoteShare
# Make all models available at package level
__all__ = [
@@ -45,5 +47,6 @@ __all__ = [
'Announcement',
'DashboardWidget', 'WidgetTemplate',
'WorkConfig',
'CompanyInvitation'
'CompanyInvitation',
'Note', 'NoteVisibility', 'NoteLink', 'NoteFolder', 'NoteShare'
]

316
models/note.py Normal file
View File

@@ -0,0 +1,316 @@
"""
Note models for markdown-based documentation and knowledge management.
Migrated from models_old.py to maintain consistency with the new modular structure.
"""
import enum
import re
from datetime import datetime, timedelta
from sqlalchemy import UniqueConstraint
from . import db
from .enums import Role
class NoteVisibility(enum.Enum):
"""Note sharing visibility levels"""
PRIVATE = "Private"
TEAM = "Team"
COMPANY = "Company"
class Note(db.Model):
"""Markdown notes with sharing capabilities"""
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False) # Markdown content
slug = db.Column(db.String(100), nullable=False) # URL-friendly identifier
# Visibility and sharing
visibility = db.Column(db.Enum(NoteVisibility), nullable=False, default=NoteVisibility.PRIVATE)
# Folder organization
folder = db.Column(db.String(100), nullable=True) # Folder path like "Work/Projects" or "Personal"
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
# Associations
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Optional associations
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) # For team-specific notes
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True) # Link to project
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True) # Link to task
# Tags for organization
tags = db.Column(db.String(500)) # Comma-separated tags
# Pin important notes
is_pinned = db.Column(db.Boolean, default=False)
# Soft delete
is_archived = db.Column(db.Boolean, default=False)
archived_at = db.Column(db.DateTime, nullable=True)
# Relationships
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='notes')
company = db.relationship('Company', backref='notes')
team = db.relationship('Team', backref='notes')
project = db.relationship('Project', backref='notes')
task = db.relationship('Task', backref='notes')
# Unique constraint on slug per company
__table_args__ = (db.UniqueConstraint('company_id', 'slug', name='uq_note_slug_per_company'),)
def __repr__(self):
return f'<Note {self.title}>'
def generate_slug(self):
"""Generate URL-friendly slug from title and set it on the model"""
import re
# Remove special characters and convert to lowercase
slug = re.sub(r'[^\w\s-]', '', self.title.lower())
# Replace spaces with hyphens
slug = re.sub(r'[-\s]+', '-', slug)
# Remove leading/trailing hyphens
slug = slug.strip('-')
# Ensure uniqueness within company
base_slug = slug
counter = 1
while Note.query.filter_by(company_id=self.company_id, slug=slug).filter(Note.id != self.id).first():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
return slug
def can_user_view(self, user):
"""Check if user can view this note"""
# Creator can always view
if user.id == self.created_by_id:
return True
# Check company match
if user.company_id != self.company_id:
return False
# Check visibility
if self.visibility == NoteVisibility.COMPANY:
return True
elif self.visibility == NoteVisibility.TEAM:
# Check if user is in the same team
if self.team_id and user.team_id == self.team_id:
return True
# Admins can view all team notes
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
return True
return False
def can_user_edit(self, user):
"""Check if user can edit this note"""
# Creator can always edit
if user.id == self.created_by_id:
return True
# Admins can edit company notes
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] and user.company_id == self.company_id:
return True
return False
def get_tags_list(self):
"""Get tags as a list"""
if not self.tags:
return []
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
def set_tags_list(self, tags_list):
"""Set tags from a list"""
self.tags = ','.join(tags_list) if tags_list else None
def get_preview(self, length=200):
"""Get a plain text preview of the note content"""
# Strip markdown formatting for preview
import re
from frontmatter_utils import parse_frontmatter
# Extract body content without frontmatter
_, body = parse_frontmatter(self.content)
text = body
# Remove headers
text = re.sub(r'^#+\s+', '', text, flags=re.MULTILINE)
# Remove emphasis
text = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text)
text = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text)
# Remove links
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
# Remove code blocks
text = re.sub(r'```[^`]*```', '', text, flags=re.DOTALL)
text = re.sub(r'`([^`]+)`', r'\1', text)
# Clean up whitespace
text = ' '.join(text.split())
if len(text) > length:
return text[:length] + '...'
return text
def render_html(self):
"""Render markdown content to HTML"""
try:
import markdown
from frontmatter_utils import parse_frontmatter
# Extract body content without frontmatter
_, body = parse_frontmatter(self.content)
# Use extensions for better markdown support
html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc'])
return html
except ImportError:
# Fallback if markdown not installed
return f'<pre>{self.content}</pre>'
def get_frontmatter(self):
"""Get frontmatter metadata from content"""
from frontmatter_utils import parse_frontmatter
metadata, _ = parse_frontmatter(self.content)
return metadata
def update_frontmatter(self):
"""Update content with current metadata as frontmatter"""
from frontmatter_utils import update_frontmatter
metadata = {
'title': self.title,
'visibility': self.visibility.value.lower(),
'folder': self.folder,
'tags': self.get_tags_list() if self.tags else None,
'project': self.project.code if self.project else None,
'task_id': self.task_id,
'pinned': self.is_pinned if self.is_pinned else None,
'created': self.created_at.isoformat() if self.created_at else None,
'updated': self.updated_at.isoformat() if self.updated_at else None,
'author': self.created_by.username if self.created_by else None
}
# Remove None values
metadata = {k: v for k, v in metadata.items() if v is not None}
self.content = update_frontmatter(self.content, metadata)
def sync_from_frontmatter(self):
"""Update model fields from frontmatter in content"""
from frontmatter_utils import parse_frontmatter
metadata, _ = parse_frontmatter(self.content)
if metadata:
# Update fields from frontmatter
if 'title' in metadata:
self.title = metadata['title']
if 'visibility' in metadata:
try:
self.visibility = NoteVisibility[metadata['visibility'].upper()]
except KeyError:
pass
if 'folder' in metadata:
self.folder = metadata['folder']
if 'tags' in metadata:
if isinstance(metadata['tags'], list):
self.set_tags_list(metadata['tags'])
elif isinstance(metadata['tags'], str):
self.tags = metadata['tags']
if 'pinned' in metadata:
self.is_pinned = bool(metadata['pinned'])
def create_share_link(self, expires_in_days=None, password=None, max_views=None, created_by=None):
"""Create a public share link for this note"""
from .note_share import NoteShare
from flask import g
share = NoteShare(
note_id=self.id,
created_by_id=created_by.id if created_by else g.user.id
)
# Set expiration
if expires_in_days:
share.expires_at = datetime.now() + timedelta(days=expires_in_days)
# Set password
if password:
share.set_password(password)
# Set view limit
if max_views:
share.max_views = max_views
db.session.add(share)
return share
def get_active_shares(self):
"""Get all active share links for this note"""
return [s for s in self.shares if s.is_valid()]
def get_all_shares(self):
"""Get all share links for this note"""
from models.note_share import NoteShare
return self.shares.order_by(NoteShare.created_at.desc()).all()
def has_active_shares(self):
"""Check if this note has any active share links"""
return any(s.is_valid() for s in self.shares)
class NoteLink(db.Model):
"""Links between notes for creating relationships"""
id = db.Column(db.Integer, primary_key=True)
# Source and target notes with cascade deletion
source_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
target_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
# Link metadata
link_type = db.Column(db.String(50), default='related') # related, parent, child, etc.
created_at = db.Column(db.DateTime, default=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships with cascade deletion
source_note = db.relationship('Note', foreign_keys=[source_note_id],
backref=db.backref('outgoing_links', cascade='all, delete-orphan'))
target_note = db.relationship('Note', foreign_keys=[target_note_id],
backref=db.backref('incoming_links', cascade='all, delete-orphan'))
created_by = db.relationship('User', foreign_keys=[created_by_id])
# Unique constraint to prevent duplicate links
__table_args__ = (db.UniqueConstraint('source_note_id', 'target_note_id', name='uq_note_link'),)
def __repr__(self):
return f'<NoteLink {self.source_note_id} -> {self.target_note_id}>'
class NoteFolder(db.Model):
"""Represents a folder for organizing notes"""
id = db.Column(db.Integer, primary_key=True)
# Folder properties
name = db.Column(db.String(100), nullable=False)
path = db.Column(db.String(500), nullable=False) # Full path like "Work/Projects/Q1"
parent_path = db.Column(db.String(500), nullable=True) # Parent folder path
description = db.Column(db.Text, 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)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Relationships
created_by = db.relationship('User', foreign_keys=[created_by_id])
company = db.relationship('Company', foreign_keys=[company_id])
# Unique constraint to prevent duplicate paths within a company
__table_args__ = (db.UniqueConstraint('path', 'company_id', name='uq_folder_path_company'),)
def __repr__(self):
return f'<NoteFolder {self.path}>'

107
models/note_share.py Normal file
View File

@@ -0,0 +1,107 @@
from datetime import datetime, timedelta
from . import db
import secrets
import string
from werkzeug.security import generate_password_hash, check_password_hash
class NoteShare(db.Model):
"""Public sharing links for notes"""
__tablename__ = 'note_share'
id = db.Column(db.Integer, primary_key=True)
note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
# Share settings
expires_at = db.Column(db.DateTime, nullable=True) # None means no expiry
password_hash = db.Column(db.String(255), nullable=True) # Optional password protection
view_count = db.Column(db.Integer, default=0)
max_views = db.Column(db.Integer, nullable=True) # Limit number of views
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
last_accessed_at = db.Column(db.DateTime, nullable=True)
# Relationships
note = db.relationship('Note', backref=db.backref('shares', cascade='all, delete-orphan', lazy='dynamic'))
created_by = db.relationship('User', foreign_keys=[created_by_id])
def __init__(self, **kwargs):
super(NoteShare, self).__init__(**kwargs)
if not self.token:
self.token = self.generate_token()
@staticmethod
def generate_token():
"""Generate a secure random token"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(32))
def set_password(self, password):
"""Set password protection for the share"""
if password:
self.password_hash = generate_password_hash(password)
else:
self.password_hash = None
def check_password(self, password):
"""Check if the provided password is correct"""
if not self.password_hash:
return True
return check_password_hash(self.password_hash, password)
def is_valid(self):
"""Check if share link is still valid"""
# Check expiration
if self.expires_at and datetime.now() > self.expires_at:
return False
# Check view count
if self.max_views and self.view_count >= self.max_views:
return False
return True
def is_expired(self):
"""Check if the share has expired"""
if self.expires_at and datetime.now() > self.expires_at:
return True
return False
def is_view_limit_reached(self):
"""Check if view limit has been reached"""
if self.max_views and self.view_count >= self.max_views:
return True
return False
def record_access(self):
"""Record that the share was accessed"""
self.view_count += 1
self.last_accessed_at = datetime.now()
def get_share_url(self, _external=True):
"""Get the full URL for this share"""
from flask import url_for
return url_for('notes_public.view_shared_note',
token=self.token,
_external=_external)
def to_dict(self):
"""Convert share to dictionary for API responses"""
return {
'id': self.id,
'token': self.token,
'url': self.get_share_url(),
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'has_password': bool(self.password_hash),
'max_views': self.max_views,
'view_count': self.view_count,
'created_at': self.created_at.isoformat(),
'created_by': self.created_by.username,
'last_accessed_at': self.last_accessed_at.isoformat() if self.last_accessed_at else None,
'is_valid': self.is_valid(),
'is_expired': self.is_expired(),
'is_view_limit_reached': self.is_view_limit_reached()
}

View File

@@ -1242,3 +1242,267 @@ class WidgetTemplate(db.Model):
required_level = role_hierarchy.get(self.required_role, 0)
return user_level >= required_level
# Note Sharing Visibility
class NoteVisibility(enum.Enum):
PRIVATE = "Private"
TEAM = "Team"
COMPANY = "Company"
class Note(db.Model):
"""Markdown notes with sharing capabilities"""
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False) # Markdown content
slug = db.Column(db.String(100), nullable=False) # URL-friendly identifier
# Visibility and sharing
visibility = db.Column(db.Enum(NoteVisibility), nullable=False, default=NoteVisibility.PRIVATE)
# Folder organization
folder = db.Column(db.String(100), nullable=True) # Folder path like "Work/Projects" or "Personal"
# Metadata
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
# Associations
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Optional associations
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) # For team-specific notes
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True) # Link to project
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True) # Link to task
# Tags for organization
tags = db.Column(db.String(500)) # Comma-separated tags
# Pin important notes
is_pinned = db.Column(db.Boolean, default=False)
# Soft delete
is_archived = db.Column(db.Boolean, default=False)
archived_at = db.Column(db.DateTime, nullable=True)
# Relationships
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='notes')
company = db.relationship('Company', backref='notes')
team = db.relationship('Team', backref='notes')
project = db.relationship('Project', backref='notes')
task = db.relationship('Task', backref='notes')
# Unique constraint on slug per company
__table_args__ = (db.UniqueConstraint('company_id', 'slug', name='uq_note_slug_per_company'),)
def __repr__(self):
return f'<Note {self.title}>'
def generate_slug(self):
"""Generate URL-friendly slug from title"""
import re
# Remove special characters and convert to lowercase
slug = re.sub(r'[^\w\s-]', '', self.title.lower())
# Replace spaces with hyphens
slug = re.sub(r'[-\s]+', '-', slug)
# Remove leading/trailing hyphens
slug = slug.strip('-')
# Ensure uniqueness within company
base_slug = slug
counter = 1
while Note.query.filter_by(company_id=self.company_id, slug=slug).filter(Note.id != self.id).first():
slug = f"{base_slug}-{counter}"
counter += 1
return slug
def can_user_view(self, user):
"""Check if user can view this note"""
# Creator can always view
if user.id == self.created_by_id:
return True
# Check company match
if user.company_id != self.company_id:
return False
# Check visibility
if self.visibility == NoteVisibility.COMPANY:
return True
elif self.visibility == NoteVisibility.TEAM:
# Check if user is in the same team
if self.team_id and user.team_id == self.team_id:
return True
# Admins can view all team notes
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
return True
return False
def can_user_edit(self, user):
"""Check if user can edit this note"""
# Creator can always edit
if user.id == self.created_by_id:
return True
# Admins can edit company notes
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] and user.company_id == self.company_id:
return True
return False
def get_tags_list(self):
"""Get tags as a list"""
if not self.tags:
return []
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
def set_tags_list(self, tags_list):
"""Set tags from a list"""
self.tags = ','.join(tags_list) if tags_list else None
def get_preview(self, length=200):
"""Get a plain text preview of the note content"""
# Strip markdown formatting for preview
import re
from frontmatter_utils import parse_frontmatter
# Extract body content without frontmatter
_, body = parse_frontmatter(self.content)
text = body
# Remove headers
text = re.sub(r'^#+\s+', '', text, flags=re.MULTILINE)
# Remove emphasis
text = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text)
text = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text)
# Remove links
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
# Remove code blocks
text = re.sub(r'```[^`]*```', '', text, flags=re.DOTALL)
text = re.sub(r'`([^`]+)`', r'\1', text)
# Clean up whitespace
text = ' '.join(text.split())
if len(text) > length:
return text[:length] + '...'
return text
def render_html(self):
"""Render markdown content to HTML"""
try:
import markdown
from frontmatter_utils import parse_frontmatter
# Extract body content without frontmatter
_, body = parse_frontmatter(self.content)
# Use extensions for better markdown support
html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc'])
return html
except ImportError:
# Fallback if markdown not installed
return f'<pre>{self.content}</pre>'
def get_frontmatter(self):
"""Get frontmatter metadata from content"""
from frontmatter_utils import parse_frontmatter
metadata, _ = parse_frontmatter(self.content)
return metadata
def update_frontmatter(self):
"""Update content with current metadata as frontmatter"""
from frontmatter_utils import update_frontmatter
metadata = {
'title': self.title,
'visibility': self.visibility.value.lower(),
'folder': self.folder,
'tags': self.get_tags_list() if self.tags else None,
'project': self.project.code if self.project else None,
'task_id': self.task_id,
'pinned': self.is_pinned if self.is_pinned else None,
'created': self.created_at.isoformat() if self.created_at else None,
'updated': self.updated_at.isoformat() if self.updated_at else None,
'author': self.created_by.username if self.created_by else None
}
# Remove None values
metadata = {k: v for k, v in metadata.items() if v is not None}
self.content = update_frontmatter(self.content, metadata)
def sync_from_frontmatter(self):
"""Update model fields from frontmatter in content"""
from frontmatter_utils import parse_frontmatter
metadata, _ = parse_frontmatter(self.content)
if metadata:
# Update fields from frontmatter
if 'title' in metadata:
self.title = metadata['title']
if 'visibility' in metadata:
try:
self.visibility = NoteVisibility[metadata['visibility'].upper()]
except KeyError:
pass
if 'folder' in metadata:
self.folder = metadata['folder']
if 'tags' in metadata:
if isinstance(metadata['tags'], list):
self.set_tags_list(metadata['tags'])
elif isinstance(metadata['tags'], str):
self.tags = metadata['tags']
if 'pinned' in metadata:
self.is_pinned = bool(metadata['pinned'])
class NoteLink(db.Model):
"""Links between notes for creating relationships"""
id = db.Column(db.Integer, primary_key=True)
# Source and target notes with cascade deletion
source_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
target_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
# Link metadata
link_type = db.Column(db.String(50), default='related') # related, parent, child, etc.
created_at = db.Column(db.DateTime, default=datetime.now)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships with cascade deletion
source_note = db.relationship('Note', foreign_keys=[source_note_id],
backref=db.backref('outgoing_links', cascade='all, delete-orphan'))
target_note = db.relationship('Note', foreign_keys=[target_note_id],
backref=db.backref('incoming_links', cascade='all, delete-orphan'))
created_by = db.relationship('User', foreign_keys=[created_by_id])
# Unique constraint to prevent duplicate links
__table_args__ = (db.UniqueConstraint('source_note_id', 'target_note_id', name='uq_note_link'),)
def __repr__(self):
return f'<NoteLink {self.source_note_id} -> {self.target_note_id}>'
class NoteFolder(db.Model):
"""Represents a folder for organizing notes"""
id = db.Column(db.Integer, primary_key=True)
# Folder properties
name = db.Column(db.String(100), nullable=False)
path = db.Column(db.String(500), nullable=False) # Full path like "Work/Projects/Q1"
parent_path = db.Column(db.String(500), nullable=True) # Parent folder path
description = db.Column(db.Text, 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)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
# Relationships
created_by = db.relationship('User', foreign_keys=[created_by_id])
company = db.relationship('Company', foreign_keys=[company_id])
# Unique constraint to prevent duplicate paths within a company
__table_args__ = (db.UniqueConstraint('path', 'company_id', name='uq_folder_path_company'),)
def __repr__(self):
return f'<NoteFolder {self.path}>'

View File

@@ -14,3 +14,5 @@ pandas==1.5.3
xlsxwriter==3.1.2
Flask-Mail==0.9.1
psycopg2-binary==2.9.9
markdown==3.4.4
PyYAML==6.0.1

1
routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Routes package initialization

View File

@@ -218,27 +218,8 @@ def admin_company():
@admin_required
@company_required
def company_users():
"""List all users in the company with detailed information"""
users = User.query.filter_by(company_id=g.company.id).order_by(User.created_at.desc()).all()
# Calculate user statistics
user_stats = {
'total': len(users),
'verified': len([u for u in users if u.is_verified]),
'unverified': len([u for u in users if not u.is_verified]),
'blocked': len([u for u in users if u.is_blocked]),
'active': len([u for u in users if not u.is_blocked and u.is_verified]),
'admins': len([u for u in users if u.role == Role.ADMIN]),
'supervisors': len([u for u in users if u.role == Role.SUPERVISOR]),
'team_leaders': len([u for u in users if u.role == Role.TEAM_LEADER]),
'team_members': len([u for u in users if u.role == Role.TEAM_MEMBER]),
}
return render_template('company_users.html',
title='Company Users',
company=g.company,
users=users,
stats=user_stats)
"""Redirect to the unified organization management page"""
return redirect(url_for('organization.admin_organization'))
# Setup company route (separate from company blueprint due to different URL)

498
routes/notes.py Normal file
View File

@@ -0,0 +1,498 @@
# Standard library imports
from datetime import datetime, timezone
# Third-party imports
from flask import (Blueprint, abort, flash, g, jsonify, redirect,
render_template, request, url_for)
from sqlalchemy import and_, or_
# Local application imports
from models import (Note, NoteFolder, NoteLink, NoteVisibility, Project,
Task, db)
from routes.auth import company_required, login_required
# Create blueprint
notes_bp = Blueprint('notes', __name__, url_prefix='/notes')
@notes_bp.route('')
@login_required
@company_required
def notes_list():
"""List all notes with optional filtering"""
import logging
logger = logging.getLogger(__name__)
logger.info("Notes list route called")
# Get filter parameters
folder_filter = request.args.get('folder', '')
tag_filter = request.args.get('tag', '')
visibility_filter = request.args.get('visibility', '')
search_query = request.args.get('search', '')
# Base query - only non-archived notes for the user's company
query = Note.query.filter_by(
company_id=g.user.company_id,
is_archived=False
)
# Apply folder filter
if folder_filter:
query = query.filter_by(folder=folder_filter)
# Apply tag filter
if tag_filter:
query = query.filter(Note.tags.contains(tag_filter))
# Apply visibility filter
if visibility_filter:
if visibility_filter == 'private':
query = query.filter_by(visibility=NoteVisibility.PRIVATE, created_by_id=g.user.id)
elif visibility_filter == 'team':
query = query.filter(
and_(
Note.visibility == NoteVisibility.TEAM,
or_(
Note.created_by.has(team_id=g.user.team_id),
Note.created_by_id == g.user.id
)
)
)
elif visibility_filter == 'company':
query = query.filter_by(visibility=NoteVisibility.COMPANY)
else:
# Default visibility filtering - show notes user can see
query = query.filter(
or_(
# Private notes created by user
and_(Note.visibility == NoteVisibility.PRIVATE, Note.created_by_id == g.user.id),
# Team notes from user's team
and_(Note.visibility == NoteVisibility.TEAM, Note.created_by.has(team_id=g.user.team_id)),
# Company notes
Note.visibility == NoteVisibility.COMPANY
)
)
# Apply search filter
if search_query:
search_pattern = f'%{search_query}%'
query = query.filter(
or_(
Note.title.ilike(search_pattern),
Note.content.ilike(search_pattern),
Note.tags.ilike(search_pattern)
)
)
# Order by pinned first, then by updated date
notes = query.order_by(Note.is_pinned.desc(), Note.updated_at.desc()).all()
# Get all folders for the sidebar
all_folders = NoteFolder.query.filter_by(
company_id=g.user.company_id
).order_by(NoteFolder.path).all()
# Build folder tree structure
folder_tree = {}
folder_counts = {}
# Count notes per folder
folder_note_counts = db.session.query(
Note.folder, db.func.count(Note.id)
).filter_by(
company_id=g.user.company_id,
is_archived=False
).group_by(Note.folder).all()
for folder, count in folder_note_counts:
if folder:
folder_counts[folder] = count
# Build folder tree structure
for folder in all_folders:
parts = folder.path.split('/')
current = folder_tree
for i, part in enumerate(parts):
if i == len(parts) - 1:
# Leaf folder - use full path as key
current[folder.path] = {}
else:
# Navigate to parent using full path
parent_path = '/'.join(parts[:i+1])
if parent_path not in current:
current[parent_path] = {}
current = current[parent_path]
# Get all unique tags
all_notes_for_tags = Note.query.filter_by(
company_id=g.user.company_id,
is_archived=False
).all()
all_tags = set()
tag_counts = {}
for note in all_notes_for_tags:
if note.tags:
note_tags = note.get_tags_list()
for tag in note_tags:
all_tags.add(tag)
tag_counts[tag] = tag_counts.get(tag, 0) + 1
all_tags = sorted(list(all_tags))
# Count notes by visibility
visibility_counts = {
'private': Note.query.filter_by(
company_id=g.user.company_id,
visibility=NoteVisibility.PRIVATE,
created_by_id=g.user.id,
is_archived=False
).count(),
'team': Note.query.filter(
Note.company_id == g.user.company_id,
Note.visibility == NoteVisibility.TEAM,
Note.created_by.has(team_id=g.user.team_id),
Note.is_archived == False
).count(),
'company': Note.query.filter_by(
company_id=g.user.company_id,
visibility=NoteVisibility.COMPANY,
is_archived=False
).count()
}
try:
logger.info(f"Rendering template with {len(notes)} notes, folder_tree type: {type(folder_tree)}")
return render_template('notes_list.html',
notes=notes,
folder_tree=folder_tree,
folder_counts=folder_counts,
all_tags=all_tags,
tag_counts=tag_counts,
visibility_counts=visibility_counts,
folder_filter=folder_filter,
tag_filter=tag_filter,
visibility_filter=visibility_filter,
search_query=search_query,
title='Notes')
except Exception as e:
logger.error(f"Error rendering notes template: {str(e)}", exc_info=True)
raise
@notes_bp.route('/new', methods=['GET', 'POST'])
@login_required
@company_required
def create_note():
"""Create a new note"""
if request.method == 'POST':
title = request.form.get('title', '').strip()
content = request.form.get('content', '').strip()
visibility = request.form.get('visibility', 'Private')
folder = request.form.get('folder', '').strip()
tags = request.form.get('tags', '').strip()
project_id = request.form.get('project_id')
task_id = request.form.get('task_id')
is_pinned = request.form.get('is_pinned') == '1'
# Validate
if not title:
flash('Title is required', 'error')
return redirect(url_for('notes.create_note'))
if not content:
flash('Content is required', 'error')
return redirect(url_for('notes.create_note'))
# Validate visibility
try:
visibility_enum = NoteVisibility[visibility.upper()]
except KeyError:
visibility_enum = NoteVisibility.PRIVATE
# Validate project if provided
project = None
if project_id:
project = Project.query.filter_by(
id=project_id,
company_id=g.user.company_id
).first()
if not project:
flash('Invalid project selected', 'error')
return redirect(url_for('notes.create_note'))
# Validate task if provided
task = None
if task_id:
task = Task.query.filter_by(id=task_id).first()
if not task or (project and task.project_id != project.id):
flash('Invalid task selected', 'error')
return redirect(url_for('notes.create_note'))
# Create note
note = Note(
title=title,
content=content,
visibility=visibility_enum,
folder=folder if folder else None,
tags=tags if tags else None,
company_id=g.user.company_id,
created_by_id=g.user.id,
project_id=project.id if project else None,
task_id=task.id if task else None,
is_pinned=is_pinned
)
# Generate slug before saving
note.generate_slug()
db.session.add(note)
db.session.commit()
flash('Note created successfully', 'success')
return redirect(url_for('notes.view_note', slug=note.slug))
# GET request - show form
# Get folders for dropdown
folders = NoteFolder.query.filter_by(
company_id=g.user.company_id
).order_by(NoteFolder.path).all()
# Get projects for dropdown
projects = Project.query.filter_by(
company_id=g.user.company_id,
is_active=True
).order_by(Project.name).all()
# Get task if specified in URL
task_id = request.args.get('task_id')
task = None
if task_id:
task = Task.query.filter_by(id=task_id).first()
return render_template('note_editor.html',
folders=folders,
projects=projects,
task=task,
title='Create Note')
@notes_bp.route('/<slug>')
@login_required
@company_required
def view_note(slug):
"""View a note"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
# Check permissions
if not note.can_user_view(g.user):
abort(403)
# Get linked notes
outgoing_links = NoteLink.query.filter_by(
source_note_id=note.id
).join(
Note, NoteLink.target_note_id == Note.id
).filter(
Note.is_archived == False
).all()
incoming_links = NoteLink.query.filter_by(
target_note_id=note.id
).join(
Note, NoteLink.source_note_id == Note.id
).filter(
Note.is_archived == False
).all()
# Get linkable notes for the modal
linkable_notes = Note.query.filter(
Note.company_id == g.user.company_id,
Note.id != note.id,
Note.is_archived == False
).filter(
or_(
# User's private notes
and_(Note.visibility == NoteVisibility.PRIVATE, Note.created_by_id == g.user.id),
# Team notes
and_(Note.visibility == NoteVisibility.TEAM, Note.created_by.has(team_id=g.user.team_id)),
# Company notes
Note.visibility == NoteVisibility.COMPANY
)
).order_by(Note.title).all()
return render_template('note_view.html',
note=note,
outgoing_links=outgoing_links,
incoming_links=incoming_links,
linkable_notes=linkable_notes,
title=note.title)
@notes_bp.route('/<slug>/mindmap')
@login_required
@company_required
def view_note_mindmap(slug):
"""View a note as a mind map"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
# Check permissions
if not note.can_user_view(g.user):
abort(403)
return render_template('note_mindmap.html', note=note, title=f"{note.title} - Mind Map")
@notes_bp.route('/<slug>/edit', methods=['GET', 'POST'])
@login_required
@company_required
def edit_note(slug):
"""Edit an existing note"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
# Check permissions
if not note.can_user_edit(g.user):
abort(403)
if request.method == 'POST':
title = request.form.get('title', '').strip()
content = request.form.get('content', '').strip()
visibility = request.form.get('visibility', 'Private')
folder = request.form.get('folder', '').strip()
tags = request.form.get('tags', '').strip()
project_id = request.form.get('project_id')
task_id = request.form.get('task_id')
is_pinned = request.form.get('is_pinned') == '1'
# Validate
if not title:
flash('Title is required', 'error')
return redirect(url_for('notes.edit_note', slug=slug))
if not content:
flash('Content is required', 'error')
return redirect(url_for('notes.edit_note', slug=slug))
# Validate visibility
try:
visibility_enum = NoteVisibility[visibility.upper()]
except KeyError:
visibility_enum = NoteVisibility.PRIVATE
# Validate project if provided
project = None
if project_id:
project = Project.query.filter_by(
id=project_id,
company_id=g.user.company_id
).first()
if not project:
flash('Invalid project selected', 'error')
return redirect(url_for('notes.edit_note', slug=slug))
# Validate task if provided
task = None
if task_id:
task = Task.query.filter_by(id=task_id).first()
if not task or (project and task.project_id != project.id):
flash('Invalid task selected', 'error')
return redirect(url_for('notes.edit_note', slug=slug))
# Update note
note.title = title
note.content = content
note.visibility = visibility_enum
note.folder = folder if folder else None
note.tags = tags if tags else None
note.project_id = project.id if project else None
note.task_id = task.id if task else None
note.is_pinned = is_pinned
note.updated_at = datetime.now(timezone.utc)
# Update slug if title changed
note.generate_slug()
db.session.commit()
flash('Note updated successfully', 'success')
return redirect(url_for('notes.view_note', slug=note.slug))
# GET request - show form
# Get folders for dropdown
folders = NoteFolder.query.filter_by(
company_id=g.user.company_id
).order_by(NoteFolder.path).all()
# Get projects for dropdown
projects = Project.query.filter_by(
company_id=g.user.company_id,
is_active=True
).order_by(Project.name).all()
return render_template('note_editor.html',
note=note,
folders=folders,
projects=projects,
title=f'Edit {note.title}')
@notes_bp.route('/<slug>/delete', methods=['POST'])
@login_required
@company_required
def delete_note(slug):
"""Delete (archive) a note"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
# Check permissions
if not note.can_user_edit(g.user):
abort(403)
# Archive the note
note.is_archived = True
note.updated_at = datetime.utcnow()
db.session.commit()
flash('Note deleted successfully', 'success')
return redirect(url_for('notes.notes_list'))
@notes_bp.route('/folders')
@login_required
@company_required
def notes_folders():
"""Manage note folders"""
# Get all folders
folders = NoteFolder.query.filter_by(
company_id=g.user.company_id
).order_by(NoteFolder.path).all()
# Get note counts per folder
folder_counts = {}
folder_note_counts = db.session.query(
Note.folder, db.func.count(Note.id)
).filter_by(
company_id=g.user.company_id,
is_archived=False
).filter(Note.folder.isnot(None)).group_by(Note.folder).all()
for folder, count in folder_note_counts:
folder_counts[folder] = count
# Build folder tree
folder_tree = {}
for folder in folders:
parts = folder.path.split('/')
current = folder_tree
for part in parts:
if part not in current:
current[part] = {}
current = current[part]
return render_template('notes_folders.html',
folders=folders,
folder_tree=folder_tree,
folder_counts=folder_counts,
title='Manage Folders')

560
routes/notes_api.py Normal file
View File

@@ -0,0 +1,560 @@
# Standard library imports
from datetime import datetime, timezone
# Third-party imports
from flask import Blueprint, abort, g, jsonify, request
from sqlalchemy import and_, or_
# Local application imports
from models import Note, NoteFolder, NoteLink, NoteVisibility, db
from routes.auth import company_required, login_required
# Create blueprint
notes_api_bp = Blueprint('notes_api', __name__, url_prefix='/api/notes')
@notes_api_bp.route('/folder-details')
@login_required
@company_required
def api_folder_details():
"""Get folder details including note count"""
folder_path = request.args.get('folder', '')
# Get note count for this folder
note_count = Note.query.filter_by(
company_id=g.user.company_id,
folder=folder_path,
is_archived=False
).count()
# Check if folder exists in NoteFolder table
folder = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=folder_path
).first()
return jsonify({
'success': True,
'folder': {
'path': folder_path,
'name': folder_path.split('/')[-1] if folder_path else 'Root',
'exists': folder is not None,
'note_count': note_count,
'created_at': folder.created_at.isoformat() if folder else None
}
})
@notes_api_bp.route('/folders', methods=['POST'])
@login_required
@company_required
def api_create_folder():
"""Create a new folder"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'message': 'No data provided'}), 400
folder_name = data.get('name', '').strip()
parent_path = data.get('parent', '').strip()
description = data.get('description', '').strip()
if not folder_name:
return jsonify({'success': False, 'message': 'Folder name is required'}), 400
# Validate folder name
if '/' in folder_name:
return jsonify({'success': False, 'message': 'Folder name cannot contain /'}), 400
# Build full path
if parent_path:
full_path = f"{parent_path}/{folder_name}"
else:
full_path = folder_name
# Check if folder already exists
existing = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=full_path
).first()
if existing:
return jsonify({'success': False, 'message': 'Folder already exists'}), 400
# Create folder
folder = NoteFolder(
name=folder_name,
path=full_path,
parent_path=parent_path if parent_path else None,
description=description if description else None,
company_id=g.user.company_id,
created_by_id=g.user.id
)
db.session.add(folder)
db.session.commit()
return jsonify({
'success': True,
'message': 'Folder created successfully',
'folder': {
'path': folder.path,
'name': folder_name
}
})
@notes_api_bp.route('/folders', methods=['PUT'])
@login_required
@company_required
def api_rename_folder():
"""Rename a folder"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'message': 'No data provided'}), 400
old_path = data.get('old_path', '').strip()
new_name = data.get('new_name', '').strip()
if not old_path or not new_name:
return jsonify({'success': False, 'message': 'Both old path and new name are required'}), 400
# Validate new name
if '/' in new_name:
return jsonify({'success': False, 'message': 'Folder name cannot contain /'}), 400
# Find the folder
folder = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=old_path
).first()
if not folder:
return jsonify({'success': False, 'message': 'Folder not found'}), 404
# Build new path
path_parts = old_path.split('/')
path_parts[-1] = new_name
new_path = '/'.join(path_parts)
# Check if new path already exists
existing = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=new_path
).first()
if existing:
return jsonify({'success': False, 'message': 'A folder with this name already exists'}), 400
# Update folder path
folder.path = new_path
# Update all notes in this folder
Note.query.filter_by(
company_id=g.user.company_id,
folder=old_path
).update({Note.folder: new_path})
# Update all subfolders
subfolders = NoteFolder.query.filter(
NoteFolder.company_id == g.user.company_id,
NoteFolder.path.like(f"{old_path}/%")
).all()
for subfolder in subfolders:
subfolder.path = subfolder.path.replace(old_path, new_path, 1)
# Update all notes in subfolders
notes_in_subfolders = Note.query.filter(
Note.company_id == g.user.company_id,
Note.folder.like(f"{old_path}/%")
).all()
for note in notes_in_subfolders:
note.folder = note.folder.replace(old_path, new_path, 1)
db.session.commit()
return jsonify({
'success': True,
'message': 'Folder renamed successfully',
'folder': {
'old_path': old_path,
'new_path': new_path
}
})
@notes_api_bp.route('/folders', methods=['DELETE'])
@login_required
@company_required
def api_delete_folder():
"""Delete a folder"""
folder_path = request.args.get('path', '').strip()
if not folder_path:
return jsonify({'success': False, 'message': 'Folder path is required'}), 400
# Check if folder has notes
note_count = Note.query.filter_by(
company_id=g.user.company_id,
folder=folder_path,
is_archived=False
).count()
if note_count > 0:
return jsonify({
'success': False,
'message': f'Cannot delete folder with {note_count} notes. Please move or delete the notes first.'
}), 400
# Find and delete the folder
folder = NoteFolder.query.filter_by(
company_id=g.user.company_id,
path=folder_path
).first()
if folder:
db.session.delete(folder)
db.session.commit()
return jsonify({
'success': True,
'message': 'Folder deleted successfully'
})
@notes_api_bp.route('/<slug>/folder', methods=['PUT'])
@login_required
@company_required
def update_note_folder(slug):
"""Update a note's folder via drag and drop"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
# Check permissions
if not note.can_user_edit(g.user):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
data = request.get_json()
new_folder = data.get('folder', '').strip()
# Update note folder
note.folder = new_folder if new_folder else None
note.updated_at = datetime.now(timezone.utc)
db.session.commit()
return jsonify({
'success': True,
'message': 'Note moved successfully'
})
@notes_api_bp.route('/<int:note_id>/move', methods=['POST'])
@login_required
@company_required
def move_note_to_folder(note_id):
"""Move a note to a different folder (used by drag and drop)"""
note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first()
if not note:
return jsonify({'success': False, 'error': 'Note not found'}), 404
# Check permissions
if not note.can_user_edit(g.user):
return jsonify({'success': False, 'error': 'Permission denied'}), 403
data = request.get_json()
new_folder = data.get('folder', '').strip()
# Update note folder
note.folder = new_folder if new_folder else None
note.updated_at = datetime.now(timezone.utc)
db.session.commit()
return jsonify({
'success': True,
'message': 'Note moved successfully',
'folder': note.folder or ''
})
@notes_api_bp.route('/<int:note_id>/tags', methods=['POST'])
@login_required
@company_required
def add_tags_to_note(note_id):
"""Add tags to a note"""
note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first()
if not note:
return jsonify({'success': False, 'message': 'Note not found'}), 404
# Check permissions
if not note.can_user_edit(g.user):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
data = request.get_json()
new_tags = data.get('tags', '').strip()
if not new_tags:
return jsonify({'success': False, 'message': 'No tags provided'}), 400
# Merge with existing tags
existing_tags = note.get_tags_list()
new_tag_list = [tag.strip() for tag in new_tags.split(',') if tag.strip()]
# Combine and deduplicate
all_tags = list(set(existing_tags + new_tag_list))
# Update note
note.tags = ', '.join(sorted(all_tags))
note.updated_at = datetime.now(timezone.utc)
db.session.commit()
return jsonify({
'success': True,
'message': 'Tags added successfully',
'tags': all_tags
})
@notes_api_bp.route('/<int:note_id>/link', methods=['POST'])
@login_required
@company_required
def link_notes(note_id):
"""Create a link between two notes"""
source_note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first()
if not source_note:
return jsonify({'success': False, 'message': 'Source note not found'}), 404
# Check permissions
if not source_note.can_user_edit(g.user):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
data = request.get_json()
target_note_id = data.get('target_note_id')
link_type = data.get('link_type', 'related')
if not target_note_id:
return jsonify({'success': False, 'message': 'Target note ID is required'}), 400
# Get target note
target_note = Note.query.filter_by(id=target_note_id, company_id=g.user.company_id).first()
if not target_note:
return jsonify({'success': False, 'message': 'Target note not found'}), 404
# Check if user can view target note
if not target_note.can_user_view(g.user):
return jsonify({'success': False, 'message': 'You cannot link to a note you cannot view'}), 403
# Check if link already exists
existing_link = NoteLink.query.filter_by(
source_note_id=source_note.id,
target_note_id=target_note.id
).first()
if existing_link:
return jsonify({'success': False, 'message': 'Link already exists'}), 400
# Create link
link = NoteLink(
source_note_id=source_note.id,
target_note_id=target_note.id,
link_type=link_type,
created_by_id=g.user.id
)
db.session.add(link)
db.session.commit()
return jsonify({
'success': True,
'message': 'Notes linked successfully'
})
@notes_api_bp.route('/<int:note_id>/link', methods=['DELETE'])
@login_required
@company_required
def unlink_notes(note_id):
"""Remove a link between two notes"""
source_note = Note.query.filter_by(id=note_id, company_id=g.user.company_id).first()
if not source_note:
return jsonify({'success': False, 'message': 'Source note not found'}), 404
# Check permissions
if not source_note.can_user_edit(g.user):
return jsonify({'success': False, 'message': 'Permission denied'}), 403
data = request.get_json()
target_note_id = data.get('target_note_id')
if not target_note_id:
return jsonify({'success': False, 'message': 'Target note ID is required'}), 400
# Find and delete the link (check both directions)
link = NoteLink.query.filter(
or_(
and_(
NoteLink.source_note_id == source_note.id,
NoteLink.target_note_id == target_note_id
),
and_(
NoteLink.source_note_id == target_note_id,
NoteLink.target_note_id == source_note.id
)
)
).first()
if not link:
return jsonify({'success': False, 'message': 'Link not found'}), 404
db.session.delete(link)
db.session.commit()
return jsonify({
'success': True,
'message': 'Link removed successfully'
})
@notes_api_bp.route('/<slug>/shares', methods=['POST'])
@login_required
@company_required
def create_note_share(slug):
"""Create a share link for a note"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first()
if not note:
return jsonify({'success': False, 'error': 'Note not found'}), 404
# Check permissions - only editors can create shares
if not note.can_user_edit(g.user):
return jsonify({'success': False, 'error': 'Permission denied'}), 403
data = request.get_json()
try:
share = note.create_share_link(
expires_in_days=data.get('expires_in_days'),
password=data.get('password'),
max_views=data.get('max_views')
)
db.session.commit()
return jsonify({
'success': True,
'share': share.to_dict()
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@notes_api_bp.route('/<slug>/shares', methods=['GET'])
@login_required
@company_required
def list_note_shares(slug):
"""List all share links for a note"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first()
if not note:
return jsonify({'success': False, 'error': 'Note not found'}), 404
# Check permissions
if not note.can_user_view(g.user):
return jsonify({'success': False, 'error': 'Permission denied'}), 403
# Get all shares (not just active ones)
shares = note.get_all_shares()
return jsonify({
'success': True,
'shares': [s.to_dict() for s in shares]
})
@notes_api_bp.route('/shares/<int:share_id>', methods=['DELETE'])
@login_required
@company_required
def delete_note_share(share_id):
"""Delete a share link"""
from models import NoteShare
share = NoteShare.query.get(share_id)
if not share:
return jsonify({'success': False, 'error': 'Share not found'}), 404
# Check permissions
if share.note.company_id != g.user.company_id:
return jsonify({'success': False, 'error': 'Permission denied'}), 403
if not share.note.can_user_edit(g.user):
return jsonify({'success': False, 'error': 'Permission denied'}), 403
try:
db.session.delete(share)
db.session.commit()
return jsonify({
'success': True,
'message': 'Share link deleted successfully'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
@notes_api_bp.route('/shares/<int:share_id>', methods=['PUT'])
@login_required
@company_required
def update_note_share(share_id):
"""Update a share link settings"""
from models import NoteShare
share = NoteShare.query.get(share_id)
if not share:
return jsonify({'success': False, 'error': 'Share not found'}), 404
# Check permissions
if share.note.company_id != g.user.company_id:
return jsonify({'success': False, 'error': 'Permission denied'}), 403
if not share.note.can_user_edit(g.user):
return jsonify({'success': False, 'error': 'Permission denied'}), 403
data = request.get_json()
try:
# Update expiration
if 'expires_in_days' in data:
if data['expires_in_days'] is None:
share.expires_at = None
else:
from datetime import datetime, timedelta
share.expires_at = datetime.now() + timedelta(days=data['expires_in_days'])
# Update password
if 'password' in data:
share.set_password(data['password'])
# Update view limit
if 'max_views' in data:
share.max_views = data['max_views']
db.session.commit()
return jsonify({
'success': True,
'share': share.to_dict()
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500

288
routes/notes_download.py Normal file
View File

@@ -0,0 +1,288 @@
# Standard library imports
import os
import re
import tempfile
import zipfile
from datetime import datetime
from urllib.parse import unquote
# Third-party imports
from flask import (Blueprint, Response, abort, flash, g, redirect, request,
send_file, url_for)
# Local application imports
from frontmatter_utils import parse_frontmatter
from models import Note, db
from routes.auth import company_required, login_required
# Create blueprint
notes_download_bp = Blueprint('notes_download', __name__)
@notes_download_bp.route('/notes/<slug>/download/<format>')
@login_required
@company_required
def download_note(slug, format):
"""Download a note in various formats"""
note = Note.query.filter_by(slug=slug, company_id=g.user.company_id).first_or_404()
# Check permissions
if not note.can_user_view(g.user):
abort(403)
# Prepare filename
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
timestamp = datetime.now().strftime('%Y%m%d')
if format == 'md':
# Download as Markdown with frontmatter
content = note.content
response = Response(content, mimetype='text/markdown')
response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.md"'
return response
elif format == 'html':
# Download as HTML
html_content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{note.title}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; }}
h1, h2, h3 {{ margin-top: 2rem; }}
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }}
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; }}
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 1rem; color: #666; }}
.metadata {{ background: #f9f9f9; padding: 1rem; border-radius: 5px; margin-bottom: 2rem; }}
.metadata dl {{ margin: 0; }}
.metadata dt {{ font-weight: bold; display: inline-block; width: 120px; }}
.metadata dd {{ display: inline; margin: 0; }}
</style>
</head>
<body>
<div class="metadata">
<h1>{note.title}</h1>
<dl>
<dt>Author:</dt><dd>{note.created_by.username}</dd><br>
<dt>Created:</dt><dd>{note.created_at.strftime('%Y-%m-%d %H:%M')}</dd><br>
<dt>Updated:</dt><dd>{note.updated_at.strftime('%Y-%m-%d %H:%M')}</dd><br>
<dt>Visibility:</dt><dd>{note.visibility.value}</dd><br>
{'<dt>Folder:</dt><dd>' + note.folder + '</dd><br>' if note.folder else ''}
{'<dt>Tags:</dt><dd>' + note.tags + '</dd><br>' if note.tags else ''}
</dl>
</div>
{note.render_html()}
</body>
</html>"""
response = Response(html_content, mimetype='text/html')
response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.html"'
return response
elif format == 'txt':
# Download as plain text
metadata, body = parse_frontmatter(note.content)
# Create plain text version
text_content = f"{note.title}\n{'=' * len(note.title)}\n\n"
text_content += f"Author: {note.created_by.username}\n"
text_content += f"Created: {note.created_at.strftime('%Y-%m-%d %H:%M')}\n"
text_content += f"Updated: {note.updated_at.strftime('%Y-%m-%d %H:%M')}\n"
text_content += f"Visibility: {note.visibility.value}\n"
if note.folder:
text_content += f"Folder: {note.folder}\n"
if note.tags:
text_content += f"Tags: {note.tags}\n"
text_content += "\n" + "-" * 40 + "\n\n"
# Remove markdown formatting
text_body = body
# Remove headers markdown
text_body = re.sub(r'^#+\s+', '', text_body, flags=re.MULTILINE)
# Remove emphasis
text_body = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text_body)
text_body = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text_body)
# Remove links but keep text
text_body = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text_body)
# Remove images
text_body = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'[Image: \1]', text_body)
# Remove code blocks markers
text_body = re.sub(r'```[^`]*```', lambda m: m.group(0).replace('```', ''), text_body, flags=re.DOTALL)
text_body = re.sub(r'`([^`]+)`', r'\1', text_body)
text_content += text_body
response = Response(text_content, mimetype='text/plain')
response.headers['Content-Disposition'] = f'attachment; filename="{safe_filename}_{timestamp}.txt"'
return response
else:
abort(404)
@notes_download_bp.route('/notes/download-bulk', methods=['POST'])
@login_required
@company_required
def download_notes_bulk():
"""Download multiple notes as a zip file"""
note_ids = request.form.getlist('note_ids[]')
format = request.form.get('format', 'md')
if not note_ids:
flash('No notes selected for download', 'error')
return redirect(url_for('notes.notes_list'))
# Create a temporary file for the zip
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip')
try:
with zipfile.ZipFile(temp_file.name, 'w') as zipf:
for note_id in note_ids:
note = Note.query.filter_by(id=int(note_id), company_id=g.user.company_id).first()
if note and note.can_user_view(g.user):
# Get content based on format
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
if format == 'md':
content = note.content
filename = f"{safe_filename}.md"
elif format == 'html':
content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{note.title}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; }}
h1, h2, h3 {{ margin-top: 2rem; }}
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }}
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; }}
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 1rem; color: #666; }}
</style>
</head>
<body>
<h1>{note.title}</h1>
{note.render_html()}
</body>
</html>"""
filename = f"{safe_filename}.html"
else: # txt
metadata, body = parse_frontmatter(note.content)
content = f"{note.title}\n{'=' * len(note.title)}\n\n{body}"
filename = f"{safe_filename}.txt"
# Add file to zip
zipf.writestr(filename, content)
# Send the zip file
temp_file.seek(0)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
return send_file(
temp_file.name,
mimetype='application/zip',
as_attachment=True,
download_name=f'notes_{timestamp}.zip'
)
finally:
# Clean up temp file after sending
os.unlink(temp_file.name)
@notes_download_bp.route('/notes/folder/<path:folder_path>/download/<format>')
@login_required
@company_required
def download_folder(folder_path, format):
"""Download all notes in a folder as a zip file"""
# Decode folder path (replace URL encoding)
folder_path = unquote(folder_path)
# Get all notes in this folder
notes = Note.query.filter_by(
company_id=g.user.company_id,
folder=folder_path,
is_archived=False
).all()
# Filter notes user can view
viewable_notes = [note for note in notes if note.can_user_view(g.user)]
if not viewable_notes:
flash('No notes found in this folder or you don\'t have permission to view them.', 'warning')
return redirect(url_for('notes.notes_list'))
# Create a temporary file for the zip
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip')
try:
with zipfile.ZipFile(temp_file.name, 'w') as zipf:
for note in viewable_notes:
# Get content based on format
safe_filename = re.sub(r'[^a-zA-Z0-9_-]', '_', note.title)
if format == 'md':
content = note.content
filename = f"{safe_filename}.md"
elif format == 'html':
content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{note.title}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; }}
h1, h2, h3 {{ margin-top: 2rem; }}
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }}
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; }}
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 1rem; color: #666; }}
.metadata {{ background: #f9f9f9; padding: 1rem; border-radius: 5px; margin-bottom: 2rem; }}
</style>
</head>
<body>
<div class="metadata">
<h1>{note.title}</h1>
<p>Author: {note.created_by.username} | Created: {note.created_at.strftime('%Y-%m-%d %H:%M')} | Folder: {note.folder}</p>
</div>
{note.render_html()}
</body>
</html>"""
filename = f"{safe_filename}.html"
else: # txt
metadata, body = parse_frontmatter(note.content)
# Remove markdown formatting
text_body = body
text_body = re.sub(r'^#+\s+', '', text_body, flags=re.MULTILINE)
text_body = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text_body)
text_body = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text_body)
text_body = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text_body)
text_body = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'[Image: \1]', text_body)
text_body = re.sub(r'```[^`]*```', lambda m: m.group(0).replace('```', ''), text_body, flags=re.DOTALL)
text_body = re.sub(r'`([^`]+)`', r'\1', text_body)
content = f"{note.title}\n{'=' * len(note.title)}\n\n"
content += f"Author: {note.created_by.username}\n"
content += f"Created: {note.created_at.strftime('%Y-%m-%d %H:%M')}\n"
content += f"Folder: {note.folder}\n\n"
content += "-" * 40 + "\n\n"
content += text_body
filename = f"{safe_filename}.txt"
# Add file to zip
zipf.writestr(filename, content)
# Send the zip file
temp_file.seek(0)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_folder_name = re.sub(r'[^a-zA-Z0-9_-]', '_', folder_path.replace('/', '_'))
return send_file(
temp_file.name,
mimetype='application/zip',
as_attachment=True,
download_name=f'{safe_folder_name}_notes_{timestamp}.zip'
)
finally:
# Clean up temp file after sending
os.unlink(temp_file.name)

191
routes/notes_public.py Normal file
View File

@@ -0,0 +1,191 @@
"""
Public routes for viewing shared notes without authentication
"""
from flask import Blueprint, render_template, abort, request, session, jsonify
from werkzeug.security import check_password_hash
from models import NoteShare, db
notes_public_bp = Blueprint('notes_public', __name__, url_prefix='/public/notes')
@notes_public_bp.route('/<token>')
def view_shared_note(token):
"""View a publicly shared note"""
# Find the share
share = NoteShare.query.filter_by(token=token).first()
if not share:
abort(404, "Share link not found")
# Check if share is valid
if not share.is_valid():
if share.is_expired():
abort(410, "This share link has expired")
elif share.is_view_limit_reached():
abort(410, "This share link has reached its view limit")
else:
abort(404, "This share link is no longer valid")
# Check password if required
if share.password_hash:
# Check if password was already verified in session
verified_shares = session.get('verified_shares', [])
if share.id not in verified_shares:
# For GET request, show password form
if request.method == 'GET':
return render_template('notes/share_password.html',
token=token,
note_title=share.note.title)
# Record access
share.record_access()
db.session.commit()
# Render the note (read-only view)
return render_template('notes/public_view.html',
note=share.note,
share=share)
@notes_public_bp.route('/<token>/verify', methods=['POST'])
def verify_share_password(token):
"""Verify password for a protected share"""
share = NoteShare.query.filter_by(token=token).first()
if not share:
abort(404, "Share link not found")
if not share.is_valid():
abort(410, "This share link is no longer valid")
password = request.form.get('password', '')
if share.check_password(password):
# Store verification in session
verified_shares = session.get('verified_shares', [])
if share.id not in verified_shares:
verified_shares.append(share.id)
session['verified_shares'] = verified_shares
# Redirect to the note view
return jsonify({'success': True, 'redirect': f'/public/notes/{token}'})
else:
return jsonify({'success': False, 'error': 'Invalid password'}), 401
@notes_public_bp.route('/<token>/download/<format>')
def download_shared_note(token, format):
"""Download a shared note in various formats"""
share = NoteShare.query.filter_by(token=token).first()
if not share:
abort(404, "Share link not found")
if not share.is_valid():
abort(410, "This share link is no longer valid")
# Check password protection
if share.password_hash:
verified_shares = session.get('verified_shares', [])
if share.id not in verified_shares:
abort(403, "Password verification required")
# Record access
share.record_access()
db.session.commit()
# Generate download based on format
from flask import Response, send_file
import markdown
import tempfile
import os
from datetime import datetime
note = share.note
if format == 'md':
# Markdown download
response = Response(note.content, mimetype='text/markdown')
response.headers['Content-Disposition'] = f'attachment; filename="{note.slug}.md"'
return response
elif format == 'html':
# HTML download
html_content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{note.title}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; }}
h1, h2, h3, h4, h5, h6 {{ margin-top: 2rem; margin-bottom: 1rem; }}
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }}
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; }}
blockquote {{ border-left: 4px solid #ddd; margin-left: 0; padding-left: 1rem; color: #666; }}
</style>
</head>
<body>
<h1>{note.title}</h1>
<p><em>Created: {note.created_at.strftime('%B %d, %Y')}</em></p>
{note.render_html()}
</body>
</html>"""
response = Response(html_content, mimetype='text/html')
response.headers['Content-Disposition'] = f'attachment; filename="{note.slug}.html"'
return response
elif format == 'pdf':
# PDF download using weasyprint
try:
import weasyprint
# Generate HTML first
html_content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{note.title}</title>
<style>
@page {{ size: A4; margin: 2cm; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; line-height: 1.6; }}
h1, h2, h3, h4, h5, h6 {{ margin-top: 1.5rem; margin-bottom: 0.75rem; page-break-after: avoid; }}
code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.9em; }}
pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; page-break-inside: avoid; }}
blockquote {{ border-left: 4px solid #ddd; margin-left: 0; padding-left: 1rem; color: #666; }}
table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
th, td {{ border: 1px solid #ddd; padding: 0.5rem; text-align: left; }}
th {{ background: #f4f4f4; font-weight: bold; }}
img {{ max-width: 100%; height: auto; }}
</style>
</head>
<body>
<h1>{note.title}</h1>
<p><em>Created: {note.created_at.strftime('%B %d, %Y')}</em></p>
{note.render_html()}
</body>
</html>"""
# Create temporary file for PDF
temp_file = tempfile.NamedTemporaryFile(mode='wb', suffix='.pdf', delete=False)
# Generate PDF
weasyprint.HTML(string=html_content).write_pdf(temp_file.name)
temp_file.close()
# Send file
response = send_file(
temp_file.name,
mimetype='application/pdf',
as_attachment=True,
download_name=f'{note.slug}.pdf'
)
# Clean up temp file after sending
os.unlink(temp_file.name)
return response
except ImportError:
# If weasyprint is not installed, return error
abort(500, "PDF generation not available")
else:
abort(400, "Invalid format")

231
routes/organization.py Normal file
View File

@@ -0,0 +1,231 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, g
from models import db, User, Team, Role, Company
from routes.auth import login_required, admin_required, company_required
from sqlalchemy import or_
# Create the blueprint
organization_bp = Blueprint('organization', __name__)
@organization_bp.route('/admin/organization')
@login_required
@company_required
@admin_required
def admin_organization():
"""Comprehensive organization management interface"""
company = g.user.company
# Get all teams and users for the company
teams = Team.query.filter_by(company_id=company.id).order_by(Team.name).all()
users = User.query.filter_by(company_id=company.id).order_by(User.username).all()
return render_template('admin_organization.html',
title='Organization Management',
teams=teams,
users=users,
Role=Role)
@organization_bp.route('/api/organization/teams/<int:team_id>', methods=['GET', 'PUT', 'DELETE'])
@login_required
@company_required
@admin_required
def api_team(team_id):
"""API endpoint for team operations"""
team = Team.query.filter_by(id=team_id, company_id=g.user.company_id).first_or_404()
if request.method == 'GET':
return jsonify({
'id': team.id,
'name': team.name,
'description': team.description,
'members': [{'id': u.id, 'username': u.username} for u in team.users]
})
elif request.method == 'PUT':
data = request.get_json()
team.name = data.get('name', team.name)
team.description = data.get('description', team.description)
try:
db.session.commit()
return jsonify({'success': True, 'message': 'Team updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)}), 400
elif request.method == 'DELETE':
# Unassign all users from the team
for user in team.users:
user.team_id = None
db.session.delete(team)
db.session.commit()
return jsonify({'success': True, 'message': 'Team deleted successfully'})
@organization_bp.route('/api/organization/teams', methods=['POST'])
@login_required
@company_required
@admin_required
def api_create_team():
"""API endpoint to create a new team"""
data = request.get_json()
team = Team(
name=data.get('name'),
description=data.get('description'),
company_id=g.user.company_id
)
try:
db.session.add(team)
db.session.commit()
return jsonify({'success': True, 'message': 'Team created successfully', 'team_id': team.id})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)}), 400
@organization_bp.route('/api/organization/users/<int:user_id>', methods=['GET', 'PUT', 'DELETE'])
@login_required
@company_required
@admin_required
def api_user(user_id):
"""API endpoint for user operations"""
user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404()
if request.method == 'GET':
return jsonify({
'id': user.id,
'username': user.username,
'email': user.email,
'role': user.role.name if user.role else 'TEAM_MEMBER',
'team_id': user.team_id,
'is_blocked': user.is_blocked
})
elif request.method == 'PUT':
data = request.get_json()
# Update user fields
if 'email' in data:
user.email = data['email']
if 'role' in data:
user.role = Role[data['role']]
if 'team_id' in data:
user.team_id = data['team_id'] if data['team_id'] else None
if 'is_blocked' in data:
user.is_blocked = data['is_blocked']
if 'password' in data and data['password']:
user.set_password(data['password'])
try:
db.session.commit()
return jsonify({'success': True, 'message': 'User updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)}), 400
elif request.method == 'DELETE':
if user.id == g.user.id:
return jsonify({'success': False, 'message': 'Cannot delete your own account'}), 400
db.session.delete(user)
db.session.commit()
return jsonify({'success': True, 'message': 'User deleted successfully'})
@organization_bp.route('/api/organization/users', methods=['POST'])
@login_required
@company_required
@admin_required
def api_create_user():
"""API endpoint to create a new user"""
data = request.get_json()
# Check if username already exists
if User.query.filter_by(username=data.get('username')).first():
return jsonify({'success': False, 'message': 'Username already exists'}), 400
user = User(
username=data.get('username'),
email=data.get('email'),
company_id=g.user.company_id,
role=Role[data.get('role', 'TEAM_MEMBER')],
team_id=data.get('team_id') if data.get('team_id') else None
)
user.set_password(data.get('password'))
try:
db.session.add(user)
db.session.commit()
return jsonify({'success': True, 'message': 'User created successfully', 'user_id': user.id})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)}), 400
@organization_bp.route('/api/organization/users/<int:user_id>/toggle-status', methods=['POST'])
@login_required
@company_required
@admin_required
def api_toggle_user_status(user_id):
"""Toggle user active/blocked status"""
user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404()
if user.id == g.user.id:
return jsonify({'success': False, 'message': 'Cannot block your own account'}), 400
user.is_blocked = not user.is_blocked
db.session.commit()
status = 'blocked' if user.is_blocked else 'unblocked'
return jsonify({'success': True, 'message': f'User {status} successfully'})
@organization_bp.route('/api/organization/users/<int:user_id>/assign-team', methods=['POST'])
@login_required
@company_required
@admin_required
def api_assign_team(user_id):
"""Assign user to a team"""
user = User.query.filter_by(id=user_id, company_id=g.user.company_id).first_or_404()
data = request.get_json()
team_id = data.get('team_id')
if team_id:
team = Team.query.filter_by(id=team_id, company_id=g.user.company_id).first_or_404()
user.team_id = team.id
else:
user.team_id = None
db.session.commit()
return jsonify({'success': True, 'message': 'Team assignment updated'})
@organization_bp.route('/api/organization/search', methods=['GET'])
@login_required
@company_required
@admin_required
def api_organization_search():
"""Search users and teams"""
query = request.args.get('q', '').lower()
if not query:
return jsonify({'users': [], 'teams': []})
# Search users
users = User.query.filter(
User.company_id == g.user.company_id,
or_(
User.username.ilike(f'%{query}%'),
User.email.ilike(f'%{query}%')
)
).limit(10).all()
# Search teams
teams = Team.query.filter(
Team.company_id == g.user.company_id,
or_(
Team.name.ilike(f'%{query}%'),
Team.description.ilike(f'%{query}%')
)
).limit(10).all()
return jsonify({
'users': [{'id': u.id, 'username': u.username, 'email': u.email} for u in users],
'teams': [{'id': t.id, 'name': t.name, 'description': t.description} for t in teams]
})

View File

@@ -6,7 +6,8 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
from models import (db, Company, User, Role, Team, Project, TimeEntry, SystemSettings,
SystemEvent, BrandingSettings, Task, SubTask, TaskDependency, Sprint,
Comment, UserPreferences, UserDashboard, WorkConfig, CompanySettings,
CompanyWorkConfig, ProjectCategory)
CompanyWorkConfig, ProjectCategory, Note, NoteFolder, NoteShare,
Announcement, CompanyInvitation)
from routes.auth import system_admin_required
from flask import session
from sqlalchemy import func
@@ -226,6 +227,34 @@ def delete_company(company_id):
db.session.query(User.id).filter(User.company_id == company_id)
)).delete(synchronize_session=False)
# Delete notes and note-related data
user_ids_subquery = db.session.query(User.id).filter(User.company_id == company_id).subquery()
# Delete note shares
NoteShare.query.filter(NoteShare.created_by_id.in_(user_ids_subquery)).delete(synchronize_session=False)
# Delete notes
Note.query.filter(Note.created_by_id.in_(user_ids_subquery)).delete(synchronize_session=False)
# Delete note folders
NoteFolder.query.filter(NoteFolder.created_by_id.in_(user_ids_subquery)).delete(synchronize_session=False)
# Delete announcements
Announcement.query.filter(Announcement.created_by_id.in_(user_ids_subquery)).delete(synchronize_session=False)
# Delete invitations
CompanyInvitation.query.filter(
(CompanyInvitation.invited_by_id.in_(user_ids_subquery)) |
(CompanyInvitation.accepted_by_user_id.in_(user_ids_subquery))
).delete(synchronize_session=False)
# Delete system events associated with users from this company
SystemEvent.query.filter(SystemEvent.user_id.in_(user_ids_subquery)).delete(synchronize_session=False)
# Clear branding settings updated_by references
BrandingSettings.query.filter(BrandingSettings.updated_by_id.in_(user_ids_subquery)).update(
{BrandingSettings.updated_by_id: None}, synchronize_session=False)
# Delete users
User.query.filter_by(company_id=company_id).delete()

View File

@@ -16,9 +16,8 @@ teams_bp = Blueprint('teams', __name__, url_prefix='/admin/teams')
@admin_required
@company_required
def admin_teams():
team_repo = TeamRepository()
teams = team_repo.get_with_member_count(g.user.company_id)
return render_template('admin_teams.html', title='Team Management', teams=teams)
# Redirect to the new unified organization management page
return redirect(url_for('organization.admin_organization'))
@teams_bp.route('/create', methods=['GET', 'POST'])

View File

@@ -38,9 +38,8 @@ def get_available_roles():
@admin_required
@company_required
def admin_users():
user_repo = UserRepository()
users = user_repo.get_by_company(g.user.company_id)
return render_template('admin_users.html', title='User Management', users=users)
# Redirect to the new unified organization management page
return redirect(url_for('organization.admin_organization'))
@users_bp.route('/create', methods=['GET', 'POST'])

View File

@@ -367,7 +367,8 @@ body.auth-page::after {
}
.company-code-group::before {
content: '🏢';
content: '\eebe'; /* Tabler icon building */
font-family: 'tabler-icons';
position: absolute;
left: 1rem;
top: 2.5rem; /* Position below the label */

View File

@@ -0,0 +1,390 @@
/* ===================================================================
TIMETRACK HOVER STANDARDS
Consistent hover states based on primary gradient colors
Primary: #667eea to #764ba2
=================================================================== */
:root {
/* Primary gradient colors */
--primary-gradient-start: #667eea;
--primary-gradient-end: #764ba2;
--primary-color: #667eea;
/* Hover color variations */
--hover-primary: #5569d6; /* Darker primary for hover */
--hover-primary-dark: #4a5bc8; /* Even darker primary */
--hover-secondary: #6a4195; /* Darker gradient end */
/* Background hover colors */
--hover-bg-light: rgba(102, 126, 234, 0.05); /* 5% primary */
--hover-bg-medium: rgba(102, 126, 234, 0.1); /* 10% primary */
--hover-bg-strong: rgba(102, 126, 234, 0.15); /* 15% primary */
/* Shadow definitions */
--hover-shadow-light: 0 2px 4px rgba(102, 126, 234, 0.15);
--hover-shadow-medium: 0 4px 12px rgba(102, 126, 234, 0.2);
--hover-shadow-strong: 0 6px 20px rgba(102, 126, 234, 0.25);
--hover-shadow-heavy: 0 8px 30px rgba(102, 126, 234, 0.3);
/* Transform values */
--hover-lift-subtle: translateY(-1px);
--hover-lift-small: translateY(-2px);
--hover-lift-medium: translateY(-3px);
--hover-lift-large: translateY(-5px);
/* Transition timing */
--hover-transition-fast: all 0.2s ease;
--hover-transition-normal: all 0.3s ease;
--hover-transition-smooth: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ===================================================================
GLOBAL HOVER STYLES
=================================================================== */
/* All links */
a {
transition: var(--hover-transition-fast);
}
a:hover {
color: var(--primary-color);
text-decoration: none;
}
/* ===================================================================
BUTTON HOVER STYLES
=================================================================== */
/* Base button hover */
.btn {
transition: var(--hover-transition-normal);
}
.btn:hover {
transform: var(--hover-lift-subtle);
box-shadow: var(--hover-shadow-light);
}
/* Primary button with gradient */
.btn-primary {
background: linear-gradient(135deg, var(--primary-gradient-start) 0%, var(--primary-gradient-end) 100%);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--hover-primary) 0%, var(--hover-secondary) 100%);
transform: var(--hover-lift-small);
box-shadow: var(--hover-shadow-medium);
border-color: var(--hover-primary);
}
/* Secondary button */
.btn-secondary:hover {
background-color: var(--hover-bg-medium);
border-color: var(--primary-color);
color: var(--primary-color);
transform: var(--hover-lift-subtle);
box-shadow: var(--hover-shadow-light);
}
/* Success button - maintain green but with consistent effects */
.btn-success:hover {
transform: var(--hover-lift-small);
box-shadow: var(--hover-shadow-medium);
filter: brightness(0.9);
}
/* Danger button - maintain red but with consistent effects */
.btn-danger:hover {
transform: var(--hover-lift-small);
box-shadow: var(--hover-shadow-medium);
filter: brightness(0.9);
}
/* Outline buttons */
.btn-outline:hover {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: white;
transform: var(--hover-lift-subtle);
box-shadow: var(--hover-shadow-light);
}
/* Small buttons */
.btn-sm:hover {
transform: var(--hover-lift-subtle);
box-shadow: var(--hover-shadow-light);
}
/* ===================================================================
NAVIGATION HOVER STYLES
=================================================================== */
/* Sidebar */
.sidebar {
transition: var(--hover-transition-normal);
}
.sidebar:hover {
box-shadow: var(--hover-shadow-heavy);
}
/* Sidebar navigation items */
.sidebar-nav li a {
transition: var(--hover-transition-fast);
}
.sidebar-nav li a:hover {
background-color: var(--hover-bg-medium);
border-left-color: var(--primary-color);
color: var(--primary-color);
}
/* Active state should be stronger */
.sidebar-nav li.active a {
background-color: var(--hover-bg-strong);
border-left-color: var(--primary-gradient-end);
color: var(--primary-gradient-end);
}
/* Top navigation */
.navbar-nav .nav-link:hover {
color: var(--primary-color);
background-color: var(--hover-bg-light);
border-radius: 4px;
}
/* Dropdown items */
.dropdown-item:hover {
background-color: var(--hover-bg-medium);
color: var(--primary-color);
}
/* ===================================================================
CARD & PANEL HOVER STYLES
=================================================================== */
/* Base card hover */
.card,
.panel,
.dashboard-card,
.admin-card,
.stat-card {
transition: var(--hover-transition-normal);
}
.card:hover,
.panel:hover,
.dashboard-card:hover {
transform: var(--hover-lift-small);
box-shadow: var(--hover-shadow-medium);
}
/* Clickable cards get stronger effect */
.admin-card:hover,
.clickable-card:hover {
transform: var(--hover-lift-medium);
box-shadow: var(--hover-shadow-strong);
cursor: pointer;
}
/* Stat cards */
.stat-card:hover {
transform: var(--hover-lift-small);
box-shadow: var(--hover-shadow-medium);
border-color: var(--hover-bg-strong);
}
/* Management cards */
.management-card {
transition: var(--hover-transition-normal);
}
.management-card:hover {
transform: var(--hover-lift-small);
box-shadow: var(--hover-shadow-medium);
border-color: var(--hover-bg-medium);
}
/* ===================================================================
TABLE HOVER STYLES
=================================================================== */
/* Table rows */
.table tbody tr,
.data-table tbody tr,
.time-history tbody tr {
transition: var(--hover-transition-fast);
}
.table tbody tr:hover,
.data-table tbody tr:hover,
.time-history tbody tr:hover {
background-color: var(--hover-bg-light);
}
/* Clickable rows get pointer */
.clickable-row:hover {
cursor: pointer;
background-color: var(--hover-bg-medium);
}
/* ===================================================================
FORM ELEMENT HOVER STYLES
=================================================================== */
/* Input fields */
.form-control,
.form-select {
transition: var(--hover-transition-fast);
}
.form-control:hover:not(:focus),
.form-select:hover:not(:focus) {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--hover-bg-light);
}
/* Checkboxes and radios */
.form-check-input:hover {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--hover-bg-medium);
}
/* ===================================================================
ICON HOVER STYLES
=================================================================== */
/* Icon buttons */
.icon-btn {
transition: var(--hover-transition-fast);
}
.icon-btn:hover {
color: var(--primary-color);
background-color: var(--hover-bg-medium);
border-radius: 50%;
}
/* Action icons */
.action-icon:hover {
color: var(--primary-color);
transform: scale(1.1);
}
/* ===================================================================
SPECIAL COMPONENT HOVER STYLES
=================================================================== */
/* Task cards */
.task-card {
transition: var(--hover-transition-normal);
}
.task-card:hover {
transform: var(--hover-lift-subtle);
box-shadow: var(--hover-shadow-medium);
border-left-color: var(--primary-color);
}
/* Note cards */
.note-card:hover {
transform: var(--hover-lift-small);
box-shadow: var(--hover-shadow-medium);
border-color: var(--hover-bg-strong);
}
/* Export sections */
.export-section:hover {
transform: var(--hover-lift-small);
box-shadow: var(--hover-shadow-strong);
}
/* Widget hover */
.widget:hover {
transform: var(--hover-lift-small);
box-shadow: var(--hover-shadow-medium);
border-color: var(--hover-bg-medium);
}
/* Mode buttons */
.mode-btn:hover:not(.active) {
background-color: var(--hover-bg-medium);
color: var(--primary-color);
border-color: var(--primary-color);
}
/* Tab buttons */
.tab-btn:hover:not(.active) {
background-color: var(--hover-bg-light);
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
/* ===================================================================
UTILITY HOVER CLASSES
=================================================================== */
/* Add these classes to elements for consistent hover effects */
.hover-lift:hover {
transform: var(--hover-lift-small);
}
.hover-lift-large:hover {
transform: var(--hover-lift-large);
}
.hover-shadow:hover {
box-shadow: var(--hover-shadow-medium);
}
.hover-shadow-strong:hover {
box-shadow: var(--hover-shadow-strong);
}
.hover-bg:hover {
background-color: var(--hover-bg-medium);
}
.hover-primary:hover {
color: var(--primary-color);
}
.hover-scale:hover {
transform: scale(1.05);
}
.hover-brightness:hover {
filter: brightness(1.1);
}
/* ===================================================================
ANIMATION UTILITIES
=================================================================== */
/* Smooth all transitions */
.smooth-transition {
transition: var(--hover-transition-smooth);
}
/* Quick transitions for responsive feel */
.quick-transition {
transition: var(--hover-transition-fast);
}
/* Disable transitions on request */
.no-transition {
transition: none !important;
}
/* ===================================================================
DARK MODE ADJUSTMENTS (if applicable)
=================================================================== */
@media (prefers-color-scheme: dark) {
:root {
--hover-bg-light: rgba(102, 126, 234, 0.1);
--hover-bg-medium: rgba(102, 126, 234, 0.2);
--hover-bg-strong: rgba(102, 126, 234, 0.3);
}
}

View File

@@ -9,7 +9,7 @@
/* Hero Section */
.splash-hero {
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 6rem 2rem;
text-align: center;
@@ -21,6 +21,21 @@
justify-content: center;
}
/* Add geometric pattern overlay */
.splash-hero::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(255,255,255,0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(255,255,255,0.05) 0%, transparent 50%),
radial-gradient(circle at 40% 20%, rgba(255,255,255,0.08) 0%, transparent 50%);
pointer-events: none;
}
.hero-content {
max-width: 800px;
margin: 0 auto;
@@ -64,15 +79,35 @@
}
.btn-primary {
background: #4CAF50;
background: #667eea;
color: white;
box-shadow: 0 4px 15px rgba(76, 175, 80, 0.3);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
position: relative;
overflow: hidden;
}
.btn-primary::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn-primary:hover {
background: #45a049;
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.4);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.btn-primary:hover::before {
width: 300px;
height: 300px;
}
.btn-secondary {
@@ -83,7 +118,9 @@
.btn-secondary:hover {
background: white;
color: #2a5298;
color: #667eea;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
/* Floating Clock Animation */
@@ -135,7 +172,7 @@
width: 2px;
height: 110px;
margin-left: -1px;
background: #4CAF50;
background: #764ba2;
animation: rotate 60s linear infinite;
}
@@ -149,8 +186,22 @@
text-align: center;
font-size: 2.5rem;
margin-bottom: 3rem;
color: #333;
color: #1f2937;
font-weight: 600;
position: relative;
padding-bottom: 1rem;
}
.section-title::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 80px;
height: 4px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 2px;
}
.feature-cards {
@@ -168,32 +219,40 @@
text-align: center;
box-shadow: 0 5px 20px rgba(0,0,0,0.08);
transition: all 0.3s ease;
border: 1px solid #e5e7eb;
}
.feature-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0,0,0,0.12);
transform: translateY(-3px);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.2);
border: 1px solid rgba(102, 126, 234, 0.2);
background: linear-gradient(white, white) padding-box,
linear-gradient(135deg, #667eea 0%, #764ba2 100%) border-box;
}
.feature-icon {
font-size: 3rem;
margin-bottom: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.feature-card h3 {
font-size: 1.5rem;
margin-bottom: 1rem;
color: #333;
color: #1f2937;
}
.feature-card p {
color: #666;
color: #6b7280;
line-height: 1.6;
}
/* Statistics Section */
.statistics {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #374151 0%, #4b5563 100%);
padding: 5rem 2rem;
display: flex;
justify-content: space-around;
@@ -249,7 +308,7 @@
/* Testimonials */
.testimonials {
padding: 5rem 2rem;
background: white;
background: #f9fafb;
}
.testimonial-grid {
@@ -261,10 +320,20 @@
}
.testimonial-card {
background: #f8f9fa;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
padding: 2rem;
border-radius: 12px;
text-align: center;
border: 1px solid rgba(102, 126, 234, 0.1);
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.1);
transition: all 0.3s ease;
}
.testimonial-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(102, 126, 234, 0.15);
border-color: rgba(102, 126, 234, 0.2);
}
.stars {
@@ -275,7 +344,7 @@
.testimonial-card p {
font-size: 1.1rem;
line-height: 1.6;
color: #555;
color: #4b5563;
margin-bottom: 1.5rem;
font-style: italic;
}
@@ -287,18 +356,18 @@
}
.testimonial-author strong {
color: #333;
color: #1f2937;
}
.testimonial-author span {
color: #666;
color: #6b7280;
font-size: 0.9rem;
}
/* Pricing Section */
.pricing {
padding: 5rem 2rem;
background: #f8f9fa;
background: #f3f4f6;
}
.pricing-cards {
@@ -317,11 +386,21 @@
position: relative;
box-shadow: 0 5px 20px rgba(0,0,0,0.08);
transition: all 0.3s ease;
border: 2px solid transparent;
}
.pricing-card:hover {
transform: translateY(-5px);
box-shadow: 0 15px 40px rgba(102, 126, 234, 0.15);
}
.pricing-card.featured {
transform: scale(1.05);
box-shadow: 0 10px 40px rgba(0,0,0,0.15);
border: 2px solid;
border-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%) 1;
background: linear-gradient(white, white) padding-box,
linear-gradient(135deg, #667eea 0%, #764ba2 100%) border-box;
}
.badge {
@@ -329,7 +408,7 @@
top: -15px;
left: 50%;
transform: translateX(-50%);
background: #4CAF50;
background: #667eea;
color: white;
padding: 0.5rem 1.5rem;
border-radius: 20px;
@@ -340,20 +419,20 @@
.pricing-card h3 {
font-size: 1.8rem;
margin-bottom: 1rem;
color: #333;
color: #1f2937;
}
.price {
font-size: 3rem;
font-weight: 700;
color: #2a5298;
color: #667eea;
margin-bottom: 2rem;
}
.price span {
font-size: 1rem;
font-weight: 400;
color: #666;
color: #6b7280;
}
.pricing-features {
@@ -364,8 +443,8 @@
.pricing-features li {
padding: 0.75rem 0;
color: #555;
border-bottom: 1px solid #eee;
color: #4b5563;
border-bottom: 1px solid #e5e7eb;
}
.pricing-features li:last-child {
@@ -375,7 +454,7 @@
.btn-pricing {
display: inline-block;
padding: 1rem 2rem;
background: #4CAF50;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 6px;
@@ -385,21 +464,23 @@
}
.btn-pricing:hover {
background: #45a049;
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
.pricing-card.featured .btn-pricing {
background: #2a5298;
background: #764ba2;
}
.pricing-card.featured .btn-pricing:hover {
background: #1e3c72;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
/* Final CTA */
.final-cta {
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 5rem 2rem;
text-align: center;
@@ -421,6 +502,99 @@
padding: 1.25rem 3rem;
}
/* Sliding Features Banner */
.features-banner {
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
padding: 1.5rem 0;
overflow: hidden;
position: relative;
white-space: nowrap;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1), 0 -2px 10px rgba(0, 0, 0, 0.1);
margin: 3rem 0;
}
.features-banner.reverse {
background: linear-gradient(135deg, #374151 0%, #4b5563 100%);
}
.features-slider {
width: 100%;
overflow: hidden;
}
.features-track {
display: flex;
animation: slide 40s linear infinite;
}
.features-banner.reverse .features-track {
animation: slideReverse 45s linear infinite;
}
.feature-item {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0 3rem;
color: white;
font-size: 1rem;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.feature-item i {
font-size: 1.25rem;
color: #a78bfa;
text-shadow: 0 0 10px rgba(167, 139, 250, 0.5);
transition: all 0.3s ease;
}
.feature-item:hover i {
color: #c4b5fd;
text-shadow: 0 0 15px rgba(196, 181, 253, 0.7);
transform: scale(1.1);
}
.feature-item span {
opacity: 0.9;
}
@keyframes slide {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
@keyframes slideReverse {
0% {
transform: translateX(-50%);
}
100% {
transform: translateX(0);
}
}
/* Pause on hover */
.features-banner:hover .features-track {
animation-play-state: paused;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.feature-item {
padding: 0 2rem;
font-size: 0.9rem;
}
.feature-item i {
font-size: 1.1rem;
}
}
/* Animations */
@keyframes fadeInUp {
from {
@@ -433,6 +607,34 @@
}
}
/* Add gradient animation */
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.splash-hero {
background-size: 200% 200%;
animation: gradientShift 15s ease infinite;
}
.statistics {
background-size: 200% 200%;
animation: gradientShift 20s ease infinite;
}
.final-cta {
background-size: 200% 200%;
animation: gradientShift 18s ease infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(-50%) translateX(0);

View File

@@ -112,7 +112,7 @@ button {
}
.sidebar:hover {
box-shadow: 0 6px 30px rgba(0,0,0,0.12);
box-shadow: 0 6px 30px rgba(102, 126, 234, 0.15);
}
/* Custom scrollbar for sidebar */
@@ -169,7 +169,7 @@ button {
}
.sidebar-header h2 a:hover {
color: #4CAF50;
color: #667eea;
}
.sidebar-header small {
@@ -229,20 +229,20 @@ button {
.sidebar-nav li a:hover {
background-color: #e9ecef;
border-left-color: #4CAF50;
border-left-color: #667eea;
color: #333;
}
.sidebar-nav li a.active {
background-color: #e8f5e9;
border-left-color: #4CAF50;
color: #2e7d32;
background-color: rgba(102, 126, 234, 0.1);
border-left-color: #667eea;
color: #5569d6;
font-weight: 600;
}
.nav-icon {
margin-right: 0.75rem;
font-size: 1.1rem;
font-size: 1.5rem;
min-width: 20px;
text-align: center;
}
@@ -366,7 +366,7 @@ body:has(.sidebar.collapsed) .main-content {
}
.feature h3 {
color: #4CAF50;
color: #667eea;
margin-top: 0;
}
@@ -457,8 +457,10 @@ button {
}
.btn-primary:hover {
background-color: #0056b3;
border-color: #0056b3;
background-color: #5569d6;
border-color: #5569d6;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}
.btn-secondary {
@@ -470,6 +472,8 @@ button {
.btn-secondary:hover {
background-color: #545b62;
border-color: #545b62;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}
.btn-success {
@@ -481,6 +485,8 @@ button {
.btn-success:hover {
background-color: #218838;
border-color: #218838;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}
.btn-danger {
@@ -492,6 +498,8 @@ button {
.btn-danger:hover {
background-color: #c82333;
border-color: #c82333;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}
.btn-warning {
@@ -503,6 +511,8 @@ button {
.btn-warning:hover {
background-color: #e0a800;
border-color: #e0a800;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}
.btn-info {
@@ -514,18 +524,23 @@ button {
.btn-info:hover {
background-color: #138496;
border-color: #138496;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}
/* Button Outline Variants */
.btn-outline {
border: 1px solid #007bff;
color: #007bff;
border: 1px solid #6c757d;
color: #495057;
background: transparent;
}
.btn-outline:hover {
background-color: #007bff;
background-color: #667eea;
color: white;
border-color: #667eea;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}
/* Special Button Styles */
@@ -539,7 +554,9 @@ button {
.btn-filter:hover {
background-color: #f8f9fa;
border-color: #adb5bd;
border-color: #667eea;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.15);
}
.btn-filter.active {
@@ -551,7 +568,7 @@ button {
/* Generic Button Hover */
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.15);
}
footer {
@@ -600,7 +617,7 @@ footer {
}
.footer-links a:hover {
color: var(--primary-color);
color: #667eea;
}
.footer-separator {
@@ -662,8 +679,8 @@ footer {
}
.email-nag-dismiss:hover {
background-color: rgba(0,0,0,0.1);
color: #333;
background-color: rgba(102, 126, 234, 0.1);
color: #5569d6;
}
@keyframes slideDown {
@@ -748,7 +765,9 @@ body:has(.sidebar.collapsed) footer {
}
.arrive-btn:hover {
background-color: #45a049;
background-color: #667eea;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}
.leave-btn {
@@ -760,6 +779,8 @@ body:has(.sidebar.collapsed) footer {
.leave-btn:hover {
background-color: #d32f2f;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}
.time-history {
@@ -788,7 +809,7 @@ body:has(.sidebar.collapsed) footer {
}
.time-history tr:hover {
background-color: #f5f5f5;
background-color: rgba(102, 126, 234, 0.05);
}
.button-group {
@@ -807,6 +828,8 @@ body:has(.sidebar.collapsed) footer {
.pause-btn:hover {
background-color: #f57c00;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}
.resume-btn {
@@ -817,7 +840,9 @@ body:has(.sidebar.collapsed) footer {
}
.resume-btn:hover {
background-color: #1976D2;
background-color: #667eea;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}
.break-info {
@@ -971,7 +996,9 @@ body:has(.sidebar.collapsed) footer {
}
.edit-entry-btn:hover {
background-color: #0b7dda;
background-color: #667eea;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
}
.delete-entry-btn {
@@ -981,6 +1008,8 @@ body:has(.sidebar.collapsed) footer {
.delete-entry-btn:hover {
background-color: #d32f2f;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.15);
}
input[type="date"], input[type="time"] {
@@ -1037,8 +1066,8 @@ input[type="time"]::-webkit-datetime-edit {
}
.admin-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.15);
}
.admin-card h2 {
@@ -1113,11 +1142,11 @@ input[type="time"]::-webkit-datetime-edit {
}
.checkbox-container:hover input ~ .checkmark {
background-color: #ccc;
background-color: rgba(102, 126, 234, 0.15);
}
.checkbox-container input:checked ~ .checkmark {
background-color: #2196F3;
background-color: #667eea;
}
.checkmark:after {
@@ -1171,7 +1200,7 @@ input[type="time"]::-webkit-datetime-edit {
}
.data-table tr:hover {
background-color: #f5f5f5;
background-color: rgba(102, 126, 234, 0.05);
}
/* Team Hours Page Styles */
@@ -1266,16 +1295,16 @@ input[type="time"]::-webkit-datetime-edit {
.export-section:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.2);
}
.export-section h3 {
color: #4CAF50;
color: #667eea;
margin-top: 0;
margin-bottom: 1.5rem;
font-size: 1.3rem;
font-weight: 600;
border-bottom: 2px solid #4CAF50;
border-bottom: 2px solid #667eea;
padding-bottom: 0.5rem;
}
@@ -1296,7 +1325,7 @@ input[type="time"]::-webkit-datetime-edit {
.quick-export-buttons .btn:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(76, 175, 80, 0.3);
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.25);
}
.export-button-container {
@@ -1305,7 +1334,7 @@ input[type="time"]::-webkit-datetime-edit {
}
.export-button-container .btn {
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 2rem;
font-size: 1.1rem;
@@ -1314,13 +1343,13 @@ input[type="time"]::-webkit-datetime-edit {
display: inline-block;
transition: all 0.2s ease;
font-weight: 600;
box-shadow: 0 2px 10px rgba(76, 175, 80, 0.3);
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.3);
}
.export-button-container .btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(76, 175, 80, 0.4);
background: linear-gradient(135deg, #45a049 0%, #4CAF50 100%);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.25);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
/* Custom date range form styling */
@@ -1348,8 +1377,8 @@ input[type="time"]::-webkit-datetime-edit {
.export-section .form-group input:focus,
.export-section .form-group select:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* Team Hours Export Styling */
@@ -1364,7 +1393,7 @@ input[type="time"]::-webkit-datetime-edit {
}
#export-buttons h4 {
color: #4CAF50;
color: #667eea;
margin-bottom: 1rem;
font-size: 1.2rem;
font-weight: 600;
@@ -1387,7 +1416,7 @@ input[type="time"]::-webkit-datetime-edit {
#export-buttons .quick-export-buttons .btn:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(76, 175, 80, 0.3);
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.25);
}
/* Responsive Design for Sidebar Navigation */
@media (max-width: 1024px) {
@@ -1528,14 +1557,14 @@ input[type="time"]::-webkit-datetime-edit {
}
.mode-btn.active {
background: #4CAF50;
background: #667eea;
color: white;
box-shadow: 0 2px 4px rgba(76, 175, 80, 0.3);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
}
.mode-btn:hover:not(.active) {
background: #e9ecef;
color: #495057;
background: rgba(102, 126, 234, 0.1);
color: #5569d6;
}
.filter-panel {
@@ -1579,8 +1608,8 @@ input[type="time"]::-webkit-datetime-edit {
.filter-group input:focus,
.filter-group select:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.view-tabs {
@@ -1603,14 +1632,14 @@ input[type="time"]::-webkit-datetime-edit {
}
.tab-btn.active {
color: #4CAF50;
border-bottom-color: #4CAF50;
background: rgba(76, 175, 80, 0.05);
color: #667eea;
border-bottom-color: #667eea;
background: rgba(102, 126, 234, 0.05);
}
.tab-btn:hover:not(.active) {
color: #495057;
background: rgba(0, 0, 0, 0.05);
color: #5569d6;
background: rgba(102, 126, 234, 0.05);
}
.view-content {
@@ -1700,7 +1729,7 @@ input[type="time"]::-webkit-datetime-edit {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
border-left: 4px solid #4CAF50;
border-left: 4px solid #667eea;
}
.stat-card h4 {
@@ -1796,8 +1825,8 @@ input[type="time"]::-webkit-datetime-edit {
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* Responsive Design for Analytics */
@@ -2031,7 +2060,7 @@ input[type="time"]::-webkit-datetime-edit {
}
.view-btn:hover:not(.active) {
background: #e9ecef;
background: rgba(102, 126, 234, 0.1);
}
/* Statistics Cards */
@@ -2165,7 +2194,7 @@ input[type="time"]::-webkit-datetime-edit {
}
.management-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
transform: translateY(-2px);
}
@@ -2289,7 +2318,7 @@ input[type="time"]::-webkit-datetime-edit {
}
.card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.card-header {
@@ -2362,7 +2391,7 @@ input[type="time"]::-webkit-datetime-edit {
}
.widget:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.2);
}
.widget-header {
@@ -2549,8 +2578,8 @@ input[type="time"]::-webkit-datetime-edit {
}
.user-dropdown-toggle:hover {
background-color: #e9ecef;
color: #333;
background-color: rgba(102, 126, 234, 0.1);
color: #5569d6;
}
/* Removed nav-icon style as we're using avatar instead */
@@ -2643,7 +2672,7 @@ input[type="time"]::-webkit-datetime-edit {
}
.user-dropdown-menu a:hover {
background-color: #f0f0f0;
background-color: rgba(102, 126, 234, 0.1);
}
.user-dropdown-menu .nav-icon {

View File

@@ -0,0 +1,770 @@
/* Time Tracking Styles */
/* Container */
.time-tracking-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Page Header - reuse existing styles */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
.header-actions {
display: flex;
gap: 1rem;
}
/* Timer Section */
.timer-section {
margin-bottom: 2rem;
}
.timer-card {
background: white;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
}
.timer-card.active {
border: 2px solid #10b981;
background: linear-gradient(135deg, #10b98110 0%, #059b6910 100%);
}
.timer-display {
text-align: center;
margin-bottom: 2rem;
}
.timer-value {
font-size: 4rem;
font-weight: 700;
color: #1f2937;
font-family: 'Monaco', 'Courier New', monospace;
letter-spacing: 0.1em;
}
.timer-status {
margin-top: 0.5rem;
}
.status-badge {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.active {
background: #10b981;
color: white;
}
.status-badge.paused {
background: #f59e0b;
color: white;
}
.timer-info {
background: #f8f9fa;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid #e5e7eb;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-weight: 600;
color: #6b7280;
}
.info-value {
color: #1f2937;
}
.project-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 16px;
color: white;
font-size: 0.875rem;
font-weight: 500;
}
.task-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #e5e7eb;
border-radius: 8px;
font-size: 0.875rem;
}
.timer-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
/* Start Work Form */
.start-work-container {
text-align: center;
}
.start-work-container h2 {
font-size: 1.75rem;
color: #1f2937;
margin-bottom: 0.5rem;
}
.start-work-container p {
color: #6b7280;
margin-bottom: 2rem;
}
.modern-form {
max-width: 600px;
margin: 0 auto;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-weight: 600;
color: #374151;
font-size: 0.95rem;
}
.optional-badge {
background: #e5e7eb;
color: #6b7280;
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
margin-left: 0.5rem;
}
.form-control {
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s ease;
background-color: #f9fafb;
}
.form-control:focus {
outline: none;
border-color: #667eea;
background-color: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-actions {
margin-top: 1.5rem;
}
/* Stats Section */
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
text-align: center;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: #667eea;
}
.stat-label {
font-size: 0.9rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
/* Entries Section */
.entries-section {
background: white;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.view-toggle {
display: flex;
gap: 0.5rem;
}
.toggle-btn {
padding: 0.5rem 1rem;
border: 2px solid #e5e7eb;
background: white;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: 500;
}
.toggle-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.view-container {
display: none;
}
.view-container.active {
display: block;
}
/* Table Styles */
.entries-table-container {
overflow-x: auto;
}
.entries-table {
width: 100%;
border-collapse: collapse;
}
.entries-table th {
text-align: left;
padding: 1rem;
border-bottom: 2px solid #e5e7eb;
font-weight: 600;
color: #374151;
background: #f8f9fa;
}
.entries-table td {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.entry-row:hover {
background: #f8f9fa;
}
.date-cell {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.date-day {
font-size: 1.25rem;
font-weight: 700;
color: #1f2937;
}
.date-month {
font-size: 0.875rem;
color: #6b7280;
text-transform: uppercase;
}
.time-cell {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: 'Monaco', 'Courier New', monospace;
}
.time-separator {
color: #9ca3af;
}
.project-task-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.project-tag {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
color: white;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.task-name,
.project-name {
color: #374151;
}
.no-project {
color: #9ca3af;
font-style: italic;
}
.duration-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #ede9fe;
color: #5b21b6;
border-radius: 16px;
font-weight: 600;
font-size: 0.875rem;
}
.break-duration {
color: #6b7280;
}
.notes-preview {
color: #6b7280;
font-size: 0.875rem;
}
.actions-cell {
display: flex;
gap: 0.5rem;
}
/* Specific button hover states */
.btn-icon.resume-work-btn:hover:not(:disabled) {
color: #10b981;
border-color: #10b981;
background: #f0fdf4;
}
.btn-icon.edit-entry-btn:hover {
color: #667eea;
border-color: #667eea;
background: #f3f4f6;
}
.btn-icon.delete-entry-btn:hover {
color: #ef4444;
border-color: #ef4444;
background: #fef2f2;
}
/* Grid View */
.entries-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.entry-card {
background: #f8f9fa;
border-radius: 12px;
padding: 1.5rem;
border: 1px solid #e5e7eb;
transition: all 0.3s ease;
}
.entry-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.entry-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.entry-date {
font-weight: 600;
color: #374151;
}
.entry-duration {
font-size: 1.25rem;
font-weight: 700;
color: #667eea;
}
.entry-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.entry-project {
padding-left: 1rem;
border-left: 4px solid;
}
.entry-task,
.entry-time,
.entry-notes {
display: flex;
align-items: flex-start;
gap: 0.5rem;
color: #6b7280;
font-size: 0.875rem;
}
.entry-footer {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-large {
padding: 1rem 2rem;
font-size: 1.125rem;
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.btn-icon {
padding: 0.5rem;
border: 1px solid #e5e7eb;
background: white;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
color: #6b7280;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
}
.btn-icon:hover {
background: #f3f4f6;
transform: translateY(-2px);
color: #374151;
}
.btn-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-icon:disabled:hover {
transform: none;
background: white;
color: #6b7280;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: white;
color: #4b5563;
border: 2px solid #e5e7eb;
}
.btn-secondary:hover {
background: #f3f4f6;
}
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover {
background: #059669;
transform: translateY(-2px);
}
.btn-warning {
background: #f59e0b;
color: white;
}
.btn-warning:hover {
background: #d97706;
transform: translateY(-2px);
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
transform: translateY(-2px);
}
.btn-ghost {
background: transparent;
color: #6b7280;
}
.btn-ghost:hover {
color: #374151;
background: #f3f4f6;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.5rem;
}
.empty-state p {
color: #6b7280;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
}
.modal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background: white;
border-radius: 16px;
max-width: 600px;
margin: 5vh auto;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
}
.modal-small {
max-width: 400px;
}
.modal-header {
padding: 2rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s ease;
}
.modal-close:hover {
background: #f3f4f6;
color: #1f2937;
}
.modal-body {
padding: 2rem;
}
.modal-footer {
padding: 1.5rem 2rem;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
gap: 1rem;
}
/* Responsive */
@media (max-width: 768px) {
.time-tracking-container {
padding: 1rem;
}
.form-row {
grid-template-columns: 1fr;
}
.timer-value {
font-size: 3rem;
}
.entries-grid {
grid-template-columns: 1fr;
}
.entries-table {
font-size: 0.875rem;
}
.entries-table th,
.entries-table td {
padding: 0.5rem;
}
}

23
static/js/ace/ace.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
define("ace/theme/github-css",["require","exports","module"],function(e,t,n){n.exports='/* CSS style content from github\'s default pygments highlighter template.\n Cursor and selection styles from textmate.css. */\n.ace-github .ace_gutter {\n background: #e8e8e8;\n color: #AAA;\n}\n\n.ace-github {\n background: #fff;\n color: #000;\n}\n\n.ace-github .ace_keyword {\n font-weight: bold;\n}\n\n.ace-github .ace_string {\n color: #D14;\n}\n\n.ace-github .ace_variable.ace_class {\n color: teal;\n}\n\n.ace-github .ace_constant.ace_numeric {\n color: #099;\n}\n\n.ace-github .ace_constant.ace_buildin {\n color: #0086B3;\n}\n\n.ace-github .ace_support.ace_function {\n color: #0086B3;\n}\n\n.ace-github .ace_comment {\n color: #998;\n font-style: italic;\n}\n\n.ace-github .ace_variable.ace_language {\n color: #0086B3;\n}\n\n.ace-github .ace_paren {\n font-weight: bold;\n}\n\n.ace-github .ace_boolean {\n font-weight: bold;\n}\n\n.ace-github .ace_string.ace_regexp {\n color: #009926;\n font-weight: normal;\n}\n\n.ace-github .ace_variable.ace_instance {\n color: teal;\n}\n\n.ace-github .ace_constant.ace_language {\n font-weight: bold;\n}\n\n.ace-github .ace_cursor {\n color: black;\n}\n\n.ace-github.ace_focus .ace_marker-layer .ace_active-line {\n background: rgb(255, 255, 204);\n}\n.ace-github .ace_marker-layer .ace_active-line {\n background: rgb(245, 245, 245);\n}\n\n.ace-github .ace_marker-layer .ace_selection {\n background: rgb(181, 213, 255);\n}\n\n.ace-github.ace_multiselect .ace_selection.ace_start {\n box-shadow: 0 0 3px 0px white;\n}\n/* bold keywords cause cursor issues for some fonts */\n/* this disables bold style for editor and keeps for static highlighter */\n.ace-github.ace_nobold .ace_line > span {\n font-weight: normal !important;\n}\n\n.ace-github .ace_marker-layer .ace_step {\n background: rgb(252, 255, 0);\n}\n\n.ace-github .ace_marker-layer .ace_stack {\n background: rgb(164, 229, 101);\n}\n\n.ace-github .ace_marker-layer .ace_bracket {\n margin: -1px 0 0 -1px;\n border: 1px solid rgb(192, 192, 192);\n}\n\n.ace-github .ace_gutter-active-line {\n background-color : rgba(0, 0, 0, 0.07);\n}\n\n.ace-github .ace_marker-layer .ace_selected-word {\n background: rgb(250, 250, 255);\n border: 1px solid rgb(200, 200, 250);\n}\n\n.ace-github .ace_invisible {\n color: #BFBFBF\n}\n\n.ace-github .ace_print-margin {\n width: 1px;\n background: #e8e8e8;\n}\n\n.ace-github .ace_indent-guide {\n background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bLly//BwAmVgd1/w11/gAAAABJRU5ErkJggg==") right repeat-y;\n}\n\n.ace-github .ace_indent-guide-active {\n background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAIGNIUk0AAHolAACAgwAA+f8AAIDpAAB1MAAA6mAAADqYAAAXb5JfxUYAAAAZSURBVHjaYvj///9/hivKyv8BAAAA//8DACLqBhbvk+/eAAAAAElFTkSuQmCC") right repeat-y;\n}\n'}),define("ace/theme/github",["require","exports","module","ace/theme/github-css","ace/lib/dom"],function(e,t,n){t.isDark=!1,t.cssClass="ace-github",t.cssText=e("./github-css");var r=e("../lib/dom");r.importCssString(t.cssText,t.cssClass,!1)}); (function() {
window.require(["ace/theme/github"], function(m) {
if (typeof module == "object" && typeof exports == "object" && module) {
module.exports = m;
}
});
})();

View File

@@ -0,0 +1,8 @@
define("ace/theme/monokai-css",["require","exports","module"],function(e,t,n){n.exports=".ace-monokai .ace_gutter {\n background: #2F3129;\n color: #8F908A\n}\n\n.ace-monokai .ace_print-margin {\n width: 1px;\n background: #555651\n}\n\n.ace-monokai {\n background-color: #272822;\n color: #F8F8F2\n}\n\n.ace-monokai .ace_cursor {\n color: #F8F8F0\n}\n\n.ace-monokai .ace_marker-layer .ace_selection {\n background: #49483E\n}\n\n.ace-monokai.ace_multiselect .ace_selection.ace_start {\n box-shadow: 0 0 3px 0px #272822;\n}\n\n.ace-monokai .ace_marker-layer .ace_step {\n background: rgb(102, 82, 0)\n}\n\n.ace-monokai .ace_marker-layer .ace_bracket {\n margin: -1px 0 0 -1px;\n border: 1px solid #49483E\n}\n\n.ace-monokai .ace_marker-layer .ace_active-line {\n background: #202020\n}\n\n.ace-monokai .ace_gutter-active-line {\n background-color: #272727\n}\n\n.ace-monokai .ace_marker-layer .ace_selected-word {\n border: 1px solid #49483E\n}\n\n.ace-monokai .ace_invisible {\n color: #52524d\n}\n\n.ace-monokai .ace_entity.ace_name.ace_tag,\n.ace-monokai .ace_keyword,\n.ace-monokai .ace_meta.ace_tag,\n.ace-monokai .ace_storage {\n color: #F92672\n}\n\n.ace-monokai .ace_punctuation,\n.ace-monokai .ace_punctuation.ace_tag {\n color: #fff\n}\n\n.ace-monokai .ace_constant.ace_character,\n.ace-monokai .ace_constant.ace_language,\n.ace-monokai .ace_constant.ace_numeric,\n.ace-monokai .ace_constant.ace_other {\n color: #AE81FF\n}\n\n.ace-monokai .ace_invalid {\n color: #F8F8F0;\n background-color: #F92672\n}\n\n.ace-monokai .ace_invalid.ace_deprecated {\n color: #F8F8F0;\n background-color: #AE81FF\n}\n\n.ace-monokai .ace_support.ace_constant,\n.ace-monokai .ace_support.ace_function {\n color: #66D9EF\n}\n\n.ace-monokai .ace_fold {\n background-color: #A6E22E;\n border-color: #F8F8F2\n}\n\n.ace-monokai .ace_storage.ace_type,\n.ace-monokai .ace_support.ace_class,\n.ace-monokai .ace_support.ace_type {\n font-style: italic;\n color: #66D9EF\n}\n\n.ace-monokai .ace_entity.ace_name.ace_function,\n.ace-monokai .ace_entity.ace_other,\n.ace-monokai .ace_entity.ace_other.ace_attribute-name,\n.ace-monokai .ace_variable {\n color: #A6E22E\n}\n\n.ace-monokai .ace_variable.ace_parameter {\n font-style: italic;\n color: #FD971F\n}\n\n.ace-monokai .ace_string {\n color: #E6DB74\n}\n\n.ace-monokai .ace_comment {\n color: #75715E\n}\n\n.ace-monokai .ace_indent-guide {\n background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWPQ0FD0ZXBzd/wPAAjVAoxeSgNeAAAAAElFTkSuQmCC) right repeat-y\n}\n\n.ace-monokai .ace_indent-guide-active {\n background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQIW2PQ1dX9zzBz5sz/ABCcBFFentLlAAAAAElFTkSuQmCC) right repeat-y;\n}\n"}),define("ace/theme/monokai",["require","exports","module","ace/theme/monokai-css","ace/lib/dom"],function(e,t,n){t.isDark=!0,t.cssClass="ace-monokai",t.cssText=e("./monokai-css");var r=e("../lib/dom");r.importCssString(t.cssText,t.cssClass,!1)}); (function() {
window.require(["ace/theme/monokai"], function(m) {
if (typeof module == "object" && typeof exports == "object" && module) {
module.exports = m;
}
});
})();

View File

@@ -103,7 +103,7 @@ document.addEventListener('DOMContentLoaded', function() {
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'password-toggle';
toggleBtn.innerHTML = '👁️';
toggleBtn.innerHTML = '<i class="ti ti-eye"></i>';
toggleBtn.style.cssText = `
position: absolute;
right: 1rem;
@@ -120,10 +120,10 @@ document.addEventListener('DOMContentLoaded', function() {
toggleBtn.addEventListener('click', function() {
if (input.type === 'password') {
input.type = 'text';
this.innerHTML = '🙈';
this.innerHTML = '<i class="ti ti-eye-off"></i>';
} else {
input.type = 'password';
this.innerHTML = '👁️';
this.innerHTML = '<i class="ti ti-eye"></i>';
}
});

View File

@@ -148,10 +148,10 @@ document.addEventListener('DOMContentLoaded', function() {
matchIndicator.textContent = '';
matchIndicator.className = 'password-match-indicator';
} else if (password === confirmInput.value) {
matchIndicator.textContent = '✓ Passwords match';
matchIndicator.innerHTML = '<i class="ti ti-check"></i> Passwords match';
matchIndicator.className = 'password-match-indicator match';
} else {
matchIndicator.textContent = '✗ Passwords do not match';
matchIndicator.innerHTML = '<i class="ti ti-x"></i> Passwords do not match';
matchIndicator.className = 'password-match-indicator no-match';
}
}

View File

@@ -0,0 +1,356 @@
<!-- Time Tracking Interface - Shared Component -->
<div class="time-tracking-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<i class="ti ti-clock page-icon"></i>
Time Tracking
</h1>
<p class="page-subtitle">Track your work hours efficiently</p>
</div>
<div class="header-actions">
<button id="manual-entry-btn" class="btn btn-secondary">
<i class="ti ti-pencil"></i>
Manual Entry
</button>
<a href="{{ url_for('analytics') }}" class="btn btn-secondary">
<i class="ti ti-chart-bar"></i>
View Analytics
</a>
</div>
</div>
</div>
<!-- Timer Section -->
<div class="timer-section">
{% if active_entry %}
<!-- Active Timer -->
<div class="timer-card active">
<div class="timer-display">
<div class="timer-value" id="timer"
data-start="{{ active_entry.arrival_time.timestamp() }}"
data-breaks="{{ active_entry.total_break_duration }}"
data-paused="{{ 'true' if active_entry.is_paused else 'false' }}">
00:00:00
</div>
<div class="timer-status">
{% if active_entry.is_paused %}
<span class="status-badge paused">On Break</span>
{% else %}
<span class="status-badge active">Working</span>
{% endif %}
</div>
</div>
<div class="timer-info">
<div class="info-row">
<span class="info-label">Started:</span>
<span class="info-value">{{ active_entry.arrival_time|format_datetime }}</span>
</div>
{% if active_entry.project %}
<div class="info-row">
<span class="info-label">Project:</span>
<span class="info-value project-badge" style="background-color: {{ active_entry.project.color or '#667eea' }}">
{{ active_entry.project.code }} - {{ active_entry.project.name }}
</span>
</div>
{% endif %}
{% if active_entry.task %}
<div class="info-row">
<span class="info-label">Task:</span>
<span class="info-value task-badge">
{{ active_entry.task.title }}
</span>
</div>
{% endif %}
{% if active_entry.notes %}
<div class="info-row">
<span class="info-label">Notes:</span>
<span class="info-value">{{ active_entry.notes }}</span>
</div>
{% endif %}
{% if active_entry.is_paused %}
<div class="info-row">
<span class="info-label">Break started:</span>
<span class="info-value">{{ active_entry.pause_start_time|format_time }}</span>
</div>
{% endif %}
{% if active_entry.total_break_duration > 0 %}
<div class="info-row">
<span class="info-label">Total breaks:</span>
<span class="info-value">{{ active_entry.total_break_duration|format_duration }}</span>
</div>
{% endif %}
</div>
<div class="timer-actions">
<button id="pause-btn" class="btn {% if active_entry.is_paused %}btn-success{% else %}btn-warning{% endif %}"
data-id="{{ active_entry.id }}">
{% if active_entry.is_paused %}
<i class="ti ti-player-play"></i>
Resume Work
{% else %}
<i class="ti ti-player-pause"></i>
Take Break
{% endif %}
</button>
<button id="leave-btn" class="btn btn-danger" data-id="{{ active_entry.id }}">
<i class="ti ti-player-stop"></i>
Stop Working
</button>
</div>
</div>
{% else %}
<!-- Inactive Timer -->
<div class="timer-card inactive">
<div class="start-work-container">
<h2>Start Tracking Time</h2>
<p>Select a project and task to begin tracking your work</p>
<form id="start-work-form" class="modern-form">
<div class="form-row">
<div class="form-group">
<label for="project-select" class="form-label">
Project <span class="optional-badge">Optional</span>
</label>
<select id="project-select" name="project_id" class="form-control">
<option value="">No specific project</option>
{% for project in available_projects %}
<option value="{{ project.id }}" data-color="{{ project.color or '#667eea' }}">
{{ project.code }} - {{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="task-select" class="form-label">
Task <span class="optional-badge">Optional</span>
</label>
<select id="task-select" name="task_id" class="form-control" disabled>
<option value="">Select a project first</option>
</select>
</div>
</div>
<div class="form-group">
<label for="work-notes" class="form-label">
Notes <span class="optional-badge">Optional</span>
</label>
<textarea id="work-notes" name="notes" class="form-control"
rows="2" placeholder="What are you working on?"></textarea>
</div>
<div class="form-actions">
<button type="submit" id="arrive-btn" class="btn btn-primary btn-large">
<i class="ti ti-player-play"></i>
Start Working
</button>
</div>
</form>
</div>
</div>
{% endif %}
</div>
<!-- Quick Stats -->
<div class="stats-section">
<div class="stat-card">
<div class="stat-value">{{ today_hours|format_duration }}</div>
<div class="stat-label">Today</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ week_hours|format_duration }}</div>
<div class="stat-label">This Week</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ month_hours|format_duration }}</div>
<div class="stat-label">This Month</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ active_projects|length }}</div>
<div class="stat-label">Active Projects</div>
</div>
</div>
<!-- Recent Entries -->
<div class="entries-section">
<div class="section-header">
<h2 class="section-title">
<i class="ti ti-clipboard-list"></i>
Recent Time Entries
</h2>
<div class="view-toggle">
<button class="toggle-btn active" data-view="list">
<i class="ti ti-list"></i>
List
</button>
<button class="toggle-btn" data-view="grid">
<i class="ti ti-layout-grid"></i>
Grid
</button>
</div>
</div>
<!-- List View -->
<div id="list-view" class="view-container active">
{% if history %}
<div class="entries-table-container">
<table class="entries-table">
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>Project / Task</th>
<th>Duration</th>
<th>Break</th>
<th>Notes</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for entry in history %}
<tr data-entry-id="{{ entry.id }}" class="entry-row">
<td>
<div class="date-cell">
<span class="date-day">{{ entry.arrival_time.strftime('%d') }}</span>
<span class="date-month">{{ entry.arrival_time.strftime('%b') }}</span>
</div>
</td>
<td>
<div class="time-cell">
<span class="time-start">{{ entry.arrival_time|format_time }}</span>
<span class="time-separator"><i class="ti ti-arrow-right"></i></span>
<span class="time-end">{{ entry.departure_time|format_time if entry.departure_time else 'Active' }}</span>
</div>
</td>
<td>
<div class="project-task-cell">
{% if entry.project %}
<span class="project-tag" style="background-color: {{ entry.project.color or '#667eea' }}">
{{ entry.project.code }}
</span>
{% endif %}
{% if entry.task %}
<span class="task-name">{{ entry.task.title }}</span>
{% elif entry.project %}
<span class="project-name">{{ entry.project.name }}</span>
{% else %}
<span class="no-project">No project</span>
{% endif %}
</div>
</td>
<td>
<span class="duration-badge">
{{ entry.duration|format_duration if entry.duration is not none else 'In progress' }}
</span>
</td>
<td>
<span class="break-duration">
{{ entry.total_break_duration|format_duration if entry.total_break_duration else '-' }}
</span>
</td>
<td>
<span class="notes-preview" title="{{ entry.notes or '' }}">
{{ entry.notes[:30] + '...' if entry.notes and entry.notes|length > 30 else entry.notes or '-' }}
</span>
</td>
<td>
<div class="actions-cell">
{% if entry.departure_time and not active_entry %}
{% if entry.arrival_time.date() >= today %}
<button class="btn-icon resume-work-btn" data-id="{{ entry.id }}" title="Resume">
<i class="ti ti-refresh"></i>
</button>
{% else %}
<button class="btn-icon resume-work-btn" data-id="{{ entry.id }}" title="Cannot resume entries from previous days" disabled style="opacity: 0.5; cursor: not-allowed;">
<i class="ti ti-refresh"></i>
</button>
{% endif %}
{% endif %}
<button class="btn-icon edit-entry-btn" data-id="{{ entry.id }}" title="Edit">
<i class="ti ti-pencil"></i>
</button>
<button class="btn-icon delete-entry-btn" data-id="{{ entry.id }}" title="Delete">
<i class="ti ti-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon"><i class="ti ti-mail-opened" style="font-size: 4rem;"></i></div>
<h3>No time entries yet</h3>
<p>Start tracking your time to see entries here</p>
</div>
{% endif %}
</div>
<!-- Grid View -->
<div id="grid-view" class="view-container">
<div class="entries-grid">
{% for entry in history %}
<div class="entry-card" data-entry-id="{{ entry.id }}">
<div class="entry-header">
<div class="entry-date">
{{ entry.arrival_time.strftime('%d %b %Y') }}
</div>
<div class="entry-duration">
{{ entry.duration|format_duration if entry.duration is not none else 'Active' }}
</div>
</div>
<div class="entry-body">
{% if entry.project %}
<div class="entry-project" style="border-left-color: {{ entry.project.color or '#667eea' }}">
<strong>{{ entry.project.code }}</strong> - {{ entry.project.name }}
</div>
{% endif %}
{% if entry.task %}
<div class="entry-task">
<i class="ti ti-clipboard-list"></i> {{ entry.task.title }}
</div>
{% endif %}
<div class="entry-time">
<i class="ti ti-clock"></i>
{{ entry.arrival_time|format_time }} - {{ entry.departure_time|format_time if entry.departure_time else 'Active' }}
</div>
{% if entry.notes %}
<div class="entry-notes">
<i class="ti ti-notes"></i>
{{ entry.notes }}
</div>
{% endif %}
</div>
<div class="entry-footer">
<button class="btn-sm edit-entry-btn" data-id="{{ entry.id }}">Edit</button>
<button class="btn-sm btn-danger delete-entry-btn" data-id="{{ entry.id }}">Delete</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Modals -->
{% include '_time_tracking_modals.html' %}
<!-- Scripts -->
<script src="{{ url_for('static', filename='js/timer.js') }}"></script>
<script src="{{ url_for('static', filename='js/time-tracking.js') }}"></script>

View File

@@ -0,0 +1,146 @@
<!-- Time Tracking Modals -->
<!-- Edit Entry Modal -->
<div id="edit-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Edit Time Entry</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="edit-entry-form" class="modern-form">
<input type="hidden" id="edit-entry-id">
<div class="form-row">
<div class="form-group">
<label for="edit-arrival-date" class="form-label">Start Date</label>
<input type="date" id="edit-arrival-date" class="form-control" required>
</div>
<div class="form-group">
<label for="edit-arrival-time" class="form-label">Start Time</label>
<input type="time" id="edit-arrival-time" class="form-control" required step="60">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="edit-departure-date" class="form-label">End Date</label>
<input type="date" id="edit-departure-date" class="form-control">
</div>
<div class="form-group">
<label for="edit-departure-time" class="form-label">End Time</label>
<input type="time" id="edit-departure-time" class="form-control" step="60">
</div>
</div>
<div class="form-group">
<label for="edit-project" class="form-label">Project</label>
<select id="edit-project" class="form-control">
<option value="">No specific project</option>
{% for project in available_projects %}
<option value="{{ project.id }}">{{ project.code }} - {{ project.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="edit-notes" class="form-label">Notes</label>
<textarea id="edit-notes" class="form-control" rows="3"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" class="btn btn-ghost modal-cancel">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<!-- Manual Entry Modal -->
<div id="manual-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Add Manual Time Entry</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="manual-entry-form" class="modern-form">
<div class="form-row">
<div class="form-group">
<label for="manual-start-date" class="form-label">Start Date</label>
<input type="date" id="manual-start-date" class="form-control" required>
</div>
<div class="form-group">
<label for="manual-start-time" class="form-label">Start Time</label>
<input type="time" id="manual-start-time" class="form-control" required step="60">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="manual-end-date" class="form-label">End Date</label>
<input type="date" id="manual-end-date" class="form-control" required>
</div>
<div class="form-group">
<label for="manual-end-time" class="form-label">End Time</label>
<input type="time" id="manual-end-time" class="form-control" required step="60">
</div>
</div>
<div class="form-group">
<label for="manual-project" class="form-label">Project</label>
<select id="manual-project" name="project_id" class="form-control">
<option value="">No specific project</option>
{% for project in available_projects %}
<option value="{{ project.id }}">{{ project.code }} - {{ project.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="manual-task" class="form-label">Task</label>
<select id="manual-task" name="task_id" class="form-control" disabled>
<option value="">Select a project first</option>
</select>
</div>
<div class="form-group">
<label for="manual-break" class="form-label">Break Duration (minutes)</label>
<input type="number" id="manual-break" class="form-control" min="0" value="0">
</div>
<div class="form-group">
<label for="manual-notes" class="form-label">Notes</label>
<textarea id="manual-notes" class="form-control" rows="3" placeholder="Description of work performed"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Add Entry</button>
<button type="button" class="btn btn-ghost modal-cancel">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content modal-small">
<div class="modal-header">
<h3 class="modal-title">Confirm Deletion</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this time entry? This action cannot be undone.</p>
<input type="hidden" id="delete-entry-id">
</div>
<div class="modal-footer">
<button id="confirm-delete" class="btn btn-danger">Delete Entry</button>
<button class="btn btn-ghost modal-cancel">Cancel</button>
</div>
</div>
</div>

View File

@@ -23,17 +23,17 @@
</div>
<div class="feature-item">
<h3>👥 Team Management</h3>
<h3><i class="ti ti-users"></i> Team Management</h3>
<p>Managers can organize users into teams, monitor team performance, and track collective working hours. Role-based access ensures appropriate permissions for different user levels.</p>
</div>
<div class="feature-item">
<h3>📊 Comprehensive Reporting</h3>
<h3><i class="ti ti-chart-bar"></i> Comprehensive Reporting</h3>
<p>View detailed time entry history, team performance metrics, and individual productivity reports. Export data for payroll and project management purposes.</p>
</div>
<div class="feature-item">
<h3>🔧 Flexible Configuration</h3>
<h3><i class="ti ti-tool"></i> Flexible Configuration</h3>
<p>Customize work hour requirements, mandatory break durations, and threshold settings to match your organization's policies and labor regulations.</p>
</div>
@@ -80,22 +80,22 @@
<div class="benefits-list">
<div class="benefit">
<h3> Compliance Ready</h3>
<h3><i class="ti ti-check"></i> Compliance Ready</h3>
<p>Automatically enforces break policies and work hour regulations to help your organization stay compliant with labor laws.</p>
</div>
<div class="benefit">
<h3> Easy to Use</h3>
<h3><i class="ti ti-check"></i> Easy to Use</h3>
<p>Intuitive interface requires minimal training. Start tracking time immediately without complicated setup procedures.</p>
</div>
<div class="benefit">
<h3> Scalable Solution</h3>
<h3><i class="ti ti-check"></i> Scalable Solution</h3>
<p>Grows with your organization from small teams to large enterprises. Multi-tenant architecture supports multiple companies, complex organizational structures, and unlimited growth potential.</p>
</div>
<div class="benefit">
<h3> Data-Driven Insights</h3>
<h3><i class="ti ti-check"></i> Data-Driven Insights</h3>
<p>Generate meaningful reports and analytics to optimize productivity, identify trends, and make informed business decisions.</p>
</div>
</div>
@@ -115,7 +115,7 @@
</div>
<div class="setup-option">
<h3>👥 Join Existing Company</h3>
<h3><i class="ti ti-users"></i> Join Existing Company</h3>
<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>

View File

@@ -7,7 +7,7 @@
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon">🏢</span>
<span class="page-icon"><i class="ti ti-building"></i></span>
Company Management
</h1>
<p class="page-subtitle">Configure your company settings and policies</p>
@@ -26,22 +26,22 @@
<div class="stat-card">
<div class="stat-value">{{ stats.total_users }}</div>
<div class="stat-label">Total Users</div>
<a href="{{ url_for('companies.company_users') }}" class="stat-link">View all </a>
<a href="{{ url_for('companies.company_users') }}" class="stat-link">View all <i class="ti ti-arrow-right"></i></a>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.total_teams }}</div>
<div class="stat-label">Teams</div>
<a href="{{ url_for('teams.admin_teams') }}" class="stat-link">Manage </a>
<a href="{{ url_for('organization.admin_organization') }}" class="stat-link">Manage <i class="ti ti-arrow-right"></i></a>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.total_projects }}</div>
<div class="stat-label">Total Projects</div>
<a href="{{ url_for('projects.admin_projects') }}" class="stat-link">View all </a>
<a href="{{ url_for('projects.admin_projects') }}" class="stat-link">View all <i class="ti ti-arrow-right"></i></a>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.active_projects }}</div>
<div class="stat-label">Active Projects</div>
<a href="{{ url_for('projects.admin_projects') }}" class="stat-link">Manage </a>
<a href="{{ url_for('projects.admin_projects') }}" class="stat-link">Manage <i class="ti ti-arrow-right"></i></a>
</div>
</div>
@@ -53,7 +53,7 @@
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"></span>
<span class="icon"><i class="ti ti-info-circle"></i></span>
Company Information
</h2>
</div>
@@ -97,13 +97,13 @@
<div class="info-panel">
<div class="info-item">
<span class="info-icon">🔑</span>
<span class="info-icon"><i class="ti ti-key"></i></span>
<div class="info-content">
<label class="info-label">Company Code</label>
<div class="code-display">
<input type="text" value="{{ company.slug }}" readonly id="companyCode" class="code-input">
<button type="button" class="btn btn-copy" onclick="copyCompanyCode()">
<span id="copyIcon">📋</span>
<span id="copyIcon"><i class="ti ti-clipboard"></i></span>
<span id="copyText">Copy</span>
</button>
</div>
@@ -111,7 +111,7 @@
</div>
</div>
<div class="info-item">
<span class="info-icon">📅</span>
<span class="info-icon"><i class="ti ti-calendar"></i></span>
<div class="info-content">
<label class="info-label">Created</label>
<span class="info-value">{{ company.created_at.strftime('%B %d, %Y at %I:%M %p') }}</span>
@@ -121,7 +121,7 @@
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span class="icon"></span>
<span class="icon"><i class="ti ti-check"></i></span>
Save Company Details
</button>
</div>
@@ -133,30 +133,22 @@
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"></span>
<span class="icon"><i class="ti ti-bolt"></i></span>
Quick Actions
</h2>
</div>
<div class="card-body">
<div class="action-grid">
<a href="{{ url_for('users.admin_users') }}" class="action-item">
<div class="action-icon">👥</div>
<a href="{{ url_for('organization.admin_organization') }}" class="action-item">
<div class="action-icon"><i class="ti ti-sitemap"></i></div>
<div class="action-content">
<h3>Manage Users</h3>
<p>User accounts & permissions</p>
</div>
</a>
<a href="{{ url_for('teams.admin_teams') }}" class="action-item">
<div class="action-icon">👨‍👩‍👧‍👦</div>
<div class="action-content">
<h3>Manage Teams</h3>
<p>Organize company structure</p>
<h3>Manage Organization</h3>
<p>Users, teams & structure</p>
</div>
</a>
<a href="{{ url_for('projects.admin_projects') }}" class="action-item">
<div class="action-icon">📁</div>
<div class="action-icon"><i class="ti ti-folder"></i></div>
<div class="action-content">
<h3>Manage Projects</h3>
<p>Time tracking projects</p>
@@ -164,7 +156,7 @@
</a>
<a href="{{ url_for('invitations.send_invitation') }}" class="action-item">
<div class="action-icon">📨</div>
<div class="action-icon"><i class="ti ti-mail"></i></div>
<div class="action-content">
<h3>Send Invitation</h3>
<p>Invite team members</p>
@@ -181,7 +173,7 @@
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon">📋</span>
<span class="icon"><i class="ti ti-clipboard-list"></i></span>
Work Policies
</h2>
</div>
@@ -251,7 +243,7 @@
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span class="icon"></span>
<span class="icon"><i class="ti ti-check"></i></span>
Update Policies
</button>
</div>
@@ -264,7 +256,7 @@
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon">👤</span>
<span class="icon"><i class="ti ti-user"></i></span>
User Registration
</h2>
</div>
@@ -304,7 +296,7 @@
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span class="icon"></span>
<span class="icon"><i class="ti ti-check"></i></span>
Update Settings
</button>
</div>
@@ -728,6 +720,11 @@ input:checked + .toggle-slider:before {
flex-shrink: 0;
}
.action-icon i {
font-size: 2rem;
color: #667eea;
}
.action-content h3 {
font-size: 1.05rem;
font-weight: 600;
@@ -874,17 +871,17 @@ function copyCompanyCode() {
const copyText = document.getElementById('copyText');
// Store original values
const originalIcon = copyIcon.textContent;
const originalIcon = copyIcon.innerHTML;
const originalText = copyText.textContent;
// Update to success state
copyIcon.textContent = '✓';
copyIcon.innerHTML = '<i class="ti ti-check"></i>';
copyText.textContent = 'Copied!';
button.classList.add('success');
// Reset after 2 seconds
setTimeout(() => {
copyIcon.textContent = originalIcon;
copyIcon.innerHTML = originalIcon;
copyText.textContent = originalText;
button.classList.remove('success');
}, 2000);

File diff suppressed because it is too large Load Diff

View File

@@ -7,18 +7,18 @@
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon">📁</span>
<i class="ti ti-folder page-icon"></i>
Project Management
</h1>
<p class="page-subtitle">Manage projects, categories, and track time entries</p>
</div>
<div class="header-actions">
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary">
<span class="icon">+</span>
<i class="ti ti-plus"></i>
Create New Project
</a>
<button id="manage-categories-btn" class="btn btn-secondary">
<span class="icon">🏷️</span>
<i class="ti ti-tag"></i>
Manage Categories
</button>
</div>
@@ -51,7 +51,7 @@
<div id="categories-section" class="categories-section" style="display: none;">
<div class="section-header">
<h2 class="section-title">
<span class="icon">🏷️</span>
<i class="ti ti-tag"></i>
Project Categories
</h2>
<button id="create-category-btn" class="btn btn-sm btn-primary">
@@ -65,7 +65,7 @@
<div class="category-card" data-category-id="{{ category.id }}">
<div class="category-header" style="background: linear-gradient(135deg, {{ category.color }}20 0%, {{ category.color }}10 100%); border-left: 4px solid {{ category.color }};">
<div class="category-title">
<span class="category-icon">{{ category.icon or '📁' }}</span>
<span class="category-icon">{{ category.icon|safe if category.icon else '<i class="ti ti-folder"></i>'|safe }}</span>
<span class="category-name">{{ category.name }}</span>
</div>
<div class="category-stats">
@@ -76,11 +76,11 @@
<p class="category-description">{{ category.description or 'No description provided' }}</p>
<div class="category-actions">
<button class="btn btn-sm btn-edit edit-category-btn" data-id="{{ category.id }}">
<span class="icon">✏️</span>
<i class="ti ti-pencil"></i>
Edit
</button>
<button class="btn btn-sm btn-delete delete-category-btn" data-id="{{ category.id }}">
<span class="icon">🗑️</span>
<i class="ti ti-trash"></i>
Delete
</button>
</div>
@@ -88,7 +88,7 @@
</div>
{% else %}
<div class="empty-categories">
<div class="empty-icon">🏷️</div>
<div class="empty-icon"><i class="ti ti-tag" style="font-size: 4rem;"></i></div>
<p class="empty-message">No categories created yet</p>
<button id="first-category-btn" class="btn btn-primary">Create your first category</button>
</div>
@@ -102,7 +102,7 @@
<!-- View Controls -->
<div class="view-controls">
<div class="search-container">
<span class="search-icon">🔍</span>
<i class="ti ti-search search-icon"></i>
<input type="text"
class="search-input"
id="projectSearch"
@@ -110,10 +110,10 @@
</div>
<div class="view-toggle">
<button class="toggle-btn active" data-view="grid" title="Grid View">
<span class="icon"></span>
<i class="ti ti-layout-grid"></i>
</button>
<button class="toggle-btn" data-view="list" title="List View">
<span class="icon"></span>
<i class="ti ti-list"></i>
</button>
</div>
</div>
@@ -141,18 +141,18 @@
{% if project.category %}
<div class="project-category">
<span class="category-badge" style="background-color: {{ project.category.color }}20; color: {{ project.category.color }};">
{{ project.category.icon or '📁' }} {{ project.category.name }}
{{ project.category.icon|safe if project.category.icon else '<i class="ti ti-folder"></i>'|safe }} {{ project.category.name }}
</span>
</div>
{% endif %}
<div class="project-info">
<div class="info-item">
<span class="info-icon">👥</span>
<i class="ti ti-users info-icon"></i>
<span class="info-text">{{ project.team.name if project.team else 'All Teams' }}</span>
</div>
<div class="info-item">
<span class="info-icon">📅</span>
<i class="ti ti-calendar info-icon"></i>
<span class="info-text">
{% if project.start_date %}
{{ project.start_date.strftime('%b %d, %Y') }}
@@ -163,11 +163,11 @@
</span>
</div>
<div class="info-item">
<span class="info-icon">⏱️</span>
<i class="ti ti-clock info-icon"></i>
<span class="info-text">{{ project.time_entries|length }} time entries</span>
</div>
<div class="info-item">
<span class="info-icon">👤</span>
<i class="ti ti-user info-icon"></i>
<span class="info-text">Created by {{ project.created_by.username }}</span>
</div>
</div>
@@ -175,18 +175,18 @@
<div class="project-actions">
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="btn btn-edit">
<span class="icon">✏️</span>
<i class="ti ti-pencil"></i>
Edit
</a>
<a href="{{ url_for('projects.manage_project_tasks', project_id=project.id) }}" class="btn btn-tasks">
<span class="icon">📋</span>
<i class="ti ti-clipboard-list"></i>
Tasks
</a>
{% if g.user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] %}
<form method="POST" action="{{ url_for('projects.delete_project', project_id=project.id) }}" class="delete-form"
onsubmit="return confirm('Are you sure you want to delete this project? This will delete all tasks, time entries, and related data!')">
<button type="submit" class="btn btn-delete" title="Delete project">
<span class="icon">🗑️</span>
<i class="ti ti-trash"></i>
</button>
</form>
{% endif %}
@@ -226,7 +226,7 @@
<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 }}
{{ project.category.icon|safe if project.category.icon else '<i class="ti ti-folder"></i>'|safe }} {{ project.category.name }}
</span>
{% else %}
<span class="text-muted">-</span>
@@ -251,16 +251,16 @@
<td>
<div class="table-actions">
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="btn-action btn-edit" title="Edit">
<span class="icon">✏️</span>
<i class="ti ti-pencil"></i>
</a>
<a href="{{ url_for('projects.manage_project_tasks', project_id=project.id) }}" class="btn-action btn-tasks" title="Tasks">
<span class="icon">📋</span>
<i class="ti ti-clipboard-list"></i>
</a>
{% if g.user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] %}
<form method="POST" action="{{ url_for('projects.delete_project', project_id=project.id) }}" class="inline-form"
onsubmit="return confirm('Are you sure you want to delete this project? This will delete all tasks, time entries, and related data!')">
<button type="submit" class="btn-action btn-delete" title="Delete">
<span class="icon">🗑️</span>
<i class="ti ti-trash"></i>
</button>
</form>
{% endif %}
@@ -275,18 +275,18 @@
<!-- No Results Message -->
<div class="no-results" id="noResults" style="display: none;">
<div class="empty-icon">🔍</div>
<div class="empty-icon"><i class="ti ti-search-off" style="font-size: 4rem;"></i></div>
<p class="empty-message">No projects found matching your search</p>
<p class="empty-hint">Try searching with different keywords</p>
</div>
{% else %}
<!-- Empty State -->
<div class="empty-state">
<div class="empty-icon">📁</div>
<div class="empty-icon"><i class="ti ti-folder-off" style="font-size: 4rem;"></i></div>
<h2 class="empty-title">No Projects Yet</h2>
<p class="empty-message">Create your first project to start tracking time</p>
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary btn-lg">
<span class="icon">+</span>
<i class="ti ti-plus"></i>
Create First Project
</a>
</div>
@@ -337,7 +337,7 @@
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="cancel-category">Cancel</button>
<button type="submit" form="category-form" class="btn btn-primary">
<span class="icon"></span>
<i class="ti ti-check"></i>
Save Category
</button>
</div>
@@ -1203,7 +1203,7 @@ document.addEventListener('DOMContentLoaded', function() {
manageCategoriesBtn.addEventListener('click', function() {
const isVisible = categoriesSection.style.display !== 'none';
categoriesSection.style.display = isVisible ? 'none' : 'block';
this.innerHTML = isVisible ? '<span class="icon">🏷️</span> Manage Categories' : '<span class="icon">🏷️</span> Hide Categories';
this.innerHTML = isVisible ? '<i class="ti ti-tag"></i> Manage Categories' : '<i class="ti ti-tag"></i> Hide Categories';
});
// View Toggle

View File

@@ -1,602 +0,0 @@
{% extends 'layout.html' %}
{% block content %}
<div class="teams-admin-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon">👥</span>
Team Management
</h1>
<p class="page-subtitle">Manage teams and their members across your organization</p>
</div>
<div class="header-actions">
<a href="{{ url_for('teams.create_team') }}" class="btn btn-primary">
<span class="icon">+</span>
Create New Team
</a>
</div>
</div>
</div>
<!-- Team Statistics -->
{% if teams %}
<div class="stats-section">
<div class="stat-card">
<div class="stat-value">{{ teams|length if teams else 0 }}</div>
<div class="stat-label">Total Teams</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ teams|map(attribute='users')|map('length')|sum if teams else 0 }}</div>
<div class="stat-label">Total Members</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ (teams|map(attribute='users')|map('length')|sum / teams|length)|round(1) if teams else 0 }}</div>
<div class="stat-label">Avg Team Size</div>
</div>
</div>
{% endif %}
<!-- Main Content -->
<div class="teams-content">
{% if teams %}
<!-- Search Bar -->
<div class="search-section">
<div class="search-container">
<span class="search-icon">🔍</span>
<input type="text"
class="search-input"
id="teamSearch"
placeholder="Search teams by name or description...">
</div>
</div>
<!-- Teams Grid -->
<div class="teams-grid" id="teamsGrid">
{% for team in teams %}
<div class="team-card" data-team-name="{{ team.name.lower() }}" data-team-desc="{{ team.description.lower() if team.description else '' }}">
<div class="team-header">
<div class="team-icon-wrapper">
<span class="team-icon">👥</span>
</div>
<div class="team-meta">
<span class="member-count">{{ team.users|length }} members</span>
</div>
</div>
<div class="team-body">
<h3 class="team-name">{{ team.name }}</h3>
<p class="team-description">
{{ team.description if team.description else 'No description provided' }}
</p>
<div class="team-info">
<div class="info-item">
<span class="info-icon">📅</span>
<span class="info-text">Created {{ team.created_at.strftime('%b %d, %Y') }}</span>
</div>
{% if team.users %}
<div class="info-item">
<span class="info-icon">👤</span>
<span class="info-text">Led by {{ team.users[0].username }}</span>
</div>
{% endif %}
</div>
<!-- Member Avatars -->
{% if team.users %}
<div class="member-avatars">
{% for member in team.users[:5] %}
<div class="member-avatar" title="{{ member.username }}">
{{ member.username[:2].upper() }}
</div>
{% endfor %}
{% if team.users|length > 5 %}
<div class="member-avatar more" title="{{ team.users|length - 5 }} more members">
+{{ team.users|length - 5 }}
</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="team-actions">
<a href="{{ url_for('teams.manage_team', team_id=team.id) }}" class="btn btn-manage">
<span class="icon">⚙️</span>
Manage Team
</a>
<form method="POST"
action="{{ url_for('teams.delete_team', team_id=team.id) }}"
class="delete-form"
onsubmit="return confirm('Are you sure you want to delete the team \"{{ team.name }}\"? This action cannot be undone.');">
<button type="submit" class="btn btn-delete" title="Delete Team">
<span class="icon">🗑️</span>
</button>
</form>
</div>
</div>
{% endfor %}
</div>
<!-- No Results Message -->
<div class="no-results" id="noResults" style="display: none;">
<div class="empty-icon">🔍</div>
<p class="empty-message">No teams found matching your search</p>
<p class="empty-hint">Try searching with different keywords</p>
</div>
{% else %}
<!-- Empty State -->
<div class="empty-state">
<div class="empty-icon">👥</div>
<h2 class="empty-title">No Teams Yet</h2>
<p class="empty-message">Create your first team to start organizing your workforce</p>
<a href="{{ url_for('teams.create_team') }}" class="btn btn-primary btn-lg">
<span class="icon">+</span>
Create First Team
</a>
</div>
{% endif %}
</div>
</div>
<style>
/* Container */
.teams-admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Page Header */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Stats Section */
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
text-align: center;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: #667eea;
}
.stat-label {
font-size: 0.9rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
/* Search Section */
.search-section {
margin-bottom: 2rem;
}
.search-container {
position: relative;
max-width: 500px;
}
.search-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
font-size: 1.25rem;
opacity: 0.6;
}
.search-input {
width: 100%;
padding: 1rem 1rem 1rem 3rem;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 1rem;
transition: all 0.3s ease;
background: white;
}
.search-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* Teams Grid */
.teams-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
/* Team Card */
.team-card {
background: white;
border-radius: 12px;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
overflow: hidden;
display: flex;
flex-direction: column;
}
.team-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.team-header {
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.team-icon-wrapper {
width: 60px;
height: 60px;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.team-icon {
font-size: 2rem;
}
.member-count {
background: white;
color: #6b7280;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
}
.team-body {
padding: 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
}
.team-name {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 0.5rem 0;
}
.team-description {
color: #6b7280;
line-height: 1.6;
margin-bottom: 1.5rem;
flex: 1;
}
.team-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.info-item {
display: flex;
align-items: center;
gap: 0.5rem;
color: #6b7280;
font-size: 0.875rem;
}
.info-icon {
font-size: 1rem;
}
/* Member Avatars */
.member-avatars {
display: flex;
margin-bottom: 1rem;
}
.member-avatar {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: 600;
margin-right: -8px;
border: 2px solid white;
position: relative;
z-index: 1;
transition: all 0.2s ease;
}
.member-avatar:hover {
transform: scale(1.1);
z-index: 10;
}
.member-avatar.more {
background: #6b7280;
font-size: 0.75rem;
}
/* Team Actions */
.team-actions {
padding: 1.5rem;
background: #f8f9fa;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 0.75rem;
align-items: center;
}
.delete-form {
margin: 0;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-manage {
background: white;
color: #667eea;
border: 2px solid #e5e7eb;
flex: 1;
justify-content: center;
}
.btn-manage:hover {
background: #f3f4f6;
border-color: #667eea;
}
.btn-delete {
background: #fee2e2;
color: #dc2626;
padding: 0.75rem;
min-width: auto;
}
.btn-delete:hover {
background: #dc2626;
color: white;
}
.btn-lg {
padding: 1rem 2rem;
font-size: 1.1rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
border: 2px dashed #e5e7eb;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1.5rem;
opacity: 0.3;
}
.empty-title {
font-size: 1.75rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 0.5rem;
}
.empty-message {
font-size: 1.1rem;
color: #6b7280;
margin-bottom: 2rem;
}
.empty-hint {
color: #9ca3af;
font-size: 0.875rem;
}
/* No Results */
.no-results {
text-align: center;
padding: 3rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.teams-admin-container {
padding: 1rem;
}
.page-header {
padding: 1.5rem;
}
.page-title {
font-size: 2rem;
}
.header-content {
flex-direction: column;
text-align: center;
}
.header-stats {
grid-template-columns: 1fr;
}
.teams-grid {
grid-template-columns: 1fr;
}
.team-actions {
flex-direction: column;
}
.btn-manage {
width: 100%;
}
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.team-card {
animation: slideIn 0.4s ease-out;
animation-fill-mode: both;
}
.team-card:nth-child(1) { animation-delay: 0.05s; }
.team-card:nth-child(2) { animation-delay: 0.1s; }
.team-card:nth-child(3) { animation-delay: 0.15s; }
.team-card:nth-child(4) { animation-delay: 0.2s; }
.team-card:nth-child(5) { animation-delay: 0.25s; }
.team-card:nth-child(6) { animation-delay: 0.3s; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('teamSearch');
const teamsGrid = document.getElementById('teamsGrid');
const noResults = document.getElementById('noResults');
if (searchInput && teamsGrid) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase().trim();
const teamCards = teamsGrid.querySelectorAll('.team-card');
let visibleCount = 0;
teamCards.forEach(card => {
const teamName = card.getAttribute('data-team-name');
const teamDesc = card.getAttribute('data-team-desc');
if (teamName.includes(searchTerm) || teamDesc.includes(searchTerm)) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
// Show/hide no results message
if (noResults) {
noResults.style.display = visibleCount === 0 ? 'block' : 'none';
}
});
}
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,25 @@
{% block content %}
<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 in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN] %}
<button class="mode-btn {% if mode == 'team' %}active{% endif %}"
onclick="switchMode('team')">Team</button>
{% endif %}
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon"><i class="ti ti-chart-bar"></i></span>
Time Analytics
</h1>
<p class="page-subtitle">Analyze time tracking data and generate insights</p>
</div>
<div class="header-actions">
<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 in [Role.TEAM_LEADER, Role.SUPERVISOR, Role.ADMIN] %}
<button class="mode-btn {% if mode == 'team' %}active{% endif %}"
onclick="switchMode('team')">Team</button>
{% endif %}
</div>
</div>
</div>
</div>
@@ -43,10 +53,10 @@
<!-- View Tabs -->
<div class="view-tabs">
<button class="tab-btn active" data-view="table">📋 Table View</button>
<button class="tab-btn" data-view="graph">📈 Graph View</button>
<button class="tab-btn active" data-view="table"><i class="ti ti-clipboard-list"></i> Table View</button>
<button class="tab-btn" data-view="graph"><i class="ti ti-trending-up"></i> Graph View</button>
{% if mode == 'team' %}
<button class="tab-btn" data-view="team">👥 Team Summary</button>
<button class="tab-btn" data-view="team"><i class="ti ti-users"></i> Team Summary</button>
{% endif %}
</div>

View File

@@ -1,204 +0,0 @@
{% extends "layout.html" %}
{% block content %}
<div class="admin-container">
<div class="admin-header">
<h1>Company Users - {{ company.name }}</h1>
<a href="{{ url_for('users.create_user') }}" class="btn btn-success">Create New User</a>
</div>
<!-- User Statistics -->
<div class="stats-section">
<h2>User Statistics</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>{{ stats.total }}</h3>
<p>Total Users</p>
</div>
<div class="stat-card">
<h3>{{ stats.active }}</h3>
<p>Active Users</p>
</div>
<div class="stat-card">
<h3>{{ stats.unverified }}</h3>
<p>Unverified</p>
</div>
<div class="stat-card">
<h3>{{ stats.blocked }}</h3>
<p>Blocked</p>
</div>
<div class="stat-card">
<h3>{{ stats.admins }}</h3>
<p>Administrators</p>
</div>
<div class="stat-card">
<h3>{{ stats.supervisors }}</h3>
<p>Supervisors</p>
</div>
</div>
</div>
<!-- User List -->
<div class="admin-section">
<h2>User List</h2>
{% if users %}
<div class="user-list">
<table class="data-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Team</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
{{ user.username }}
{% if user.two_factor_enabled %}
<span class="security-badge" title="2FA Enabled">🔒</span>
{% endif %}
</td>
<td>{{ user.email }}</td>
<td>
<span class="role-badge role-{{ user.role.name.lower() }}">
{{ user.role.value }}
</span>
</td>
<td>
{% if user.team %}
<span class="team-badge">{{ user.team.name }}</span>
{% else %}
<span class="text-muted">No team</span>
{% endif %}
</td>
<td>
<span class="status-badge {% if user.is_blocked %}status-blocked{% elif not user.is_verified %}status-unverified{% else %}status-active{% endif %}">
{% if user.is_blocked %}Blocked{% elif not user.is_verified %}Unverified{% else %}Active{% endif %}
</span>
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<a href="{{ url_for('users.edit_user', user_id=user.id) }}" class="btn btn-sm btn-primary">Edit</a>
{% if user.id != g.user.id %}
<form method="POST" action="{{ url_for('users.toggle_user_status', user_id=user.id) }}" style="display: inline;">
{% if user.is_blocked %}
<button type="submit" class="btn btn-sm btn-success">Unblock</button>
{% else %}
<button type="submit" class="btn btn-sm btn-warning">Block</button>
{% endif %}
</form>
<button class="btn btn-sm btn-danger" onclick="confirmDelete({{ user.id }}, '{{ user.username }}')">Delete</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<h3>No Users Found</h3>
<p>There are no users in this company yet.</p>
<a href="{{ url_for('users.create_user') }}" class="btn btn-primary">Add First User</a>
</div>
{% endif %}
</div>
<!-- Navigation -->
<div class="admin-section">
<a href="{{ url_for('companies.admin_company') }}" class="btn btn-secondary">← Back to Company Management</a>
</div>
</div>
<script>
function confirmDelete(userId, username) {
if (confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
fetch(`/admin/users/delete/${userId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
}).then(response => {
if (response.ok) {
location.reload();
} else {
alert('Error deleting user');
}
});
}
}
</script>
<style>
.security-badge {
font-size: 12px;
margin-left: 5px;
}
.role-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
display: inline-block;
}
.role-admin {
background-color: #ff6b6b;
color: white;
}
.role-supervisor {
background-color: #ffa726;
color: white;
}
.role-team_leader {
background-color: #42a5f5;
color: white;
}
.role-team_member {
background-color: #66bb6a;
color: white;
}
.team-badge {
background-color: #e3f2fd;
color: #1976d2;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
border: 1px solid #bbdefb;
}
.status-unverified {
background-color: #fff3cd;
color: #856404;
}
.empty-state {
text-align: center;
padding: 40px 20px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
}
.empty-state h3 {
color: #666;
margin-bottom: 10px;
}
.empty-state p {
color: #888;
margin-bottom: 20px;
}
</style>
{% endblock %}

View File

@@ -192,33 +192,33 @@ document.addEventListener('DOMContentLoaded', function() {
if (interval === 15) {
if (roundToNearest) {
examples.push('9:07 AM 9:00 AM');
examples.push('9:08 AM 9:15 AM');
examples.push('9:23 AM 9:30 AM');
examples.push('9:07 AM <i class="ti ti-arrow-right"></i> 9:00 AM');
examples.push('9:08 AM <i class="ti ti-arrow-right"></i> 9:15 AM');
examples.push('9:23 AM <i class="ti ti-arrow-right"></i> 9:30 AM');
} else {
examples.push('9:01 AM 9:15 AM');
examples.push('9:16 AM 9:30 AM');
examples.push('9:31 AM 9:45 AM');
examples.push('9:01 AM <i class="ti ti-arrow-right"></i> 9:15 AM');
examples.push('9:16 AM <i class="ti ti-arrow-right"></i> 9:30 AM');
examples.push('9:31 AM <i class="ti ti-arrow-right"></i> 9:45 AM');
}
} else if (interval === 30) {
if (roundToNearest) {
examples.push('9:14 AM 9:00 AM');
examples.push('9:16 AM 9:30 AM');
examples.push('9:45 AM 10:00 AM');
examples.push('9:14 AM <i class="ti ti-arrow-right"></i> 9:00 AM');
examples.push('9:16 AM <i class="ti ti-arrow-right"></i> 9:30 AM');
examples.push('9:45 AM <i class="ti ti-arrow-right"></i> 10:00 AM');
} else {
examples.push('9:01 AM 9:30 AM');
examples.push('9:31 AM 10:00 AM');
examples.push('10:01 AM 10:30 AM');
examples.push('9:01 AM <i class="ti ti-arrow-right"></i> 9:30 AM');
examples.push('9:31 AM <i class="ti ti-arrow-right"></i> 10:00 AM');
examples.push('10:01 AM <i class="ti ti-arrow-right"></i> 10:30 AM');
}
} else if (interval === 60) {
if (roundToNearest) {
examples.push('9:29 AM 9:00 AM');
examples.push('9:31 AM 10:00 AM');
examples.push('10:30 AM 11:00 AM');
examples.push('9:29 AM <i class="ti ti-arrow-right"></i> 9:00 AM');
examples.push('9:31 AM <i class="ti ti-arrow-right"></i> 10:00 AM');
examples.push('10:30 AM <i class="ti ti-arrow-right"></i> 11:00 AM');
} else {
examples.push('9:01 AM 10:00 AM');
examples.push('10:01 AM 11:00 AM');
examples.push('11:01 AM 12:00 PM');
examples.push('9:01 AM <i class="ti ti-arrow-right"></i> 10:00 AM');
examples.push('10:01 AM <i class="ti ti-arrow-right"></i> 11:00 AM');
examples.push('11:01 AM <i class="ti ti-arrow-right"></i> 12:00 PM');
}
}

View File

@@ -3,10 +3,10 @@
{% block content %}
<div class="admin-container">
<div class="header-section">
<h1>⚠️ Confirm Company Deletion</h1>
<h1><i class="ti ti-alert-triangle"></i> Confirm Company Deletion</h1>
<p class="subtitle">Critical Action Required - Review All Data Before Proceeding</p>
<a href="{{ url_for('users.admin_users') if g.user.role != Role.SYSTEM_ADMIN else url_for('users.system_admin_users') }}"
class="btn btn-md btn-secondary"> Back to User Management</a>
class="btn btn-md btn-secondary"><i class="ti ti-arrow-left"></i> Back to User Management</a>
</div>
<div class="alert alert-danger">
@@ -21,7 +21,7 @@
<!-- Company Information -->
<div class="table-section">
<h3>🏢 Company Information</h3>
<h3><i class="ti ti-building"></i> Company Information</h3>
<table class="data-table">
<tr>
<th>Company Name:</th>
@@ -45,7 +45,7 @@
<!-- Users -->
{% if users %}
<div class="table-section">
<h3>👥 Users ({{ users|length }})</h3>
<h3><i class="ti ti-users"></i> Users ({{ users|length }})</h3>
<table class="data-table">
<thead>
<tr>
@@ -83,7 +83,7 @@
<!-- Teams -->
{% if teams %}
<div class="table-section">
<h3>🏭 Teams ({{ teams|length }})</h3>
<h3><i class="ti ti-users-group"></i> Teams ({{ teams|length }})</h3>
<table class="data-table">
<thead>
<tr>
@@ -141,7 +141,7 @@
<!-- Tasks -->
{% if tasks %}
<div class="table-section">
<h3> Tasks ({{ tasks|length }})</h3>
<h3><i class="ti ti-check"></i> Tasks ({{ tasks|length }})</h3>
<table class="data-table">
<thead>
<tr>

View File

@@ -1,55 +0,0 @@
{% extends "layout.html" %}
{% block content %}
<div class="admin-container">
<h1>Create New User</h1>
<form method="POST" action="{{ url_for('users.create_user') }}" class="user-form">
<div class="form-group">
<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>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<div class="form-group">
<label for="role">Role</label>
<select id="role" name="role" class="form-control">
{% for role in roles %}
<option value="{{ role.name }}">{{ role.value }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="team_id">Team (Optional)</label>
<select id="team_id" name="team_id" class="form-control">
<option value="">No Team</option>
{% for team in teams %}
<option value="{{ team.id }}">{{ team.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="checkbox-container">
<input type="checkbox" name="auto_verify"> Auto-verify (skip email verification)
<span class="checkmark"></span>
</label>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success">Create User</button>
<a href="{{ url_for('users.admin_users') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -21,7 +21,7 @@
<!-- Empty Dashboard Message -->
<div id="empty-dashboard" class="empty-dashboard" style="display: none;">
<div class="empty-dashboard-content">
<div class="empty-dashboard-icon">📊</div>
<div class="empty-dashboard-icon"><i class="ti ti-chart-bar"></i></div>
<h3>Your Dashboard is Empty</h3>
<p>Add widgets to create your personalized dashboard.</p>
<button id="add-first-widget-btn" class="btn btn-md btn-primary">Add Your First Widget</button>

View File

@@ -103,7 +103,7 @@
<!-- Danger Zone (only for admins) -->
{% if g.user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] %}
<div class="danger-zone">
<h3>⚠️ Danger Zone</h3>
<h3><i class="ti ti-alert-triangle"></i> Danger Zone</h3>
<div class="danger-content">
<p><strong>Delete Project</strong></p>
<p>Once you delete a project, there is no going back. This will permanently delete:</p>

View File

@@ -1,53 +0,0 @@
{% extends "layout.html" %}
{% block content %}
<div class="admin-container">
<h1>Edit User: {{ user.username }}</h1>
<form method="POST" action="{{ url_for('users.edit_user', user_id=user.id) }}" class="user-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-control" value="{{ user.username }}" required>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" class="form-control" value="{{ user.email }}" required>
</div>
<div class="form-group">
<label for="password">New Password (leave blank to keep current)</label>
<input type="password" id="password" name="password" class="form-control">
</div>
<div class="form-group">
<label for="role">Role</label>
<select id="role" name="role" class="form-control">
{% for role in roles %}
<option value="{{ role.name }}" {% if user.role == role %}selected{% endif %}>
{{ role.name.replace('_', ' ').title() }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="team_id">Team</label>
<select id="team_id" name="team_id" class="form-control">
<option value="">-- No Team --</option>
{% for team in teams %}
<option value="{{ team.id }}" {% if user.team_id == team.id %}selected{% endif %}>
{{ team.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Update User</button>
<a href="{{ url_for('users.admin_users') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -10,7 +10,7 @@
</div>
<div class="imprint-footer">
<a href="{{ url_for('home') }}" class="btn btn-secondary"> Back to Home</a>
<a href="{{ url_for('home') }}" class="btn btn-secondary"><i class="ti ti-arrow-left"></i> Back to Home</a>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -31,12 +31,12 @@
<h2 class="section-title">Powerful Features for Modern Teams</h2>
<div class="feature-cards">
<div class="feature-card">
<div class="feature-icon"></div>
<div class="feature-icon"><i class="ti ti-bolt"></i></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>
<div class="feature-icon"><i class="ti ti-chart-bar"></i></div>
<h3>Advanced Analytics</h3>
<p>Gain insights with comprehensive reports and visual dashboards</p>
</div>
@@ -46,7 +46,7 @@
<p>Organize work into sprints with agile project tracking</p>
</div>
<div class="feature-card">
<div class="feature-icon">👥</div>
<div class="feature-icon"><i class="ti ti-users"></i></div>
<h3>Team Collaboration</h3>
<p>Manage teams, projects, and resources all in one place</p>
</div>
@@ -71,7 +71,7 @@
<div class="stat-label">Free & Open Source</div>
</div>
<div class="stat-item">
<div class="stat-number"></div>
<div class="stat-number"><i class="ti ti-infinity"></i></div>
<div class="stat-label">Unlimited Tracking</div>
</div>
<div class="stat-item">
@@ -115,14 +115,14 @@
<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>
<li><i class="ti ti-check"></i> Unlimited users</li>
<li><i class="ti ti-check"></i> All features included</li>
<li><i class="ti ti-check"></i> Time tracking & analytics</li>
<li><i class="ti ti-check"></i> Sprint management</li>
<li><i class="ti ti-check"></i> Team collaboration</li>
<li><i class="ti ti-check"></i> Project management</li>
<li><i class="ti ti-check"></i> Self-hosted option</li>
<li><i class="ti ti-check"></i> No restrictions</li>
</ul>
<a href="{{ url_for('register') }}" class="btn-pricing">Get Started Free</a>
</div>
@@ -376,7 +376,7 @@
<td>
<div class="time-cell">
<span class="time-start">{{ entry.arrival_time|format_time }}</span>
<span class="time-separator"></span>
<span class="time-separator"><i class="ti ti-arrow-right"></i></span>
<span class="time-end">{{ entry.departure_time|format_time if entry.departure_time else 'Active' }}</span>
</div>
</td>

View File

@@ -7,7 +7,7 @@
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon">📨</span>
<span class="page-icon"><i class="ti ti-mail"></i></span>
Invitations
</h1>
<p class="page-subtitle">Manage team invitations for {{ g.user.company.name }}</p>
@@ -45,7 +45,7 @@
{% if pending_invitations %}
<div class="section">
<h2 class="section-title">
<span class="icon"></span>
<span class="icon"><i class="ti ti-hourglass"></i></span>
Pending Invitations
</h2>
<div class="invitations-list">
@@ -56,15 +56,15 @@
<h3 class="invitation-email">{{ invitation.email }}</h3>
<div class="invitation-meta">
<span class="meta-item">
<span class="icon">👤</span>
<span class="icon"><i class="ti ti-user"></i></span>
Role: {{ invitation.role }}
</span>
<span class="meta-item">
<span class="icon">📅</span>
<span class="icon"><i class="ti ti-calendar"></i></span>
Sent {{ invitation.created_at.strftime('%b %d, %Y') }}
</span>
<span class="meta-item">
<span class="icon"></span>
<span class="icon"><i class="ti ti-clock"></i></span>
Expires {{ invitation.expires_at.strftime('%b %d, %Y') }}
</span>
</div>
@@ -72,13 +72,13 @@
<div class="invitation-actions">
<form method="POST" action="{{ url_for('invitations.resend_invitation', invitation_id=invitation.id) }}" style="display: inline;">
<button type="submit" class="btn btn-sm btn-secondary">
<span class="icon">🔄</span>
<span class="icon"><i class="ti ti-refresh"></i></span>
Resend
</button>
</form>
<form method="POST" action="{{ url_for('invitations.revoke_invitation', invitation_id=invitation.id) }}" style="display: inline;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to revoke this invitation?');">
<span class="icon"></span>
<span class="icon"><i class="ti ti-x"></i></span>
Revoke
</button>
</form>
@@ -97,7 +97,7 @@
{% if accepted_invitations %}
<div class="section">
<h2 class="section-title">
<span class="icon"></span>
<span class="icon"><i class="ti ti-check"></i></span>
Accepted Invitations
</h2>
<div class="invitations-list">
@@ -108,18 +108,18 @@
<h3 class="invitation-email">{{ invitation.email }}</h3>
<div class="invitation-meta">
<span class="meta-item">
<span class="icon">👤</span>
<span class="icon"><i class="ti ti-user"></i></span>
Joined as: {{ invitation.accepted_by.username }} ({{ invitation.role }})
</span>
<span class="meta-item">
<span class="icon">📅</span>
<span class="icon"><i class="ti ti-calendar"></i></span>
Accepted {{ invitation.accepted_at.strftime('%b %d, %Y') }}
</span>
</div>
</div>
<div class="invitation-actions">
<a href="{{ url_for('users.view_user', user_id=invitation.accepted_by.id) }}" class="btn btn-sm btn-secondary">
<span class="icon">👁️</span>
<span class="icon"><i class="ti ti-eye"></i></span>
View User
</a>
</div>
@@ -137,7 +137,7 @@
{% if expired_invitations %}
<div class="section">
<h2 class="section-title">
<span class="icon">⏱️</span>
<span class="icon"><i class="ti ti-clock"></i></span>
Expired Invitations
</h2>
<div class="invitations-list">
@@ -148,11 +148,11 @@
<h3 class="invitation-email">{{ invitation.email }}</h3>
<div class="invitation-meta">
<span class="meta-item">
<span class="icon">👤</span>
<span class="icon"><i class="ti ti-user"></i></span>
Role: {{ invitation.role }}
</span>
<span class="meta-item">
<span class="icon">📅</span>
<span class="icon"><i class="ti ti-calendar"></i></span>
Expired {{ invitation.expires_at.strftime('%b %d, %Y') }}
</span>
</div>
@@ -160,7 +160,7 @@
<div class="invitation-actions">
<form method="POST" action="{{ url_for('invitations.resend_invitation', invitation_id=invitation.id) }}" style="display: inline;">
<button type="submit" class="btn btn-sm btn-primary">
<span class="icon">📤</span>
<span class="icon"><i class="ti ti-send"></i></span>
Send New Invitation
</button>
</form>
@@ -178,7 +178,7 @@
<!-- Empty State -->
{% if not pending_invitations and not accepted_invitations and not expired_invitations %}
<div class="empty-state">
<div class="empty-icon">📨</div>
<div class="empty-icon"><i class="ti ti-mail"></i></div>
<h3>No invitations yet</h3>
<p>Start building your team by sending invitations</p>
<a href="{{ url_for('invitations.send_invitation') }}" class="btn btn-primary">

View File

@@ -7,14 +7,14 @@
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon">✉️</span>
<span class="page-icon"><i class="ti ti-mail"></i></span>
Send Invitation
</h1>
<p class="page-subtitle">Invite team members to join {{ g.user.company.name }}</p>
</div>
<div class="header-actions">
<a href="{{ url_for('invitations.list_invitations') }}" class="btn btn-secondary">
<span class="icon"></span>
<i class="ti ti-arrow-left"></i>
Back to Invitations
</a>
</div>
@@ -26,7 +26,7 @@
<div class="card invitation-form-card">
<div class="card-header">
<h2 class="card-title">
<span class="icon">👥</span>
<span class="icon"><i class="ti ti-users"></i></span>
Invitation Details
</h2>
</div>
@@ -60,7 +60,7 @@
<div class="info-panel">
<div class="info-item">
<span class="info-icon">📧</span>
<span class="info-icon"><i class="ti ti-mail-opened"></i></span>
<div class="info-content">
<h4>What happens next?</h4>
<ul>
@@ -75,7 +75,7 @@
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span class="icon">📤</span>
<span class="icon"><i class="ti ti-send"></i></span>
Send Invitation
</button>
<a href="{{ url_for('invitations.list_invitations') }}" class="btn btn-ghost">
@@ -90,7 +90,7 @@
<div class="card preview-card">
<div class="card-header">
<h2 class="card-title">
<span class="icon">👁️</span>
<span class="icon"><i class="ti ti-eye"></i></span>
Email Preview
</h2>
</div>

View File

@@ -6,6 +6,11 @@
<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') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/hover-standards.css') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons@latest/iconfont/tabler-icons.min.css">
{% if g.user %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/time-tracking.css') }}">
{% endif %}
{% if not g.user %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/splash.css') }}">
{% endif %}
@@ -14,21 +19,12 @@
{% 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' }};
--primary-color: {{ g.branding.primary_color if g.branding else '#667eea' }};
--primary-gradient-start: {{ g.branding.primary_color if g.branding else '#667eea' }};
--primary-gradient-end: #764ba2;
}
.nav-icon {
color: var(--primary-color);
}
a:hover {
color: var(--primary-color);
color: var(--primary-gradient-end);
}
.mobile-logo {
max-height: 30px;
@@ -116,10 +112,10 @@
</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><a href="{{ url_for('profile') }}"><i class="nav-icon ti ti-user"></i>Profile</a></li>
<li><a href="{{ url_for('config') }}"><i class="nav-icon ti ti-settings"></i>Settings</a></li>
<li class="user-dropdown-divider"></li>
<li><a href="{{ url_for('logout') }}"><i class="nav-icon">🚪</i>Logout</a></li>
<li><a href="{{ url_for('logout') }}"><i class="nav-icon ti ti-logout"></i>Logout</a></li>
</ul>
</div>
</div>
@@ -128,37 +124,39 @@
<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('time_tracking') }}" data-tooltip="Time Tracking"><i class="nav-icon">⏱️</i><span class="nav-text">Time Tracking</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('tasks.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('sprints.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>
<li><a href="{{ url_for('home') }}" data-tooltip="Home"><i class="nav-icon ti ti-home"></i><span class="nav-text">Home</span></a></li>
<li><a href="{{ url_for('time_tracking') }}" data-tooltip="Time Tracking"><i class="nav-icon ti ti-clock"></i><span class="nav-text">Time Tracking</span></a></li>
<!-- Dashboard disabled due to widget issues -->
<!-- <li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon ti ti-dashboard"></i><span class="nav-text">Dashboard</span></a></li> -->
<li><a href="{{ url_for('tasks.unified_task_management') }}" data-tooltip="Task Management"><i class="nav-icon ti ti-clipboard-list"></i><span class="nav-text">Task Management</span></a></li>
<li><a href="{{ url_for('sprints.sprint_management') }}" data-tooltip="Sprint Management"><i class="nav-icon ti ti-run"></i><span class="nav-text">Sprints</span></a></li>
<li><a href="{{ url_for('notes.notes_list') }}" data-tooltip="Notes"><i class="nav-icon ti ti-notes"></i><span class="nav-text">Notes</span></a></li>
<li><a href="{{ url_for('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon ti ti-chart-bar"></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('companies.admin_company') }}" data-tooltip="Company Settings"><i class="nav-icon">🏢</i><span class="nav-text">Company Settings</span></a></li>
<li><a href="{{ url_for('users.admin_users') }}" data-tooltip="Manage Users"><i class="nav-icon">👥</i><span class="nav-text">Manage Users</span></a></li>
<li><a href="{{ url_for('invitations.list_invitations') }}" data-tooltip="Invitations"><i class="nav-icon">📨</i><span class="nav-text">Invitations</span></a></li>
<li><a href="{{ url_for('teams.admin_teams') }}" data-tooltip="Manage Teams"><i class="nav-icon">🏭</i><span class="nav-text">Manage Teams</span></a></li>
<li><a href="{{ url_for('projects.admin_projects') }}" data-tooltip="Manage Projects"><i class="nav-icon">📝</i><span class="nav-text">Manage Projects</span></a></li>
<li><a href="{{ url_for('organization.admin_organization') }}" data-tooltip="Organization"><i class="nav-icon ti ti-sitemap"></i><span class="nav-text">Organization</span></a></li>
<li><a href="{{ url_for('companies.admin_company') }}" data-tooltip="Company Settings"><i class="nav-icon ti ti-building"></i><span class="nav-text">Company Settings</span></a></li>
<li><a href="{{ url_for('invitations.list_invitations') }}" data-tooltip="Invitations"><i class="nav-icon ti ti-mail"></i><span class="nav-text">Invitations</span></a></li>
<li><a href="{{ url_for('projects.admin_projects') }}" data-tooltip="Manage Projects"><i class="nav-icon ti ti-folder"></i><span class="nav-text">Manage Projects</span></a></li>
{% if g.user.role == Role.SYSTEM_ADMIN %}
<li class="nav-divider">System Admin</li>
<li><a href="{{ url_for('system_admin.system_admin_dashboard') }}" data-tooltip="System Dashboard"><i class="nav-icon">🌐</i><span class="nav-text">System Dashboard</span></a></li>
<li><a href="{{ url_for('announcements.index') }}" data-tooltip="Announcements"><i class="nav-icon">📢</i><span class="nav-text">Announcements</span></a></li>
<li><a href="{{ url_for('system_admin.system_admin_dashboard') }}" data-tooltip="System Dashboard"><i class="nav-icon ti ti-world"></i><span class="nav-text">System Dashboard</span></a></li>
<li><a href="{{ url_for('announcements.index') }}" data-tooltip="Announcements"><i class="nav-icon ti ti-speakerphone"></i><span class="nav-text">Announcements</span></a></li>
{% endif %}
{% elif g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
<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>
<!-- Dashboard disabled due to widget issues -->
<!-- <li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon ti ti-chart-line"></i><span class="nav-text">Dashboard</span></a></li> -->
{% if g.user.role == Role.SUPERVISOR %}
<li><a href="{{ url_for('projects.admin_projects') }}" data-tooltip="Manage Projects"><i class="nav-icon">📝</i><span class="nav-text">Manage Projects</span></a></li>
<li><a href="{{ url_for('projects.admin_projects') }}" data-tooltip="Manage Projects"><i class="nav-icon ti ti-folder"></i><span class="nav-text">Manage Projects</span></a></li>
{% endif %}
{% endif %}
{% 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>
<li><a href="{{ url_for('register') }}" data-tooltip="Register"><i class="nav-icon">📝</i><span class="nav-text">Register</span></a></li>
<li><a href="{{ url_for('about') }}" data-tooltip="About"><i class="nav-icon ti ti-info-circle"></i><span class="nav-text">About</span></a></li>
<li><a href="{{ url_for('login') }}" data-tooltip="Login"><i class="nav-icon ti ti-key"></i><span class="nav-text">Login</span></a></li>
<li><a href="{{ url_for('register') }}" data-tooltip="Register"><i class="nav-icon ti ti-user-plus"></i><span class="nav-text">Register</span></a></li>
{% endif %}
</ul>
</nav>
@@ -209,23 +207,23 @@
{% 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-icon"><i class="ti ti-mail"></i></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>
<button class="email-nag-dismiss" onclick="dismissEmailNag()" title="Dismiss for this session"><i class="ti ti-x"></i></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-icon"><i class="ti ti-mail-opened"></i></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>
<button class="email-nag-dismiss" onclick="dismissEmailNag()" title="Dismiss for this session"><i class="ti ti-x"></i></button>
</div>
</div>
{% endif %}

1136
templates/note_editor.html Normal file

File diff suppressed because it is too large Load Diff

766
templates/note_mindmap.html Normal file
View File

@@ -0,0 +1,766 @@
{% extends "layout.html" %}
{% block content %}
<div class="note-mindmap-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon">🧠</span>
Mind Map View
</h1>
<p class="page-subtitle">{{ note.title }}</p>
</div>
<div class="header-actions">
<button type="button" class="btn btn-secondary" onclick="resetZoom()">
<span class="icon">🔍</span>
Reset Zoom
</button>
<button type="button" class="btn btn-secondary" onclick="expandAll()">
<span class="icon"></span>
Expand All
</button>
<button type="button" class="btn btn-secondary" onclick="collapseAll()">
<span class="icon"></span>
Collapse All
</button>
<button type="button" class="btn btn-secondary" onclick="toggleFullscreen()">
<span class="icon">🗖️</span>
Fullscreen
</button>
<a href="{{ url_for('notes.view_note', slug=note.slug) }}" class="btn btn-secondary">
<i class="ti ti-arrow-left"></i>
Back to Note
</a>
</div>
</div>
</div>
<div id="mindmap-container" class="mindmap-container">
<svg id="mindmap-svg"></svg>
<div class="node-count" id="node-count"></div>
</div>
</div>
<style>
.note-mindmap-container {
max-width: 1600px;
margin: 0 auto;
padding: 2rem;
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
}
/* Page Header - Time Tracking style */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0% { transform: translateY(0); }
50% { transform: translateY(-10px); }
100% { transform: translateY(0); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
.header-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
/* Button styles */
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
border: 2px solid transparent;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
cursor: pointer;
}
.btn-secondary {
background: white;
color: #667eea;
border-color: #e5e7eb;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #667eea;
}
.btn .icon {
font-size: 1.1em;
}
.mindmap-container {
flex: 1;
background: white;
border: 1px solid #e5e7eb;
border-radius: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
overflow: hidden;
position: relative;
min-height: 600px;
}
#mindmap-svg {
width: 100%;
height: 100%;
cursor: grab;
}
#mindmap-svg.grabbing {
cursor: grabbing;
}
.node {
cursor: pointer;
}
.node circle {
fill: #fff;
stroke: #667eea;
stroke-width: 3px;
}
.node rect {
fill: #f9f9f9;
stroke: #667eea;
stroke-width: 2px;
rx: 8px;
}
.node text {
font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
pointer-events: none;
text-anchor: middle;
dominant-baseline: central;
}
.link {
fill: none;
stroke: #e5e7eb;
stroke-width: 2px;
}
.node.root rect {
fill: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
stroke: none;
}
.node.root text {
fill: white;
font-weight: bold;
font-size: 16px;
}
.node.header1 rect {
fill: #e0e7ff;
stroke: #667eea;
}
.node.header2 rect {
fill: #f0e6ff;
stroke: #9333ea;
}
.node.header3 rect {
fill: #fef3c7;
stroke: #f59e0b;
}
.node.list rect {
fill: #f3f4f6;
stroke: #9ca3af;
stroke-width: 1px;
}
.node.list text {
font-size: 12px;
}
.node.paragraph rect {
fill: #fafafa;
stroke: #d1d5db;
stroke-dasharray: 4;
}
/* Fullscreen styles */
.mindmap-container.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
margin: 0;
border-radius: 0;
}
/* Tooltip styles */
.tooltip {
position: absolute;
text-align: left;
padding: 8px;
font: 12px sans-serif;
background: rgba(0, 0, 0, 0.8);
color: white;
border: 0px;
border-radius: 4px;
pointer-events: none;
}
/* Node count indicator */
.node-count {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
}
</style>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
// Store the markdown content and title safely
const noteData = {
content: {{ note.content|tojson }},
title: {{ note.title|tojson }}
};
// Improved Markdown parser
function parseMarkdownToTree(markdown) {
const lines = markdown.split('\n');
const root = {
name: noteData.title,
children: [],
level: 0,
type: 'root'
};
const headerStack = [root];
let currentParent = root;
let lastNode = root;
let inCodeBlock = false;
let codeBlockContent = [];
lines.forEach((line, index) => {
// Handle code blocks
if (line.trim().startsWith('```')) {
if (inCodeBlock) {
// End code block
const codeNode = {
name: 'Code Block',
content: codeBlockContent.join('\n'),
children: [],
type: 'code',
level: currentParent.level + 1
};
currentParent.children.push(codeNode);
codeBlockContent = [];
inCodeBlock = false;
} else {
// Start code block
inCodeBlock = true;
}
return;
}
if (inCodeBlock) {
codeBlockContent.push(line);
return;
}
// Skip empty lines
if (!line.trim()) return;
// Check for headers
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const text = headerMatch[2].trim();
// Pop stack to find correct parent
while (headerStack.length > 1 && headerStack[headerStack.length - 1].level >= level) {
headerStack.pop();
}
const node = {
name: text,
children: [],
level: level,
type: `header${level}`
};
const parent = headerStack[headerStack.length - 1];
parent.children.push(node);
headerStack.push(node);
currentParent = node;
lastNode = node;
return;
}
// Check for unordered list items
const listMatch = line.match(/^(\s*)[*\-+]\s+(.+)$/);
if (listMatch) {
const indent = listMatch[1].length;
const text = listMatch[2].trim();
const node = {
name: text,
children: [],
level: currentParent.level + 1,
type: 'list',
indent: indent
};
// Handle nested lists
if (lastNode.type === 'list' && indent > lastNode.indent) {
lastNode.children.push(node);
} else {
currentParent.children.push(node);
}
lastNode = node;
return;
}
// Check for ordered list items
const orderedListMatch = line.match(/^(\s*)\d+\.\s+(.+)$/);
if (orderedListMatch) {
const indent = orderedListMatch[1].length;
const text = orderedListMatch[2].trim();
const node = {
name: text,
children: [],
level: currentParent.level + 1,
type: 'list',
indent: indent
};
currentParent.children.push(node);
lastNode = node;
return;
}
// Check for blockquotes
if (line.trim().startsWith('>')) {
const text = line.replace(/^>\s*/, '').trim();
if (text) {
const node = {
name: text,
children: [],
level: currentParent.level + 1,
type: 'quote'
};
currentParent.children.push(node);
lastNode = node;
}
return;
}
// Regular paragraph text
const text = line.trim();
if (text) {
const node = {
name: text.length > 80 ? text.substring(0, 80) + '...' : text,
fullText: text,
children: [],
level: currentParent.level + 1,
type: 'paragraph'
};
currentParent.children.push(node);
lastNode = node;
}
});
return root;
}
// D3 Mind Map Implementation
const treeData = parseMarkdownToTree(noteData.content);
// Setup dimensions
const containerWidth = document.getElementById('mindmap-container').clientWidth;
const containerHeight = document.getElementById('mindmap-container').clientHeight;
// Calculate dynamic dimensions based on content
function calculateTreeDimensions(root) {
let maxDepth = 0;
let leafCount = 0;
root.each(d => {
if (d.depth > maxDepth) maxDepth = d.depth;
if (!d.children && !d._children) leafCount++;
});
// Dynamic sizing based on content
const nodeHeight = 50; // Height per node including spacing
const nodeWidth = 250; // Width per depth level
const width = Math.max(containerWidth, (maxDepth + 1) * nodeWidth);
const height = Math.max(containerHeight, leafCount * nodeHeight);
return { width, height, nodeWidth, nodeHeight };
}
// Create SVG with initial viewBox
const svg = d3.select("#mindmap-svg")
.attr("viewBox", [0, 0, containerWidth, containerHeight]);
const g = svg.append("g");
// Add zoom behavior with extended range
const zoom = d3.zoom()
.scaleExtent([0.02, 10])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);
// Create tree layout - will be configured dynamically
const tree = d3.tree()
.separation((a, b) => {
// Dynamic separation based on node content
const aSize = (a.data.children ? a.data.children.length : 1);
const bSize = (b.data.children ? b.data.children.length : 1);
return (a.parent === b.parent ? 1 : 2) * Math.max(1, (aSize + bSize) / 10);
});
// Create hierarchy
const root = d3.hierarchy(treeData);
root.x0 = 0;
root.y0 = 0;
// Collapse after the second level for large trees
let totalNodes = 0;
root.descendants().forEach((d, i) => {
d.id = i;
totalNodes++;
});
// Only auto-collapse if tree is large
if (totalNodes > 50) {
root.descendants().forEach((d) => {
if (d.depth && d.depth > 2) {
d._children = d.children;
d.children = null;
}
});
}
// Update node count
document.getElementById('node-count').textContent = `Nodes: ${totalNodes} (Visible: ${root.descendants().filter(d => d.children || !d.parent).length})`;
// Create tooltip
const tooltip = d3.select("body").append("div")
.attr("class", "tooltip")
.style("opacity", 0);
update(root);
function update(source) {
const duration = 750;
// Compute tree layout
const treeData = tree(root);
const nodes = treeData.descendants();
const links = treeData.links();
// Calculate dynamic dimensions
const dimensions = calculateTreeDimensions(root);
// Update tree size based on content
tree.size([dimensions.height, dimensions.width * 0.8]);
// Recompute layout with new size
tree(root);
// Normalize for fixed-depth with dynamic spacing
nodes.forEach(d => {
d.y = d.depth * dimensions.nodeWidth;
});
// Update SVG viewBox to accommodate all content
const xExtent = d3.extent(nodes, d => d.x);
const yExtent = d3.extent(nodes, d => d.y);
const padding = 100;
svg.attr("viewBox", [
yExtent[0] - padding,
xExtent[0] - padding,
yExtent[1] - yExtent[0] + padding * 2,
xExtent[1] - xExtent[0] + padding * 2
]);
// Update nodes
const node = g.selectAll("g.node")
.data(nodes, d => d.id);
const nodeEnter = node.enter().append("g")
.attr("class", d => `node ${d.data.type || ''}`)
.attr("transform", d => `translate(${source.y0},${source.x0})`)
.on("click", click)
.on("mouseover", (event, d) => {
if (d.data.fullText) {
tooltip.transition()
.duration(200)
.style("opacity", .9);
tooltip.html(d.data.fullText)
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 28) + "px");
}
})
.on("mouseout", (d) => {
tooltip.transition()
.duration(500)
.style("opacity", 0);
});
// Add rectangles for nodes
nodeEnter.append("rect")
.attr("width", 1e-6)
.attr("height", 1e-6)
.attr("x", -1)
.attr("y", -1);
// Add circles for collapse/expand
nodeEnter.append("circle")
.attr("r", 1e-6)
.style("fill", d => d._children ? "#667eea" : "#fff")
.style("display", d => d.children || d._children ? null : "none");
// Add text
nodeEnter.append("text")
.attr("dy", ".35em")
.attr("text-anchor", "middle")
.text(d => d.data.name)
.style("fill-opacity", 1e-6);
// Transition nodes to their new position
const nodeUpdate = nodeEnter.merge(node);
nodeUpdate.transition()
.duration(duration)
.attr("transform", d => `translate(${d.y},${d.x})`);
// Update rectangles
nodeUpdate.select("rect")
.attr("width", d => {
// Better text measurement
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
context.font = '14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
const metrics = context.measureText(d.data.name);
const textLength = metrics.width + 40; // Add padding
return Math.max(textLength, 120);
})
.attr("height", 40)
.attr("x", d => {
const textLength = d.data.name.length * 8 + 30;
return -Math.max(textLength, 120) / 2;
})
.attr("y", -18);
// Update circles
nodeUpdate.select("circle")
.attr("r", 6)
.attr("cx", d => {
const textLength = d.data.name.length * 8 + 30;
return Math.max(textLength, 120) / 2 + 10;
})
.style("fill", d => d._children ? "#667eea" : "#fff");
nodeUpdate.select("text")
.style("fill-opacity", 1);
// Remove exiting nodes
const nodeExit = node.exit().transition()
.duration(duration)
.attr("transform", d => `translate(${source.y},${source.x})`)
.remove();
nodeExit.select("rect")
.attr("width", 1e-6)
.attr("height", 1e-6);
nodeExit.select("circle")
.attr("r", 1e-6);
nodeExit.select("text")
.style("fill-opacity", 1e-6);
// Update links
const link = g.selectAll("path.link")
.data(links, d => d.target.id);
const linkEnter = link.enter().insert("path", "g")
.attr("class", "link")
.attr("d", d => {
const o = {x: source.x0, y: source.y0};
return diagonal({source: o, target: o});
});
const linkUpdate = linkEnter.merge(link);
linkUpdate.transition()
.duration(duration)
.attr("d", diagonal);
const linkExit = link.exit().transition()
.duration(duration)
.attr("d", d => {
const o = {x: source.x, y: source.y};
return diagonal({source: o, target: o});
})
.remove();
// Store old positions
nodes.forEach(d => {
d.x0 = d.x;
d.y0 = d.y;
});
// Update node count
const visibleNodes = root.descendants().filter(d => {
let parent = d.parent;
while (parent) {
if (!parent.children) return false;
parent = parent.parent;
}
return true;
}).length;
document.getElementById('node-count').textContent = `Nodes: ${totalNodes} (Visible: ${visibleNodes})`;
}
// Diagonal link generator
function diagonal(d) {
return `M ${d.source.y} ${d.source.x}
C ${(d.source.y + d.target.y) / 2} ${d.source.x},
${(d.source.y + d.target.y) / 2} ${d.target.x},
${d.target.y} ${d.target.x}`;
}
// Toggle children on click
function click(event, d) {
if (d.children) {
d._children = d.children;
d.children = null;
} else {
d.children = d._children;
d._children = null;
}
update(d);
}
// Utility functions
function resetZoom() {
autoFit();
}
function toggleFullscreen() {
const container = document.getElementById('mindmap-container');
container.classList.toggle('fullscreen');
// Recalculate dimensions
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
svg.attr("viewBox", [-newWidth / 2, -newHeight / 2, newWidth, newHeight]);
}
// Auto-fit the content initially
function autoFit() {
const bounds = g.node().getBBox();
const fullWidth = bounds.width;
const fullHeight = bounds.height;
const width = containerWidth;
const height = containerHeight;
const midX = bounds.x + fullWidth / 2;
const midY = bounds.y + fullHeight / 2;
const scale = 0.9 / Math.max(fullWidth / width, fullHeight / height);
const translate = [width / 2 - scale * midX, height / 2 - scale * midY];
svg.transition()
.duration(750)
.call(zoom.transform, d3.zoomIdentity
.translate(translate[0], translate[1])
.scale(scale));
}
// Center the tree initially
setTimeout(autoFit, 100);
// Expand all nodes
function expandAll() {
root.descendants().forEach(d => {
if (d._children) {
d.children = d._children;
d._children = null;
}
});
update(root);
setTimeout(autoFit, 800);
}
// Collapse all nodes except root
function collapseAll() {
root.descendants().forEach(d => {
if (d.depth > 0 && d.children) {
d._children = d.children;
d.children = null;
}
});
update(root);
setTimeout(autoFit, 800);
}
</script>
{% endblock %}

View File

@@ -0,0 +1 @@
{% extends "layout.html" %}{% block content %}<div>Test</div>{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "layout.html" %}
{% block content %}
<div class="note-mindmap-container">
<h1>Mind Map Test - {{ note.title }}</h1>
<p>If you see this, the template is working.</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "layout.html" %}
{% block content %}
<div class="note-mindmap-container">
<h1>Mind Map Test - {{ note.title }}</h1>
<p>If you see this, the template is working.</p>
</div>
{% endblock %}

1469
templates/note_view.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ note.title }}</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="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css">
<style>
body {
background: #f9fafb;
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
.public-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem 0;
margin-bottom: 2rem;
}
.header-content {
max-width: 900px;
margin: 0 auto;
padding: 0 2rem;
}
.note-title {
font-size: 2rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
}
.note-meta {
font-size: 0.875rem;
opacity: 0.9;
}
.content-container {
max-width: 900px;
margin: 0 auto;
padding: 0 2rem 2rem;
}
.content-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
padding: 3rem;
margin-bottom: 2rem;
}
.markdown-content {
line-height: 1.8;
color: #333;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
}
.markdown-content h1 { font-size: 2rem; }
.markdown-content h2 { font-size: 1.75rem; }
.markdown-content h3 { font-size: 1.5rem; }
.markdown-content h4 { font-size: 1.25rem; }
.markdown-content h5 { font-size: 1.125rem; }
.markdown-content h6 { font-size: 1rem; }
.markdown-content p {
margin-bottom: 1rem;
}
.markdown-content ul,
.markdown-content ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.markdown-content li {
margin-bottom: 0.5rem;
}
.markdown-content code {
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
.markdown-content pre {
background: #f3f4f6;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1rem;
}
.markdown-content pre code {
background: none;
padding: 0;
}
.markdown-content blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
margin-left: 0;
margin-bottom: 1rem;
color: #6b7280;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.markdown-content th,
.markdown-content td {
border: 1px solid #e5e7eb;
padding: 0.75rem;
text-align: left;
}
.markdown-content th {
background: #f9fafb;
font-weight: 600;
}
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
}
.markdown-content a {
color: #667eea;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.share-info {
background: #f3f4f6;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
font-size: 0.875rem;
color: #6b7280;
}
.share-info-item {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.share-info-item:last-child {
margin-bottom: 0;
}
.download-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #e5e7eb;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
border: 2px solid transparent;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
cursor: pointer;
}
.btn-secondary {
background: white;
color: #667eea;
border-color: #e5e7eb;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #667eea;
}
.footer {
text-align: center;
padding: 2rem;
color: #6b7280;
font-size: 0.875rem;
}
.footer a {
color: #667eea;
text-decoration: none;
}
</style>
</head>
<body>
<div class="public-header">
<div class="header-content">
<h1 class="note-title">{{ note.title }}</h1>
<div class="note-meta">
Shared by {{ note.created_by.username }} •
Created {{ note.created_at|format_date }}
{% if note.updated_at > note.created_at %}
• Updated {{ note.updated_at|format_date }}
{% endif %}
</div>
</div>
</div>
<div class="content-container">
{% if share %}
<div class="share-info">
<div class="share-info-item">
<span>👁️</span>
<span>Views: {{ share.view_count }}{% if share.max_views %} / {{ share.max_views }}{% endif %}</span>
</div>
{% if share.expires_at %}
<div class="share-info-item">
<span></span>
<span>Expires: {{ share.expires_at|format_datetime }}</span>
</div>
{% endif %}
</div>
{% endif %}
<div class="content-card">
<div class="markdown-content">
{{ note.render_html()|safe }}
</div>
<div class="download-buttons">
<a href="{{ url_for('notes_public.download_shared_note', token=share.token, format='md') }}"
class="btn btn-secondary">
<span>📄</span>
Download as Markdown
</a>
<a href="{{ url_for('notes_public.download_shared_note', token=share.token, format='html') }}"
class="btn btn-secondary">
<span>🌐</span>
Download as HTML
</a>
<a href="{{ url_for('notes_public.download_shared_note', token=share.token, format='pdf') }}"
class="btn btn-secondary">
<span>📑</span>
Download as PDF
</a>
</div>
</div>
</div>
<div class="footer">
<p>Powered by TimeTrack Notes</p>
</div>
<!-- Syntax highlighting -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Protected Note - {{ note_title }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<style>
body {
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
.password-container {
background: white;
border-radius: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
padding: 3rem;
max-width: 400px;
width: 100%;
text-align: center;
}
.lock-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: #1f2937;
}
.note-title {
color: #6b7280;
margin-bottom: 2rem;
font-size: 1rem;
}
.form-group {
margin-bottom: 1.5rem;
text-align: left;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #374151;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.btn {
width: 100%;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
.error-message {
background: #fee;
color: #dc2626;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.spinner {
display: none;
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="password-container">
<div class="lock-icon">🔒</div>
<h1>Password Protected Note</h1>
<p class="note-title">{{ note_title }}</p>
<form id="password-form">
<div id="error-container"></div>
<div class="form-group">
<label for="password" class="form-label">Enter Password</label>
<input type="password"
id="password"
name="password"
class="form-control"
required
autofocus
placeholder="Enter the password to view this note">
</div>
<button type="submit" class="btn btn-primary" id="submit-btn">
<span id="btn-text">Unlock Note</span>
<div class="spinner" id="spinner"></div>
</button>
</form>
</div>
<script>
document.getElementById('password-form').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
const submitBtn = document.getElementById('submit-btn');
const btnText = document.getElementById('btn-text');
const spinner = document.getElementById('spinner');
const errorContainer = document.getElementById('error-container');
// Clear previous errors
errorContainer.innerHTML = '';
// Show loading state
submitBtn.disabled = true;
btnText.style.display = 'none';
spinner.style.display = 'block';
try {
const response = await fetch(`/public/notes/{{ token }}/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `password=${encodeURIComponent(password)}`
});
const data = await response.json();
if (data.success) {
window.location.href = data.redirect;
} else {
errorContainer.innerHTML = '<div class="error-message">Invalid password. Please try again.</div>';
submitBtn.disabled = false;
btnText.style.display = 'inline';
spinner.style.display = 'none';
}
} catch (error) {
errorContainer.innerHTML = '<div class="error-message">An error occurred. Please try again.</div>';
submitBtn.disabled = false;
btnText.style.display = 'inline';
spinner.style.display = 'none';
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,642 @@
{% extends "layout.html" %}
{% block content %}
<div class="notes-folders-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon"><i class="ti ti-folder"></i></span>
Note Folders
</h1>
<p class="page-subtitle">Organize your notes with folders</p>
</div>
<div class="header-actions">
<button type="button" class="btn btn-success" onclick="showCreateFolderModal()">
<span class="icon"><i class="ti ti-plus"></i></span>
Create Folder
</button>
<a href="{{ url_for('notes.notes_list') }}" class="btn btn-secondary">
<span class="icon"><i class="ti ti-arrow-left"></i></span>
Back to Notes
</a>
</div>
</div>
</div>
<div class="folders-layout">
<!-- Folder Tree -->
<div class="folder-tree-panel">
<h3>Folder Structure</h3>
<div class="folder-tree" id="folder-tree">
{{ render_folder_tree(folder_tree)|safe }}
</div>
</div>
<!-- Folder Details -->
<div class="folder-details-panel">
<div id="folder-info">
<p class="text-muted">Select a folder to view details</p>
</div>
</div>
</div>
</div>
<!-- Create/Edit Folder Modal -->
<div class="modal" id="folderModal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">Create New Folder</h3>
<button type="button" class="close-btn" onclick="closeFolderModal()">&times;</button>
</div>
<div class="modal-body">
<form id="folderForm">
<div class="form-group">
<label for="folderName">Folder Name</label>
<input type="text" id="folderName" name="name" class="form-control" required
placeholder="e.g., Projects, Meeting Notes">
</div>
<div class="form-group">
<label for="parentFolder">Parent Folder</label>
<select id="parentFolder" name="parent" class="form-control">
<option value="">Root (Top Level)</option>
{% for folder in all_folders %}
<option value="{{ folder }}">{{ folder }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="folderDescription">Description (Optional)</label>
<textarea id="folderDescription" name="description" class="form-control"
rows="3" placeholder="What kind of notes will go in this folder?"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeFolderModal()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveFolder()">Save Folder</button>
</div>
</div>
</div>
<style>
.notes-folders-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Page Header - Time Tracking style */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
.header-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
/* Button styles */
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
border: 2px solid transparent;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
cursor: pointer;
}
.btn-success {
background: #10b981;
color: white;
border: none;
}
.btn-success:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(16, 185, 129, 0.3);
}
.btn-secondary {
background: white;
color: #667eea;
border-color: #e5e7eb;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #667eea;
}
.btn .icon {
font-size: 1.1em;
}
.folders-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
min-height: 600px;
}
.folder-tree-panel,
.folder-details-panel {
background: white;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
.folder-tree-panel h3,
.folder-details-panel h3 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.2rem;
color: #333;
}
/* Folder Tree Styles */
.folder-tree {
font-size: 0.95rem;
}
.folder-item {
position: relative;
margin: 0.25rem 0;
}
.folder-item.has-children > .folder-content::before {
content: "▶";
position: absolute;
left: -15px;
transition: transform 0.2s;
cursor: pointer;
}
.folder-item.has-children.expanded > .folder-content::before {
transform: rotate(90deg);
}
.folder-content {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
margin-left: 1rem;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.folder-content:hover {
background: #f8f9fa;
}
.folder-content.selected {
background: #e3f2fd;
font-weight: 500;
}
.folder-icon {
margin-right: 0.5rem;
}
.folder-name {
flex: 1;
}
.folder-count {
font-size: 0.85rem;
color: #666;
margin-left: 0.5rem;
}
.folder-children {
margin-left: 1.5rem;
display: none;
}
.folder-item.expanded > .folder-children {
display: block;
}
/* Folder Details */
.folder-details {
padding: 1rem;
}
.folder-details h4 {
margin-top: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.folder-path {
font-size: 0.9rem;
color: #666;
margin-bottom: 1rem;
font-family: monospace;
background: #f8f9fa;
padding: 0.5rem;
border-radius: 4px;
}
.folder-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin: 1.5rem 0;
}
.stat-box {
background: #f8f9fa;
padding: 1rem;
border-radius: 6px;
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: #333;
}
.stat-label {
font-size: 0.85rem;
color: #666;
margin-top: 0.25rem;
}
.folder-actions {
margin-top: 2rem;
display: flex;
gap: 0.5rem;
}
.notes-preview {
margin-top: 2rem;
}
.notes-preview h5 {
margin-bottom: 1rem;
color: #333;
}
.note-preview-item {
padding: 0.75rem;
background: #f8f9fa;
border-radius: 4px;
margin-bottom: 0.5rem;
transition: background 0.2s;
}
.note-preview-item:hover {
background: #e9ecef;
}
.note-preview-title {
font-weight: 500;
color: #333;
text-decoration: none;
display: block;
}
.note-preview-date {
font-size: 0.8rem;
color: #666;
margin-top: 0.25rem;
}
/* Modal Styles */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
width: 90%;
max-width: 500px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #dee2e6;
}
.modal-header h3 {
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #333;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* Responsive */
@media (max-width: 768px) {
.folders-layout {
grid-template-columns: 1fr;
}
.folder-tree-panel {
max-height: 300px;
overflow-y: auto;
}
}
</style>
<script>
let selectedFolder = null;
function selectFolder(folderPath) {
// Remove previous selection
document.querySelectorAll('.folder-content').forEach(el => {
el.classList.remove('selected');
});
// Add selection to clicked folder
event.currentTarget.classList.add('selected');
selectedFolder = folderPath;
// Load folder details
loadFolderDetails(folderPath);
}
function toggleFolder(event, folderPath) {
event.stopPropagation();
const folderItem = event.currentTarget.closest('.folder-item');
folderItem.classList.toggle('expanded');
}
function loadFolderDetails(folderPath) {
fetch(`/api/notes/folder-details?path=${encodeURIComponent(folderPath)}`)
.then(response => response.json())
.then(data => {
const detailsHtml = `
<div class="folder-details">
<h4><span class="folder-icon"><i class="ti ti-folder"></i></span> ${data.name}</h4>
<div class="folder-path">${data.path}</div>
<div class="folder-stats">
<div class="stat-box">
<div class="stat-value">${data.note_count}</div>
<div class="stat-label">Notes</div>
</div>
<div class="stat-box">
<div class="stat-value">${data.subfolder_count}</div>
<div class="stat-label">Subfolders</div>
</div>
</div>
<div class="folder-actions">
<a href="/notes?folder=${encodeURIComponent(data.path)}" class="btn btn-sm btn-primary">
View Notes
</a>
<button type="button" class="btn btn-sm btn-info" onclick="editFolder('${data.path}')">
Rename
</button>
${data.note_count === 0 && data.subfolder_count === 0 ?
`<button type="button" class="btn btn-sm btn-danger" onclick="deleteFolder('${data.path}')">
Delete
</button>` : ''}
</div>
${data.recent_notes.length > 0 ? `
<div class="notes-preview">
<h5>Recent Notes</h5>
${data.recent_notes.map(note => `
<div class="note-preview-item">
<a href="/notes/${note.slug}" class="note-preview-title">${note.title}</a>
<div class="note-preview-date">${note.updated_at}</div>
</div>
`).join('')}
</div>
` : ''}
</div>
`;
document.getElementById('folder-info').innerHTML = detailsHtml;
})
.catch(error => {
console.error('Error loading folder details:', error);
});
}
function showCreateFolderModal() {
document.getElementById('modalTitle').textContent = 'Create New Folder';
document.getElementById('folderForm').reset();
document.getElementById('folderModal').style.display = 'flex';
}
function closeFolderModal() {
document.getElementById('folderModal').style.display = 'none';
}
function saveFolder() {
const formData = new FormData(document.getElementById('folderForm'));
const data = {
name: formData.get('name'),
parent: formData.get('parent'),
description: formData.get('description')
};
// Check if we're editing or creating
const modalTitle = document.getElementById('modalTitle').textContent;
const isEditing = modalTitle.includes('Edit');
if (isEditing && selectedFolder) {
// Rename folder
fetch('/api/notes/folders', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
old_path: selectedFolder,
new_name: data.name
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert(result.message);
window.location.reload();
} else {
alert('Error: ' + result.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error renaming folder');
});
} else {
// Create new folder
fetch('/api/notes/folders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert(result.message);
window.location.reload();
} else {
alert('Error: ' + result.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error creating folder');
});
}
}
function editFolder(folderPath) {
const parts = folderPath.split('/');
const folderName = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join('/');
document.getElementById('modalTitle').textContent = 'Edit Folder';
document.getElementById('folderName').value = folderName;
document.getElementById('parentFolder').value = parentPath;
document.getElementById('folderModal').style.display = 'flex';
}
function deleteFolder(folderPath) {
if (confirm(`Are you sure you want to delete the folder "${folderPath}"? This cannot be undone.`)) {
fetch(`/api/notes/folders?path=${encodeURIComponent(folderPath)}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert(result.message);
window.location.reload();
} else {
alert('Error: ' + result.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error deleting folder');
});
}
}
// Close modal when clicking outside
document.getElementById('folderModal').addEventListener('click', function(e) {
if (e.target === this) {
closeFolderModal();
}
});
</script>
{% endblock %}
{% macro render_folder_tree(tree, level=0) %}
{% for folder, children in tree.items() %}
<div class="folder-item {% if children %}has-children{% endif %}" data-folder="{{ folder }}">
<div class="folder-content" onclick="selectFolder('{{ folder }}')">
{% if children %}
<span onclick="toggleFolder(event, '{{ folder }}')" style="position: absolute; left: -15px; cursor: pointer;"></span>
{% endif %}
<span class="folder-icon"><i class="ti ti-folder"></i></span>
<span class="folder-name">{{ folder.split('/')[-1] }}</span>
<span class="folder-count">({{ folder_counts.get(folder, 0) }})</span>
</div>
{% if children %}
<div class="folder-children">
{{ render_folder_tree(children, level + 1)|safe }}
</div>
{% endif %}
</div>
{% endfor %}
{% endmacro %}

1089
templates/notes_list.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -20,11 +20,11 @@
<span class="stat-text">{{ user.company.name if user.company else 'No Company' }}</span>
</div>
<div class="stat-badge">
<span class="stat-icon">👥</span>
<span class="stat-icon"><i class="ti ti-users"></i></span>
<span class="stat-text">{{ user.team.name if user.team else 'No Team' }}</span>
</div>
<div class="stat-badge">
<span class="stat-icon">👤</span>
<span class="stat-icon"><i class="ti ti-user"></i></span>
<span class="stat-text">{{ user.role.value if user.role else 'Team Member' }}</span>
</div>
</div>
@@ -37,7 +37,7 @@
<div class="flash-messages">
{% for category, message in messages %}
<div class="alert alert-{{ category }}">
<span class="alert-icon">{% if category == 'success' %}✓{% elif category == 'error' %}✕{% else %}{% endif %}</span>
<span class="alert-icon">{% if category == 'success' %}<i class="ti ti-check"></i>{% elif category == 'error' %}<i class="ti ti-x"></i>{% else %}<i class="ti ti-info-circle"></i>{% endif %}</span>
{{ message }}
</div>
{% endfor %}
@@ -53,7 +53,7 @@
<div class="card avatar-card">
<div class="card-header">
<h2 class="card-title">
<span class="icon">🖼️</span>
<span class="icon"><i class="ti ti-photo"></i></span>
Profile Picture
</h2>
</div>
@@ -65,15 +65,15 @@
<div class="avatar-controls">
<div class="control-tabs">
<button class="tab-btn active" data-tab="default">
<span class="tab-icon">👤</span>
<span class="tab-icon"><i class="ti ti-user"></i></span>
Default
</button>
<button class="tab-btn" data-tab="upload">
<span class="tab-icon">📤</span>
<span class="tab-icon"><i class="ti ti-upload"></i></span>
Upload
</button>
<button class="tab-btn" data-tab="url">
<span class="tab-icon">🔗</span>
<span class="tab-icon"><i class="ti ti-link"></i></span>
URL
</button>
</div>
@@ -81,7 +81,7 @@
<!-- Default Avatar Tab -->
<div class="tab-content active" id="default-tab">
<div class="info-message">
<span class="info-icon">💡</span>
<span class="info-icon"><i class="ti ti-bulb"></i></span>
<p>Your default avatar is automatically generated based on your username.</p>
</div>
<button type="button" class="btn btn-outline" onclick="resetAvatar()">
@@ -95,7 +95,7 @@
<form method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data" class="modern-form">
<div class="upload-area">
<label for="avatar_file" class="upload-label">
<div class="upload-icon">📁</div>
<div class="upload-icon"><i class="ti ti-folder-upload"></i></div>
<div class="upload-text">Drop image here or click to browse</div>
<div class="upload-hint">Max 5MB • JPG, PNG, GIF, WebP</div>
<div class="file-name" id="file-name"></div>
@@ -107,7 +107,7 @@
<img id="upload-preview-img" src="" alt="Preview">
</div>
<button type="submit" class="btn btn-primary" id="upload-btn" disabled>
<span class="icon"></span>
<span class="icon"><i class="ti ti-upload"></i></span>
Upload Avatar
</button>
</form>
@@ -124,7 +124,7 @@
<span class="form-hint">Enter a direct link to an image</span>
</div>
<button type="submit" class="btn btn-primary">
<span class="icon"></span>
<span class="icon"><i class="ti ti-check"></i></span>
Set Avatar URL
</button>
</form>
@@ -137,7 +137,7 @@
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"></span>
<span class="icon"><i class="ti ti-info-circle"></i></span>
Account Information
</h2>
</div>
@@ -175,7 +175,7 @@
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon">✉️</span>
<span class="icon"><i class="ti ti-mail"></i></span>
Email Settings
</h2>
</div>
@@ -190,7 +190,7 @@
{% if user.email and not user.is_verified %}
<div class="alert alert-warning">
<span class="alert-icon">⚠️</span>
<span class="alert-icon"><i class="ti ti-alert-triangle"></i></span>
<div>
<p>Your email address is not verified.</p>
<a href="{{ url_for('profile') }}" class="btn btn-sm btn-warning">Send Verification Email</a>
@@ -198,14 +198,14 @@
</div>
{% elif not user.email %}
<div class="alert alert-info">
<span class="alert-icon"></span>
<span class="alert-icon"><i class="ti ti-info-circle"></i></span>
<p>Adding an email enables account recovery and notifications.</p>
</div>
{% endif %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span class="icon"></span>
<span class="icon"><i class="ti ti-check"></i></span>
{% if user.email %}Update{% else %}Add{% endif %} Email
</button>
</div>
@@ -217,7 +217,7 @@
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon">🔒</span>
<span class="icon"><i class="ti ti-lock"></i></span>
Security Settings
</h2>
</div>
@@ -251,7 +251,7 @@
<div class="form-actions">
<button type="submit" class="btn btn-warning">
<span class="icon">🔑</span>
<span class="icon"><i class="ti ti-key"></i></span>
Change Password
</button>
</div>
@@ -264,7 +264,7 @@
<div class="tfa-status">
{% if user.two_factor_enabled %}
<div class="status-indicator enabled">
<span class="status-icon">🛡️</span>
<span class="status-icon"><i class="ti ti-shield"></i></span>
<div>
<div class="status-text">Enabled</div>
<div class="status-description">Your account is protected with 2FA</div>
@@ -279,13 +279,13 @@
class="form-control" placeholder="Enter your password to disable 2FA" required>
</div>
<button type="submit" class="btn btn-danger">
<span class="icon"></span>
<span class="icon"><i class="ti ti-x"></i></span>
Disable 2FA
</button>
</form>
{% else %}
<div class="status-indicator disabled">
<span class="status-icon">⚠️</span>
<span class="status-icon"><i class="ti ti-alert-triangle"></i></span>
<div>
<div class="status-text">Disabled</div>
<div class="status-description">Add extra security to your account</div>
@@ -293,7 +293,7 @@
</div>
<a href="{{ url_for('setup_2fa') }}" class="btn btn-success">
<span class="icon"></span>
<span class="icon"><i class="ti ti-check"></i></span>
Enable 2FA
</a>
{% endif %}

View File

@@ -234,7 +234,7 @@
</div>
<div class="verification-notice">
<p>💡 You can register without an email, but we recommend adding one for account recovery.</p>
<p><i class="ti ti-bulb"></i> You can register without an email, but we recommend adding one for account recovery.</p>
</div>
</form>
</div>

View File

@@ -91,7 +91,7 @@
<span>Email: <strong>{{ invitation.email }}</strong></span>
</div>
<div class="detail-item">
<span class="detail-icon">👥</span>
<span class="detail-icon"><i class="ti ti-users"></i></span>
<span>Invited by: <strong>{{ invitation.invited_by.username }}</strong></span>
</div>
</div>
@@ -150,7 +150,7 @@
</div>
<div class="verification-notice">
<p> Your email is pre-verified through this invitation</p>
<p><i class="ti ti-check"></i> Your email is pre-verified through this invitation</p>
</div>
</form>
</div>

View File

@@ -13,17 +13,17 @@
<!-- Info Message -->
{% if is_initial_setup %}
<div class="info-message">
<h3>🎉 Let's Get Started!</h3>
<h3><i class="ti ti-confetti"></i> Let's Get Started!</h3>
<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>
<h3><i class="ti ti-building"></i> New Company Setup</h3>
<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">
<h3>⚠️ Access Denied</h3>
<h3><i class="ti ti-alert-triangle"></i> Access Denied</h3>
<p>You do not have permission to create new companies.</p>
<a href="{{ url_for('home') }}" class="btn btn-secondary">Return Home</a>
</div>
@@ -102,11 +102,11 @@
<div class="form-actions">
{% if is_super_admin %}
<a href="{{ url_for('companies.admin_company') }}" class="btn btn-secondary">
Back to Dashboard
<i class="ti ti-arrow-left"></i> Back to Dashboard
</a>
{% endif %}
<button type="submit" class="btn btn-success">
🚀 {% if is_initial_setup %}Create Company & Admin Account{% else %}Create New Company{% endif %}
<i class="ti ti-rocket"></i> {% if is_initial_setup %}Create Company & Admin Account{% else %}Create New Company{% endif %}
</button>
</div>
</form>

View File

@@ -1,43 +1,56 @@
{% extends "layout.html" %}
{% block content %}
<div class="management-container sprint-management-container">
<div class="page-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 class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon"><i class="ti ti-run"></i></span>
Sprint Management
</h1>
<p class="page-subtitle">Manage sprints and track progress</p>
</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 class="header-actions">
<button id="refresh-sprints" class="btn btn-secondary">
<i class="ti ti-refresh"></i>
Refresh
</button>
<button id="add-sprint-btn" class="btn btn-primary">
<i class="ti ti-plus"></i>
New Sprint
</button>
</div>
</div>
</div>
<!-- Filter Section -->
<div class="filter-section">
<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>
</div>
<!-- Sprint Statistics -->
<div class="management-stats sprint-stats">
<div class="stats-section">
<div class="stat-card">
<div class="stat-number" id="total-sprints">0</div>
<div class="stat-value" 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-value" 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-value" 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-value" id="total-tasks">0</div>
<div class="stat-label">Total Tasks</div>
</div>
</div>
@@ -112,7 +125,7 @@
<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>
<button type="button" class="calendar-picker-btn" onclick="openCalendarPicker('sprint-start-date')" title="Open calendar"><i class="ti ti-calendar"></i></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>
@@ -121,7 +134,7 @@
<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>
<button type="button" class="calendar-picker-btn" onclick="openCalendarPicker('sprint-end-date')" title="Open calendar"><i class="ti ti-calendar"></i></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>
@@ -143,38 +156,23 @@
<!-- Styles -->
<style>
.sprint-management-container {
padding: 1rem;
max-width: 100%;
margin: 0 auto;
}
/* Container styles - using default page spacing */
.sprint-header {
display: flex;
justify-content: space-between;
align-items: center;
/* Header styles handled by common page-header classes */
.filter-section {
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;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
}
.view-switcher {
display: flex;
background: #f8f9fa;
background: white;
border-radius: 6px;
padding: 2px;
width: fit-content;
}
.view-btn {
@@ -198,37 +196,7 @@
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;
}
/* Statistics styles handled by common stats-section classes */
.sprint-grid {
display: grid;
@@ -731,7 +699,7 @@ class SprintManager {
</div>
<div class="sprint-dates">
📅 ${formatUserDate(sprint.start_date)} - ${formatUserDate(sprint.end_date)}
<i class="ti ti-calendar"></i> ${formatUserDate(sprint.start_date)} - ${formatUserDate(sprint.end_date)}
${sprint.days_remaining > 0 ? `(${sprint.days_remaining} days left)` : ''}
</div>

View File

@@ -1,164 +1,213 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="header-section">
<h1>{{ "Edit" if announcement else "Create" }} Announcement</h1>
<p class="subtitle">{{ "Update" if announcement else "Create new" }} system announcement for users</p>
<a href="{{ url_for('announcements.index') }}" class="btn btn-secondary">
← Back to Announcements
</a>
<div class="announcement-form-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon"><i class="ti ti-speakerphone"></i></span>
{{ "Edit" if announcement else "Create" }} Announcement
</h1>
<p class="page-subtitle">{{ "Update" if announcement else "Create new" }} system announcement for users</p>
</div>
<div class="header-actions">
<a href="{{ url_for('announcements.index') }}" class="btn btn-secondary">
<i class="ti ti-arrow-left"></i>
Back to Announcements
</a>
</div>
</div>
</div>
<div class="form-section">
<form method="POST" class="announcement-form">
<div class="form-group">
<label for="title">Title</label>
<input type="text"
id="title"
name="title"
value="{{ announcement.title if announcement else '' }}"
required
maxlength="200"
class="form-control">
<!-- Main Form -->
<form method="POST" class="announcement-form">
<!-- Basic Information -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-forms"></i></span>
Basic Information
</h2>
</div>
<div class="form-group">
<label for="content">Content</label>
<textarea id="content"
name="content"
required
rows="6"
class="form-control">{{ announcement.content if announcement else '' }}</textarea>
<small class="form-text">You can use HTML formatting in the content.</small>
</div>
<div class="form-row">
<div class="card-body">
<div class="form-group">
<label for="announcement_type">Type</label>
<select id="announcement_type" name="announcement_type" class="form-control">
<option value="info" {{ 'selected' if announcement and announcement.announcement_type == 'info' else '' }}>Info</option>
<option value="warning" {{ 'selected' if announcement and announcement.announcement_type == 'warning' else '' }}>Warning</option>
<option value="success" {{ 'selected' if announcement and announcement.announcement_type == 'success' else '' }}>Success</option>
<option value="danger" {{ 'selected' if announcement and announcement.announcement_type == 'danger' else '' }}>Danger</option>
</select>
<label for="title">Title</label>
<input type="text"
id="title"
name="title"
value="{{ announcement.title if announcement else '' }}"
required
maxlength="200"
class="form-control"
placeholder="Enter announcement title">
</div>
<div class="form-group">
<label class="checkbox-container">
<input type="checkbox"
name="is_urgent"
{{ 'checked' if announcement and announcement.is_urgent else '' }}>
<span class="checkmark"></span>
Mark as Urgent
</label>
<label for="content">Content</label>
<textarea id="content"
name="content"
required
rows="6"
class="form-control"
placeholder="Enter announcement content">{{ announcement.content if announcement else '' }}</textarea>
<small class="form-text">You can use HTML formatting in the content.</small>
</div>
<div class="form-group">
<label class="checkbox-container">
<input type="checkbox"
name="is_active"
{{ 'checked' if not announcement or announcement.is_active else '' }}>
<span class="checkmark"></span>
Active
</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="announcement_type">Type</label>
<select id="announcement_type" name="announcement_type" class="form-control">
<option value="info" {{ 'selected' if announcement and announcement.announcement_type == 'info' else '' }}>
<i class="ti ti-info-circle"></i> Info
</option>
<option value="warning" {{ 'selected' if announcement and announcement.announcement_type == 'warning' else '' }}>
<i class="ti ti-alert-triangle"></i> Warning
</option>
<option value="success" {{ 'selected' if announcement and announcement.announcement_type == 'success' else '' }}>
<i class="ti ti-circle-check"></i> Success
</option>
<option value="danger" {{ 'selected' if announcement and announcement.announcement_type == 'danger' else '' }}>
<i class="ti ti-alert-circle"></i> Danger
</option>
</select>
</div>
<div class="form-section">
<h3>Scheduling</h3>
<div class="form-row">
<div class="form-group">
<label for="start_date">Start Date/Time (Optional)</label>
<input type="datetime-local"
id="start_date"
name="start_date"
value="{{ announcement.start_date.strftime('%Y-%m-%dT%H:%M') if announcement and announcement.start_date else '' }}"
class="form-control">
<small class="form-text">Leave empty to show immediately</small>
</div>
<div class="form-group">
<label>Options</label>
<div class="checkbox-group">
<label class="toggle-label">
<input type="checkbox"
name="is_urgent"
{{ 'checked' if announcement and announcement.is_urgent else '' }}>
<span class="toggle-slider"></span>
<span class="toggle-text">Mark as Urgent</span>
</label>
<div class="form-group">
<label for="end_date">End Date/Time (Optional)</label>
<input type="datetime-local"
id="end_date"
name="end_date"
value="{{ announcement.end_date.strftime('%Y-%m-%dT%H:%M') if announcement and announcement.end_date else '' }}"
class="form-control">
<small class="form-text">Leave empty for no expiry</small>
<label class="toggle-label">
<input type="checkbox"
name="is_active"
{{ 'checked' if not announcement or announcement.is_active else '' }}>
<span class="toggle-slider"></span>
<span class="toggle-text">Active</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="form-section">
<h3>Targeting</h3>
<div class="form-row">
<!-- Scheduling -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-calendar-event"></i></span>
Scheduling
</h2>
</div>
<div class="card-body">
<div class="form-row">
<div class="form-group">
<label for="start_date">Start Date/Time</label>
<input type="datetime-local"
id="start_date"
name="start_date"
value="{{ announcement.start_date.strftime('%Y-%m-%dT%H:%M') if announcement and announcement.start_date else '' }}"
class="form-control">
<small class="form-text">Leave empty to show immediately</small>
</div>
<div class="form-group">
<label for="end_date">End Date/Time</label>
<input type="datetime-local"
id="end_date"
name="end_date"
value="{{ announcement.end_date.strftime('%Y-%m-%dT%H:%M') if announcement and announcement.end_date else '' }}"
class="form-control">
<small class="form-text">Leave empty for no expiry</small>
</div>
</div>
</div>
</div>
<!-- Targeting -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-target"></i></span>
Targeting
</h2>
</div>
<div class="card-body">
<div class="form-group">
<label class="checkbox-container">
<label class="toggle-label main-toggle">
<input type="checkbox"
name="target_all_users"
id="target_all_users"
{{ 'checked' if not announcement or announcement.target_all_users else '' }}
onchange="toggleTargeting()">
<span class="checkmark"></span>
Target All Users
<span class="toggle-slider"></span>
<span class="toggle-text">Target All Users</span>
</label>
<small class="form-text">When enabled, announcement will be shown to all users regardless of role or company</small>
</div>
</div>
<div id="targeting_options" style="display: {{ 'none' if not announcement or announcement.target_all_users else 'block' }};">
<div class="form-row">
<div class="form-group">
<label>Target Roles</label>
<div class="checkbox-list">
{% set selected_roles = [] %}
{% if announcement and announcement.target_roles %}
{% set selected_roles = announcement.target_roles|from_json %}
{% endif %}
{% for role in roles %}
<label class="checkbox-container">
<input type="checkbox"
name="target_roles"
value="{{ role }}"
{{ 'checked' if role in selected_roles else '' }}>
<span class="checkmark"></span>
{{ role }}
</label>
{% endfor %}
<div id="targeting_options" style="display: {{ 'none' if not announcement or announcement.target_all_users else 'block' }};">
<div class="form-row">
<div class="form-group">
<label><i class="ti ti-user-check"></i> Target Roles</label>
<div class="checkbox-list">
{% set selected_roles = [] %}
{% if announcement and announcement.target_roles %}
{% set selected_roles = announcement.target_roles|from_json %}
{% endif %}
{% for role in roles %}
<label class="checkbox-item">
<input type="checkbox"
name="target_roles"
value="{{ role }}"
{{ 'checked' if role in selected_roles else '' }}>
<span class="checkbox-custom"></span>
<span class="checkbox-label">{{ role }}</span>
</label>
{% endfor %}
</div>
</div>
</div>
<div class="form-group">
<label>Target Companies</label>
<div class="checkbox-list">
{% set selected_companies = [] %}
{% if announcement and announcement.target_companies %}
{% set selected_companies = announcement.target_companies|from_json %}
{% endif %}
{% for company in companies %}
<label class="checkbox-container">
<input type="checkbox"
name="target_companies"
value="{{ company.id }}"
{{ 'checked' if company.id in selected_companies else '' }}>
<span class="checkmark"></span>
{{ company.name }}
</label>
{% endfor %}
<div class="form-group">
<label><i class="ti ti-building"></i> Target Companies</label>
<div class="checkbox-list">
{% set selected_companies = [] %}
{% if announcement and announcement.target_companies %}
{% set selected_companies = announcement.target_companies|from_json %}
{% endif %}
{% for company in companies %}
<label class="checkbox-item">
<input type="checkbox"
name="target_companies"
value="{{ company.id }}"
{{ 'checked' if company.id in selected_companies else '' }}>
<span class="checkbox-custom"></span>
<span class="checkbox-label">{{ company.name }}</span>
</label>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{{ "Update" if announcement else "Create" }} Announcement
</button>
<a href="{{ url_for('announcements.index') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="ti ti-device-floppy"></i>
{{ "Update" if announcement else "Create" }} Announcement
</button>
<a href="{{ url_for('announcements.index') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<script>
@@ -180,155 +229,334 @@ document.addEventListener('DOMContentLoaded', function() {
</script>
<style>
.header-section {
margin-bottom: 2rem;
/* Container */
.announcement-form-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.subtitle {
color: #6c757d;
margin-bottom: 1rem;
}
.form-section {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
/* Page Header */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Cards */
.card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
margin-bottom: 1.5rem;
overflow: hidden;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.card-header {
background: #f8f9fa;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: #1f2937;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-title .icon {
font-size: 1.5rem;
}
.card-body {
padding: 1.5rem;
}
/* Form */
.announcement-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-section h3 {
margin-top: 0;
margin-bottom: 1rem;
color: #495057;
font-size: 1.2rem;
border-bottom: 1px solid #e9ecef;
padding-bottom: 0.5rem;
gap: 0;
}
.form-group {
margin-bottom: 1rem;
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
font-weight: 500;
color: #495057;
color: #374151;
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ced4da;
border-radius: 4px;
padding: 0.625rem 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
box-sizing: border-box;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
textarea.form-control {
resize: vertical;
min-height: 120px;
}
.form-text {
display: block;
margin-top: 0.25rem;
color: #6c757d;
font-size: 0.875rem;
color: #6b7280;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
/* Toggle Switches */
.checkbox-group {
display: flex;
flex-direction: column;
gap: 1rem;
}
.checkbox-container {
display: block;
position: relative;
padding-left: 35px;
margin-bottom: 12px;
.toggle-label {
display: inline-flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
font-size: 16px;
user-select: none;
margin-bottom: 0;
padding: 0;
}
.checkbox-container input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 25px;
width: 25px;
background-color: #eee;
border-radius: 4px;
}
.checkbox-container:hover input ~ .checkmark {
background-color: #ccc;
}
.checkbox-container input:checked ~ .checkmark {
background-color: #2196F3;
}
.checkmark:after {
content: "";
position: absolute;
.toggle-label input[type="checkbox"] {
display: none;
}
.checkbox-container input:checked ~ .checkmark:after {
display: block;
.toggle-slider {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
background: #e5e7eb;
border-radius: 24px;
transition: background 0.3s;
flex-shrink: 0;
}
.checkbox-container .checkmark:after {
left: 9px;
top: 5px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 3px 3px 0;
transform: rotate(45deg);
.toggle-slider::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
top: 2px;
left: 2px;
transition: transform 0.3s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}
.toggle-label input[type="checkbox"]:checked + .toggle-slider {
background: #667eea;
}
.toggle-label input[type="checkbox"]:checked + .toggle-slider::before {
transform: translateX(26px);
}
.toggle-text {
font-weight: 500;
color: #1f2937;
}
.main-toggle {
margin-bottom: 1rem;
}
/* Checkbox Lists */
.checkbox-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
max-height: 200px;
gap: 0.75rem;
max-height: 250px;
overflow-y: auto;
border: 1px solid #ced4da;
border-radius: 0.25rem;
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem;
background: #f8f9fa;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.25rem 0;
}
.checkbox-item input[type="checkbox"] {
display: none;
}
.checkbox-custom {
width: 20px;
height: 20px;
border: 2px solid #e5e7eb;
border-radius: 4px;
background: white;
transition: all 0.2s ease;
position: relative;
flex-shrink: 0;
}
.checkbox-item:hover .checkbox-custom {
border-color: #667eea;
}
.checkbox-item input[type="checkbox"]:checked + .checkbox-custom {
background: #667eea;
border-color: #667eea;
}
.checkbox-item input[type="checkbox"]:checked + .checkbox-custom::after {
content: '';
position: absolute;
left: 6px;
top: 2px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.checkbox-label {
color: #374151;
font-size: 0.95rem;
}
/* Form Actions */
.form-actions {
display: flex;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid #e9ecef;
padding: 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
margin-top: 2rem;
}
/* Button styles now centralized in main style.css */
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid rgba(255, 255, 255, 0.3);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
/* Responsive Design */
@media (max-width: 768px) {
.announcement-form-container {
padding: 1rem;
}
.page-header {
padding: 1.5rem;
}
.header-content {
flex-direction: column;
text-align: center;
}
.form-row {
grid-template-columns: 1fr;
}
@@ -336,6 +564,36 @@ document.addEventListener('DOMContentLoaded', function() {
.checkbox-list {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: slideIn 0.3s ease-out;
animation-fill-mode: both;
}
.card:nth-child(1) { animation-delay: 0.1s; }
.card:nth-child(2) { animation-delay: 0.2s; }
.card:nth-child(3) { animation-delay: 0.3s; }
</style>
{% endblock %}

View File

@@ -1,19 +1,35 @@
{% extends "layout.html" %}
{% block content %}
<div class="content-header">
<div class="header-row">
<h1>System Announcements</h1>
<a href="{{ url_for('announcements.create') }}" class="btn btn-md btn-primary">
<i class="icon"></i> New Announcement
</a>
<div class="announcements-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon"><i class="ti ti-speakerphone"></i></span>
System Announcements
</h1>
<p class="page-subtitle">Manage system-wide announcements and notifications</p>
</div>
<div class="header-actions">
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">
<i class="ti ti-arrow-left"></i>
Back to Dashboard
</a>
<a href="{{ url_for('announcements.create') }}" class="btn btn-primary">
<i class="ti ti-plus"></i>
New Announcement
</a>
</div>
</div>
</div>
</div>
<div class="content-body">
<!-- Announcements Table -->
{% if announcements.items %}
<div class="table-container">
<table class="data-table">
<div class="card">
<div class="card-body no-padding">
<table class="table">
<thead>
<tr>
<th>Title</th>
@@ -28,62 +44,72 @@
</thead>
<tbody>
{% for announcement in announcements.items %}
<tr class="{% if not announcement.is_active %}inactive{% endif %}">
<tr class="{% if not announcement.is_active %}inactive-row{% endif %}">
<td>
<strong>{{ announcement.title }}</strong>
{% if announcement.is_urgent %}
<span class="badge badge-danger">URGENT</span>
{% endif %}
<div class="announcement-title">
<strong>{{ announcement.title }}</strong>
{% if announcement.is_urgent %}
<span class="badge badge-urgent">URGENT</span>
{% endif %}
</div>
</td>
<td>
<span class="badge badge-{{ announcement.announcement_type }}">
<span class="type-badge type-{{ announcement.announcement_type }}">
{{ announcement.announcement_type.title() }}
</span>
</td>
<td>
{% if announcement.is_active %}
{% if announcement.is_visible_now() %}
<span class="badge badge-success">Active</span>
<span class="status-badge status-active">Active</span>
{% else %}
<span class="badge badge-warning">Scheduled</span>
<span class="status-badge status-scheduled">Scheduled</span>
{% endif %}
{% else %}
<span class="badge badge-secondary">Inactive</span>
<span class="status-badge status-inactive">Inactive</span>
{% endif %}
</td>
<td>
{% if announcement.start_date %}
{{ announcement.start_date.strftime('%Y-%m-%d %H:%M') }}
<span class="date-text">{{ announcement.start_date.strftime('%Y-%m-%d %H:%M') }}</span>
{% else %}
<em>Immediate</em>
<em class="text-muted">Immediate</em>
{% endif %}
</td>
<td>
{% if announcement.end_date %}
{{ announcement.end_date.strftime('%Y-%m-%d %H:%M') }}
<span class="date-text">{{ announcement.end_date.strftime('%Y-%m-%d %H:%M') }}</span>
{% else %}
<em>No expiry</em>
<em class="text-muted">No expiry</em>
{% endif %}
</td>
<td>
{% if announcement.target_all_users %}
All Users
<span class="target-badge target-all">
<i class="ti ti-users"></i>
All Users
</span>
{% else %}
<span class="text-muted">Targeted</span>
<span class="target-badge target-specific">
<i class="ti ti-target"></i>
Targeted
</span>
{% endif %}
</td>
<td>{{ announcement.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<span class="date-text">{{ announcement.created_at.strftime('%Y-%m-%d') }}</span>
</td>
<td>
<div class="action-buttons">
<a href="{{ url_for('announcements.edit', id=announcement.id) }}"
class="btn btn-sm btn-outline-primary" title="Edit">
✏️
class="btn-icon" title="Edit">
<i class="ti ti-pencil"></i>
</a>
<form method="POST" action="{{ url_for('announcements.delete', id=announcement.id) }}"
style="display: inline-block;"
onsubmit="return confirm('Are you sure you want to delete this announcement?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
🗑️
<button type="submit" class="btn-icon danger" title="Delete">
<i class="ti ti-trash"></i>
</button>
</form>
</div>
@@ -93,15 +119,20 @@
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
{% if announcements.pages > 1 %}
<div class="pagination-container">
<div class="pagination">
{% if announcements.has_prev %}
<a href="{{ url_for('announcements.index', page=announcements.prev_num) }}" class="page-link">« Previous</a>
{% endif %}
<!-- Pagination -->
{% if announcements.pages > 1 %}
<div class="pagination-container">
<div class="pagination">
{% if announcements.has_prev %}
<a href="{{ url_for('announcements.index', page=announcements.prev_num) }}" class="page-link">
<i class="ti ti-chevron-left"></i>
Previous
</a>
{% endif %}
<div class="page-numbers">
{% for page_num in announcements.iter_pages() %}
{% if page_num %}
{% if page_num != announcements.page %}
@@ -110,56 +141,452 @@
<span class="page-link current">{{ page_num }}</span>
{% endif %}
{% else %}
<span class="page-link"></span>
<span class="page-dots">...</span>
{% endif %}
{% endfor %}
{% if announcements.has_next %}
<a href="{{ url_for('announcements.index', page=announcements.next_num) }}" class="page-link">Next »</a>
{% endif %}
</div>
{% if announcements.has_next %}
<a href="{{ url_for('announcements.index', page=announcements.next_num) }}" class="page-link">
Next
<i class="ti ti-chevron-right"></i>
</a>
{% endif %}
</div>
{% endif %}
<div class="pagination-info">
Showing {{ announcements.per_page * (announcements.page - 1) + 1 }} -
{{ announcements.per_page * (announcements.page - 1) + announcements.items|length }} of {{ announcements.total }} announcements
</div>
</div>
{% endif %}
{% else %}
<div class="empty-state">
<h3>No announcements found</h3>
<p>Create your first announcement to communicate with users.</p>
<a href="{{ url_for('announcements.create') }}" class="btn btn-primary">
Create Announcement
</a>
</div>
<!-- Empty State -->
<div class="empty-state">
<div class="empty-icon"><i class="ti ti-speakerphone"></i></div>
<h3 class="empty-title">No announcements found</h3>
<p class="empty-message">Create your first announcement to communicate with users.</p>
<a href="{{ url_for('announcements.create') }}" class="btn btn-primary">
<i class="ti ti-plus"></i>
Create Announcement
</a>
</div>
{% endif %}
</div>
<style>
.inactive {
/* Container */
.announcements-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Page Header */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
.header-actions {
display: flex;
gap: 1rem;
}
/* Cards */
.card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
margin-bottom: 1.5rem;
overflow: hidden;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.card-body {
padding: 1.5rem;
}
.card-body.no-padding {
padding: 0;
}
/* Table */
.table {
width: 100%;
border-collapse: collapse;
margin: 0;
}
.table th,
.table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.table th {
background: #f8f9fa;
font-weight: 600;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 0.8rem;
}
.table tr:hover {
background: #f8f9fa;
}
.inactive-row {
opacity: 0.6;
}
.badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75em;
font-weight: bold;
text-transform: uppercase;
/* Announcement Title */
.announcement-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge-info { background: #17a2b8; color: white; }
.badge-warning { background: #ffc107; color: #212529; }
.badge-success { background: #28a745; color: white; }
.badge-danger { background: #dc3545; color: white; }
.badge-secondary { background: #6c757d; color: white; }
/* Badges */
.badge,
.status-badge,
.type-badge,
.target-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.badge-urgent {
background: #fee2e2;
color: #991b1b;
}
/* Type Badges */
.type-info {
background: #dbeafe;
color: #1e40af;
}
.type-warning {
background: #fef3c7;
color: #92400e;
}
.type-success {
background: #d1fae5;
color: #065f46;
}
.type-danger {
background: #fee2e2;
color: #991b1b;
}
/* Status Badges */
.status-active {
background: #d1fae5;
color: #065f46;
}
.status-scheduled {
background: #fef3c7;
color: #92400e;
}
.status-inactive {
background: #e5e7eb;
color: #374151;
}
/* Target Badges */
.target-badge {
font-size: 0.875rem;
}
.target-all {
background: #ede9fe;
color: #5b21b6;
}
.target-specific {
background: #dbeafe;
color: #1e40af;
}
/* Date Text */
.date-text {
color: #6b7280;
font-size: 0.9rem;
}
.text-muted {
color: #9ca3af;
font-style: italic;
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: 5px;
gap: 0.5rem;
}
.btn-icon {
color: #6b7280;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
cursor: pointer;
}
.btn-icon:hover {
background: #667eea;
color: white;
transform: translateY(-1px);
}
.btn-icon.danger:hover {
background: #dc2626;
}
/* Pagination */
.pagination-container {
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
background: white;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.pagination {
display: flex;
align-items: center;
gap: 0.5rem;
}
.page-numbers {
display: flex;
gap: 0.25rem;
}
.page-link {
padding: 0.5rem 1rem;
border: 1px solid #e5e7eb;
color: #667eea;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.page-link:hover {
background: #f3f4f6;
border-color: #667eea;
}
.page-link.current {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: transparent;
}
.page-dots {
padding: 0.5rem;
color: #6b7280;
}
.pagination-info {
color: #6b7280;
font-size: 0.875rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem;
color: #6c757d;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1.5rem;
opacity: 0.3;
}
.empty-title {
font-size: 1.75rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 0.5rem;
}
.empty-message {
font-size: 1.1rem;
color: #6b7280;
margin-bottom: 2rem;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid rgba(255, 255, 255, 0.3);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
/* Responsive Design */
@media (max-width: 768px) {
.announcements-container {
padding: 1rem;
}
.page-header {
padding: 1.5rem;
}
.header-content {
flex-direction: column;
text-align: center;
}
.header-actions {
width: 100%;
flex-direction: column;
}
.table {
font-size: 0.8rem;
}
.table th,
.table td {
padding: 0.5rem;
}
.action-buttons {
flex-direction: column;
gap: 0.25rem;
}
.pagination-container {
flex-direction: column;
}
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: slideIn 0.3s ease-out;
animation-fill-mode: both;
}
.card:nth-child(1) { animation-delay: 0.1s; }
.card:nth-child(2) { animation-delay: 0.2s; }
.card:nth-child(3) { animation-delay: 0.3s; }
</style>
{% endblock %}

View File

@@ -1,54 +1,69 @@
{% 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.system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
<div class="branding-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon"><i class="ti ti-palette"></i></span>
Branding Settings
</h1>
<p class="page-subtitle">Customize the appearance and branding of {{ branding.app_name }}</p>
</div>
<div class="header-actions">
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">
<i class="ti ti-arrow-left"></i>
Back to Dashboard
</a>
</div>
</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 class="card preview-card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-eye"></i></span>
Current Branding Preview
</h2>
</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>
<!-- Branding Settings Form -->
<div class="management-section">
<h2>🔧 Branding Configuration</h2>
<div class="management-card">
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-settings"></i></span>
Branding Configuration
</h2>
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data" class="settings-form">
<!-- Application Name -->
<div class="form-section">
<h3>📝 Basic Information</h3>
<h3><i class="ti ti-forms"></i> Basic Information</h3>
<div class="form-group">
<label for="app_name">Application Name</label>
<input type="text" id="app_name" name="app_name"
@@ -56,7 +71,7 @@
class="form-control"
placeholder="TimeTrack"
required>
<small class="form-text text-muted">
<small class="form-text">
This name will appear in the title, navigation, and throughout the interface.
</small>
</div>
@@ -67,7 +82,7 @@
value="{{ branding.logo_alt_text }}"
class="form-control"
placeholder="Company Logo">
<small class="form-text text-muted">
<small class="form-text">
Text displayed when the logo cannot be loaded (accessibility).
</small>
</div>
@@ -75,7 +90,7 @@
<!-- Visual Assets -->
<div class="form-section">
<h3>🖼️ Visual Assets</h3>
<h3><i class="ti ti-photo"></i> Visual Assets</h3>
<div class="form-row">
<div class="form-group col-md-6">
<label for="logo_file">Logo Image</label>
@@ -90,7 +105,7 @@
<span class="asset-label">Current logo</span>
</div>
{% endif %}
<small class="form-text text-muted">
<small class="form-text">
PNG, JPG, GIF, SVG. Recommended: 200x50px
</small>
</div>
@@ -108,7 +123,7 @@
<span class="asset-label">Current favicon</span>
</div>
{% endif %}
<small class="form-text text-muted">
<small class="form-text">
ICO, PNG. Recommended: 16x16px or 32x32px
</small>
</div>
@@ -117,7 +132,7 @@
<!-- Theme Settings -->
<div class="form-section">
<h3>🎨 Theme Settings</h3>
<h3><i class="ti ti-color-swatch"></i> Theme Settings</h3>
<div class="form-group">
<label for="primary_color">Primary Color</label>
<div class="color-picker-wrapper">
@@ -130,7 +145,7 @@
pattern="^#[0-9A-Fa-f]{6}$"
placeholder="#007bff">
</div>
<small class="form-text text-muted">
<small class="form-text">
This color will be used for buttons, links, and other UI elements.
</small>
</div>
@@ -138,7 +153,7 @@
<!-- Imprint/Legal Page -->
<div class="form-section">
<h3>⚖️ Imprint / Legal Page</h3>
<h3><i class="ti ti-scale"></i> Imprint / Legal Page</h3>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" name="imprint_enabled" id="imprint_enabled"
@@ -146,7 +161,7 @@
<span class="toggle-slider"></span>
<span class="toggle-text">Enable Imprint Page</span>
</label>
<small class="form-text text-muted">
<small class="form-text">
When enabled, an "Imprint" link will appear in the footer linking to your custom legal page.
</small>
</div>
@@ -158,7 +173,7 @@
value="{{ branding.imprint_title or 'Imprint' }}"
class="form-control"
placeholder="Imprint">
<small class="form-text text-muted">
<small class="form-text">
The title that will be displayed on the imprint page.
</small>
</div>
@@ -169,7 +184,7 @@
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">
<small class="form-text">
You can use HTML to format your content. Common tags: &lt;h2&gt;, &lt;h3&gt;, &lt;p&gt;, &lt;strong&gt;, &lt;br&gt;, &lt;a href=""&gt;
</small>
</div>
@@ -178,7 +193,10 @@
<!-- Save Button -->
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 Save Branding Settings</button>
<button type="submit" class="btn btn-primary">
<i class="ti ti-device-floppy"></i>
Save Branding Settings
</button>
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
@@ -187,8 +205,99 @@
</div>
<style>
/* Branding-specific styles */
.branding-preview-card {
/* Container */
.branding-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
/* Page Header */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: rotate 8s linear infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Cards */
.card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
margin-bottom: 1.5rem;
overflow: hidden;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.card-header {
background: #f8f9fa;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: #1f2937;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-title .icon {
font-size: 1.5rem;
}
.card-body {
padding: 1.5rem;
}
/* Preview Card */
.preview-card {
margin-bottom: 2rem;
}
@@ -202,7 +311,7 @@
.demo-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #dee2e6;
border-bottom: 1px solid #e5e7eb;
}
.demo-logo {
@@ -213,8 +322,8 @@
.demo-text-logo {
font-size: 1.5rem;
font-weight: 600;
color: #333;
font-weight: 700;
color: #1f2937;
}
.demo-content {
@@ -222,7 +331,7 @@
}
.demo-content p {
color: #6c757d;
color: #6b7280;
margin-bottom: 1rem;
}
@@ -230,12 +339,80 @@
margin-right: 1rem;
}
/* Current assets display */
/* Form Sections */
.form-section {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid #e5e7eb;
}
.form-section:last-child {
border-bottom: none;
margin-bottom: 0;
}
.form-section h3 {
margin-bottom: 1rem;
color: #1f2937;
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-section h3 i {
font-size: 1.25rem;
}
/* Form Controls */
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
color: #374151;
}
.form-control {
width: 100%;
padding: 0.625rem 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-text {
display: block;
margin-top: 0.25rem;
font-size: 0.875rem;
color: #6b7280;
}
/* File Input */
.form-control-file {
display: block;
width: 100%;
padding: 0.375rem 0;
font-size: 0.875rem;
}
/* Current Assets */
.current-asset {
margin-top: 0.5rem;
padding: 0.75rem;
background: #f8f9fa;
border-radius: 4px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 1rem;
@@ -255,10 +432,10 @@
.asset-label {
font-size: 0.875rem;
color: #6c757d;
color: #6b7280;
}
/* Color picker styling */
/* Color Picker */
.color-picker-wrapper {
display: flex;
gap: 0.5rem;
@@ -270,8 +447,8 @@
width: 60px;
height: 38px;
padding: 0.25rem;
border: 1px solid #ced4da;
border-radius: 4px;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
}
@@ -280,58 +457,8 @@
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 {
/* Toggle Switch */
.toggle-label {
display: inline-flex;
align-items: center;
gap: 0.75rem;
@@ -349,7 +476,7 @@
display: inline-block;
width: 50px;
height: 24px;
background: #ccc;
background: #e5e7eb;
border-radius: 24px;
transition: background 0.3s;
flex-shrink: 0;
@@ -365,10 +492,11 @@
top: 2px;
left: 2px;
transition: transform 0.3s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}
.toggle-label input[type="checkbox"]:checked + .toggle-slider {
background: var(--primary-color);
background: #667eea;
}
.toggle-label input[type="checkbox"]:checked + .toggle-slider::before {
@@ -377,18 +505,18 @@
.toggle-text {
font-weight: 500;
color: #495057;
color: #1f2937;
line-height: 1;
}
/* Content editor styling */
/* Content Editor */
.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;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0.75rem;
resize: vertical;
}
@@ -400,6 +528,108 @@
border-radius: 8px;
transition: all 0.3s ease;
}
/* Form Row */
.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;
}
/* Form Actions */
.form-actions {
display: flex;
gap: 1rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
margin-top: 2rem;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid rgba(255, 255, 255, 0.3);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
/* Responsive Design */
@media (max-width: 768px) {
.branding-container {
padding: 1rem;
}
.page-header {
padding: 1.5rem;
}
.header-content {
flex-direction: column;
text-align: center;
}
.form-row > .col-md-6 {
flex: 0 0 100%;
max-width: 100%;
}
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: slideIn 0.3s ease-out;
animation-fill-mode: both;
}
.card:nth-child(1) { animation-delay: 0.1s; }
.card:nth-child(2) { animation-delay: 0.2s; }
.card:nth-child(3) { animation-delay: 0.3s; }
</style>
<script>

View File

@@ -1,313 +1,591 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="header-section">
<h1>🏢 System Admin - All Companies</h1>
<p class="subtitle">Manage companies across the entire system</p>
<div class="header-actions">
<a href="/setup" class="btn btn-md btn-success">+ Add New Company</a>
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-md btn-secondary">← Back to Dashboard</a>
<div class="companies-admin-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon"><i class="ti ti-building"></i></span>
All Companies
</h1>
<p class="page-subtitle">Manage companies across the entire system</p>
</div>
<div class="header-actions">
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">
<i class="ti ti-arrow-left"></i>
Back to Dashboard
</a>
<a href="/setup" class="btn btn-primary">
<i class="ti ti-plus"></i>
Add New Company
</a>
</div>
</div>
</div>
<!-- Companies Table -->
{% if companies.items %}
<div class="table-section">
<table class="table">
<thead>
<tr>
<th>Company Name</th>
<th>Type</th>
<th>Users</th>
<th>Admins</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for company in companies.items %}
<tr class="{% if not company.is_active %}inactive-company{% endif %}">
<td>
<strong>{{ company.name }}</strong>
{% if company.slug %}
<br><small class="text-muted">{{ company.slug }}</small>
{% endif %}
</td>
<td>
{% if company.is_personal %}
<span class="badge badge-freelancer">Freelancer</span>
{% else %}
<span class="badge badge-company">Company</span>
{% endif %}
</td>
<td>
<span class="stat-number">{{ company_stats[company.id]['user_count'] }}</span>
<small>users</small>
</td>
<td>
<span class="stat-number">{{ company_stats[company.id]['admin_count'] }}</span>
<small>admins</small>
</td>
<td>
{% if company.is_active %}
<span class="status-badge status-active">Active</span>
{% else %}
<span class="status-badge status-inactive">Inactive</span>
{% endif %}
</td>
<td>{{ company.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<div class="action-buttons">
<a href="{{ url_for('system_admin.system_admin_company_detail', company_id=company.id) }}"
class="btn btn-sm btn-primary">View Details</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Summary Statistics -->
<div class="stats-section">
<div class="stat-card">
<div class="stat-value">{{ companies.total }}</div>
<div class="stat-label">Total Companies</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ companies.items | selectattr('is_personal') | list | length }}</div>
<div class="stat-label">Personal Companies</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ companies.items | rejectattr('is_personal') | list | length }}</div>
<div class="stat-label">Business Companies</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ companies.items | selectattr('is_active') | list | length }}</div>
<div class="stat-label">Active Companies</div>
</div>
</div>
<!-- Pagination -->
{% if companies.pages > 1 %}
<div class="pagination-section">
<div class="pagination">
{% if companies.has_prev %}
<a href="{{ url_for('system_admin.system_admin_companies', page=companies.prev_num) }}" class="page-link">← Previous</a>
{% endif %}
<!-- Main Content -->
<div class="content-section">
{% if companies.items %}
<!-- Companies Table -->
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Company</th>
<th>Type</th>
<th>Users</th>
<th>Admins</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for company in companies.items %}
<tr class="{% if not company.is_active %}inactive-row{% endif %}">
<td>
<div class="company-cell">
<div class="company-name">{{ company.name }}</div>
{% if company.slug %}
<div class="company-slug">{{ company.slug }}</div>
{% endif %}
</div>
</td>
<td>
{% if company.is_personal %}
<span class="badge badge-freelancer">Freelancer</span>
{% else %}
<span class="badge badge-company">Company</span>
{% endif %}
</td>
<td>
<div class="stat-cell">
<span class="stat-number">{{ company_stats[company.id]['user_count'] }}</span>
<span class="stat-label">users</span>
</div>
</td>
<td>
<div class="stat-cell">
<span class="stat-number">{{ company_stats[company.id]['admin_count'] }}</span>
<span class="stat-label">admins</span>
</div>
</td>
<td>
{% if company.is_active %}
<span class="status-badge status-active">Active</span>
{% else %}
<span class="status-badge status-inactive">Inactive</span>
{% endif %}
</td>
<td>
<span class="date-text">{{ company.created_at.strftime('%Y-%m-%d') }}</span>
</td>
<td>
<div class="table-actions">
<a href="{{ url_for('system_admin.system_admin_company_detail', company_id=company.id) }}"
class="btn-icon" title="View Details">
<i class="ti ti-eye"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% for page_num in companies.iter_pages() %}
{% if page_num %}
{% if page_num != companies.page %}
<a href="{{ url_for('system_admin.system_admin_companies', page=page_num) }}" class="page-link">{{ page_num }}</a>
{% else %}
<span class="page-link current">{{ page_num }}</span>
<!-- Pagination -->
{% if companies.pages > 1 %}
<div class="pagination-container">
<div class="pagination">
{% if companies.has_prev %}
<a href="{{ url_for('system_admin.system_admin_companies', page=companies.prev_num) }}"
class="page-link">
<i class="ti ti-chevron-left"></i>
Previous
</a>
{% endif %}
{% else %}
<span class="page-link"></span>
{% endif %}
{% endfor %}
{% if companies.has_next %}
<a href="{{ url_for('system_admin.system_admin_companies', page=companies.next_num) }}" class="page-link">Next →</a>
<div class="page-numbers">
{% for page_num in companies.iter_pages() %}
{% if page_num %}
{% if page_num != companies.page %}
<a href="{{ url_for('system_admin.system_admin_companies', page=page_num) }}"
class="page-link">{{ page_num }}</a>
{% else %}
<span class="page-link current">{{ page_num }}</span>
{% endif %}
{% else %}
<span class="page-dots">...</span>
{% endif %}
{% endfor %}
</div>
{% if companies.has_next %}
<a href="{{ url_for('system_admin.system_admin_companies', page=companies.next_num) }}"
class="page-link">
Next
<i class="ti ti-chevron-right"></i>
</a>
{% endif %}
</div>
<div class="pagination-info">
Showing {{ companies.per_page * (companies.page - 1) + 1 }} -
{{ companies.per_page * (companies.page - 1) + companies.items|length }} of {{ companies.total }} companies
</div>
</div>
{% endif %}
</div>
<p class="pagination-info">
Showing {{ companies.per_page * (companies.page - 1) + 1 }} -
{{ companies.per_page * (companies.page - 1) + companies.items|length }} of {{ companies.total }} companies
</p>
</div>
{% endif %}
{% else %}
<div class="empty-state">
<h3>No companies found</h3>
<p>No companies exist in the system yet.</p>
</div>
{% endif %}
<!-- Company Statistics Summary -->
<div class="summary-section">
<h3>📊 Company Summary</h3>
<div class="summary-grid">
<div class="summary-card">
<h4>Total Companies</h4>
<p class="summary-number">{{ companies.total }}</p>
{% else %}
<!-- Empty State -->
<div class="empty-state">
<div class="empty-icon"><i class="ti ti-building-community"></i></div>
<h3 class="empty-title">No Companies Yet</h3>
<p class="empty-message">No companies exist in the system.</p>
<a href="/setup" class="btn btn-primary">
<i class="ti ti-plus"></i>
Create First Company
</a>
</div>
<div class="summary-card">
<h4>Personal Companies</h4>
<p class="summary-number">{{ companies.items | selectattr('is_personal') | list | length }}</p>
</div>
<div class="summary-card">
<h4>Business Companies</h4>
<p class="summary-number">{{ companies.items | rejectattr('is_personal') | list | length }}</p>
</div>
<div class="summary-card">
<h4>Active Companies</h4>
<p class="summary-number">{{ companies.items | selectattr('is_active') | list | length }}</p>
</div>
</div>
{% endif %}
</div>
</div>
<style>
.header-section {
/* Container */
.companies-admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Page Header */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
.header-actions {
display: flex;
gap: 1rem;
}
/* Stats Section */
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.subtitle {
color: #6c757d;
margin-bottom: 1rem;
}
.table-section {
.stat-card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 2rem;
padding: 1.5rem;
border-radius: 12px;
text-align: center;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.table {
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: #667eea;
}
.stat-label {
font-size: 0.9rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
/* Content Section */
.content-section {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
overflow: hidden;
}
/* Table Container */
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
margin: 0;
}
.table th,
.table td {
padding: 1rem;
.data-table th,
.data-table td {
padding: 1rem 1.5rem;
text-align: left;
border-bottom: 1px solid #dee2e6;
border-bottom: 1px solid #e5e7eb;
}
.table th {
.data-table th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
.inactive-company {
background-color: #f8f9fa !important;
opacity: 0.7;
}
.text-muted {
color: #6c757d;
color: #374151;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table tr:hover {
background: #f8f9fa;
}
.inactive-row {
opacity: 0.6;
}
/* Company Cell */
.company-cell {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.company-name {
font-weight: 600;
color: #1f2937;
}
.company-slug {
font-size: 0.875rem;
color: #6b7280;
}
/* Stat Cell */
.stat-cell {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.stat-cell .stat-number {
font-weight: 700;
color: #667eea;
font-size: 1.1rem;
}
.stat-cell .stat-label {
font-size: 0.875rem;
color: #6b7280;
}
/* Date Text */
.date-text {
color: #6b7280;
font-size: 0.95rem;
}
/* Badges */
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
font-weight: 600;
text-transform: uppercase;
}
.badge-company {
background: #d1ecf1;
color: #0c5460;
background: #dbeafe;
color: #1e40af;
}
.badge-freelancer {
background: #d4edda;
color: #155724;
}
.stat-number {
font-weight: 600;
color: #007bff;
background: #d1fae5;
color: #065f46;
}
/* Status Badges */
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
font-weight: 600;
text-transform: uppercase;
}
.status-active {
background: #d4edda;
color: #155724;
background: #d1fae5;
color: #065f46;
}
.status-inactive {
background: #f8d7da;
color: #721c24;
background: #fee2e2;
color: #991b1b;
}
.action-buttons {
/* Table Actions */
.table-actions {
display: flex;
gap: 0.5rem;
}
/* Button styles now centralized in main style.css */
.btn-icon {
color: #6b7280;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
cursor: pointer;
}
.pagination-section {
margin: 2rem 0;
.btn-icon:hover {
background: #667eea;
color: white;
transform: translateY(-1px);
}
/* Pagination */
.pagination-container {
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
border-top: 1px solid #e5e7eb;
}
.pagination {
display: flex;
align-items: center;
gap: 0.5rem;
}
.page-numbers {
display: flex;
gap: 0.25rem;
}
.page-link {
padding: 0.5rem 0.75rem;
border: 1px solid #dee2e6;
color: #007bff;
padding: 0.5rem 1rem;
border: 1px solid #e5e7eb;
color: #667eea;
text-decoration: none;
border-radius: 4px;
border-radius: 8px;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.page-link:hover {
background: #e9ecef;
background: #f3f4f6;
border-color: #667eea;
}
.page-link.current {
background: #007bff;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: #007bff;
border-color: transparent;
}
.page-dots {
padding: 0.5rem;
color: #6b7280;
}
.pagination-info {
color: #6c757d;
margin: 0;
font-size: 0.9rem;
color: #6b7280;
font-size: 0.875rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem;
color: #6c757d;
padding: 4rem 2rem;
}
.summary-section {
background: #f8f9fa;
border-radius: 8px;
padding: 2rem;
}
.summary-section h3 {
margin-top: 0;
.empty-icon {
font-size: 4rem;
margin-bottom: 1.5rem;
color: #495057;
opacity: 0.3;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
.empty-title {
font-size: 1.75rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 0.5rem;
}
.summary-card {
background: white;
.empty-message {
font-size: 1.1rem;
color: #6b7280;
margin-bottom: 2rem;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
border: 1px solid #dee2e6;
}
.summary-card h4 {
margin: 0 0 0.5rem 0;
color: #6c757d;
font-size: 0.875rem;
font-weight: 500;
}
.summary-number {
font-size: 2rem;
font-weight: 600;
color: #007bff;
margin: 0;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid rgba(255, 255, 255, 0.3);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
/* Responsive Design */
@media (max-width: 768px) {
.companies-admin-container {
padding: 1rem;
}
.page-header {
padding: 1.5rem;
}
.header-content {
flex-direction: column;
text-align: center;
}
.header-actions {
width: 100%;
flex-direction: column;
}
.table-container {
margin: 0 -1rem;
}
.data-table {
font-size: 0.875rem;
}
.data-table th,
.data-table td {
padding: 0.75rem;
}
.pagination-container {
flex-direction: column;
}
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.content-section {
animation: slideIn 0.3s ease-out;
}
</style>
{% endblock %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,38 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="header-section">
<h1>✏️ Edit User: {{ user.username }}</h1>
<p class="subtitle">System Administrator - Edit user across companies</p>
<a href="{{ url_for('users.system_admin_users') }}" class="btn btn-secondary">← Back to Users</a>
<div class="edit-user-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon"><i class="ti ti-user-edit"></i></span>
Edit User: {{ user.username }}
</h1>
<p class="page-subtitle">System Administrator - Edit user across companies</p>
</div>
<div class="header-actions">
<a href="{{ url_for('users.system_admin_users') }}" class="btn btn-secondary">
<i class="ti ti-arrow-left"></i>
Back to Users
</a>
</div>
</div>
</div>
<div class="form-container">
<form method="POST">
<div class="form-grid">
<!-- Basic Information -->
<div class="form-section">
<h3>Basic Information</h3>
<!-- Main Form -->
<form method="POST" class="user-edit-form">
<div class="form-grid">
<!-- Basic Information -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-user"></i></span>
Basic Information
</h2>
</div>
<div class="card-body">
<div class="form-group">
<label for="username">Username</label>
@@ -29,10 +48,17 @@
class="form-control">
</div>
</div>
</div>
<!-- Company & Team Assignment -->
<div class="form-section">
<h3>Company & Team</h3>
<!-- Company & Team Assignment -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-building"></i></span>
Company & Team
</h2>
</div>
<div class="card-body">
<div class="form-group">
<label for="company_id">Company</label>
@@ -60,10 +86,17 @@
</select>
</div>
</div>
</div>
<!-- Role & Permissions -->
<div class="form-section">
<h3>Role & Permissions</h3>
<!-- Role & Permissions -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-shield-check"></i></span>
Role & Permissions
</h2>
</div>
<div class="card-body">
<div class="form-group">
<label for="role">Role</label>
@@ -76,40 +109,54 @@
{% endfor %}
</select>
{% if user.role == Role.SYSTEM_ADMIN %}
<small class="form-text">⚠️ Warning: This user is a System Administrator</small>
<small class="form-text warning-text"><i class="ti ti-alert-triangle"></i> Warning: This user is a System Administrator</small>
{% endif %}
</div>
</div>
</div>
<!-- Account Status -->
<div class="form-section">
<h3>Account Status</h3>
<!-- Account Status -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-settings"></i></span>
Account Status
</h2>
</div>
<div class="card-body">
<div class="form-group">
<label class="checkbox-label">
<label class="toggle-label">
<input type="checkbox" name="is_verified"
{% if user.is_verified %}checked{% endif %}>
<span class="checkmark"></span>
Email Verified
<span class="toggle-slider"></span>
<span class="toggle-text">Email Verified</span>
</label>
<small class="form-text">Whether the user's email address has been verified</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<label class="toggle-label">
<input type="checkbox" name="is_blocked"
{% if user.is_blocked %}checked{% endif %}>
<span class="checkmark"></span>
Account Blocked
<span class="toggle-slider"></span>
<span class="toggle-text">Account Blocked</span>
</label>
<small class="form-text">Blocked users cannot log in to the system</small>
</div>
</div>
</div>
</div>
<!-- User Information Display -->
<div class="info-section">
<h3>User Information</h3>
<!-- User Information Display -->
<div class="card full-width">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-info-circle"></i></span>
User Information
</h2>
</div>
<div class="card-body">
<div class="info-grid">
<div class="info-item">
<label>Account Type:</label>
@@ -138,26 +185,46 @@
</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="{{ url_for('users.system_admin_users') }}" class="btn btn-secondary">Cancel</a>
<!-- Form Actions -->
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="ti ti-device-floppy"></i>
Save Changes
</button>
<a href="{{ url_for('users.system_admin_users') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
{% if user.id != g.user.id and not (user.role == Role.SYSTEM_ADMIN and user.id == g.user.id) %}
<div class="danger-zone">
<h4>Danger Zone</h4>
<p>Permanently delete this user account. This action cannot be undone.</p>
<!-- Danger Zone -->
{% if user.id != g.user.id and not (user.role == Role.SYSTEM_ADMIN and user.id == g.user.id) %}
<div class="danger-zone">
<div class="danger-header">
<h2 class="danger-title">
<i class="ti ti-alert-triangle"></i>
Danger Zone
</h2>
</div>
<div class="danger-content">
<div class="danger-item">
<div class="danger-info">
<h4>Delete User Account</h4>
<p>Permanently delete this user account. This will also delete all their time entries and cannot be undone.</p>
</div>
<div class="danger-actions">
<form method="POST" action="{{ url_for('users.system_admin_delete_user', user_id=user.id) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete user \'{{ user.username }}\'? This will also delete all their time entries and cannot be undone.')">
<button type="submit" class="btn btn-danger">Delete User</button>
<button type="submit" class="btn btn-danger">
<i class="ti ti-trash"></i>
Delete User
</button>
</form>
</div>
{% endif %}
</div>
</form>
</div>
</div>
{% endif %}
</div>
<script>
@@ -191,175 +258,402 @@ document.getElementById('company_id').addEventListener('change', function() {
</script>
<style>
.header-section {
margin-bottom: 2rem;
}
.subtitle {
color: #6c757d;
margin-bottom: 1rem;
}
.form-container {
max-width: 800px;
background: white;
border-radius: 8px;
/* Container */
.edit-user-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* Page Header */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Form Grid */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.form-section {
border: 1px solid #dee2e6;
border-radius: 8px;
/* Cards */
.card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
overflow: hidden;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.card.full-width {
grid-column: 1 / -1;
}
.card-header {
background: #f8f9fa;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: #1f2937;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-title .icon {
font-size: 1.5rem;
}
.card-body {
padding: 1.5rem;
}
.form-section h3 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #495057;
border-bottom: 2px solid #e9ecef;
padding-bottom: 0.5rem;
}
/* Form Elements */
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
font-weight: 500;
color: #495057;
color: #374151;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #ced4da;
border-radius: 4px;
padding: 0.625rem 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
transition: all 0.2s ease;
}
.form-control:focus {
border-color: #007bff;
outline: 0;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-text {
display: block;
margin-top: 0.25rem;
font-size: 0.875rem;
color: #6c757d;
color: #6b7280;
}
.checkbox-label {
display: flex;
.warning-text {
color: #dc2626 !important;
font-weight: 500;
}
/* Toggle Switches */
.toggle-label {
display: inline-flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
font-weight: normal;
margin-bottom: 0.5rem;
}
.checkbox-label input[type="checkbox"] {
margin-right: 0.5rem;
margin-bottom: 0;
.toggle-label input[type="checkbox"] {
display: none;
}
.info-section {
background: #f8f9fa;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
.toggle-slider {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
background: #e5e7eb;
border-radius: 24px;
transition: background 0.3s;
flex-shrink: 0;
}
.info-section h3 {
margin-top: 0;
margin-bottom: 1rem;
color: #495057;
.toggle-slider::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
top: 2px;
left: 2px;
transition: transform 0.3s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}
.toggle-label input[type="checkbox"]:checked + .toggle-slider {
background: #667eea;
}
.toggle-label input[type="checkbox"]:checked + .toggle-slider::before {
transform: translateX(26px);
}
.toggle-text {
font-weight: 500;
color: #1f2937;
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 0.5rem;
}
.info-item label {
font-weight: 600;
color: #6c757d;
color: #6b7280;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Badges */
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
font-weight: 600;
text-transform: uppercase;
display: inline-block;
width: fit-content;
}
.badge-company {
background: #d1ecf1;
color: #0c5460;
background: #dbeafe;
color: #1e40af;
}
.badge-freelancer {
background: #d4edda;
color: #155724;
background: #d1fae5;
color: #065f46;
}
.text-success {
color: #28a745;
color: #10b981;
font-weight: 500;
}
.text-muted {
color: #6c757d;
color: #6b7280;
}
/* Form Actions */
.form-actions {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
padding: 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
/* Button styles now centralized in main style.css */
/* Danger Zone */
.danger-zone {
margin-left: auto;
padding: 1rem;
border: 2px solid #dc3545;
background: #fef2f2;
border: 2px solid #fecaca;
border-radius: 12px;
overflow: hidden;
}
.danger-header {
background: #fee2e2;
padding: 1.5rem;
border-bottom: 1px solid #fecaca;
}
.danger-title {
font-size: 1.25rem;
font-weight: 600;
color: #991b1b;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.danger-content {
padding: 2rem;
}
.danger-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
}
.danger-info h4 {
margin: 0 0 0.5rem 0;
color: #991b1b;
font-weight: 600;
}
.danger-info p {
margin: 0;
color: #7f1d1d;
font-size: 0.9rem;
line-height: 1.5;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
background: #f8d7da;
max-width: 300px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.danger-zone h4 {
color: #721c24;
margin-top: 0;
margin-bottom: 0.5rem;
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.danger-zone p {
color: #721c24;
font-size: 0.875rem;
margin-bottom: 1rem;
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid rgba(255, 255, 255, 0.3);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover {
background: #b91c1c;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
}
/* Responsive Design */
@media (max-width: 768px) {
.edit-user-container {
padding: 1rem;
}
.page-header {
padding: 1.5rem;
}
.header-content {
flex-direction: column;
text-align: center;
}
.form-grid {
grid-template-columns: 1fr;
}
.danger-item {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: slideIn 0.3s ease-out;
animation-fill-mode: both;
}
.card:nth-child(1) { animation-delay: 0.1s; }
.card:nth-child(2) { animation-delay: 0.2s; }
.card:nth-child(3) { animation-delay: 0.3s; }
.card:nth-child(4) { animation-delay: 0.4s; }
</style>
<script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +1,51 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="header-section">
<h1>⚙️ System Administrator Settings</h1>
<p class="subtitle">Global system configuration and management</p>
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
</div>
<!-- System Statistics -->
<div class="stats-section">
<h3>📊 System Overview</h3>
<div class="stats-grid">
<div class="stat-card">
<h4>{{ total_companies }}</h4>
<p>Total Companies</p>
<div class="settings-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon"><i class="ti ti-settings"></i></span>
System Administrator Settings
</h1>
<p class="page-subtitle">Global system configuration and management</p>
</div>
<div class="stat-card">
<h4>{{ total_users }}</h4>
<p>Total Users</p>
</div>
<div class="stat-card">
<h4>{{ total_system_admins }}</h4>
<p>System Administrators</p>
<div class="header-actions">
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">
<i class="ti ti-arrow-left"></i>
Back to Dashboard
</a>
</div>
</div>
</div>
<!-- System Statistics -->
<div class="stats-section">
<div class="stat-card">
<div class="stat-value">{{ total_companies }}</div>
<div class="stat-label">Total Companies</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ total_users }}</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ total_system_admins }}</div>
<div class="stat-label">System Administrators</div>
</div>
</div>
<!-- System Settings Form -->
<div class="settings-section">
<h3>🔧 System Configuration</h3>
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-adjustments"></i></span>
System Configuration
</h2>
</div>
<div class="card-body">
<form method="POST" class="settings-form">
<div class="setting-group">
<div class="setting-header">
@@ -111,11 +127,18 @@
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<!-- System Information -->
<div class="info-section">
<h3> System Information</h3>
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-info-circle"></i></span>
System Information
</h2>
</div>
<div class="card-body">
<div class="info-grid">
<div class="info-card">
<h4>Application Version</h4>
@@ -130,11 +153,17 @@
<p>Full system control</p>
</div>
</div>
</div>
</div>
<!-- Danger Zone -->
<div class="danger-section">
<h3>⚠️ Danger Zone</h3>
<div class="danger-zone">
<div class="danger-header">
<h2 class="danger-title">
<i class="ti ti-alert-triangle"></i>
Danger Zone
</h2>
</div>
<div class="danger-content">
<div class="danger-item">
<div class="danger-info">
@@ -164,11 +193,17 @@
</div>
<!-- Quick Actions -->
<div class="actions-section">
<h3>🚀 Quick Actions</h3>
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-rocket"></i></span>
Quick Actions
</h2>
</div>
<div class="card-body">
<div class="actions-grid">
<a href="{{ url_for('users.system_admin_users') }}" class="action-card">
<div class="action-icon">👥</div>
<div class="action-icon"><i class="ti ti-users"></i></div>
<div class="action-content">
<h4>Manage All Users</h4>
<p>View, edit, and manage users across all companies</p>
@@ -176,7 +211,7 @@
</a>
<a href="{{ url_for('system_admin.system_admin_companies') }}" class="action-card">
<div class="action-icon">🏢</div>
<div class="action-icon"><i class="ti ti-building"></i></div>
<div class="action-content">
<h4>Manage Companies</h4>
<p>View and manage all companies in the system</p>
@@ -184,77 +219,145 @@
</a>
<a href="{{ url_for('system_admin.system_admin_time_entries') }}" class="action-card">
<div class="action-icon">⏱️</div>
<div class="action-icon"><i class="ti ti-clock"></i></div>
<div class="action-content">
<h4>View Time Entries</h4>
<p>Browse time tracking data across all companies</p>
</div>
</a>
</div>
</div>
</div>
</div>
<style>
.header-section {
margin-bottom: 2rem;
/* Container */
.settings-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.subtitle {
color: #6c757d;
margin-bottom: 1rem;
}
.stats-section {
background: #f8f9fa;
border-radius: 8px;
/* Page Header */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.stats-section h3 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #495057;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.stats-grid {
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: rotate 8s linear infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Stats Section */
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.5rem;
border-radius: 12px;
text-align: center;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.stat-card h4 {
font-size: 2rem;
margin: 0 0 0.5rem 0;
color: #007bff;
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.stat-card p {
margin: 0;
color: #6c757d;
font-weight: 500;
.stat-value {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: #667eea;
}
.settings-section {
.stat-label {
font-size: 0.9rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
/* Cards */
.card {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
margin-bottom: 1.5rem;
overflow: hidden;
transition: all 0.3s ease;
}
.settings-section h3 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #495057;
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.card-header {
background: #f8f9fa;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: #1f2937;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-title .icon {
font-size: 1.5rem;
}
.card-body {
padding: 1.5rem;
}
.settings-form {
@@ -268,8 +371,9 @@
grid-template-columns: 1fr 1fr;
gap: 2rem;
padding: 1.5rem;
border: 1px solid #e9ecef;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8f9fa;
}
.setting-group.full-width {
@@ -295,7 +399,8 @@
.setting-header h4 {
margin: 0 0 0.5rem 0;
color: #495057;
color: #1f2937;
font-weight: 600;
}
.setting-header p {
@@ -320,7 +425,7 @@
position: relative;
width: 50px;
height: 24px;
background: #ccc;
background: #e5e7eb;
border-radius: 24px;
transition: background 0.3s;
}
@@ -335,10 +440,11 @@
top: 2px;
left: 2px;
transition: transform 0.3s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}
.toggle-label input[type="checkbox"]:checked + .toggle-slider {
background: #007bff;
background: #667eea;
}
.toggle-label input[type="checkbox"]:checked + .toggle-slider::before {
@@ -347,7 +453,7 @@
.toggle-text {
font-weight: 500;
color: #495057;
color: #1f2937;
}
.setting-description {
@@ -359,21 +465,9 @@
.form-actions {
display: flex;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid #e9ecef;
}
.info-section {
background: #f8f9fa;
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
}
.info-section h3 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #495057;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
margin-top: 2rem;
}
.info-grid {
@@ -383,41 +477,62 @@
}
.info-card {
background: white;
border: 1px solid #dee2e6;
background: #f8f9fa;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
}
.info-card h4 {
margin: 0 0 0.5rem 0;
color: #495057;
font-size: 1rem;
color: #374151;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.info-card p {
margin: 0;
color: #6c757d;
color: #1f2937;
font-size: 1.1rem;
font-weight: 500;
}
.danger-section {
background: #f8d7da;
border: 2px solid #dc3545;
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
/* Danger Zone */
.danger-zone {
margin-top: 3rem;
background: #fef2f2;
border: 2px solid #fecaca;
border-radius: 12px;
overflow: hidden;
}
.danger-section h3 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #721c24;
.danger-header {
background: #fee2e2;
padding: 1.5rem;
border-bottom: 1px solid #fecaca;
}
.danger-title {
font-size: 1.25rem;
font-weight: 600;
color: #991b1b;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.danger-title i {
font-size: 1.5rem;
}
.danger-content {
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
gap: 1.5rem;
}
.danger-item {
@@ -427,82 +542,145 @@
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #dc3545;
border: 1px solid #fecaca;
gap: 2rem;
}
.danger-info h4 {
margin: 0 0 0.5rem 0;
color: #721c24;
color: #991b1b;
font-weight: 600;
}
.danger-info p {
margin: 0;
color: #721c24;
color: #7f1d1d;
font-size: 0.9rem;
}
.actions-section {
background: #f8f9fa;
border-radius: 8px;
padding: 2rem;
}
.actions-section h3 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #495057;
line-height: 1.5;
}
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
gap: 1.5rem;
}
.action-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
gap: 1.5rem;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: all 0.2s;
transition: all 0.2s ease;
border: 2px solid transparent;
align-items: center;
}
.action-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
background: white;
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
text-decoration: none;
color: inherit;
}
.action-icon {
font-size: 2rem;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border-radius: 8px;
font-size: 2.5rem;
flex-shrink: 0;
}
.action-icon i {
font-size: 2.5rem;
color: #667eea;
}
.action-content h4 {
margin: 0 0 0.5rem 0;
color: #495057;
margin: 0 0 0.25rem 0;
color: #1f2937;
font-weight: 600;
font-size: 1.1rem;
}
.action-content p {
margin: 0;
color: #6c757d;
font-size: 0.875rem;
color: #6b7280;
font-size: 0.9rem;
}
/* Button styles now centralized in main style.css */
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid rgba(255, 255, 255, 0.3);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover {
background: #b91c1c;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
}
.btn-warning {
background: #f59e0b;
color: white;
}
.btn-warning:hover {
background: #d97706;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
}
/* Responsive Design */
@media (max-width: 768px) {
.settings-container {
padding: 1rem;
}
.page-header {
padding: 1.5rem;
}
.header-content {
flex-direction: column;
text-align: center;
}
.setting-group {
grid-template-columns: 1fr;
}
@@ -513,5 +691,26 @@
gap: 1rem;
}
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: slideIn 0.3s ease-out;
animation-fill-mode: both;
}
.card:nth-child(1) { animation-delay: 0.1s; }
.card:nth-child(2) { animation-delay: 0.2s; }
.card:nth-child(3) { animation-delay: 0.3s; }
</style>
{% endblock %}

View File

@@ -1,16 +1,35 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="header-section">
<h1>⏱️ System Admin - Time Entries</h1>
<p class="subtitle">View time entries across all companies</p>
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
<div class="time-entries-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon"><i class="ti ti-clock"></i></span>
System Admin - Time Entries
</h1>
<p class="page-subtitle">View time entries across all companies</p>
</div>
<div class="header-actions">
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">
<i class="ti ti-arrow-left"></i>
Back to Dashboard
</a>
</div>
</div>
</div>
<!-- Filter Section -->
<div class="filter-section">
<h3>Filter Time Entries</h3>
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-filter"></i></span>
Filter Time Entries
</h2>
</div>
<div class="card-body">
<form method="GET" class="filter-form">
<div class="filter-group">
<label for="company">Company:</label>
@@ -28,11 +47,13 @@
<a href="{{ url_for('system_admin.system_admin_time_entries') }}" class="btn btn-sm btn-outline">Clear Filter</a>
{% endif %}
</form>
</div>
</div>
<!-- Time Entries Table -->
{% if entries.items %}
<div class="table-section">
<div class="card">
<div class="card-body no-padding">
<table class="table">
<thead>
<tr>
@@ -105,6 +126,7 @@
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
@@ -112,7 +134,7 @@
<div class="pagination-section">
<div class="pagination">
{% if entries.has_prev %}
<a href="{{ url_for('system_admin.system_admin_time_entries', page=entries.prev_num, company=current_company) }}" class="page-link"> Previous</a>
<a href="{{ url_for('system_admin.system_admin_time_entries', page=entries.prev_num, company=current_company) }}" class="page-link"><i class="ti ti-arrow-left"></i> Previous</a>
{% endif %}
{% for page_num in entries.iter_pages() %}
@@ -128,7 +150,7 @@
{% endfor %}
{% if entries.has_next %}
<a href="{{ url_for('system_admin.system_admin_time_entries', page=entries.next_num, company=current_company) }}" class="page-link">Next </a>
<a href="{{ url_for('system_admin.system_admin_time_entries', page=entries.next_num, company=current_company) }}" class="page-link">Next <i class="ti ti-arrow-right"></i></a>
{% endif %}
</div>
@@ -152,55 +174,127 @@
<!-- Summary Statistics -->
{% if entries.items %}
<div class="summary-section">
<h3>📊 Summary Statistics</h3>
<div class="summary-grid">
<div class="summary-card">
<h4>Total Entries</h4>
<p class="summary-number">{{ entries.total }}</p>
</div>
<div class="summary-card">
<h4>Active Sessions</h4>
<p class="summary-number">{{ entries.items | selectattr('0.departure_time', 'equalto', None) | list | length }}</p>
</div>
<div class="summary-card">
<h4>Paused Sessions</h4>
<p class="summary-number">{{ entries.items | selectattr('0.is_paused', 'equalto', True) | list | length }}</p>
</div>
<div class="summary-card">
<h4>Completed Today</h4>
<p class="summary-number">
{{ entries.items | selectattr('0.arrival_time') | selectattr('0.departure_time', 'defined') |
list | length }}
</p>
<div class="stats-section">
<div class="stat-card">
<div class="stat-value">{{ entries.total }}</div>
<div class="stat-label">Total Entries</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ entries.items | selectattr('0.departure_time', 'equalto', None) | list | length }}</div>
<div class="stat-label">Active Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ entries.items | selectattr('0.is_paused', 'equalto', True) | list | length }}</div>
<div class="stat-label">Paused Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value">
{{ entries.items | selectattr('0.arrival_time') | selectattr('0.departure_time', 'defined') |
list | length }}
</div>
<div class="stat-label">Completed Today</div>
</div>
</div>
{% endif %}
</div>
<style>
.header-section {
/* Container */
.time-entries-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Page Header */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.subtitle {
color: #6c757d;
margin-bottom: 1rem;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.filter-section {
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Cards */
.card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
margin-bottom: 1.5rem;
overflow: hidden;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.card-header {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
border-bottom: 1px solid #e5e7eb;
}
.filter-section h3 {
margin-top: 0;
margin-bottom: 1rem;
.card-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: #1f2937;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-title .icon {
font-size: 1.5rem;
}
.card-body {
padding: 1.5rem;
}
.card-body.no-padding {
padding: 0;
}
/* Filter Form */
.filter-form {
display: flex;
align-items: end;
@@ -215,24 +309,24 @@
}
.filter-group label {
font-weight: 500;
color: #495057;
font-weight: 600;
color: #374151;
font-size: 0.875rem;
}
.form-control {
padding: 0.5rem;
border: 1px solid #ced4da;
border-radius: 4px;
padding: 0.625rem 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
min-width: 200px;
min-width: 250px;
transition: all 0.2s ease;
}
.table-section {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 2rem;
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.table {
@@ -252,7 +346,10 @@
.table th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 0.8rem;
}
.paused-entry {
@@ -260,18 +357,18 @@
}
.company-name {
font-weight: 500;
color: #495057;
font-weight: 600;
color: #1f2937;
}
.project-name {
color: #007bff;
font-weight: 500;
color: #667eea;
font-weight: 600;
}
.duration {
font-weight: 600;
color: #28a745;
font-weight: 700;
color: #10b981;
}
.notes {
@@ -284,25 +381,26 @@
}
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
font-weight: 600;
text-transform: uppercase;
}
.status-active {
background: #d4edda;
color: #155724;
background: #d1fae5;
color: #065f46;
}
.status-paused {
background: #fff3cd;
color: #856404;
background: #fef3c7;
color: #92400e;
}
.status-completed {
background: #d1ecf1;
color: #0c5460;
background: #dbeafe;
color: #1e40af;
}
.pagination-section {
@@ -320,21 +418,27 @@
}
.page-link {
padding: 0.5rem 0.75rem;
border: 1px solid #dee2e6;
color: #007bff;
padding: 0.5rem 1rem;
border: 1px solid #e5e7eb;
color: #667eea;
text-decoration: none;
border-radius: 4px;
border-radius: 8px;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.page-link:hover {
background: #e9ecef;
background: #f3f4f6;
border-color: #667eea;
}
.page-link.current {
background: #007bff;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: #007bff;
border-color: transparent;
}
.pagination-info {
@@ -349,57 +453,130 @@
color: #6c757d;
}
.summary-section {
background: #f8f9fa;
border-radius: 8px;
padding: 2rem;
}
.summary-section h3 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #495057;
}
.summary-grid {
/* Stats Section */
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
gap: 1.5rem;
margin-top: 2rem;
}
.summary-card {
.stat-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
border-radius: 12px;
text-align: center;
border: 1px solid #dee2e6;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.summary-card h4 {
margin: 0 0 0.5rem 0;
color: #6c757d;
font-size: 0.875rem;
font-weight: 500;
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.summary-number {
font-size: 2rem;
.stat-value {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: #667eea;
}
.stat-label {
font-size: 0.9rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
color: #007bff;
margin: 0;
}
/* Button styles now centralized in main style.css */
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid rgba(255, 255, 255, 0.3);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
.btn-sm {
font-size: 0.875rem;
padding: 0.5rem 1rem;
}
.btn-outline {
background: transparent;
color: #007bff;
border: 1px solid #007bff;
color: #667eea;
border: 2px solid #667eea;
}
.btn-outline:hover {
background: #007bff;
background: #667eea;
color: white;
}
/* Responsive Design */
@media (max-width: 768px) {
.time-entries-container {
padding: 1rem;
}
.page-header {
padding: 1.5rem;
}
.header-content {
flex-direction: column;
text-align: center;
}
.table {
font-size: 0.8rem;
}
.table th,
.table td {
padding: 0.5rem;
}
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: slideIn 0.3s ease-out;
animation-fill-mode: both;
}
.card:nth-child(1) { animation-delay: 0.1s; }
.card:nth-child(2) { animation-delay: 0.2s; }
.card:nth-child(3) { animation-delay: 0.3s; }
</style>
{% endblock %}

View File

@@ -1,16 +1,35 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<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.system_admin_dashboard') }}" class="btn btn-md btn-secondary">← Back to Dashboard</a>
<div class="users-admin-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon"><i class="ti ti-users"></i></span>
System Admin - All Users
</h1>
<p class="page-subtitle">Manage users across all companies</p>
</div>
<div class="header-actions">
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">
<i class="ti ti-arrow-left"></i>
Back to Dashboard
</a>
</div>
</div>
</div>
<!-- Filter Options -->
<div class="filter-section">
<h3>Filter Users</h3>
<div class="card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"><i class="ti ti-filter"></i></span>
Filter Users
</h2>
</div>
<div class="card-body">
<div class="filter-buttons">
<a href="{{ url_for('users.system_admin_users') }}"
class="btn btn-filter {% if not current_filter %}active{% endif %}">
@@ -37,11 +56,13 @@
Freelancers
</a>
</div>
</div>
</div>
<!-- Users Table -->
{% if users.items %}
<div class="table-section">
<div class="card">
<div class="card-body no-padding">
<table class="table">
<thead>
<tr>
@@ -117,6 +138,7 @@
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
@@ -124,7 +146,7 @@
<div class="pagination-section">
<div class="pagination">
{% if users.has_prev %}
<a href="{{ url_for('users.system_admin_users', page=users.prev_num, filter=current_filter) }}" class="page-link"> Previous</a>
<a href="{{ url_for('users.system_admin_users', page=users.prev_num, filter=current_filter) }}" class="page-link"><i class="ti ti-arrow-left"></i> Previous</a>
{% endif %}
{% for page_num in users.iter_pages() %}
@@ -140,7 +162,7 @@
{% endfor %}
{% if users.has_next %}
<a href="{{ url_for('users.system_admin_users', page=users.next_num, filter=current_filter) }}" class="page-link">Next </a>
<a href="{{ url_for('users.system_admin_users', page=users.next_num, filter=current_filter) }}" class="page-link">Next <i class="ti ti-arrow-right"></i></a>
{% endif %}
</div>
@@ -160,60 +182,132 @@
</div>
<style>
.header-section {
/* Container */
.users-admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Page Header */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.subtitle {
color: #6c757d;
margin-bottom: 1rem;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 2rem;
}
.filter-section {
.page-title {
font-size: 2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-icon {
font-size: 2.5rem;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Cards */
.card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
margin-bottom: 1.5rem;
overflow: hidden;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.card-header {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
border-bottom: 1px solid #e5e7eb;
}
.filter-section h3 {
margin-top: 0;
margin-bottom: 1rem;
.card-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: #1f2937;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-title .icon {
font-size: 1.5rem;
}
.card-body {
padding: 1.5rem;
}
.card-body.no-padding {
padding: 0;
}
/* Filter Buttons */
.filter-buttons {
display: flex;
gap: 0.5rem;
gap: 0.75rem;
flex-wrap: wrap;
}
.btn-filter {
padding: 0.5rem 1rem;
border: 1px solid #dee2e6;
border: 1px solid #e5e7eb;
background: white;
color: #495057;
color: #6b7280;
text-decoration: none;
border-radius: 4px;
transition: all 0.2s;
border-radius: 8px;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-filter:hover {
background: #e9ecef;
background: #f3f4f6;
border-color: #667eea;
color: #667eea;
}
.btn-filter.active {
background: #007bff;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: #007bff;
}
.table-section {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border-color: transparent;
}
/* Table */
.table {
width: 100%;
border-collapse: collapse;
@@ -224,120 +318,127 @@
.table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #dee2e6;
border-bottom: 1px solid #e5e7eb;
}
.table th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 0.8rem;
}
.table tr:hover {
background: #f8f9fa;
}
.blocked-user {
background-color: #f8d7da !important;
background-color: #fef2f2 !important;
}
.company-name {
font-weight: 600;
color: #1f2937;
}
/* Badges */
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
font-weight: 600;
text-transform: uppercase;
}
.badge-self {
background: #d1ecf1;
color: #0c5460;
background: #dbeafe;
color: #1e40af;
}
.badge-personal {
background: #fff3cd;
color: #856404;
background: #fef3c7;
color: #92400e;
}
.badge-company {
background: #d1ecf1;
color: #0c5460;
background: #dbeafe;
color: #1e40af;
}
.badge-freelancer {
background: #d4edda;
color: #155724;
background: #d1fae5;
color: #065f46;
}
/* Role Badges */
.role-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
font-weight: 600;
text-transform: uppercase;
}
.role-team_member {
background: #e2e3e5;
color: #495057;
background: #e5e7eb;
color: #374151;
}
.role-team_leader {
background: #d4edda;
color: #155724;
background: #d1fae5;
color: #065f46;
}
.role-supervisor {
background: #d1ecf1;
color: #0c5460;
background: #dbeafe;
color: #1e40af;
}
.role-admin {
background: #fff3cd;
color: #856404;
background: #fef3c7;
color: #92400e;
}
.role-system_admin {
background: #f1c0e8;
color: #6a1b99;
background: #ede9fe;
color: #5b21b6;
}
/* Status Badges */
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
font-weight: 600;
text-transform: uppercase;
}
.status-active {
background: #d4edda;
color: #155724;
background: #d1fae5;
color: #065f46;
}
.status-blocked {
background: #f8d7da;
color: #721c24;
background: #fee2e2;
color: #991b1b;
}
.status-unverified {
background: #fff3cd;
color: #856404;
background: #fef3c7;
color: #92400e;
}
/* Action Buttons */
.action-buttons {
display: flex;
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;
}
/* Button styles now centralized in main style.css */
/* Pagination */
.pagination-section {
margin-top: 2rem;
margin: 2rem 0;
display: flex;
justify-content: space-between;
align-items: center;
@@ -351,37 +452,156 @@
}
.page-link {
padding: 0.5rem 0.75rem;
border: 1px solid #dee2e6;
color: #007bff;
padding: 0.5rem 1rem;
border: 1px solid #e5e7eb;
color: #667eea;
text-decoration: none;
border-radius: 4px;
border-radius: 8px;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.page-link:hover {
background: #e9ecef;
background: #f3f4f6;
border-color: #667eea;
}
.page-link.current {
background: #007bff;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: #007bff;
border-color: transparent;
}
.pagination-info {
color: #6c757d;
color: #6b7280;
margin: 0;
font-size: 0.9rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem;
color: #6c757d;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.company-name {
font-weight: 500;
.empty-state h3 {
font-size: 1.5rem;
color: #1f2937;
margin-bottom: 0.5rem;
}
.empty-state p {
color: #6b7280;
font-size: 1.1rem;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid rgba(255, 255, 255, 0.3);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover {
background: #b91c1c;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
}
.btn-sm {
font-size: 0.875rem;
padding: 0.5rem 1rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.users-admin-container {
padding: 1rem;
}
.page-header {
padding: 1.5rem;
}
.header-content {
flex-direction: column;
text-align: center;
}
.table {
font-size: 0.8rem;
}
.table th,
.table td {
padding: 0.5rem;
}
.action-buttons {
flex-direction: column;
gap: 0.25rem;
}
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: slideIn 0.3s ease-out;
animation-fill-mode: both;
}
.card:nth-child(1) { animation-delay: 0.1s; }
.card:nth-child(2) { animation-delay: 0.2s; }
.card:nth-child(3) { animation-delay: 0.3s; }
</style>
{% endblock %}

View File

@@ -11,7 +11,7 @@
<!-- Basic Information -->
<div class="form-section">
<h3>📝 Basic Information</h3>
<h3><i class="ti ti-file-text"></i> Basic Information</h3>
<div class="form-row">
<div class="form-group">
<label for="task-name">Task Name *</label>
@@ -48,7 +48,7 @@
<!-- Assignment & Planning -->
<div class="form-section">
<h3>👥 Assignment & Planning</h3>
<h3><i class="ti ti-users"></i> Assignment & Planning</h3>
<div class="form-row">
<div class="form-group">
<label for="task-project">Project</label>
@@ -89,7 +89,7 @@
<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>
<button type="button" class="calendar-picker-btn" onclick="openCalendarPicker('task-due-date')" title="Open calendar"><i class="ti ti-calendar"></i></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>
@@ -97,11 +97,11 @@
<!-- Dependencies -->
<div class="form-section">
<h3>🔗 Dependencies</h3>
<h3><i class="ti ti-link"></i> Dependencies</h3>
<div class="dependencies-grid">
<!-- Blocked By -->
<div class="dependency-column">
<h4>🚫 Blocked By</h4>
<h4><i class="ti ti-ban"></i> 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 -->
@@ -114,7 +114,7 @@
<!-- Blocks -->
<div class="dependency-column">
<h4>🔒 Blocks</h4>
<h4><i class="ti ti-lock"></i> 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 -->
@@ -129,17 +129,17 @@
<!-- Subtasks -->
<div class="form-section">
<h3>📋 Subtasks</h3>
<h3><i class="ti ti-clipboard-list"></i> 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>
<button type="button" class="btn btn-sm btn-secondary" onclick="addSubtask()"><i class="ti ti-plus"></i> Add Subtask</button>
</div>
</form>
<!-- Comments Section (outside form) -->
<div class="form-section" id="comments-section" style="display: none;">
<h3>💬 Comments</h3>
<h3><i class="ti ti-message-circle"></i> Comments</h3>
<div id="comments-container">
<!-- Comments will be populated here -->
</div>
@@ -147,8 +147,8 @@
<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>
<option value="COMPANY"><i class="ti ti-building"></i> Company</option>
<option value="TEAM"><i class="ti ti-users"></i> Team Only</option>
</select>
<button type="button" class="btn btn-sm btn-primary" onclick="addComment()">Post Comment</button>
</div>

View File

@@ -1,694 +0,0 @@
{% extends 'layout.html' %}
{% block content %}
<div class="team-form-container">
<!-- Header Section -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="team-icon">👥</span>
{% if team %}
{{ team.name }}
{% else %}
Create New Team
{% endif %}
</h1>
<p class="page-subtitle">
{% if team %}
Manage team details and members
{% else %}
Set up a new team for your organization
{% endif %}
</p>
</div>
<div class="header-actions">
<a href="{{ url_for('teams.admin_teams') }}" class="btn btn-outline">
<i class="icon"></i> Back to Teams
</a>
</div>
</div>
</div>
<!-- Main Content Grid -->
<div class="content-grid">
<!-- Left Column: Team Details -->
<div class="content-column">
<!-- Team Details Card -->
<div class="card team-details-card">
<div class="card-header">
<h2 class="card-title">
<span class="icon">📝</span>
Team Details
</h2>
</div>
<div class="card-body">
<form method="POST" action="{% if team %}{{ url_for('teams.manage_team', team_id=team.id) }}{% else %}{{ url_for('teams.create_team') }}{% endif %}" class="modern-form">
{% if team %}
<input type="hidden" name="action" value="update_team">
{% endif %}
<div class="form-group">
<label for="name" class="form-label">Team Name</label>
<input type="text"
class="form-control"
id="name"
name="name"
value="{{ team.name if team else '' }}"
placeholder="Enter team name"
required>
<span class="form-hint">Choose a descriptive name for your team</span>
</div>
<div class="form-group">
<label for="description" class="form-label">Description</label>
<textarea class="form-control"
id="description"
name="description"
rows="4"
placeholder="Describe the team's purpose and responsibilities...">{{ team.description if team else '' }}</textarea>
<span class="form-hint">Optional: Add details about this team's role</span>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span class="icon"></span>
{% if team %}Save Changes{% else %}Create Team{% endif %}
</button>
{% if not team %}
<a href="{{ url_for('teams.admin_teams') }}" class="btn btn-outline">Cancel</a>
{% endif %}
</div>
</form>
</div>
</div>
{% if team %}
<!-- Team Statistics -->
<div class="card stats-card">
<div class="card-header">
<h2 class="card-title">
<span class="icon">📊</span>
Team Statistics
</h2>
</div>
<div class="card-body">
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">{{ team_members|length if team_members else 0 }}</div>
<div class="stat-label">Team Members</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ team.projects|length if team.projects else 0 }}</div>
<div class="stat-label">Active Projects</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ team.created_at.strftime('%b %Y') if team.created_at else 'N/A' }}</div>
<div class="stat-label">Created</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Right Column: Team Members (only for existing teams) -->
{% if team %}
<div class="content-column">
<!-- Current Members Card -->
<div class="card members-card">
<div class="card-header">
<h2 class="card-title">
<span class="icon">👤</span>
Team Members
</h2>
<span class="member-count">{{ team_members|length if team_members else 0 }} members</span>
</div>
<div class="card-body">
{% if team_members %}
<div class="members-list">
{% for member in team_members %}
<div class="member-item">
<div class="member-avatar">
{{ member.username[:2].upper() }}
</div>
<div class="member-info">
<div class="member-name">{{ member.username }}</div>
<div class="member-details">
<span class="member-email">{{ member.email }}</span>
<span class="member-role role-badge role-{{ member.role.name.lower() }}">
{{ member.role.value }}
</span>
</div>
</div>
<div class="member-actions">
<form method="POST" action="{{ url_for('teams.manage_team', team_id=team.id) }}" class="remove-form">
<input type="hidden" name="action" value="remove_member">
<input type="hidden" name="user_id" value="{{ member.id }}">
<button type="submit"
class="btn-icon btn-danger"
onclick="return confirm('Remove {{ member.username }} from the team?')"
title="Remove from team">
<span class="icon">×</span>
</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">👥</div>
<p class="empty-message">No members in this team yet</p>
<p class="empty-hint">Add members using the form below</p>
</div>
{% endif %}
</div>
</div>
<!-- Add Member Card -->
<div class="card add-member-card">
<div class="card-header">
<h2 class="card-title">
<span class="icon"></span>
Add Team Member
</h2>
</div>
<div class="card-body">
{% if available_users %}
<form method="POST" action="{{ url_for('teams.manage_team', team_id=team.id) }}" class="modern-form">
<input type="hidden" name="action" value="add_member">
<div class="form-group">
<label for="user_id" class="form-label">Select User</label>
<select class="form-control form-select" id="user_id" name="user_id" required>
<option value="">Choose a user to add...</option>
{% for user in available_users %}
<option value="{{ user.id }}">
{{ user.username }} - {{ user.email }}
{% if user.role %}({{ user.role.value }}){% endif %}
</option>
{% endfor %}
</select>
<span class="form-hint">Only users not already in a team are shown</span>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-success">
<span class="icon">+</span>
Add to Team
</button>
</div>
</form>
{% else %}
<div class="empty-state">
<div class="empty-icon"></div>
<p class="empty-message">All users are assigned</p>
<p class="empty-hint">No available users to add to this team</p>
</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<!-- Placeholder for new teams -->
<div class="content-column">
<div class="card info-card">
<div class="card-body">
<div class="info-content">
<div class="info-icon">💡</div>
<h3>Team Members</h3>
<p>After creating the team, you'll be able to add members and manage team composition from this page.</p>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<style>
/* Container and Layout */
.team-form-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Page Header */
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 2.5rem;
margin-bottom: 2rem;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1.5rem;
}
.header-left {
flex: 1;
}
.page-title {
font-size: 2.5rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 1rem;
}
.team-icon {
font-size: 3rem;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.page-subtitle {
font-size: 1.1rem;
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Content Grid */
.content-grid {
display: grid;
grid-template-columns: 1fr 1.5fr;
gap: 2rem;
margin-bottom: 2rem;
}
@media (max-width: 1024px) {
.content-grid {
grid-template-columns: 1fr;
}
}
/* Cards */
.card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
margin-bottom: 1.5rem;
overflow: hidden;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.card-header {
background: #f8f9fa;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: #1f2937;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-title .icon {
font-size: 1.5rem;
}
.card-body {
padding: 1.5rem;
}
/* Info Card for New Teams */
.info-card {
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
border: none;
}
.info-content {
text-align: center;
padding: 2rem;
}
.info-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.info-content h3 {
color: #1f2937;
margin-bottom: 0.5rem;
}
.info-content p {
color: #6b7280;
font-size: 1.05rem;
line-height: 1.6;
}
/* Forms */
.modern-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-weight: 600;
color: #374151;
font-size: 0.95rem;
}
.form-control {
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s ease;
background-color: #f9fafb;
}
.form-control:focus {
outline: none;
border-color: #667eea;
background-color: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-select {
cursor: pointer;
}
.form-hint {
font-size: 0.875rem;
color: #6b7280;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
}
.btn-success:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.btn-outline {
background: white;
color: #6b7280;
border: 2px solid #e5e7eb;
}
.btn-outline:hover {
background: #f3f4f6;
border-color: #d1d5db;
}
.btn-icon {
width: 36px;
height: 36px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-danger {
background: #fee2e2;
color: #dc2626;
}
.btn-danger:hover {
background: #dc2626;
color: white;
}
/* Team Statistics */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.stat-item {
text-align: center;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #667eea;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
font-weight: 500;
}
/* Members List */
.members-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.member-item {
display: flex;
align-items: center;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
transition: all 0.2s ease;
}
.member-item:hover {
background: #f3f4f6;
transform: translateX(4px);
}
.member-avatar {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1.1rem;
margin-right: 1rem;
}
.member-info {
flex: 1;
}
.member-name {
font-weight: 600;
color: #1f2937;
font-size: 1.05rem;
}
.member-details {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.25rem;
}
.member-email {
color: #6b7280;
font-size: 0.875rem;
}
.member-count {
background: #e5e7eb;
color: #6b7280;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
}
/* Role Badges */
.role-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.role-team_member {
background: #dbeafe;
color: #1e40af;
}
.role-team_leader {
background: #fef3c7;
color: #92400e;
}
.role-supervisor {
background: #ede9fe;
color: #5b21b6;
}
.role-admin {
background: #fee2e2;
color: #991b1b;
}
.role-system_admin {
background: #fce7f3;
color: #be185d;
}
/* Empty States */
.empty-state {
text-align: center;
padding: 3rem 1.5rem;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.3;
}
.empty-message {
font-size: 1.1rem;
color: #1f2937;
margin-bottom: 0.5rem;
}
.empty-hint {
color: #6b7280;
font-size: 0.875rem;
}
/* Remove Form */
.remove-form {
margin: 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.team-form-container {
padding: 1rem;
}
.page-header {
padding: 1.5rem;
}
.page-title {
font-size: 2rem;
}
.stats-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.member-details {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
/* Animation */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: slideIn 0.3s ease-out;
}
.card:nth-child(2) {
animation-delay: 0.1s;
}
.card:nth-child(3) {
animation-delay: 0.2s;
}
</style>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,67 @@
{% extends "layout.html" %}
{% block content %}
<div class="management-container task-management-container">
<div class="page-container">
<!-- Header Section -->
<div class="management-header task-header">
<h1>📋 Task Management</h1>
<div class="management-controls task-controls">
<!-- Smart Search -->
<div class="smart-search-container">
<div class="smart-search-box">
<input type="text" id="smart-search-input" class="smart-search-input" placeholder="Search tasks... (e.g., my-tasks priority:high, project:TimeTrack, overdue)">
<button type="button" class="smart-search-clear" id="smart-search-clear" title="Clear search">×</button>
</div>
<div class="smart-search-suggestions" id="smart-search-suggestions" style="display: none;">
<!-- Suggestions will be populated here -->
</div>
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">
<span class="page-icon"><i class="ti ti-clipboard-list"></i></span>
Task Management
</h1>
<p class="page-subtitle">Manage and track all tasks across projects</p>
</div>
<div class="header-actions">
<button id="refresh-tasks" class="btn btn-secondary">
<i class="ti ti-refresh"></i>
Refresh
</button>
<button id="toggle-archived" class="btn btn-outline" title="Show/Hide Archived Tasks">
<i class="ti ti-archive"></i>
Show Archived
</button>
<button id="add-task-btn" class="btn btn-primary">
<i class="ti ti-plus"></i>
Add Task
</button>
</div>
</div>
</div>
<!-- Actions -->
<div class="management-actions task-actions">
<button id="add-task-btn" class="btn btn-primary">+ Add Task</button>
<button id="refresh-tasks" class="btn btn-secondary">🔄 Refresh</button>
<button id="toggle-archived" class="btn btn-outline" title="Show/Hide Archived Tasks">📦 Show Archived</button>
<!-- Search Section -->
<div class="search-section">
<div class="smart-search-container">
<div class="smart-search-box">
<input type="text" id="smart-search-input" class="smart-search-input" placeholder="Search tasks... (e.g., my-tasks priority:high, project:TimeTrack, overdue)">
<button type="button" class="smart-search-clear" id="smart-search-clear" title="Clear search"><i class="ti ti-x"></i></button>
</div>
<div class="smart-search-suggestions" id="smart-search-suggestions" style="display: none;">
<!-- Suggestions will be populated here -->
</div>
</div>
</div>
<!-- Task Statistics -->
<div class="management-stats task-stats">
<div class="stats-section">
<div class="stat-card">
<div class="stat-number" id="total-tasks">0</div>
<div class="stat-value" id="total-tasks">0</div>
<div class="stat-label">Total Tasks</div>
</div>
<div class="stat-card">
<div class="stat-number" id="completed-tasks">0</div>
<div class="stat-value" id="completed-tasks">0</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-card">
<div class="stat-number" id="in-progress-tasks">0</div>
<div class="stat-value" id="in-progress-tasks">0</div>
<div class="stat-label">In Progress</div>
</div>
<div class="stat-card">
<div class="stat-number" id="overdue-tasks">0</div>
<div class="stat-value" id="overdue-tasks">0</div>
<div class="stat-label">Overdue</div>
</div>
<div class="stat-card" id="archived-stat-card" style="display: none;">
<div class="stat-number" id="archived-tasks">0</div>
<div class="stat-value" id="archived-tasks">0</div>
<div class="stat-label">Archived</div>
</div>
</div>
@@ -54,7 +70,7 @@
<div class="task-board" id="task-board">
<div class="task-column" data-status="TODO">
<div class="column-header">
<h3>📝 To Do</h3>
<h3><i class="ti ti-circle"></i> To Do</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-TODO">
@@ -64,7 +80,7 @@
<div class="task-column" data-status="IN_PROGRESS">
<div class="column-header">
<h3> In Progress</h3>
<h3><i class="ti ti-player-play"></i> In Progress</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-IN_PROGRESS">
@@ -74,7 +90,7 @@
<div class="task-column" data-status="IN_REVIEW">
<div class="column-header">
<h3>🔍 In Review</h3>
<h3><i class="ti ti-eye"></i> In Review</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-IN_REVIEW">
@@ -84,7 +100,7 @@
<div class="task-column" data-status="DONE">
<div class="column-header">
<h3> Done</h3>
<h3><i class="ti ti-circle-check"></i> Done</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-DONE">
@@ -94,7 +110,7 @@
<div class="task-column" data-status="CANCELLED">
<div class="column-header">
<h3> Cancelled</h3>
<h3><i class="ti ti-circle-x"></i> Cancelled</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-CANCELLED">
@@ -104,7 +120,7 @@
<div class="task-column archived-column" data-status="ARCHIVED" style="display: none;">
<div class="column-header">
<h3>📦 Archived</h3>
<h3><i class="ti ti-archive"></i> Archived</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-ARCHIVED">
@@ -129,12 +145,26 @@
<!-- Styles -->
<style>
/* Header adjustments for Task Management */
.header-actions {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
/* Search Section */
.search-section {
margin-bottom: 1.5rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
}
/* Smart Search Styles */
.smart-search-container {
margin-bottom: 1rem;
position: relative;
width: 100%;
flex: 1;
}
.smart-search-box {
@@ -237,71 +267,9 @@
font-size: 0.8rem;
}
/* Task Management Layout */
.task-controls {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
width: 100%;
}
/* Task Management specific styles removed - using common page styles */
.task-controls .smart-search-container {
flex: 1;
min-width: 300px;
max-width: 600px;
margin-bottom: 0; /* Remove margin to align with buttons */
}
.task-controls .management-actions {
flex-shrink: 0;
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Ensure all buttons and search input have same height */
.smart-search-input,
.task-controls .btn {
height: 38px; /* Standard height for consistency */
}
.task-controls .btn {
padding: 0.5rem 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap; /* Prevent button text from wrapping */
}
/* Responsive adjustments */
@media (max-width: 992px) {
.task-controls {
flex-direction: column;
align-items: stretch;
}
.task-controls .smart-search-container {
max-width: 100%;
margin-bottom: 0.5rem;
}
.task-controls .management-actions {
justify-content: center;
}
}
@media (max-width: 576px) {
.task-controls .management-actions {
flex-wrap: wrap;
gap: 0.25rem;
}
.task-controls .btn {
font-size: 0.875rem;
padding: 0.4rem 0.8rem;
}
}
/* Responsive adjustments handled by common page styles */
/* Subtask progress styles */
.task-subtasks {
@@ -336,18 +304,6 @@
white-space: nowrap;
}
@media (max-width: 768px) {
.task-controls {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.task-controls .smart-search-container {
min-width: auto;
max-width: none;
}
}
@@ -1644,13 +1600,13 @@ class UnifiedTaskManager {
if (task.status === 'COMPLETED') {
actionButtons = `
<div class="task-actions">
<button class="archive-btn" onclick="taskManager.archiveTask(${task.id}); event.stopPropagation();" title="Archive Task">📦</button>
<button class="archive-btn" onclick="taskManager.archiveTask(${task.id}); event.stopPropagation();" title="Archive Task"><i class="ti ti-archive"></i></button>
</div>
`;
} else if (task.status === 'ARCHIVED') {
actionButtons = `
<div class="task-actions">
<button class="restore-btn" onclick="taskManager.restoreTask(${task.id}); event.stopPropagation();" title="Restore Task">↩️</button>
<button class="restore-btn" onclick="taskManager.restoreTask(${task.id}); event.stopPropagation();" title="Restore Task"><i class="ti ti-arrow-back-up"></i></button>
</div>
`;
}
@@ -1727,12 +1683,12 @@ class UnifiedTaskManager {
const archivedStatCard = document.getElementById('archived-stat-card');
if (this.showArchived) {
toggleBtn.textContent = '📦 Hide Archived';
toggleBtn.innerHTML = '<i class="ti ti-archive"></i> Hide Archived';
toggleBtn.classList.add('active');
archivedColumn.style.display = 'block';
archivedStatCard.style.display = 'block';
} else {
toggleBtn.textContent = '📦 Show Archived';
toggleBtn.innerHTML = '<i class="ti ti-archive"></i> Show Archived';
toggleBtn.classList.remove('active');
archivedColumn.style.display = 'none';
archivedStatCard.style.display = 'none';
@@ -2141,7 +2097,7 @@ class UnifiedTaskManager {
element.dataset.commentId = comment.id;
const visibilityBadge = comment.visibility === 'Team' ?
'<span class="comment-visibility-badge team">👥 Team</span>' : '';
'<span class="comment-visibility-badge team"><i class="ti ti-users"></i> Team</span>' : '';
const editedText = comment.is_edited ?
` <span class="comment-edited">(edited)</span>` : '';

View File

@@ -22,7 +22,7 @@
<div class="help-section">
<p><small>Having trouble? Make sure your device's time is synchronized and try a new code.</small></p>
<p><small><a href="{{ url_for('login') }}"> Back to Login</a></small></p>
<p><small><a href="{{ url_for('login') }}"><i class="ti ti-arrow-left"></i> Back to Login</a></small></p>
</div>
</div>
</div>