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:
54
app.py
54
app.py
@@ -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)
|
||||
@@ -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
70
frontmatter_utils.py
Normal 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'
|
||||
20
migrations/add_cascade_delete_note_links.sql
Normal file
20
migrations/add_cascade_delete_note_links.sql
Normal 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;
|
||||
25
migrations/add_cascade_delete_note_links_sqlite.sql
Normal file
25
migrations/add_cascade_delete_note_links_sqlite.sql
Normal 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;
|
||||
5
migrations/add_folder_to_notes.sql
Normal file
5
migrations/add_folder_to_notes.sql
Normal 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;
|
||||
17
migrations/add_note_folder_table.sql
Normal file
17
migrations/add_note_folder_table.sql
Normal 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);
|
||||
21
migrations/add_note_sharing.sql
Normal file
21
migrations/add_note_sharing.sql
Normal 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';
|
||||
@@ -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')
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
316
models/note.py
Normal 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
107
models/note_share.py
Normal 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()
|
||||
}
|
||||
266
models_old.py
266
models_old.py
@@ -1241,4 +1241,268 @@ class WidgetTemplate(db.Model):
|
||||
user_level = role_hierarchy.get(user.role, 0)
|
||||
required_level = role_hierarchy.get(self.required_role, 0)
|
||||
|
||||
return user_level >= required_level
|
||||
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}>'
|
||||
@@ -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
1
routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routes package initialization
|
||||
@@ -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
498
routes/notes.py
Normal 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
560
routes/notes_api.py
Normal 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
288
routes/notes_download.py
Normal 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
191
routes/notes_public.py
Normal 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
231
routes/organization.py
Normal 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]
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 */
|
||||
|
||||
390
static/css/hover-standards.css
Normal file
390
static/css/hover-standards.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
770
static/css/time-tracking.css
Normal file
770
static/css/time-tracking.css
Normal 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
23
static/js/ace/ace.js
Normal file
File diff suppressed because one or more lines are too long
8
static/js/ace/ext-language_tools.js
Normal file
8
static/js/ace/ext-language_tools.js
Normal file
File diff suppressed because one or more lines are too long
8
static/js/ace/mode-markdown.js
Normal file
8
static/js/ace/mode-markdown.js
Normal file
File diff suppressed because one or more lines are too long
8
static/js/ace/theme-github.js
Normal file
8
static/js/ace/theme-github.js
Normal 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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
8
static/js/ace/theme-monokai.js
Normal file
8
static/js/ace/theme-monokai.js
Normal 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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
356
templates/_time_tracking_interface.html
Normal file
356
templates/_time_tracking_interface.html
Normal 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>
|
||||
146
templates/_time_tracking_modals.html
Normal file
146
templates/_time_tracking_modals.html
Normal 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">×</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">×</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">×</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
1651
templates/admin_organization.html
Normal file
1651
templates/admin_organization.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
1412
templates/index.html
1412
templates/index.html
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
1136
templates/note_editor.html
Normal file
File diff suppressed because it is too large
Load Diff
766
templates/note_mindmap.html
Normal file
766
templates/note_mindmap.html
Normal 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 %}
|
||||
1
templates/note_mindmap_backup.html
Normal file
1
templates/note_mindmap_backup.html
Normal file
@@ -0,0 +1 @@
|
||||
{% extends "layout.html" %}{% block content %}<div>Test</div>{% endblock %}
|
||||
8
templates/note_mindmap_test.html
Normal file
8
templates/note_mindmap_test.html
Normal 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 %}
|
||||
8
templates/note_mindmap_test_backup.html
Normal file
8
templates/note_mindmap_test_backup.html
Normal 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
1469
templates/note_view.html
Normal file
File diff suppressed because it is too large
Load Diff
283
templates/notes/public_view.html
Normal file
283
templates/notes/public_view.html
Normal 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>
|
||||
194
templates/notes/share_password.html
Normal file
194
templates/notes/share_password.html
Normal 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>
|
||||
642
templates/notes_folders.html
Normal file
642
templates/notes_folders.html
Normal 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()">×</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
1089
templates/notes_list.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 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>
|
||||
<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>
|
||||
|
||||
<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 %}
|
||||
@@ -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 %}
|
||||
@@ -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: <h2>, <h3>, <p>, <strong>, <br>, <a href="">
|
||||
</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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
{% 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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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
@@ -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>
|
||||
|
||||
{% 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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- 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>` : '';
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user