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 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 (
|
from data_formatting import (
|
||||||
format_duration, prepare_export_data, prepare_team_hours_export_data,
|
format_duration, prepare_export_data, prepare_team_hours_export_data,
|
||||||
format_table_data, format_graph_data, format_team_data, format_burndown_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
|
from werkzeug.security import check_password_hash
|
||||||
|
|
||||||
# Import blueprints
|
# Import blueprints
|
||||||
# from routes.notes import notes_bp
|
from routes.notes import notes_bp
|
||||||
# from routes.notes_download import notes_download_bp
|
from routes.notes_download import notes_download_bp
|
||||||
# from routes.notes_api import notes_api_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 import tasks_bp, get_filtered_tasks_for_burndown
|
||||||
from routes.tasks_api import tasks_api_bp
|
from routes.tasks_api import tasks_api_bp
|
||||||
from routes.sprints import sprints_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.announcements import announcements_bp
|
||||||
from routes.export import export_bp
|
from routes.export import export_bp
|
||||||
from routes.export_api import export_api_bp
|
from routes.export_api import export_api_bp
|
||||||
|
from routes.organization import organization_bp
|
||||||
|
|
||||||
# Import auth decorators from routes.auth
|
# Import auth decorators from routes.auth
|
||||||
from routes.auth import login_required, admin_required, system_admin_required, role_required, company_required
|
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)
|
db.init_app(app)
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
# app.register_blueprint(notes_bp)
|
app.register_blueprint(notes_bp)
|
||||||
# app.register_blueprint(notes_download_bp)
|
app.register_blueprint(notes_download_bp)
|
||||||
# app.register_blueprint(notes_api_bp)
|
app.register_blueprint(notes_api_bp)
|
||||||
|
app.register_blueprint(notes_public_bp)
|
||||||
app.register_blueprint(tasks_bp)
|
app.register_blueprint(tasks_bp)
|
||||||
app.register_blueprint(tasks_api_bp)
|
app.register_blueprint(tasks_api_bp)
|
||||||
app.register_blueprint(sprints_bp)
|
app.register_blueprint(sprints_bp)
|
||||||
@@ -103,6 +106,7 @@ app.register_blueprint(system_admin_bp)
|
|||||||
app.register_blueprint(announcements_bp)
|
app.register_blueprint(announcements_bp)
|
||||||
app.register_blueprint(export_bp)
|
app.register_blueprint(export_bp)
|
||||||
app.register_blueprint(export_api_bp)
|
app.register_blueprint(export_api_bp)
|
||||||
|
app.register_blueprint(organization_bp)
|
||||||
|
|
||||||
# Import and register invitations blueprint
|
# Import and register invitations blueprint
|
||||||
from routes.invitations import invitations_bp
|
from routes.invitations import invitations_bp
|
||||||
@@ -829,8 +833,10 @@ def verify_email(token):
|
|||||||
@role_required(Role.TEAM_MEMBER)
|
@role_required(Role.TEAM_MEMBER)
|
||||||
@company_required
|
@company_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
"""User dashboard with configurable widgets."""
|
"""User dashboard with configurable widgets - DISABLED due to widget issues."""
|
||||||
return render_template('dashboard.html', title='Dashboard')
|
# 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'])
|
@app.route('/profile', methods=['GET', 'POST'])
|
||||||
@@ -2666,6 +2672,36 @@ def search_sprints():
|
|||||||
logger.error(f"Error in search_sprints: {str(e)}")
|
logger.error(f"Error in search_sprints: {str(e)}")
|
||||||
return jsonify({'success': False, 'message': 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__':
|
if __name__ == '__main__':
|
||||||
port = int(os.environ.get('PORT', 5000))
|
port = int(os.environ.get('PORT', 5000))
|
||||||
app.run(debug=True, host='0.0.0.0', port=port)
|
app.run(debug=True, host='0.0.0.0', port=port)
|
||||||
@@ -29,18 +29,20 @@ services:
|
|||||||
|
|
||||||
timetrack:
|
timetrack:
|
||||||
build: .
|
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:
|
ports:
|
||||||
- "${TIMETRACK_PORT:-5000}:5000"
|
- "${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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
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_system_events(db_path)
|
||||||
migrate_dashboard_system(db_path)
|
migrate_dashboard_system(db_path)
|
||||||
migrate_comment_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
|
# Run PostgreSQL-specific migrations if applicable
|
||||||
if FLASK_AVAILABLE:
|
if FLASK_AVAILABLE:
|
||||||
@@ -1275,6 +1277,126 @@ def migrate_postgresql_schema():
|
|||||||
"""))
|
"""))
|
||||||
db.session.commit()
|
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!")
|
print("PostgreSQL schema migration completed successfully!")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1485,6 +1607,222 @@ def migrate_comment_system(db_file=None):
|
|||||||
conn.close()
|
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():
|
def main():
|
||||||
"""Main function with command line interface."""
|
"""Main function with command line interface."""
|
||||||
parser = argparse.ArgumentParser(description='TimeTrack Database Migration Tool')
|
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
|
# List of PostgreSQL migrations in order
|
||||||
POSTGRES_MIGRATIONS = [
|
POSTGRES_MIGRATIONS = [
|
||||||
'postgres_only_migration.py', # Main migration from commit 4214e88 onward
|
'postgres_only_migration.py', # Main migration from commit 4214e88 onward
|
||||||
|
'add_note_sharing.sql', # Add note sharing functionality
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +50,34 @@ def run_migration(migration_file):
|
|||||||
print(f"\n🔄 Running migration: {migration_file}")
|
print(f"\n🔄 Running migration: {migration_file}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run the migration script
|
# 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(
|
result = subprocess.run(
|
||||||
[sys.executable, script_path],
|
[sys.executable, script_path],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ from .announcement import Announcement
|
|||||||
from .dashboard import DashboardWidget, WidgetTemplate
|
from .dashboard import DashboardWidget, WidgetTemplate
|
||||||
from .work_config import WorkConfig
|
from .work_config import WorkConfig
|
||||||
from .invitation import CompanyInvitation
|
from .invitation import CompanyInvitation
|
||||||
|
from .note import Note, NoteVisibility, NoteLink, NoteFolder
|
||||||
|
from .note_share import NoteShare
|
||||||
|
|
||||||
# Make all models available at package level
|
# Make all models available at package level
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -45,5 +47,6 @@ __all__ = [
|
|||||||
'Announcement',
|
'Announcement',
|
||||||
'DashboardWidget', 'WidgetTemplate',
|
'DashboardWidget', 'WidgetTemplate',
|
||||||
'WorkConfig',
|
'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()
|
||||||
|
}
|
||||||
264
models_old.py
264
models_old.py
@@ -1242,3 +1242,267 @@ class WidgetTemplate(db.Model):
|
|||||||
required_level = role_hierarchy.get(self.required_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
|
xlsxwriter==3.1.2
|
||||||
Flask-Mail==0.9.1
|
Flask-Mail==0.9.1
|
||||||
psycopg2-binary==2.9.9
|
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
|
@admin_required
|
||||||
@company_required
|
@company_required
|
||||||
def company_users():
|
def company_users():
|
||||||
"""List all users in the company with detailed information"""
|
"""Redirect to the unified organization management page"""
|
||||||
users = User.query.filter_by(company_id=g.company.id).order_by(User.created_at.desc()).all()
|
return redirect(url_for('organization.admin_organization'))
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
|
||||||
# Setup company route (separate from company blueprint due to different URL)
|
# 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,
|
from models import (db, Company, User, Role, Team, Project, TimeEntry, SystemSettings,
|
||||||
SystemEvent, BrandingSettings, Task, SubTask, TaskDependency, Sprint,
|
SystemEvent, BrandingSettings, Task, SubTask, TaskDependency, Sprint,
|
||||||
Comment, UserPreferences, UserDashboard, WorkConfig, CompanySettings,
|
Comment, UserPreferences, UserDashboard, WorkConfig, CompanySettings,
|
||||||
CompanyWorkConfig, ProjectCategory)
|
CompanyWorkConfig, ProjectCategory, Note, NoteFolder, NoteShare,
|
||||||
|
Announcement, CompanyInvitation)
|
||||||
from routes.auth import system_admin_required
|
from routes.auth import system_admin_required
|
||||||
from flask import session
|
from flask import session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
@@ -226,6 +227,34 @@ def delete_company(company_id):
|
|||||||
db.session.query(User.id).filter(User.company_id == company_id)
|
db.session.query(User.id).filter(User.company_id == company_id)
|
||||||
)).delete(synchronize_session=False)
|
)).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
|
# Delete users
|
||||||
User.query.filter_by(company_id=company_id).delete()
|
User.query.filter_by(company_id=company_id).delete()
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ teams_bp = Blueprint('teams', __name__, url_prefix='/admin/teams')
|
|||||||
@admin_required
|
@admin_required
|
||||||
@company_required
|
@company_required
|
||||||
def admin_teams():
|
def admin_teams():
|
||||||
team_repo = TeamRepository()
|
# Redirect to the new unified organization management page
|
||||||
teams = team_repo.get_with_member_count(g.user.company_id)
|
return redirect(url_for('organization.admin_organization'))
|
||||||
return render_template('admin_teams.html', title='Team Management', teams=teams)
|
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/create', methods=['GET', 'POST'])
|
@teams_bp.route('/create', methods=['GET', 'POST'])
|
||||||
|
|||||||
@@ -38,9 +38,8 @@ def get_available_roles():
|
|||||||
@admin_required
|
@admin_required
|
||||||
@company_required
|
@company_required
|
||||||
def admin_users():
|
def admin_users():
|
||||||
user_repo = UserRepository()
|
# Redirect to the new unified organization management page
|
||||||
users = user_repo.get_by_company(g.user.company_id)
|
return redirect(url_for('organization.admin_organization'))
|
||||||
return render_template('admin_users.html', title='User Management', users=users)
|
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/create', methods=['GET', 'POST'])
|
@users_bp.route('/create', methods=['GET', 'POST'])
|
||||||
|
|||||||
@@ -367,7 +367,8 @@ body.auth-page::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.company-code-group::before {
|
.company-code-group::before {
|
||||||
content: '🏢';
|
content: '\eebe'; /* Tabler icon building */
|
||||||
|
font-family: 'tabler-icons';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 1rem;
|
left: 1rem;
|
||||||
top: 2.5rem; /* Position below the label */
|
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 */
|
/* Hero Section */
|
||||||
.splash-hero {
|
.splash-hero {
|
||||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 6rem 2rem;
|
padding: 6rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -21,6 +21,21 @@
|
|||||||
justify-content: center;
|
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 {
|
.hero-content {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -64,15 +79,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: #4CAF50;
|
background: #667eea;
|
||||||
color: white;
|
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 {
|
.btn-primary:hover {
|
||||||
background: #45a049;
|
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||||
transform: translateY(-2px);
|
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 {
|
.btn-secondary {
|
||||||
@@ -83,7 +118,9 @@
|
|||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background: white;
|
background: white;
|
||||||
color: #2a5298;
|
color: #667eea;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Floating Clock Animation */
|
/* Floating Clock Animation */
|
||||||
@@ -135,7 +172,7 @@
|
|||||||
width: 2px;
|
width: 2px;
|
||||||
height: 110px;
|
height: 110px;
|
||||||
margin-left: -1px;
|
margin-left: -1px;
|
||||||
background: #4CAF50;
|
background: #764ba2;
|
||||||
animation: rotate 60s linear infinite;
|
animation: rotate 60s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,8 +186,22 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
color: #333;
|
color: #1f2937;
|
||||||
font-weight: 600;
|
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 {
|
.feature-cards {
|
||||||
@@ -168,32 +219,40 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 5px 20px rgba(0,0,0,0.08);
|
box-shadow: 0 5px 20px rgba(0,0,0,0.08);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card:hover {
|
.feature-card:hover {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-3px);
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.12);
|
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 {
|
.feature-icon {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
margin-bottom: 1rem;
|
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 {
|
.feature-card h3 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #333;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card p {
|
.feature-card p {
|
||||||
color: #666;
|
color: #6b7280;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Statistics Section */
|
/* Statistics Section */
|
||||||
.statistics {
|
.statistics {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #374151 0%, #4b5563 100%);
|
||||||
padding: 5rem 2rem;
|
padding: 5rem 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
@@ -249,7 +308,7 @@
|
|||||||
/* Testimonials */
|
/* Testimonials */
|
||||||
.testimonials {
|
.testimonials {
|
||||||
padding: 5rem 2rem;
|
padding: 5rem 2rem;
|
||||||
background: white;
|
background: #f9fafb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.testimonial-grid {
|
.testimonial-grid {
|
||||||
@@ -261,10 +320,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.testimonial-card {
|
.testimonial-card {
|
||||||
background: #f8f9fa;
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
text-align: center;
|
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 {
|
.stars {
|
||||||
@@ -275,7 +344,7 @@
|
|||||||
.testimonial-card p {
|
.testimonial-card p {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #555;
|
color: #4b5563;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@@ -287,18 +356,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.testimonial-author strong {
|
.testimonial-author strong {
|
||||||
color: #333;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.testimonial-author span {
|
.testimonial-author span {
|
||||||
color: #666;
|
color: #6b7280;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pricing Section */
|
/* Pricing Section */
|
||||||
.pricing {
|
.pricing {
|
||||||
padding: 5rem 2rem;
|
padding: 5rem 2rem;
|
||||||
background: #f8f9fa;
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricing-cards {
|
.pricing-cards {
|
||||||
@@ -317,11 +386,21 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 5px 20px rgba(0,0,0,0.08);
|
box-shadow: 0 5px 20px rgba(0,0,0,0.08);
|
||||||
transition: all 0.3s ease;
|
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 {
|
.pricing-card.featured {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
box-shadow: 0 10px 40px rgba(0,0,0,0.15);
|
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 {
|
.badge {
|
||||||
@@ -329,7 +408,7 @@
|
|||||||
top: -15px;
|
top: -15px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: #4CAF50;
|
background: #667eea;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.5rem 1.5rem;
|
padding: 0.5rem 1.5rem;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@@ -340,20 +419,20 @@
|
|||||||
.pricing-card h3 {
|
.pricing-card h3 {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #333;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price {
|
.price {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #2a5298;
|
color: #667eea;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price span {
|
.price span {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #666;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricing-features {
|
.pricing-features {
|
||||||
@@ -364,8 +443,8 @@
|
|||||||
|
|
||||||
.pricing-features li {
|
.pricing-features li {
|
||||||
padding: 0.75rem 0;
|
padding: 0.75rem 0;
|
||||||
color: #555;
|
color: #4b5563;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricing-features li:last-child {
|
.pricing-features li:last-child {
|
||||||
@@ -375,7 +454,7 @@
|
|||||||
.btn-pricing {
|
.btn-pricing {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
background: #4CAF50;
|
background: #667eea;
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -385,21 +464,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-pricing:hover {
|
.btn-pricing:hover {
|
||||||
background: #45a049;
|
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricing-card.featured .btn-pricing {
|
.pricing-card.featured .btn-pricing {
|
||||||
background: #2a5298;
|
background: #764ba2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricing-card.featured .btn-pricing:hover {
|
.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 */
|
||||||
.final-cta {
|
.final-cta {
|
||||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 5rem 2rem;
|
padding: 5rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -421,6 +502,99 @@
|
|||||||
padding: 1.25rem 3rem;
|
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 */
|
/* Animations */
|
||||||
@keyframes fadeInUp {
|
@keyframes fadeInUp {
|
||||||
from {
|
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 {
|
@keyframes float {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
transform: translateY(-50%) translateX(0);
|
transform: translateY(-50%) translateX(0);
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar:hover {
|
.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 */
|
/* Custom scrollbar for sidebar */
|
||||||
@@ -169,7 +169,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header h2 a:hover {
|
.sidebar-header h2 a:hover {
|
||||||
color: #4CAF50;
|
color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header small {
|
.sidebar-header small {
|
||||||
@@ -229,20 +229,20 @@ button {
|
|||||||
|
|
||||||
.sidebar-nav li a:hover {
|
.sidebar-nav li a:hover {
|
||||||
background-color: #e9ecef;
|
background-color: #e9ecef;
|
||||||
border-left-color: #4CAF50;
|
border-left-color: #667eea;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-nav li a.active {
|
.sidebar-nav li a.active {
|
||||||
background-color: #e8f5e9;
|
background-color: rgba(102, 126, 234, 0.1);
|
||||||
border-left-color: #4CAF50;
|
border-left-color: #667eea;
|
||||||
color: #2e7d32;
|
color: #5569d6;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1.5rem;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -366,7 +366,7 @@ body:has(.sidebar.collapsed) .main-content {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feature h3 {
|
.feature h3 {
|
||||||
color: #4CAF50;
|
color: #667eea;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,8 +457,10 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background-color: #0056b3;
|
background-color: #5569d6;
|
||||||
border-color: #0056b3;
|
border-color: #5569d6;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@@ -470,6 +472,8 @@ button {
|
|||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background-color: #545b62;
|
background-color: #545b62;
|
||||||
border-color: #545b62;
|
border-color: #545b62;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
@@ -481,6 +485,8 @@ button {
|
|||||||
.btn-success:hover {
|
.btn-success:hover {
|
||||||
background-color: #218838;
|
background-color: #218838;
|
||||||
border-color: #218838;
|
border-color: #218838;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@@ -492,6 +498,8 @@ button {
|
|||||||
.btn-danger:hover {
|
.btn-danger:hover {
|
||||||
background-color: #c82333;
|
background-color: #c82333;
|
||||||
border-color: #c82333;
|
border-color: #c82333;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-warning {
|
.btn-warning {
|
||||||
@@ -503,6 +511,8 @@ button {
|
|||||||
.btn-warning:hover {
|
.btn-warning:hover {
|
||||||
background-color: #e0a800;
|
background-color: #e0a800;
|
||||||
border-color: #e0a800;
|
border-color: #e0a800;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-info {
|
.btn-info {
|
||||||
@@ -514,18 +524,23 @@ button {
|
|||||||
.btn-info:hover {
|
.btn-info:hover {
|
||||||
background-color: #138496;
|
background-color: #138496;
|
||||||
border-color: #138496;
|
border-color: #138496;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button Outline Variants */
|
/* Button Outline Variants */
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
border: 1px solid #007bff;
|
border: 1px solid #6c757d;
|
||||||
color: #007bff;
|
color: #495057;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline:hover {
|
.btn-outline:hover {
|
||||||
background-color: #007bff;
|
background-color: #667eea;
|
||||||
color: white;
|
color: white;
|
||||||
|
border-color: #667eea;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Special Button Styles */
|
/* Special Button Styles */
|
||||||
@@ -539,7 +554,9 @@ button {
|
|||||||
|
|
||||||
.btn-filter:hover {
|
.btn-filter:hover {
|
||||||
background-color: #f8f9fa;
|
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 {
|
.btn-filter.active {
|
||||||
@@ -551,7 +568,7 @@ button {
|
|||||||
/* Generic Button Hover */
|
/* Generic Button Hover */
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
transform: translateY(-1px);
|
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 {
|
footer {
|
||||||
@@ -600,7 +617,7 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-links a:hover {
|
.footer-links a:hover {
|
||||||
color: var(--primary-color);
|
color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-separator {
|
.footer-separator {
|
||||||
@@ -662,8 +679,8 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.email-nag-dismiss:hover {
|
.email-nag-dismiss:hover {
|
||||||
background-color: rgba(0,0,0,0.1);
|
background-color: rgba(102, 126, 234, 0.1);
|
||||||
color: #333;
|
color: #5569d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideDown {
|
@keyframes slideDown {
|
||||||
@@ -748,7 +765,9 @@ body:has(.sidebar.collapsed) footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.arrive-btn:hover {
|
.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 {
|
.leave-btn {
|
||||||
@@ -760,6 +779,8 @@ body:has(.sidebar.collapsed) footer {
|
|||||||
|
|
||||||
.leave-btn:hover {
|
.leave-btn:hover {
|
||||||
background-color: #d32f2f;
|
background-color: #d32f2f;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-history {
|
.time-history {
|
||||||
@@ -788,7 +809,7 @@ body:has(.sidebar.collapsed) footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.time-history tr:hover {
|
.time-history tr:hover {
|
||||||
background-color: #f5f5f5;
|
background-color: rgba(102, 126, 234, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-group {
|
.button-group {
|
||||||
@@ -807,6 +828,8 @@ body:has(.sidebar.collapsed) footer {
|
|||||||
|
|
||||||
.pause-btn:hover {
|
.pause-btn:hover {
|
||||||
background-color: #f57c00;
|
background-color: #f57c00;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.resume-btn {
|
.resume-btn {
|
||||||
@@ -817,7 +840,9 @@ body:has(.sidebar.collapsed) footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resume-btn:hover {
|
.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 {
|
.break-info {
|
||||||
@@ -971,7 +996,9 @@ body:has(.sidebar.collapsed) footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.edit-entry-btn:hover {
|
.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 {
|
.delete-entry-btn {
|
||||||
@@ -981,6 +1008,8 @@ body:has(.sidebar.collapsed) footer {
|
|||||||
|
|
||||||
.delete-entry-btn:hover {
|
.delete-entry-btn:hover {
|
||||||
background-color: #d32f2f;
|
background-color: #d32f2f;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="date"], input[type="time"] {
|
input[type="date"], input[type="time"] {
|
||||||
@@ -1037,8 +1066,8 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-card:hover {
|
.admin-card:hover {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-3px);
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-card h2 {
|
.admin-card h2 {
|
||||||
@@ -1113,11 +1142,11 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-container:hover input ~ .checkmark {
|
.checkbox-container:hover input ~ .checkmark {
|
||||||
background-color: #ccc;
|
background-color: rgba(102, 126, 234, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-container input:checked ~ .checkmark {
|
.checkbox-container input:checked ~ .checkmark {
|
||||||
background-color: #2196F3;
|
background-color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkmark:after {
|
.checkmark:after {
|
||||||
@@ -1171,7 +1200,7 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.data-table tr:hover {
|
.data-table tr:hover {
|
||||||
background-color: #f5f5f5;
|
background-color: rgba(102, 126, 234, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Team Hours Page Styles */
|
/* Team Hours Page Styles */
|
||||||
@@ -1266,16 +1295,16 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
|
|
||||||
.export-section:hover {
|
.export-section:hover {
|
||||||
transform: translateY(-2px);
|
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 {
|
.export-section h3 {
|
||||||
color: #4CAF50;
|
color: #667eea;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-bottom: 2px solid #4CAF50;
|
border-bottom: 2px solid #667eea;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1296,7 +1325,7 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
|
|
||||||
.quick-export-buttons .btn:hover {
|
.quick-export-buttons .btn:hover {
|
||||||
transform: translateY(-1px);
|
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 {
|
.export-button-container {
|
||||||
@@ -1305,7 +1334,7 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.export-button-container .btn {
|
.export-button-container .btn {
|
||||||
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
@@ -1314,13 +1343,13 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
font-weight: 600;
|
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 {
|
.export-button-container .btn:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 15px rgba(76, 175, 80, 0.4);
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.25);
|
||||||
background: linear-gradient(135deg, #45a049 0%, #4CAF50 100%);
|
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom date range form styling */
|
/* 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 input:focus,
|
||||||
.export-section .form-group select:focus {
|
.export-section .form-group select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #4CAF50;
|
border-color: #667eea;
|
||||||
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Team Hours Export Styling */
|
/* Team Hours Export Styling */
|
||||||
@@ -1364,7 +1393,7 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#export-buttons h4 {
|
#export-buttons h4 {
|
||||||
color: #4CAF50;
|
color: #667eea;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1387,7 +1416,7 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
|
|
||||||
#export-buttons .quick-export-buttons .btn:hover {
|
#export-buttons .quick-export-buttons .btn:hover {
|
||||||
transform: translateY(-1px);
|
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 */
|
/* Responsive Design for Sidebar Navigation */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
@@ -1528,14 +1557,14 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mode-btn.active {
|
.mode-btn.active {
|
||||||
background: #4CAF50;
|
background: #667eea;
|
||||||
color: white;
|
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) {
|
.mode-btn:hover:not(.active) {
|
||||||
background: #e9ecef;
|
background: rgba(102, 126, 234, 0.1);
|
||||||
color: #495057;
|
color: #5569d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-panel {
|
.filter-panel {
|
||||||
@@ -1579,8 +1608,8 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
.filter-group input:focus,
|
.filter-group input:focus,
|
||||||
.filter-group select:focus {
|
.filter-group select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #4CAF50;
|
border-color: #667eea;
|
||||||
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-tabs {
|
.view-tabs {
|
||||||
@@ -1603,14 +1632,14 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn.active {
|
.tab-btn.active {
|
||||||
color: #4CAF50;
|
color: #667eea;
|
||||||
border-bottom-color: #4CAF50;
|
border-bottom-color: #667eea;
|
||||||
background: rgba(76, 175, 80, 0.05);
|
background: rgba(102, 126, 234, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn:hover:not(.active) {
|
.tab-btn:hover:not(.active) {
|
||||||
color: #495057;
|
color: #5569d6;
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(102, 126, 234, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-content {
|
.view-content {
|
||||||
@@ -1700,7 +1729,7 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-left: 4px solid #4CAF50;
|
border-left: 4px solid #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card h4 {
|
.stat-card h4 {
|
||||||
@@ -1796,8 +1825,8 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
.form-group textarea:focus,
|
.form-group textarea:focus,
|
||||||
.form-group select:focus {
|
.form-group select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #4CAF50;
|
border-color: #667eea;
|
||||||
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Design for Analytics */
|
/* Responsive Design for Analytics */
|
||||||
@@ -2031,7 +2060,7 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.view-btn:hover:not(.active) {
|
.view-btn:hover:not(.active) {
|
||||||
background: #e9ecef;
|
background: rgba(102, 126, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Statistics Cards */
|
/* Statistics Cards */
|
||||||
@@ -2165,7 +2194,7 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.management-card:hover {
|
.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);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2289,7 +2318,7 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.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 {
|
.card-header {
|
||||||
@@ -2362,7 +2391,7 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.widget:hover {
|
.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 {
|
.widget-header {
|
||||||
@@ -2549,8 +2578,8 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-dropdown-toggle:hover {
|
.user-dropdown-toggle:hover {
|
||||||
background-color: #e9ecef;
|
background-color: rgba(102, 126, 234, 0.1);
|
||||||
color: #333;
|
color: #5569d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Removed nav-icon style as we're using avatar instead */
|
/* 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 {
|
.user-dropdown-menu a:hover {
|
||||||
background-color: #f0f0f0;
|
background-color: rgba(102, 126, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-dropdown-menu .nav-icon {
|
.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');
|
const toggleBtn = document.createElement('button');
|
||||||
toggleBtn.type = 'button';
|
toggleBtn.type = 'button';
|
||||||
toggleBtn.className = 'password-toggle';
|
toggleBtn.className = 'password-toggle';
|
||||||
toggleBtn.innerHTML = '👁️';
|
toggleBtn.innerHTML = '<i class="ti ti-eye"></i>';
|
||||||
toggleBtn.style.cssText = `
|
toggleBtn.style.cssText = `
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
@@ -120,10 +120,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
toggleBtn.addEventListener('click', function() {
|
toggleBtn.addEventListener('click', function() {
|
||||||
if (input.type === 'password') {
|
if (input.type === 'password') {
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
this.innerHTML = '🙈';
|
this.innerHTML = '<i class="ti ti-eye-off"></i>';
|
||||||
} else {
|
} else {
|
||||||
input.type = 'password';
|
input.type = 'password';
|
||||||
this.innerHTML = '👁️';
|
this.innerHTML = '<i class="ti ti-eye"></i>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -148,10 +148,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
matchIndicator.textContent = '';
|
matchIndicator.textContent = '';
|
||||||
matchIndicator.className = 'password-match-indicator';
|
matchIndicator.className = 'password-match-indicator';
|
||||||
} else if (password === confirmInput.value) {
|
} 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';
|
matchIndicator.className = 'password-match-indicator match';
|
||||||
} else {
|
} 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';
|
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>
|
||||||
|
|
||||||
<div class="feature-item">
|
<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>
|
<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>
|
||||||
|
|
||||||
<div class="feature-item">
|
<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>
|
<p>View detailed time entry history, team performance metrics, and individual productivity reports. Export data for payroll and project management purposes.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feature-item">
|
<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>
|
<p>Customize work hour requirements, mandatory break durations, and threshold settings to match your organization's policies and labor regulations.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,22 +80,22 @@
|
|||||||
|
|
||||||
<div class="benefits-list">
|
<div class="benefits-list">
|
||||||
<div class="benefit">
|
<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>
|
<p>Automatically enforces break policies and work hour regulations to help your organization stay compliant with labor laws.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="benefit">
|
<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>
|
<p>Intuitive interface requires minimal training. Start tracking time immediately without complicated setup procedures.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="benefit">
|
<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>
|
<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>
|
||||||
|
|
||||||
<div class="benefit">
|
<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>
|
<p>Generate meaningful reports and analytics to optimize productivity, identify trends, and make informed business decisions.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setup-option">
|
<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>
|
<p>Already have a company using {{ g.branding.app_name }}? Get your company code from your administrator and register to join your organization.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="page-title">
|
<h1 class="page-title">
|
||||||
<span class="page-icon">🏢</span>
|
<span class="page-icon"><i class="ti ti-building"></i></span>
|
||||||
Company Management
|
Company Management
|
||||||
</h1>
|
</h1>
|
||||||
<p class="page-subtitle">Configure your company settings and policies</p>
|
<p class="page-subtitle">Configure your company settings and policies</p>
|
||||||
@@ -26,22 +26,22 @@
|
|||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value">{{ stats.total_users }}</div>
|
<div class="stat-value">{{ stats.total_users }}</div>
|
||||||
<div class="stat-label">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>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value">{{ stats.total_teams }}</div>
|
<div class="stat-value">{{ stats.total_teams }}</div>
|
||||||
<div class="stat-label">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>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value">{{ stats.total_projects }}</div>
|
<div class="stat-value">{{ stats.total_projects }}</div>
|
||||||
<div class="stat-label">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>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value">{{ stats.active_projects }}</div>
|
<div class="stat-value">{{ stats.active_projects }}</div>
|
||||||
<div class="stat-label">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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<span class="icon">ℹ️</span>
|
<span class="icon"><i class="ti ti-info-circle"></i></span>
|
||||||
Company Information
|
Company Information
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,13 +97,13 @@
|
|||||||
|
|
||||||
<div class="info-panel">
|
<div class="info-panel">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-icon">🔑</span>
|
<span class="info-icon"><i class="ti ti-key"></i></span>
|
||||||
<div class="info-content">
|
<div class="info-content">
|
||||||
<label class="info-label">Company Code</label>
|
<label class="info-label">Company Code</label>
|
||||||
<div class="code-display">
|
<div class="code-display">
|
||||||
<input type="text" value="{{ company.slug }}" readonly id="companyCode" class="code-input">
|
<input type="text" value="{{ company.slug }}" readonly id="companyCode" class="code-input">
|
||||||
<button type="button" class="btn btn-copy" onclick="copyCompanyCode()">
|
<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>
|
<span id="copyText">Copy</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-icon">📅</span>
|
<span class="info-icon"><i class="ti ti-calendar"></i></span>
|
||||||
<div class="info-content">
|
<div class="info-content">
|
||||||
<label class="info-label">Created</label>
|
<label class="info-label">Created</label>
|
||||||
<span class="info-value">{{ company.created_at.strftime('%B %d, %Y at %I:%M %p') }}</span>
|
<span class="info-value">{{ company.created_at.strftime('%B %d, %Y at %I:%M %p') }}</span>
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<span class="icon">✓</span>
|
<span class="icon"><i class="ti ti-check"></i></span>
|
||||||
Save Company Details
|
Save Company Details
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,30 +133,22 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<span class="icon">⚡</span>
|
<span class="icon"><i class="ti ti-bolt"></i></span>
|
||||||
Quick Actions
|
Quick Actions
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="action-grid">
|
<div class="action-grid">
|
||||||
<a href="{{ url_for('users.admin_users') }}" class="action-item">
|
<a href="{{ url_for('organization.admin_organization') }}" class="action-item">
|
||||||
<div class="action-icon">👥</div>
|
<div class="action-icon"><i class="ti ti-sitemap"></i></div>
|
||||||
<div class="action-content">
|
<div class="action-content">
|
||||||
<h3>Manage Users</h3>
|
<h3>Manage Organization</h3>
|
||||||
<p>User accounts & permissions</p>
|
<p>Users, teams & structure</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>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="{{ url_for('projects.admin_projects') }}" class="action-item">
|
<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">
|
<div class="action-content">
|
||||||
<h3>Manage Projects</h3>
|
<h3>Manage Projects</h3>
|
||||||
<p>Time tracking projects</p>
|
<p>Time tracking projects</p>
|
||||||
@@ -164,7 +156,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="{{ url_for('invitations.send_invitation') }}" class="action-item">
|
<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">
|
<div class="action-content">
|
||||||
<h3>Send Invitation</h3>
|
<h3>Send Invitation</h3>
|
||||||
<p>Invite team members</p>
|
<p>Invite team members</p>
|
||||||
@@ -181,7 +173,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<span class="icon">📋</span>
|
<span class="icon"><i class="ti ti-clipboard-list"></i></span>
|
||||||
Work Policies
|
Work Policies
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,7 +243,7 @@
|
|||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<span class="icon">✓</span>
|
<span class="icon"><i class="ti ti-check"></i></span>
|
||||||
Update Policies
|
Update Policies
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,7 +256,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<span class="icon">👤</span>
|
<span class="icon"><i class="ti ti-user"></i></span>
|
||||||
User Registration
|
User Registration
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,7 +296,7 @@
|
|||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<span class="icon">✓</span>
|
<span class="icon"><i class="ti ti-check"></i></span>
|
||||||
Update Settings
|
Update Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -728,6 +720,11 @@ input:checked + .toggle-slider:before {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-icon i {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
.action-content h3 {
|
.action-content h3 {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -874,17 +871,17 @@ function copyCompanyCode() {
|
|||||||
const copyText = document.getElementById('copyText');
|
const copyText = document.getElementById('copyText');
|
||||||
|
|
||||||
// Store original values
|
// Store original values
|
||||||
const originalIcon = copyIcon.textContent;
|
const originalIcon = copyIcon.innerHTML;
|
||||||
const originalText = copyText.textContent;
|
const originalText = copyText.textContent;
|
||||||
|
|
||||||
// Update to success state
|
// Update to success state
|
||||||
copyIcon.textContent = '✓';
|
copyIcon.innerHTML = '<i class="ti ti-check"></i>';
|
||||||
copyText.textContent = 'Copied!';
|
copyText.textContent = 'Copied!';
|
||||||
button.classList.add('success');
|
button.classList.add('success');
|
||||||
|
|
||||||
// Reset after 2 seconds
|
// Reset after 2 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copyIcon.textContent = originalIcon;
|
copyIcon.innerHTML = originalIcon;
|
||||||
copyText.textContent = originalText;
|
copyText.textContent = originalText;
|
||||||
button.classList.remove('success');
|
button.classList.remove('success');
|
||||||
}, 2000);
|
}, 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-content">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="page-title">
|
<h1 class="page-title">
|
||||||
<span class="page-icon">📁</span>
|
<i class="ti ti-folder page-icon"></i>
|
||||||
Project Management
|
Project Management
|
||||||
</h1>
|
</h1>
|
||||||
<p class="page-subtitle">Manage projects, categories, and track time entries</p>
|
<p class="page-subtitle">Manage projects, categories, and track time entries</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary">
|
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary">
|
||||||
<span class="icon">+</span>
|
<i class="ti ti-plus"></i>
|
||||||
Create New Project
|
Create New Project
|
||||||
</a>
|
</a>
|
||||||
<button id="manage-categories-btn" class="btn btn-secondary">
|
<button id="manage-categories-btn" class="btn btn-secondary">
|
||||||
<span class="icon">🏷️</span>
|
<i class="ti ti-tag"></i>
|
||||||
Manage Categories
|
Manage Categories
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
<div id="categories-section" class="categories-section" style="display: none;">
|
<div id="categories-section" class="categories-section" style="display: none;">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
<span class="icon">🏷️</span>
|
<i class="ti ti-tag"></i>
|
||||||
Project Categories
|
Project Categories
|
||||||
</h2>
|
</h2>
|
||||||
<button id="create-category-btn" class="btn btn-sm btn-primary">
|
<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-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-header" style="background: linear-gradient(135deg, {{ category.color }}20 0%, {{ category.color }}10 100%); border-left: 4px solid {{ category.color }};">
|
||||||
<div class="category-title">
|
<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>
|
<span class="category-name">{{ category.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="category-stats">
|
<div class="category-stats">
|
||||||
@@ -76,11 +76,11 @@
|
|||||||
<p class="category-description">{{ category.description or 'No description provided' }}</p>
|
<p class="category-description">{{ category.description or 'No description provided' }}</p>
|
||||||
<div class="category-actions">
|
<div class="category-actions">
|
||||||
<button class="btn btn-sm btn-edit edit-category-btn" data-id="{{ category.id }}">
|
<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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-delete delete-category-btn" data-id="{{ category.id }}">
|
<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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-categories">
|
<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>
|
<p class="empty-message">No categories created yet</p>
|
||||||
<button id="first-category-btn" class="btn btn-primary">Create your first category</button>
|
<button id="first-category-btn" class="btn btn-primary">Create your first category</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
<!-- View Controls -->
|
<!-- View Controls -->
|
||||||
<div class="view-controls">
|
<div class="view-controls">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<span class="search-icon">🔍</span>
|
<i class="ti ti-search search-icon"></i>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="search-input"
|
class="search-input"
|
||||||
id="projectSearch"
|
id="projectSearch"
|
||||||
@@ -110,10 +110,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="view-toggle">
|
<div class="view-toggle">
|
||||||
<button class="toggle-btn active" data-view="grid" title="Grid View">
|
<button class="toggle-btn active" data-view="grid" title="Grid View">
|
||||||
<span class="icon">⊞</span>
|
<i class="ti ti-layout-grid"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="toggle-btn" data-view="list" title="List View">
|
<button class="toggle-btn" data-view="list" title="List View">
|
||||||
<span class="icon">☰</span>
|
<i class="ti ti-list"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,18 +141,18 @@
|
|||||||
{% if project.category %}
|
{% if project.category %}
|
||||||
<div class="project-category">
|
<div class="project-category">
|
||||||
<span class="category-badge" style="background-color: {{ project.category.color }}20; color: {{ project.category.color }};">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="project-info">
|
<div class="project-info">
|
||||||
<div class="info-item">
|
<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>
|
<span class="info-text">{{ project.team.name if project.team else 'All Teams' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-icon">📅</span>
|
<i class="ti ti-calendar info-icon"></i>
|
||||||
<span class="info-text">
|
<span class="info-text">
|
||||||
{% if project.start_date %}
|
{% if project.start_date %}
|
||||||
{{ project.start_date.strftime('%b %d, %Y') }}
|
{{ project.start_date.strftime('%b %d, %Y') }}
|
||||||
@@ -163,11 +163,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<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>
|
<span class="info-text">{{ project.time_entries|length }} time entries</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<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>
|
<span class="info-text">Created by {{ project.created_by.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,18 +175,18 @@
|
|||||||
|
|
||||||
<div class="project-actions">
|
<div class="project-actions">
|
||||||
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="btn btn-edit">
|
<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
|
Edit
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('projects.manage_project_tasks', project_id=project.id) }}" class="btn btn-tasks">
|
<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
|
Tasks
|
||||||
</a>
|
</a>
|
||||||
{% if g.user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] %}
|
{% 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"
|
<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!')">
|
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">
|
<button type="submit" class="btn btn-delete" title="Delete project">
|
||||||
<span class="icon">🗑️</span>
|
<i class="ti ti-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if project.category %}
|
{% if project.category %}
|
||||||
<span class="category-badge" style="background-color: {{ project.category.color }}20; color: {{ project.category.color }};">
|
<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>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">-</span>
|
<span class="text-muted">-</span>
|
||||||
@@ -251,16 +251,16 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="btn-action btn-edit" title="Edit">
|
<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>
|
||||||
<a href="{{ url_for('projects.manage_project_tasks', project_id=project.id) }}" class="btn-action btn-tasks" title="Tasks">
|
<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>
|
</a>
|
||||||
{% if g.user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] %}
|
{% 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"
|
<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!')">
|
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">
|
<button type="submit" class="btn-action btn-delete" title="Delete">
|
||||||
<span class="icon">🗑️</span>
|
<i class="ti ti-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -275,18 +275,18 @@
|
|||||||
|
|
||||||
<!-- No Results Message -->
|
<!-- No Results Message -->
|
||||||
<div class="no-results" id="noResults" style="display: none;">
|
<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-message">No projects found matching your search</p>
|
||||||
<p class="empty-hint">Try searching with different keywords</p>
|
<p class="empty-hint">Try searching with different keywords</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div class="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>
|
<h2 class="empty-title">No Projects Yet</h2>
|
||||||
<p class="empty-message">Create your first project to start tracking time</p>
|
<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">
|
<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
|
Create First Project
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" id="cancel-category">Cancel</button>
|
<button type="button" class="btn btn-secondary" id="cancel-category">Cancel</button>
|
||||||
<button type="submit" form="category-form" class="btn btn-primary">
|
<button type="submit" form="category-form" class="btn btn-primary">
|
||||||
<span class="icon">✓</span>
|
<i class="ti ti-check"></i>
|
||||||
Save Category
|
Save Category
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1203,7 +1203,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
manageCategoriesBtn.addEventListener('click', function() {
|
manageCategoriesBtn.addEventListener('click', function() {
|
||||||
const isVisible = categoriesSection.style.display !== 'none';
|
const isVisible = categoriesSection.style.display !== 'none';
|
||||||
categoriesSection.style.display = isVisible ? 'none' : 'block';
|
categoriesSection.style.display = isVisible ? 'none' : 'block';
|
||||||
this.innerHTML = isVisible ? '<span class="icon">🏷️</span> Manage Categories' : '<span class="icon">🏷️</span> Hide Categories';
|
this.innerHTML = isVisible ? '<i class="ti ti-tag"></i> Manage Categories' : '<i class="ti ti-tag"></i> Hide Categories';
|
||||||
});
|
});
|
||||||
|
|
||||||
// View Toggle
|
// View 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,8 +2,16 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-container timetrack-container">
|
<div class="page-container timetrack-container">
|
||||||
<div class="page-header analytics-header">
|
<div class="page-header">
|
||||||
<h2>📊 Time Analytics</h2>
|
<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">
|
<div class="mode-switcher">
|
||||||
<button class="mode-btn {% if mode == 'personal' %}active{% endif %}"
|
<button class="mode-btn {% if mode == 'personal' %}active{% endif %}"
|
||||||
onclick="switchMode('personal')">Personal</button>
|
onclick="switchMode('personal')">Personal</button>
|
||||||
@@ -13,6 +21,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Unified Filter Panel -->
|
<!-- Unified Filter Panel -->
|
||||||
<div class="filter-panel">
|
<div class="filter-panel">
|
||||||
@@ -43,10 +53,10 @@
|
|||||||
|
|
||||||
<!-- View Tabs -->
|
<!-- View Tabs -->
|
||||||
<div class="view-tabs">
|
<div class="view-tabs">
|
||||||
<button class="tab-btn active" data-view="table">📋 Table 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">📈 Graph View</button>
|
<button class="tab-btn" data-view="graph"><i class="ti ti-trending-up"></i> Graph View</button>
|
||||||
{% if mode == 'team' %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</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 (interval === 15) {
|
||||||
if (roundToNearest) {
|
if (roundToNearest) {
|
||||||
examples.push('9:07 AM → 9:00 AM');
|
examples.push('9:07 AM <i class="ti ti-arrow-right"></i> 9:00 AM');
|
||||||
examples.push('9:08 AM → 9:15 AM');
|
examples.push('9:08 AM <i class="ti ti-arrow-right"></i> 9:15 AM');
|
||||||
examples.push('9:23 AM → 9:30 AM');
|
examples.push('9:23 AM <i class="ti ti-arrow-right"></i> 9:30 AM');
|
||||||
} else {
|
} else {
|
||||||
examples.push('9:01 AM → 9:15 AM');
|
examples.push('9:01 AM <i class="ti ti-arrow-right"></i> 9:15 AM');
|
||||||
examples.push('9:16 AM → 9:30 AM');
|
examples.push('9:16 AM <i class="ti ti-arrow-right"></i> 9:30 AM');
|
||||||
examples.push('9:31 AM → 9:45 AM');
|
examples.push('9:31 AM <i class="ti ti-arrow-right"></i> 9:45 AM');
|
||||||
}
|
}
|
||||||
} else if (interval === 30) {
|
} else if (interval === 30) {
|
||||||
if (roundToNearest) {
|
if (roundToNearest) {
|
||||||
examples.push('9:14 AM → 9:00 AM');
|
examples.push('9:14 AM <i class="ti ti-arrow-right"></i> 9:00 AM');
|
||||||
examples.push('9:16 AM → 9:30 AM');
|
examples.push('9:16 AM <i class="ti ti-arrow-right"></i> 9:30 AM');
|
||||||
examples.push('9:45 AM → 10:00 AM');
|
examples.push('9:45 AM <i class="ti ti-arrow-right"></i> 10:00 AM');
|
||||||
} else {
|
} else {
|
||||||
examples.push('9:01 AM → 9:30 AM');
|
examples.push('9:01 AM <i class="ti ti-arrow-right"></i> 9:30 AM');
|
||||||
examples.push('9:31 AM → 10:00 AM');
|
examples.push('9:31 AM <i class="ti ti-arrow-right"></i> 10:00 AM');
|
||||||
examples.push('10:01 AM → 10:30 AM');
|
examples.push('10:01 AM <i class="ti ti-arrow-right"></i> 10:30 AM');
|
||||||
}
|
}
|
||||||
} else if (interval === 60) {
|
} else if (interval === 60) {
|
||||||
if (roundToNearest) {
|
if (roundToNearest) {
|
||||||
examples.push('9:29 AM → 9:00 AM');
|
examples.push('9:29 AM <i class="ti ti-arrow-right"></i> 9:00 AM');
|
||||||
examples.push('9:31 AM → 10:00 AM');
|
examples.push('9:31 AM <i class="ti ti-arrow-right"></i> 10:00 AM');
|
||||||
examples.push('10:30 AM → 11:00 AM');
|
examples.push('10:30 AM <i class="ti ti-arrow-right"></i> 11:00 AM');
|
||||||
} else {
|
} else {
|
||||||
examples.push('9:01 AM → 10:00 AM');
|
examples.push('9:01 AM <i class="ti ti-arrow-right"></i> 10:00 AM');
|
||||||
examples.push('10:01 AM → 11:00 AM');
|
examples.push('10:01 AM <i class="ti ti-arrow-right"></i> 11:00 AM');
|
||||||
examples.push('11:01 AM → 12:00 PM');
|
examples.push('11:01 AM <i class="ti ti-arrow-right"></i> 12:00 PM');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="admin-container">
|
<div class="admin-container">
|
||||||
<div class="header-section">
|
<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>
|
<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') }}"
|
<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>
|
||||||
|
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<!-- Company Information -->
|
<!-- Company Information -->
|
||||||
<div class="table-section">
|
<div class="table-section">
|
||||||
<h3>🏢 Company Information</h3>
|
<h3><i class="ti ti-building"></i> Company Information</h3>
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Company Name:</th>
|
<th>Company Name:</th>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<!-- Users -->
|
<!-- Users -->
|
||||||
{% if users %}
|
{% if users %}
|
||||||
<div class="table-section">
|
<div class="table-section">
|
||||||
<h3>👥 Users ({{ users|length }})</h3>
|
<h3><i class="ti ti-users"></i> Users ({{ users|length }})</h3>
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
<!-- Teams -->
|
<!-- Teams -->
|
||||||
{% if teams %}
|
{% if teams %}
|
||||||
<div class="table-section">
|
<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">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
<!-- Tasks -->
|
<!-- Tasks -->
|
||||||
{% if tasks %}
|
{% if tasks %}
|
||||||
<div class="table-section">
|
<div class="table-section">
|
||||||
<h3>✅ Tasks ({{ tasks|length }})</h3>
|
<h3><i class="ti ti-check"></i> Tasks ({{ tasks|length }})</h3>
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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 -->
|
<!-- Empty Dashboard Message -->
|
||||||
<div id="empty-dashboard" class="empty-dashboard" style="display: none;">
|
<div id="empty-dashboard" class="empty-dashboard" style="display: none;">
|
||||||
<div class="empty-dashboard-content">
|
<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>
|
<h3>Your Dashboard is Empty</h3>
|
||||||
<p>Add widgets to create your personalized dashboard.</p>
|
<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>
|
<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) -->
|
<!-- Danger Zone (only for admins) -->
|
||||||
{% if g.user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] %}
|
{% if g.user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] %}
|
||||||
<div class="danger-zone">
|
<div class="danger-zone">
|
||||||
<h3>⚠️ Danger Zone</h3>
|
<h3><i class="ti ti-alert-triangle"></i> Danger Zone</h3>
|
||||||
<div class="danger-content">
|
<div class="danger-content">
|
||||||
<p><strong>Delete Project</strong></p>
|
<p><strong>Delete Project</strong></p>
|
||||||
<p>Once you delete a project, there is no going back. This will permanently delete:</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>
|
||||||
|
|
||||||
<div class="imprint-footer">
|
<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>
|
</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>
|
<h2 class="section-title">Powerful Features for Modern Teams</h2>
|
||||||
<div class="feature-cards">
|
<div class="feature-cards">
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon">⚡</div>
|
<div class="feature-icon"><i class="ti ti-bolt"></i></div>
|
||||||
<h3>Lightning Fast</h3>
|
<h3>Lightning Fast</h3>
|
||||||
<p>Start tracking in seconds with our intuitive one-click interface</p>
|
<p>Start tracking in seconds with our intuitive one-click interface</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<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>
|
<h3>Advanced Analytics</h3>
|
||||||
<p>Gain insights with comprehensive reports and visual dashboards</p>
|
<p>Gain insights with comprehensive reports and visual dashboards</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<p>Organize work into sprints with agile project tracking</p>
|
<p>Organize work into sprints with agile project tracking</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon">👥</div>
|
<div class="feature-icon"><i class="ti ti-users"></i></div>
|
||||||
<h3>Team Collaboration</h3>
|
<h3>Team Collaboration</h3>
|
||||||
<p>Manage teams, projects, and resources all in one place</p>
|
<p>Manage teams, projects, and resources all in one place</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
<div class="stat-label">Free & Open Source</div>
|
<div class="stat-label">Free & Open Source</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<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 class="stat-label">Unlimited Tracking</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
@@ -115,14 +115,14 @@
|
|||||||
<h3>{{ g.branding.app_name if g.branding else 'TimeTrack' }} Community</h3>
|
<h3>{{ g.branding.app_name if g.branding else 'TimeTrack' }} Community</h3>
|
||||||
<div class="price">$0<span>/forever</span></div>
|
<div class="price">$0<span>/forever</span></div>
|
||||||
<ul class="pricing-features">
|
<ul class="pricing-features">
|
||||||
<li>✓ Unlimited users</li>
|
<li><i class="ti ti-check"></i> Unlimited users</li>
|
||||||
<li>✓ All features included</li>
|
<li><i class="ti ti-check"></i> All features included</li>
|
||||||
<li>✓ Time tracking & analytics</li>
|
<li><i class="ti ti-check"></i> Time tracking & analytics</li>
|
||||||
<li>✓ Sprint management</li>
|
<li><i class="ti ti-check"></i> Sprint management</li>
|
||||||
<li>✓ Team collaboration</li>
|
<li><i class="ti ti-check"></i> Team collaboration</li>
|
||||||
<li>✓ Project management</li>
|
<li><i class="ti ti-check"></i> Project management</li>
|
||||||
<li>✓ Self-hosted option</li>
|
<li><i class="ti ti-check"></i> Self-hosted option</li>
|
||||||
<li>✓ No restrictions</li>
|
<li><i class="ti ti-check"></i> No restrictions</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a href="{{ url_for('register') }}" class="btn-pricing">Get Started Free</a>
|
<a href="{{ url_for('register') }}" class="btn-pricing">Get Started Free</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -376,7 +376,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="time-cell">
|
<div class="time-cell">
|
||||||
<span class="time-start">{{ entry.arrival_time|format_time }}</span>
|
<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>
|
<span class="time-end">{{ entry.departure_time|format_time if entry.departure_time else 'Active' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="page-title">
|
<h1 class="page-title">
|
||||||
<span class="page-icon">📨</span>
|
<span class="page-icon"><i class="ti ti-mail"></i></span>
|
||||||
Invitations
|
Invitations
|
||||||
</h1>
|
</h1>
|
||||||
<p class="page-subtitle">Manage team invitations for {{ g.user.company.name }}</p>
|
<p class="page-subtitle">Manage team invitations for {{ g.user.company.name }}</p>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
{% if pending_invitations %}
|
{% if pending_invitations %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
<span class="icon">⏳</span>
|
<span class="icon"><i class="ti ti-hourglass"></i></span>
|
||||||
Pending Invitations
|
Pending Invitations
|
||||||
</h2>
|
</h2>
|
||||||
<div class="invitations-list">
|
<div class="invitations-list">
|
||||||
@@ -56,15 +56,15 @@
|
|||||||
<h3 class="invitation-email">{{ invitation.email }}</h3>
|
<h3 class="invitation-email">{{ invitation.email }}</h3>
|
||||||
<div class="invitation-meta">
|
<div class="invitation-meta">
|
||||||
<span class="meta-item">
|
<span class="meta-item">
|
||||||
<span class="icon">👤</span>
|
<span class="icon"><i class="ti ti-user"></i></span>
|
||||||
Role: {{ invitation.role }}
|
Role: {{ invitation.role }}
|
||||||
</span>
|
</span>
|
||||||
<span class="meta-item">
|
<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') }}
|
Sent {{ invitation.created_at.strftime('%b %d, %Y') }}
|
||||||
</span>
|
</span>
|
||||||
<span class="meta-item">
|
<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') }}
|
Expires {{ invitation.expires_at.strftime('%b %d, %Y') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,13 +72,13 @@
|
|||||||
<div class="invitation-actions">
|
<div class="invitation-actions">
|
||||||
<form method="POST" action="{{ url_for('invitations.resend_invitation', invitation_id=invitation.id) }}" style="display: inline;">
|
<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">
|
<button type="submit" class="btn btn-sm btn-secondary">
|
||||||
<span class="icon">🔄</span>
|
<span class="icon"><i class="ti ti-refresh"></i></span>
|
||||||
Resend
|
Resend
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="{{ url_for('invitations.revoke_invitation', invitation_id=invitation.id) }}" style="display: inline;">
|
<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?');">
|
<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
|
Revoke
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
{% if accepted_invitations %}
|
{% if accepted_invitations %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
<span class="icon">✅</span>
|
<span class="icon"><i class="ti ti-check"></i></span>
|
||||||
Accepted Invitations
|
Accepted Invitations
|
||||||
</h2>
|
</h2>
|
||||||
<div class="invitations-list">
|
<div class="invitations-list">
|
||||||
@@ -108,18 +108,18 @@
|
|||||||
<h3 class="invitation-email">{{ invitation.email }}</h3>
|
<h3 class="invitation-email">{{ invitation.email }}</h3>
|
||||||
<div class="invitation-meta">
|
<div class="invitation-meta">
|
||||||
<span class="meta-item">
|
<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 }})
|
Joined as: {{ invitation.accepted_by.username }} ({{ invitation.role }})
|
||||||
</span>
|
</span>
|
||||||
<span class="meta-item">
|
<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') }}
|
Accepted {{ invitation.accepted_at.strftime('%b %d, %Y') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="invitation-actions">
|
<div class="invitation-actions">
|
||||||
<a href="{{ url_for('users.view_user', user_id=invitation.accepted_by.id) }}" class="btn btn-sm btn-secondary">
|
<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
|
View User
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
{% if expired_invitations %}
|
{% if expired_invitations %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
<span class="icon">⏱️</span>
|
<span class="icon"><i class="ti ti-clock"></i></span>
|
||||||
Expired Invitations
|
Expired Invitations
|
||||||
</h2>
|
</h2>
|
||||||
<div class="invitations-list">
|
<div class="invitations-list">
|
||||||
@@ -148,11 +148,11 @@
|
|||||||
<h3 class="invitation-email">{{ invitation.email }}</h3>
|
<h3 class="invitation-email">{{ invitation.email }}</h3>
|
||||||
<div class="invitation-meta">
|
<div class="invitation-meta">
|
||||||
<span class="meta-item">
|
<span class="meta-item">
|
||||||
<span class="icon">👤</span>
|
<span class="icon"><i class="ti ti-user"></i></span>
|
||||||
Role: {{ invitation.role }}
|
Role: {{ invitation.role }}
|
||||||
</span>
|
</span>
|
||||||
<span class="meta-item">
|
<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') }}
|
Expired {{ invitation.expires_at.strftime('%b %d, %Y') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
<div class="invitation-actions">
|
<div class="invitation-actions">
|
||||||
<form method="POST" action="{{ url_for('invitations.resend_invitation', invitation_id=invitation.id) }}" style="display: inline;">
|
<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">
|
<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
|
Send New Invitation
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
{% if not pending_invitations and not accepted_invitations and not expired_invitations %}
|
{% if not pending_invitations and not accepted_invitations and not expired_invitations %}
|
||||||
<div class="empty-state">
|
<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>
|
<h3>No invitations yet</h3>
|
||||||
<p>Start building your team by sending invitations</p>
|
<p>Start building your team by sending invitations</p>
|
||||||
<a href="{{ url_for('invitations.send_invitation') }}" class="btn btn-primary">
|
<a href="{{ url_for('invitations.send_invitation') }}" class="btn btn-primary">
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="page-title">
|
<h1 class="page-title">
|
||||||
<span class="page-icon">✉️</span>
|
<span class="page-icon"><i class="ti ti-mail"></i></span>
|
||||||
Send Invitation
|
Send Invitation
|
||||||
</h1>
|
</h1>
|
||||||
<p class="page-subtitle">Invite team members to join {{ g.user.company.name }}</p>
|
<p class="page-subtitle">Invite team members to join {{ g.user.company.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a href="{{ url_for('invitations.list_invitations') }}" class="btn btn-secondary">
|
<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
|
Back to Invitations
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
<div class="card invitation-form-card">
|
<div class="card invitation-form-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<span class="icon">👥</span>
|
<span class="icon"><i class="ti ti-users"></i></span>
|
||||||
Invitation Details
|
Invitation Details
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
<div class="info-panel">
|
<div class="info-panel">
|
||||||
<div class="info-item">
|
<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">
|
<div class="info-content">
|
||||||
<h4>What happens next?</h4>
|
<h4>What happens next?</h4>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<span class="icon">📤</span>
|
<span class="icon"><i class="ti ti-send"></i></span>
|
||||||
Send Invitation
|
Send Invitation
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('invitations.list_invitations') }}" class="btn btn-ghost">
|
<a href="{{ url_for('invitations.list_invitations') }}" class="btn btn-ghost">
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
<div class="card preview-card">
|
<div class="card preview-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<span class="icon">👁️</span>
|
<span class="icon"><i class="ti ti-eye"></i></span>
|
||||||
Email Preview
|
Email Preview
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
<title>{{ title }} - {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% if g.company %} - {{ g.company.name }}{% endif %}</title>
|
<title>{{ title }} - {{ g.branding.app_name if g.branding else 'TimeTrack' }}{% if g.company %} - {{ g.company.name }}{% endif %}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.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 %}
|
{% if not g.user %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/splash.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/splash.css') }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -14,21 +19,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--primary-color: {{ g.branding.primary_color if g.branding else '#007bff' }};
|
--primary-color: {{ g.branding.primary_color if g.branding else '#667eea' }};
|
||||||
}
|
--primary-gradient-start: {{ g.branding.primary_color if g.branding else '#667eea' }};
|
||||||
.btn-primary {
|
--primary-gradient-end: #764ba2;
|
||||||
background-color: var(--primary-color);
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: {{ (g.branding.primary_color if g.branding else '#007bff') + 'dd' }};
|
|
||||||
border-color: {{ (g.branding.primary_color if g.branding else '#007bff') + 'dd' }};
|
|
||||||
}
|
}
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
color: var(--primary-color);
|
color: var(--primary-gradient-end);
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
}
|
||||||
.mobile-logo {
|
.mobile-logo {
|
||||||
max-height: 30px;
|
max-height: 30px;
|
||||||
@@ -116,10 +112,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="user-dropdown-menu">
|
<div class="user-dropdown-menu">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{{ url_for('profile') }}"><i class="nav-icon">👤</i>Profile</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">⚙️</i>Settings</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 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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,37 +124,39 @@
|
|||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<ul>
|
<ul>
|
||||||
{% if g.user %}
|
{% 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('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">⏱️</i><span class="nav-text">Time Tracking</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>
|
||||||
<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('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('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('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('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('analytics') }}" data-tooltip="Time Analytics"><i class="nav-icon">📊</i><span class="nav-text">Analytics</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 -->
|
<!-- Role-based menu items -->
|
||||||
{% if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN %}
|
{% if g.user.role == Role.ADMIN or g.user.role == Role.SYSTEM_ADMIN %}
|
||||||
<li class="nav-divider">Admin</li>
|
<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('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('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('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">📨</i><span class="nav-text">Invitations</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('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 ti ti-folder"></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">📝</i><span class="nav-text">Manage Projects</span></a></li>
|
|
||||||
{% if g.user.role == Role.SYSTEM_ADMIN %}
|
{% if g.user.role == Role.SYSTEM_ADMIN %}
|
||||||
<li class="nav-divider">System Admin</li>
|
<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('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">📢</i><span class="nav-text">Announcements</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 %}
|
{% endif %}
|
||||||
{% elif g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
|
{% elif g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
|
||||||
<li class="nav-divider">Team</li>
|
<li class="nav-divider">Team</li>
|
||||||
<li><a href="{{ url_for('dashboard') }}" data-tooltip="Dashboard"><i class="nav-icon">📈</i><span class="nav-text">Dashboard</span></a></li>
|
<!-- 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 %}
|
{% 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 %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% 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('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">🔑</i><span class="nav-text">Login</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">📝</i><span class="nav-text">Register</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 %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -209,23 +207,23 @@
|
|||||||
{% if g.show_email_nag %}
|
{% if g.show_email_nag %}
|
||||||
<div class="email-nag-banner">
|
<div class="email-nag-banner">
|
||||||
<div class="email-nag-content">
|
<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">
|
<span class="email-nag-text">
|
||||||
<strong>Add your email address</strong> to enable account recovery and receive important notifications.
|
<strong>Add your email address</strong> to enable account recovery and receive important notifications.
|
||||||
</span>
|
</span>
|
||||||
<a href="{{ url_for('profile') }}" class="btn btn-sm btn-primary">Add Email</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% elif g.show_email_verification_nag %}
|
{% elif g.show_email_verification_nag %}
|
||||||
<div class="email-nag-banner email-verify">
|
<div class="email-nag-banner email-verify">
|
||||||
<div class="email-nag-content">
|
<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">
|
<span class="email-nag-text">
|
||||||
<strong>Please verify your email address</strong> to ensure you can recover your account if needed.
|
<strong>Please verify your email address</strong> to ensure you can recover your account if needed.
|
||||||
</span>
|
</span>
|
||||||
<a href="{{ url_for('profile') }}" class="btn btn-sm btn-warning">Verify Email</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
<span class="stat-text">{{ user.company.name if user.company else 'No Company' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-badge">
|
<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>
|
<span class="stat-text">{{ user.team.name if user.team else 'No Team' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-badge">
|
<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>
|
<span class="stat-text">{{ user.role.value if user.role else 'Team Member' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
<div class="flash-messages">
|
<div class="flash-messages">
|
||||||
{% for category, message in messages %}
|
{% for category, message in messages %}
|
||||||
<div class="alert alert-{{ category }}">
|
<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 }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<div class="card avatar-card">
|
<div class="card avatar-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<span class="icon">🖼️</span>
|
<span class="icon"><i class="ti ti-photo"></i></span>
|
||||||
Profile Picture
|
Profile Picture
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,15 +65,15 @@
|
|||||||
<div class="avatar-controls">
|
<div class="avatar-controls">
|
||||||
<div class="control-tabs">
|
<div class="control-tabs">
|
||||||
<button class="tab-btn active" data-tab="default">
|
<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
|
Default
|
||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" data-tab="upload">
|
<button class="tab-btn" data-tab="upload">
|
||||||
<span class="tab-icon">📤</span>
|
<span class="tab-icon"><i class="ti ti-upload"></i></span>
|
||||||
Upload
|
Upload
|
||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" data-tab="url">
|
<button class="tab-btn" data-tab="url">
|
||||||
<span class="tab-icon">🔗</span>
|
<span class="tab-icon"><i class="ti ti-link"></i></span>
|
||||||
URL
|
URL
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
<!-- Default Avatar Tab -->
|
<!-- Default Avatar Tab -->
|
||||||
<div class="tab-content active" id="default-tab">
|
<div class="tab-content active" id="default-tab">
|
||||||
<div class="info-message">
|
<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>
|
<p>Your default avatar is automatically generated based on your username.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline" onclick="resetAvatar()">
|
<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">
|
<form method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data" class="modern-form">
|
||||||
<div class="upload-area">
|
<div class="upload-area">
|
||||||
<label for="avatar_file" class="upload-label">
|
<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-text">Drop image here or click to browse</div>
|
||||||
<div class="upload-hint">Max 5MB • JPG, PNG, GIF, WebP</div>
|
<div class="upload-hint">Max 5MB • JPG, PNG, GIF, WebP</div>
|
||||||
<div class="file-name" id="file-name"></div>
|
<div class="file-name" id="file-name"></div>
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
<img id="upload-preview-img" src="" alt="Preview">
|
<img id="upload-preview-img" src="" alt="Preview">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" id="upload-btn" disabled>
|
<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
|
Upload Avatar
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
<span class="form-hint">Enter a direct link to an image</span>
|
<span class="form-hint">Enter a direct link to an image</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<span class="icon">✓</span>
|
<span class="icon"><i class="ti ti-check"></i></span>
|
||||||
Set Avatar URL
|
Set Avatar URL
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<span class="icon">ℹ️</span>
|
<span class="icon"><i class="ti ti-info-circle"></i></span>
|
||||||
Account Information
|
Account Information
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<span class="icon">✉️</span>
|
<span class="icon"><i class="ti ti-mail"></i></span>
|
||||||
Email Settings
|
Email Settings
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +190,7 @@
|
|||||||
|
|
||||||
{% if user.email and not user.is_verified %}
|
{% if user.email and not user.is_verified %}
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
<span class="alert-icon">⚠️</span>
|
<span class="alert-icon"><i class="ti ti-alert-triangle"></i></span>
|
||||||
<div>
|
<div>
|
||||||
<p>Your email address is not verified.</p>
|
<p>Your email address is not verified.</p>
|
||||||
<a href="{{ url_for('profile') }}" class="btn btn-sm btn-warning">Send Verification Email</a>
|
<a href="{{ url_for('profile') }}" class="btn btn-sm btn-warning">Send Verification Email</a>
|
||||||
@@ -198,14 +198,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% elif not user.email %}
|
{% elif not user.email %}
|
||||||
<div class="alert alert-info">
|
<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>
|
<p>Adding an email enables account recovery and notifications.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">
|
<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
|
{% if user.email %}Update{% else %}Add{% endif %} Email
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +217,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<span class="icon">🔒</span>
|
<span class="icon"><i class="ti ti-lock"></i></span>
|
||||||
Security Settings
|
Security Settings
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-warning">
|
<button type="submit" class="btn btn-warning">
|
||||||
<span class="icon">🔑</span>
|
<span class="icon"><i class="ti ti-key"></i></span>
|
||||||
Change Password
|
Change Password
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,7 +264,7 @@
|
|||||||
<div class="tfa-status">
|
<div class="tfa-status">
|
||||||
{% if user.two_factor_enabled %}
|
{% if user.two_factor_enabled %}
|
||||||
<div class="status-indicator enabled">
|
<div class="status-indicator enabled">
|
||||||
<span class="status-icon">🛡️</span>
|
<span class="status-icon"><i class="ti ti-shield"></i></span>
|
||||||
<div>
|
<div>
|
||||||
<div class="status-text">Enabled</div>
|
<div class="status-text">Enabled</div>
|
||||||
<div class="status-description">Your account is protected with 2FA</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>
|
class="form-control" placeholder="Enter your password to disable 2FA" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-danger">
|
<button type="submit" class="btn btn-danger">
|
||||||
<span class="icon">✕</span>
|
<span class="icon"><i class="ti ti-x"></i></span>
|
||||||
Disable 2FA
|
Disable 2FA
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="status-indicator disabled">
|
<div class="status-indicator disabled">
|
||||||
<span class="status-icon">⚠️</span>
|
<span class="status-icon"><i class="ti ti-alert-triangle"></i></span>
|
||||||
<div>
|
<div>
|
||||||
<div class="status-text">Disabled</div>
|
<div class="status-text">Disabled</div>
|
||||||
<div class="status-description">Add extra security to your account</div>
|
<div class="status-description">Add extra security to your account</div>
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="{{ url_for('setup_2fa') }}" class="btn btn-success">
|
<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
|
Enable 2FA
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -234,7 +234,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="verification-notice">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
<span>Email: <strong>{{ invitation.email }}</strong></span>
|
<span>Email: <strong>{{ invitation.email }}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<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>
|
<span>Invited by: <strong>{{ invitation.invited_by.username }}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="verification-notice">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,17 +13,17 @@
|
|||||||
<!-- Info Message -->
|
<!-- Info Message -->
|
||||||
{% if is_initial_setup %}
|
{% if is_initial_setup %}
|
||||||
<div class="info-message">
|
<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>
|
<p>Set up your company and create the first administrator account to begin using {{ g.branding.app_name }}.</p>
|
||||||
</div>
|
</div>
|
||||||
{% elif is_super_admin %}
|
{% elif is_super_admin %}
|
||||||
<div class="info-message">
|
<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>
|
<p>Create a new company with its own administrator. This will be a separate organization within {{ g.branding.app_name }}.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="error-message">
|
<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>
|
<p>You do not have permission to create new companies.</p>
|
||||||
<a href="{{ url_for('home') }}" class="btn btn-secondary">Return Home</a>
|
<a href="{{ url_for('home') }}" class="btn btn-secondary">Return Home</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,11 +102,11 @@
|
|||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
{% if is_super_admin %}
|
{% if is_super_admin %}
|
||||||
<a href="{{ url_for('companies.admin_company') }}" class="btn btn-secondary">
|
<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>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="submit" class="btn btn-success">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,43 +1,56 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="management-container sprint-management-container">
|
<div class="page-container">
|
||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<div class="management-header sprint-header">
|
<div class="page-header">
|
||||||
<h1>🏃♂️ Sprint Management</h1>
|
<div class="header-content">
|
||||||
<div class="management-controls sprint-controls">
|
<div class="header-left">
|
||||||
<!-- View Switcher -->
|
<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>
|
||||||
|
<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">
|
<div class="view-switcher">
|
||||||
<button class="view-btn active" data-view="active">Active Sprints</button>
|
<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="all">All Sprints</button>
|
||||||
<button class="view-btn" data-view="planning">Planning</button>
|
<button class="view-btn" data-view="planning">Planning</button>
|
||||||
<button class="view-btn" data-view="completed">Completed</button>
|
<button class="view-btn" data-view="completed">Completed</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="management-actions sprint-actions">
|
|
||||||
<button id="add-sprint-btn" class="btn btn-primary">+ New Sprint</button>
|
|
||||||
<button id="refresh-sprints" class="btn btn-secondary">🔄 Refresh</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sprint Statistics -->
|
<!-- Sprint Statistics -->
|
||||||
<div class="management-stats sprint-stats">
|
<div class="stats-section">
|
||||||
<div class="stat-card">
|
<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 class="stat-label">Total Sprints</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<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 class="stat-label">Active</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<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 class="stat-label">Completed</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<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 class="stat-label">Total Tasks</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +125,7 @@
|
|||||||
<div class="hybrid-date-input">
|
<div class="hybrid-date-input">
|
||||||
<input type="date" id="sprint-start-date-native" class="date-input-native" required>
|
<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" }}">
|
<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>
|
||||||
<div class="date-error" id="sprint-start-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
|
<div class="date-error" id="sprint-start-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +134,7 @@
|
|||||||
<div class="hybrid-date-input">
|
<div class="hybrid-date-input">
|
||||||
<input type="date" id="sprint-end-date-native" class="date-input-native" required>
|
<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" }}">
|
<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>
|
||||||
<div class="date-error" id="sprint-end-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
|
<div class="date-error" id="sprint-end-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,38 +156,23 @@
|
|||||||
|
|
||||||
<!-- Styles -->
|
<!-- Styles -->
|
||||||
<style>
|
<style>
|
||||||
.sprint-management-container {
|
/* Container styles - using default page spacing */
|
||||||
padding: 1rem;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sprint-header {
|
/* Header styles handled by common page-header classes */
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
.filter-section {
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
flex-wrap: wrap;
|
padding: 1rem;
|
||||||
gap: 1rem;
|
background: #f8f9fa;
|
||||||
}
|
border-radius: 8px;
|
||||||
|
|
||||||
.sprint-header h1 {
|
|
||||||
margin: 0;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sprint-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-switcher {
|
.view-switcher {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #f8f9fa;
|
background: white;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn {
|
.view-btn {
|
||||||
@@ -198,37 +196,7 @@
|
|||||||
color: #212529;
|
color: #212529;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sprint-actions {
|
/* Statistics styles handled by common stats-section classes */
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sprint-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-number {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #666;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sprint-grid {
|
.sprint-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -731,7 +699,7 @@ class SprintManager {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sprint-dates">
|
<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)` : ''}
|
${sprint.days_remaining > 0 ? `(${sprint.days_remaining} days left)` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,37 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="announcement-form-container">
|
||||||
<div class="header-section">
|
<!-- Header Section -->
|
||||||
<h1>{{ "Edit" if announcement else "Create" }} Announcement</h1>
|
<div class="page-header">
|
||||||
<p class="subtitle">{{ "Update" if announcement else "Create new" }} system announcement for users</p>
|
<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">
|
<a href="{{ url_for('announcements.index') }}" class="btn btn-secondary">
|
||||||
← Back to Announcements
|
<i class="ti ti-arrow-left"></i>
|
||||||
|
Back to Announcements
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
<!-- Main Form -->
|
||||||
<form method="POST" class="announcement-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="card-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title">Title</label>
|
<label for="title">Title</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
@@ -20,7 +40,8 @@
|
|||||||
value="{{ announcement.title if announcement else '' }}"
|
value="{{ announcement.title if announcement else '' }}"
|
||||||
required
|
required
|
||||||
maxlength="200"
|
maxlength="200"
|
||||||
class="form-control">
|
class="form-control"
|
||||||
|
placeholder="Enter announcement title">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -29,7 +50,8 @@
|
|||||||
name="content"
|
name="content"
|
||||||
required
|
required
|
||||||
rows="6"
|
rows="6"
|
||||||
class="form-control">{{ announcement.content if announcement else '' }}</textarea>
|
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>
|
<small class="form-text">You can use HTML formatting in the content.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -37,39 +59,57 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="announcement_type">Type</label>
|
<label for="announcement_type">Type</label>
|
||||||
<select id="announcement_type" name="announcement_type" class="form-control">
|
<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="info" {{ 'selected' if announcement and announcement.announcement_type == 'info' else '' }}>
|
||||||
<option value="warning" {{ 'selected' if announcement and announcement.announcement_type == 'warning' else '' }}>Warning</option>
|
<i class="ti ti-info-circle"></i> Info
|
||||||
<option value="success" {{ 'selected' if announcement and announcement.announcement_type == 'success' else '' }}>Success</option>
|
</option>
|
||||||
<option value="danger" {{ 'selected' if announcement and announcement.announcement_type == 'danger' else '' }}>Danger</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="checkbox-container">
|
<label>Options</label>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="toggle-label">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="is_urgent"
|
name="is_urgent"
|
||||||
{{ 'checked' if announcement and announcement.is_urgent else '' }}>
|
{{ 'checked' if announcement and announcement.is_urgent else '' }}>
|
||||||
<span class="checkmark"></span>
|
<span class="toggle-slider"></span>
|
||||||
Mark as Urgent
|
<span class="toggle-text">Mark as Urgent</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<label class="toggle-label">
|
||||||
<label class="checkbox-container">
|
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="is_active"
|
name="is_active"
|
||||||
{{ 'checked' if not announcement or announcement.is_active else '' }}>
|
{{ 'checked' if not announcement or announcement.is_active else '' }}>
|
||||||
<span class="checkmark"></span>
|
<span class="toggle-slider"></span>
|
||||||
Active
|
<span class="toggle-text">Active</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
<!-- Scheduling -->
|
||||||
<h3>Scheduling</h3>
|
<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-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="start_date">Start Date/Time (Optional)</label>
|
<label for="start_date">Start Date/Time</label>
|
||||||
<input type="datetime-local"
|
<input type="datetime-local"
|
||||||
id="start_date"
|
id="start_date"
|
||||||
name="start_date"
|
name="start_date"
|
||||||
@@ -79,7 +119,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="end_date">End Date/Time (Optional)</label>
|
<label for="end_date">End Date/Time</label>
|
||||||
<input type="datetime-local"
|
<input type="datetime-local"
|
||||||
id="end_date"
|
id="end_date"
|
||||||
name="end_date"
|
name="end_date"
|
||||||
@@ -89,60 +129,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
<!-- Targeting -->
|
||||||
<h3>Targeting</h3>
|
<div class="card">
|
||||||
<div class="form-row">
|
<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">
|
<div class="form-group">
|
||||||
<label class="checkbox-container">
|
<label class="toggle-label main-toggle">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="target_all_users"
|
name="target_all_users"
|
||||||
id="target_all_users"
|
id="target_all_users"
|
||||||
{{ 'checked' if not announcement or announcement.target_all_users else '' }}
|
{{ 'checked' if not announcement or announcement.target_all_users else '' }}
|
||||||
onchange="toggleTargeting()">
|
onchange="toggleTargeting()">
|
||||||
<span class="checkmark"></span>
|
<span class="toggle-slider"></span>
|
||||||
Target All Users
|
<span class="toggle-text">Target All Users</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<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 id="targeting_options" style="display: {{ 'none' if not announcement or announcement.target_all_users else 'block' }};">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Target Roles</label>
|
<label><i class="ti ti-user-check"></i> Target Roles</label>
|
||||||
<div class="checkbox-list">
|
<div class="checkbox-list">
|
||||||
{% set selected_roles = [] %}
|
{% set selected_roles = [] %}
|
||||||
{% if announcement and announcement.target_roles %}
|
{% if announcement and announcement.target_roles %}
|
||||||
{% set selected_roles = announcement.target_roles|from_json %}
|
{% set selected_roles = announcement.target_roles|from_json %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for role in roles %}
|
{% for role in roles %}
|
||||||
<label class="checkbox-container">
|
<label class="checkbox-item">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="target_roles"
|
name="target_roles"
|
||||||
value="{{ role }}"
|
value="{{ role }}"
|
||||||
{{ 'checked' if role in selected_roles else '' }}>
|
{{ 'checked' if role in selected_roles else '' }}>
|
||||||
<span class="checkmark"></span>
|
<span class="checkbox-custom"></span>
|
||||||
{{ role }}
|
<span class="checkbox-label">{{ role }}</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Target Companies</label>
|
<label><i class="ti ti-building"></i> Target Companies</label>
|
||||||
<div class="checkbox-list">
|
<div class="checkbox-list">
|
||||||
{% set selected_companies = [] %}
|
{% set selected_companies = [] %}
|
||||||
{% if announcement and announcement.target_companies %}
|
{% if announcement and announcement.target_companies %}
|
||||||
{% set selected_companies = announcement.target_companies|from_json %}
|
{% set selected_companies = announcement.target_companies|from_json %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for company in companies %}
|
{% for company in companies %}
|
||||||
<label class="checkbox-container">
|
<label class="checkbox-item">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="target_companies"
|
name="target_companies"
|
||||||
value="{{ company.id }}"
|
value="{{ company.id }}"
|
||||||
{{ 'checked' if company.id in selected_companies else '' }}>
|
{{ 'checked' if company.id in selected_companies else '' }}>
|
||||||
<span class="checkmark"></span>
|
<span class="checkbox-custom"></span>
|
||||||
{{ company.name }}
|
<span class="checkbox-label">{{ company.name }}</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -150,16 +197,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Actions -->
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="ti ti-device-floppy"></i>
|
||||||
{{ "Update" if announcement else "Create" }} Announcement
|
{{ "Update" if announcement else "Create" }} Announcement
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('announcements.index') }}" class="btn btn-secondary">Cancel</a>
|
<a href="{{ url_for('announcements.index') }}" class="btn btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function toggleTargeting() {
|
function toggleTargeting() {
|
||||||
@@ -180,155 +229,334 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.header-section {
|
/* Container */
|
||||||
margin-bottom: 2rem;
|
.announcement-form-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
/* Page Header */
|
||||||
color: #6c757d;
|
.page-header {
|
||||||
margin-bottom: 1rem;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
}
|
border-radius: 16px;
|
||||||
|
|
||||||
.form-section {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
margin-bottom: 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 {
|
.announcement-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.form-section h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: #495057;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-weight: 500;
|
color: #374151;
|
||||||
color: #495057;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem;
|
padding: 0.625rem 1rem;
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
box-sizing: border-box;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control:focus {
|
.form-control:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #007bff;
|
border-color: #667eea;
|
||||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-control {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-text {
|
.form-text {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
display: grid;
|
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;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-container {
|
.toggle-label {
|
||||||
display: block;
|
display: inline-flex;
|
||||||
position: relative;
|
align-items: center;
|
||||||
padding-left: 35px;
|
gap: 0.75rem;
|
||||||
margin-bottom: 12px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
margin-bottom: 0;
|
||||||
user-select: none;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-container input {
|
.toggle-label input[type="checkbox"] {
|
||||||
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;
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-container input:checked ~ .checkmark:after {
|
.toggle-slider {
|
||||||
display: block;
|
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 {
|
.toggle-slider::before {
|
||||||
left: 9px;
|
content: '';
|
||||||
top: 5px;
|
position: absolute;
|
||||||
width: 5px;
|
width: 20px;
|
||||||
height: 10px;
|
height: 20px;
|
||||||
border: solid white;
|
border-radius: 50%;
|
||||||
border-width: 0 3px 3px 0;
|
background: white;
|
||||||
transform: rotate(45deg);
|
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 {
|
.checkbox-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
max-height: 200px;
|
max-height: 250px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 0.25rem;
|
border-radius: 8px;
|
||||||
padding: 0.75rem;
|
padding: 1rem;
|
||||||
background: #f8f9fa;
|
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 {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding-top: 1rem;
|
padding: 2rem;
|
||||||
border-top: 1px solid #e9ecef;
|
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) {
|
@media (max-width: 768px) {
|
||||||
|
.announcement-form-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -336,6 +564,36 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
.checkbox-list {
|
.checkbox-list {
|
||||||
grid-template-columns: 1fr;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,19 +1,35 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="content-header">
|
<div class="announcements-container">
|
||||||
<div class="header-row">
|
<!-- Header Section -->
|
||||||
<h1>System Announcements</h1>
|
<div class="page-header">
|
||||||
<a href="{{ url_for('announcements.create') }}" class="btn btn-md btn-primary">
|
<div class="header-content">
|
||||||
<i class="icon">➕</i> New Announcement
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="content-body">
|
<!-- Announcements Table -->
|
||||||
{% if announcements.items %}
|
{% if announcements.items %}
|
||||||
<div class="table-container">
|
<div class="card">
|
||||||
<table class="data-table">
|
<div class="card-body no-padding">
|
||||||
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
@@ -28,62 +44,72 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for announcement in announcements.items %}
|
{% 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>
|
<td>
|
||||||
|
<div class="announcement-title">
|
||||||
<strong>{{ announcement.title }}</strong>
|
<strong>{{ announcement.title }}</strong>
|
||||||
{% if announcement.is_urgent %}
|
{% if announcement.is_urgent %}
|
||||||
<span class="badge badge-danger">URGENT</span>
|
<span class="badge badge-urgent">URGENT</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge badge-{{ announcement.announcement_type }}">
|
<span class="type-badge type-{{ announcement.announcement_type }}">
|
||||||
{{ announcement.announcement_type.title() }}
|
{{ announcement.announcement_type.title() }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if announcement.is_active %}
|
{% if announcement.is_active %}
|
||||||
{% if announcement.is_visible_now() %}
|
{% if announcement.is_visible_now() %}
|
||||||
<span class="badge badge-success">Active</span>
|
<span class="status-badge status-active">Active</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-warning">Scheduled</span>
|
<span class="status-badge status-scheduled">Scheduled</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-secondary">Inactive</span>
|
<span class="status-badge status-inactive">Inactive</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if announcement.start_date %}
|
{% 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 %}
|
{% else %}
|
||||||
<em>Immediate</em>
|
<em class="text-muted">Immediate</em>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if announcement.end_date %}
|
{% 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 %}
|
{% else %}
|
||||||
<em>No expiry</em>
|
<em class="text-muted">No expiry</em>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if announcement.target_all_users %}
|
{% if announcement.target_all_users %}
|
||||||
|
<span class="target-badge target-all">
|
||||||
|
<i class="ti ti-users"></i>
|
||||||
All Users
|
All Users
|
||||||
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">Targeted</span>
|
<span class="target-badge target-specific">
|
||||||
|
<i class="ti ti-target"></i>
|
||||||
|
Targeted
|
||||||
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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>
|
<td>
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a href="{{ url_for('announcements.edit', id=announcement.id) }}"
|
<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>
|
</a>
|
||||||
<form method="POST" action="{{ url_for('announcements.delete', id=announcement.id) }}"
|
<form method="POST" action="{{ url_for('announcements.delete', id=announcement.id) }}"
|
||||||
style="display: inline-block;"
|
style="display: inline-block;"
|
||||||
onsubmit="return confirm('Are you sure you want to delete this announcement?')">
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,15 +119,20 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if announcements.pages > 1 %}
|
{% if announcements.pages > 1 %}
|
||||||
<div class="pagination-container">
|
<div class="pagination-container">
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% if announcements.has_prev %}
|
{% if announcements.has_prev %}
|
||||||
<a href="{{ url_for('announcements.index', page=announcements.prev_num) }}" class="page-link">« Previous</a>
|
<a href="{{ url_for('announcements.index', page=announcements.prev_num) }}" class="page-link">
|
||||||
|
<i class="ti ti-chevron-left"></i>
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="page-numbers">
|
||||||
{% for page_num in announcements.iter_pages() %}
|
{% for page_num in announcements.iter_pages() %}
|
||||||
{% if page_num %}
|
{% if page_num %}
|
||||||
{% if page_num != announcements.page %}
|
{% if page_num != announcements.page %}
|
||||||
@@ -110,22 +141,34 @@
|
|||||||
<span class="page-link current">{{ page_num }}</span>
|
<span class="page-link current">{{ page_num }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="page-link">…</span>
|
<span class="page-dots">...</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if announcements.has_next %}
|
{% if announcements.has_next %}
|
||||||
<a href="{{ url_for('announcements.index', page=announcements.next_num) }}" class="page-link">Next »</a>
|
<a href="{{ url_for('announcements.index', page=announcements.next_num) }}" class="page-link">
|
||||||
|
Next
|
||||||
|
<i class="ti ti-chevron-right"></i>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<!-- Empty State -->
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<h3>No announcements found</h3>
|
<div class="empty-icon"><i class="ti ti-speakerphone"></i></div>
|
||||||
<p>Create your first announcement to communicate with users.</p>
|
<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">
|
<a href="{{ url_for('announcements.create') }}" class="btn btn-primary">
|
||||||
|
<i class="ti ti-plus"></i>
|
||||||
Create Announcement
|
Create Announcement
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,33 +176,417 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
/* Announcement Title */
|
||||||
padding: 2px 8px;
|
.announcement-title {
|
||||||
border-radius: 12px;
|
display: flex;
|
||||||
font-size: 0.75em;
|
align-items: center;
|
||||||
font-weight: bold;
|
gap: 0.5rem;
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-info { background: #17a2b8; color: white; }
|
/* Badges */
|
||||||
.badge-warning { background: #ffc107; color: #212529; }
|
.badge,
|
||||||
.badge-success { background: #28a745; color: white; }
|
.status-badge,
|
||||||
.badge-danger { background: #dc3545; color: white; }
|
.type-badge,
|
||||||
.badge-secondary { background: #6c757d; color: white; }
|
.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 {
|
.action-buttons {
|
||||||
display: flex;
|
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 {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem;
|
padding: 4rem 2rem;
|
||||||
color: #6c757d;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,22 +1,33 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="management-container">
|
<div class="branding-container">
|
||||||
<div class="management-header">
|
<!-- Header Section -->
|
||||||
<h1>🎨 Branding Settings</h1>
|
<div class="page-header">
|
||||||
<div class="management-actions">
|
<div class="header-content">
|
||||||
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="subtitle">Customize the appearance and branding of {{ branding.app_name }}</p>
|
|
||||||
|
|
||||||
<!-- Current Branding Preview -->
|
<!-- Current Branding Preview -->
|
||||||
<div class="management-section">
|
<div class="card preview-card">
|
||||||
<h2>👁️ Current Branding Preview</h2>
|
|
||||||
<div class="management-card branding-preview-card">
|
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Live Preview</h3>
|
<h2 class="card-title">
|
||||||
|
<span class="icon"><i class="ti ti-eye"></i></span>
|
||||||
|
Current Branding Preview
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="preview-demo">
|
<div class="preview-demo">
|
||||||
@@ -39,16 +50,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Branding Settings Form -->
|
<!-- Branding Settings Form -->
|
||||||
<div class="management-section">
|
<div class="card">
|
||||||
<h2>🔧 Branding Configuration</h2>
|
<div class="card-header">
|
||||||
<div class="management-card">
|
<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">
|
<form method="POST" enctype="multipart/form-data" class="settings-form">
|
||||||
<!-- Application Name -->
|
<!-- Application Name -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3>📝 Basic Information</h3>
|
<h3><i class="ti ti-forms"></i> Basic Information</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="app_name">Application Name</label>
|
<label for="app_name">Application Name</label>
|
||||||
<input type="text" id="app_name" name="app_name"
|
<input type="text" id="app_name" name="app_name"
|
||||||
@@ -56,7 +71,7 @@
|
|||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="TimeTrack"
|
placeholder="TimeTrack"
|
||||||
required>
|
required>
|
||||||
<small class="form-text text-muted">
|
<small class="form-text">
|
||||||
This name will appear in the title, navigation, and throughout the interface.
|
This name will appear in the title, navigation, and throughout the interface.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +82,7 @@
|
|||||||
value="{{ branding.logo_alt_text }}"
|
value="{{ branding.logo_alt_text }}"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Company Logo">
|
placeholder="Company Logo">
|
||||||
<small class="form-text text-muted">
|
<small class="form-text">
|
||||||
Text displayed when the logo cannot be loaded (accessibility).
|
Text displayed when the logo cannot be loaded (accessibility).
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,7 +90,7 @@
|
|||||||
|
|
||||||
<!-- Visual Assets -->
|
<!-- Visual Assets -->
|
||||||
<div class="form-section">
|
<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-row">
|
||||||
<div class="form-group col-md-6">
|
<div class="form-group col-md-6">
|
||||||
<label for="logo_file">Logo Image</label>
|
<label for="logo_file">Logo Image</label>
|
||||||
@@ -90,7 +105,7 @@
|
|||||||
<span class="asset-label">Current logo</span>
|
<span class="asset-label">Current logo</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<small class="form-text text-muted">
|
<small class="form-text">
|
||||||
PNG, JPG, GIF, SVG. Recommended: 200x50px
|
PNG, JPG, GIF, SVG. Recommended: 200x50px
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +123,7 @@
|
|||||||
<span class="asset-label">Current favicon</span>
|
<span class="asset-label">Current favicon</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<small class="form-text text-muted">
|
<small class="form-text">
|
||||||
ICO, PNG. Recommended: 16x16px or 32x32px
|
ICO, PNG. Recommended: 16x16px or 32x32px
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +132,7 @@
|
|||||||
|
|
||||||
<!-- Theme Settings -->
|
<!-- Theme Settings -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3>🎨 Theme Settings</h3>
|
<h3><i class="ti ti-color-swatch"></i> Theme Settings</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="primary_color">Primary Color</label>
|
<label for="primary_color">Primary Color</label>
|
||||||
<div class="color-picker-wrapper">
|
<div class="color-picker-wrapper">
|
||||||
@@ -130,7 +145,7 @@
|
|||||||
pattern="^#[0-9A-Fa-f]{6}$"
|
pattern="^#[0-9A-Fa-f]{6}$"
|
||||||
placeholder="#007bff">
|
placeholder="#007bff">
|
||||||
</div>
|
</div>
|
||||||
<small class="form-text text-muted">
|
<small class="form-text">
|
||||||
This color will be used for buttons, links, and other UI elements.
|
This color will be used for buttons, links, and other UI elements.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +153,7 @@
|
|||||||
|
|
||||||
<!-- Imprint/Legal Page -->
|
<!-- Imprint/Legal Page -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3>⚖️ Imprint / Legal Page</h3>
|
<h3><i class="ti ti-scale"></i> Imprint / Legal Page</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" name="imprint_enabled" id="imprint_enabled"
|
<input type="checkbox" name="imprint_enabled" id="imprint_enabled"
|
||||||
@@ -146,7 +161,7 @@
|
|||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
<span class="toggle-text">Enable Imprint Page</span>
|
<span class="toggle-text">Enable Imprint Page</span>
|
||||||
</label>
|
</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.
|
When enabled, an "Imprint" link will appear in the footer linking to your custom legal page.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,7 +173,7 @@
|
|||||||
value="{{ branding.imprint_title or 'Imprint' }}"
|
value="{{ branding.imprint_title or 'Imprint' }}"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Imprint">
|
placeholder="Imprint">
|
||||||
<small class="form-text text-muted">
|
<small class="form-text">
|
||||||
The title that will be displayed on the imprint page.
|
The title that will be displayed on the imprint page.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +184,7 @@
|
|||||||
class="form-control content-editor"
|
class="form-control content-editor"
|
||||||
rows="15"
|
rows="15"
|
||||||
placeholder="Enter your imprint/legal information here...">{{ branding.imprint_content or '' }}</textarea>
|
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="">
|
You can use HTML to format your content. Common tags: <h2>, <h3>, <p>, <strong>, <br>, <a href="">
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +193,10 @@
|
|||||||
|
|
||||||
<!-- Save Button -->
|
<!-- Save Button -->
|
||||||
<div class="form-actions">
|
<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>
|
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -187,8 +205,99 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Branding-specific styles */
|
/* Container */
|
||||||
.branding-preview-card {
|
.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;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +311,7 @@
|
|||||||
.demo-header {
|
.demo-header {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
border-bottom: 1px solid #dee2e6;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.demo-logo {
|
.demo-logo {
|
||||||
@@ -213,8 +322,8 @@
|
|||||||
|
|
||||||
.demo-text-logo {
|
.demo-text-logo {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #333;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.demo-content {
|
.demo-content {
|
||||||
@@ -222,7 +331,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.demo-content p {
|
.demo-content p {
|
||||||
color: #6c757d;
|
color: #6b7280;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,12 +339,80 @@
|
|||||||
margin-right: 1rem;
|
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 {
|
.current-asset {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -255,10 +432,10 @@
|
|||||||
|
|
||||||
.asset-label {
|
.asset-label {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #6c757d;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Color picker styling */
|
/* Color Picker */
|
||||||
.color-picker-wrapper {
|
.color-picker-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -270,8 +447,8 @@
|
|||||||
width: 60px;
|
width: 60px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,58 +457,8 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form sections */
|
/* Toggle Switch */
|
||||||
.form-section {
|
.toggle-label {
|
||||||
margin-bottom: 2rem;
|
|
||||||
padding-bottom: 2rem;
|
|
||||||
border-bottom: 1px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section h3 {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: #495057;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* File input styling */
|
|
||||||
.form-control-file {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.375rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form row for two-column layout */
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-right: -0.5rem;
|
|
||||||
margin-left: -0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row > .col-md-6 {
|
|
||||||
flex: 0 0 50%;
|
|
||||||
max-width: 50%;
|
|
||||||
padding-right: 0.5rem;
|
|
||||||
padding-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.form-row > .col-md-6 {
|
|
||||||
flex: 0 0 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sync color inputs */
|
|
||||||
|
|
||||||
/* Toggle label styling - ensuring proper alignment */
|
|
||||||
.form-group .toggle-label {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -349,7 +476,7 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background: #ccc;
|
background: #e5e7eb;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
transition: background 0.3s;
|
transition: background 0.3s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -365,10 +492,11 @@
|
|||||||
top: 2px;
|
top: 2px;
|
||||||
left: 2px;
|
left: 2px;
|
||||||
transition: transform 0.3s;
|
transition: transform 0.3s;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-label input[type="checkbox"]:checked + .toggle-slider {
|
.toggle-label input[type="checkbox"]:checked + .toggle-slider {
|
||||||
background: var(--primary-color);
|
background: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-label input[type="checkbox"]:checked + .toggle-slider::before {
|
.toggle-label input[type="checkbox"]:checked + .toggle-slider::before {
|
||||||
@@ -377,18 +505,18 @@
|
|||||||
|
|
||||||
.toggle-text {
|
.toggle-text {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #495057;
|
color: #1f2937;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content editor styling */
|
/* Content Editor */
|
||||||
.content-editor {
|
.content-editor {
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
@@ -400,6 +528,108 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: all 0.3s ease;
|
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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -1,23 +1,59 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="companies-admin-container">
|
||||||
<div class="header-section">
|
<!-- Header Section -->
|
||||||
<h1>🏢 System Admin - All Companies</h1>
|
<div class="page-header">
|
||||||
<p class="subtitle">Manage companies across the entire system</p>
|
<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">
|
<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-secondary">
|
||||||
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-md btn-secondary">← Back to Dashboard</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Companies 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>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="content-section">
|
||||||
{% if companies.items %}
|
{% if companies.items %}
|
||||||
<div class="table-section">
|
<!-- Companies Table -->
|
||||||
<table class="table">
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Company Name</th>
|
<th>Company</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Users</th>
|
<th>Users</th>
|
||||||
<th>Admins</th>
|
<th>Admins</th>
|
||||||
@@ -28,12 +64,14 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for company in companies.items %}
|
{% for company in companies.items %}
|
||||||
<tr class="{% if not company.is_active %}inactive-company{% endif %}">
|
<tr class="{% if not company.is_active %}inactive-row{% endif %}">
|
||||||
<td>
|
<td>
|
||||||
<strong>{{ company.name }}</strong>
|
<div class="company-cell">
|
||||||
|
<div class="company-name">{{ company.name }}</div>
|
||||||
{% if company.slug %}
|
{% if company.slug %}
|
||||||
<br><small class="text-muted">{{ company.slug }}</small>
|
<div class="company-slug">{{ company.slug }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if company.is_personal %}
|
{% if company.is_personal %}
|
||||||
@@ -43,12 +81,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
<div class="stat-cell">
|
||||||
<span class="stat-number">{{ company_stats[company.id]['user_count'] }}</span>
|
<span class="stat-number">{{ company_stats[company.id]['user_count'] }}</span>
|
||||||
<small>users</small>
|
<span class="stat-label">users</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
<div class="stat-cell">
|
||||||
<span class="stat-number">{{ company_stats[company.id]['admin_count'] }}</span>
|
<span class="stat-number">{{ company_stats[company.id]['admin_count'] }}</span>
|
||||||
<small>admins</small>
|
<span class="stat-label">admins</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if company.is_active %}
|
{% if company.is_active %}
|
||||||
@@ -57,11 +99,15 @@
|
|||||||
<span class="status-badge status-inactive">Inactive</span>
|
<span class="status-badge status-inactive">Inactive</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ company.created_at.strftime('%Y-%m-%d') }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<div class="action-buttons">
|
<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) }}"
|
<a href="{{ url_for('system_admin.system_admin_company_detail', company_id=company.id) }}"
|
||||||
class="btn btn-sm btn-primary">View Details</a>
|
class="btn-icon" title="View Details">
|
||||||
|
<i class="ti ti-eye"></i>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -72,242 +118,474 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if companies.pages > 1 %}
|
{% if companies.pages > 1 %}
|
||||||
<div class="pagination-section">
|
<div class="pagination-container">
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% if companies.has_prev %}
|
{% if companies.has_prev %}
|
||||||
<a href="{{ url_for('system_admin.system_admin_companies', page=companies.prev_num) }}" class="page-link">← Previous</a>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="page-numbers">
|
||||||
{% for page_num in companies.iter_pages() %}
|
{% for page_num in companies.iter_pages() %}
|
||||||
{% if page_num %}
|
{% if page_num %}
|
||||||
{% if page_num != companies.page %}
|
{% if page_num != companies.page %}
|
||||||
<a href="{{ url_for('system_admin.system_admin_companies', page=page_num) }}" class="page-link">{{ page_num }}</a>
|
<a href="{{ url_for('system_admin.system_admin_companies', page=page_num) }}"
|
||||||
|
class="page-link">{{ page_num }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="page-link current">{{ page_num }}</span>
|
<span class="page-link current">{{ page_num }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="page-link">…</span>
|
<span class="page-dots">...</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if companies.has_next %}
|
{% if companies.has_next %}
|
||||||
<a href="{{ url_for('system_admin.system_admin_companies', page=companies.next_num) }}" class="page-link">Next →</a>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="pagination-info">
|
<div class="pagination-info">
|
||||||
Showing {{ companies.per_page * (companies.page - 1) + 1 }} -
|
Showing {{ companies.per_page * (companies.page - 1) + 1 }} -
|
||||||
{{ companies.per_page * (companies.page - 1) + companies.items|length }} of {{ companies.total }} companies
|
{{ companies.per_page * (companies.page - 1) + companies.items|length }} of {{ companies.total }} companies
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<!-- Empty State -->
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<h3>No companies found</h3>
|
<div class="empty-icon"><i class="ti ti-building-community"></i></div>
|
||||||
<p>No companies exist in the system yet.</p>
|
<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>
|
||||||
{% endif %}
|
{% 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>
|
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.stat-card {
|
||||||
color: #6c757d;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-section {
|
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
padding: 1.5rem;
|
||||||
overflow: hidden;
|
border-radius: 12px;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
text-align: center;
|
||||||
margin-bottom: 2rem;
|
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%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table th,
|
.data-table th,
|
||||||
.table td {
|
.data-table td {
|
||||||
padding: 1rem;
|
padding: 1rem 1.5rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #dee2e6;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table th {
|
.data-table th {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #495057;
|
color: #374151;
|
||||||
}
|
|
||||||
|
|
||||||
.inactive-company {
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.875rem;
|
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 {
|
.badge {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 20px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-company {
|
.badge-company {
|
||||||
background: #d1ecf1;
|
background: #dbeafe;
|
||||||
color: #0c5460;
|
color: #1e40af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-freelancer {
|
.badge-freelancer {
|
||||||
background: #d4edda;
|
background: #d1fae5;
|
||||||
color: #155724;
|
color: #065f46;
|
||||||
}
|
|
||||||
|
|
||||||
.stat-number {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #007bff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status Badges */
|
||||||
.status-badge {
|
.status-badge {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 20px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-active {
|
.status-active {
|
||||||
background: #d4edda;
|
background: #d1fae5;
|
||||||
color: #155724;
|
color: #065f46;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-inactive {
|
.status-inactive {
|
||||||
background: #f8d7da;
|
background: #fee2e2;
|
||||||
color: #721c24;
|
color: #991b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
/* Table Actions */
|
||||||
|
.table-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
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 {
|
.btn-icon:hover {
|
||||||
margin: 2rem 0;
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination-container {
|
||||||
|
padding: 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-numbers {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-link {
|
.page-link {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 1rem;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #e5e7eb;
|
||||||
color: #007bff;
|
color: #667eea;
|
||||||
text-decoration: none;
|
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 {
|
.page-link:hover {
|
||||||
background: #e9ecef;
|
background: #f3f4f6;
|
||||||
|
border-color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-link.current {
|
.page-link.current {
|
||||||
background: #007bff;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #007bff;
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-dots {
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-info {
|
.pagination-info {
|
||||||
color: #6c757d;
|
color: #6b7280;
|
||||||
margin: 0;
|
font-size: 0.875rem;
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem;
|
padding: 4rem 2rem;
|
||||||
color: #6c757d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-section {
|
.empty-icon {
|
||||||
background: #f8f9fa;
|
font-size: 4rem;
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-section h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
color: #495057;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid {
|
.empty-title {
|
||||||
display: grid;
|
font-size: 1.75rem;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
font-weight: 700;
|
||||||
gap: 1rem;
|
color: #1f2937;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-card {
|
.empty-message {
|
||||||
background: white;
|
font-size: 1.1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
border-radius: 8px;
|
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;
|
font-weight: 600;
|
||||||
color: #007bff;
|
font-size: 1rem;
|
||||||
margin: 0;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% 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" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="edit-user-container">
|
||||||
<div class="header-section">
|
<!-- Header Section -->
|
||||||
<h1>✏️ Edit User: {{ user.username }}</h1>
|
<div class="page-header">
|
||||||
<p class="subtitle">System Administrator - Edit user across companies</p>
|
<div class="header-content">
|
||||||
<a href="{{ url_for('users.system_admin_users') }}" class="btn btn-secondary">← Back to Users</a>
|
<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>
|
||||||
|
|
||||||
<div class="form-container">
|
<!-- Main Form -->
|
||||||
<form method="POST">
|
<form method="POST" class="user-edit-form">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<!-- Basic Information -->
|
<!-- Basic Information -->
|
||||||
<div class="form-section">
|
<div class="card">
|
||||||
<h3>Basic Information</h3>
|
<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">
|
<div class="form-group">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
@@ -29,10 +48,17 @@
|
|||||||
class="form-control">
|
class="form-control">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Company & Team Assignment -->
|
<!-- Company & Team Assignment -->
|
||||||
<div class="form-section">
|
<div class="card">
|
||||||
<h3>Company & Team</h3>
|
<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">
|
<div class="form-group">
|
||||||
<label for="company_id">Company</label>
|
<label for="company_id">Company</label>
|
||||||
@@ -60,10 +86,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Role & Permissions -->
|
<!-- Role & Permissions -->
|
||||||
<div class="form-section">
|
<div class="card">
|
||||||
<h3>Role & Permissions</h3>
|
<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">
|
<div class="form-group">
|
||||||
<label for="role">Role</label>
|
<label for="role">Role</label>
|
||||||
@@ -76,40 +109,54 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
{% if user.role == Role.SYSTEM_ADMIN %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Account Status -->
|
<!-- Account Status -->
|
||||||
<div class="form-section">
|
<div class="card">
|
||||||
<h3>Account Status</h3>
|
<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">
|
<div class="form-group">
|
||||||
<label class="checkbox-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" name="is_verified"
|
<input type="checkbox" name="is_verified"
|
||||||
{% if user.is_verified %}checked{% endif %}>
|
{% if user.is_verified %}checked{% endif %}>
|
||||||
<span class="checkmark"></span>
|
<span class="toggle-slider"></span>
|
||||||
Email Verified
|
<span class="toggle-text">Email Verified</span>
|
||||||
</label>
|
</label>
|
||||||
<small class="form-text">Whether the user's email address has been verified</small>
|
<small class="form-text">Whether the user's email address has been verified</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="checkbox-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" name="is_blocked"
|
<input type="checkbox" name="is_blocked"
|
||||||
{% if user.is_blocked %}checked{% endif %}>
|
{% if user.is_blocked %}checked{% endif %}>
|
||||||
<span class="checkmark"></span>
|
<span class="toggle-slider"></span>
|
||||||
Account Blocked
|
<span class="toggle-text">Account Blocked</span>
|
||||||
</label>
|
</label>
|
||||||
<small class="form-text">Blocked users cannot log in to the system</small>
|
<small class="form-text">Blocked users cannot log in to the system</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- User Information Display -->
|
<!-- User Information Display -->
|
||||||
<div class="info-section">
|
<div class="card full-width">
|
||||||
<h3>User Information</h3>
|
<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-grid">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<label>Account Type:</label>
|
<label>Account Type:</label>
|
||||||
@@ -138,27 +185,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
<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>
|
<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) %}
|
{% 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-zone">
|
||||||
<h4>Danger Zone</h4>
|
<div class="danger-header">
|
||||||
<p>Permanently delete this user account. This action cannot be undone.</p>
|
<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) }}"
|
<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.')">
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Dynamic team loading when company changes
|
// Dynamic team loading when company changes
|
||||||
@@ -191,175 +258,402 @@ document.getElementById('company_id').addEventListener('change', function() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.header-section {
|
/* Container */
|
||||||
margin-bottom: 2rem;
|
.edit-user-container {
|
||||||
}
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
.subtitle {
|
|
||||||
color: #6c757d;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-container {
|
|
||||||
max-width: 800px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
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 {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
gap: 2rem;
|
gap: 1.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-section {
|
/* Cards */
|
||||||
border: 1px solid #dee2e6;
|
.card {
|
||||||
border-radius: 8px;
|
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;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-section h3 {
|
/* Form Elements */
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: #495057;
|
|
||||||
border-bottom: 2px solid #e9ecef;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-weight: 500;
|
color: #374151;
|
||||||
color: #495057;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.625rem 1rem;
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
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 {
|
.form-control:focus {
|
||||||
border-color: #007bff;
|
outline: none;
|
||||||
outline: 0;
|
border-color: #667eea;
|
||||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-text {
|
.form-text {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #6c757d;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-label {
|
.warning-text {
|
||||||
display: flex;
|
color: #dc2626 !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switches */
|
||||||
|
.toggle-label {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: normal;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-label input[type="checkbox"] {
|
.toggle-label input[type="checkbox"] {
|
||||||
margin-right: 0.5rem;
|
display: none;
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-section {
|
.toggle-slider {
|
||||||
background: #f8f9fa;
|
position: relative;
|
||||||
border-radius: 8px;
|
display: inline-block;
|
||||||
padding: 1.5rem;
|
width: 50px;
|
||||||
margin-bottom: 2rem;
|
height: 24px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 24px;
|
||||||
|
transition: background 0.3s;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-section h3 {
|
.toggle-slider::before {
|
||||||
margin-top: 0;
|
content: '';
|
||||||
margin-bottom: 1rem;
|
position: absolute;
|
||||||
color: #495057;
|
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 {
|
.info-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item {
|
.info-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item label {
|
.info-item label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #6c757d;
|
color: #6b7280;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
.badge {
|
.badge {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 20px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-company {
|
.badge-company {
|
||||||
background: #d1ecf1;
|
background: #dbeafe;
|
||||||
color: #0c5460;
|
color: #1e40af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-freelancer {
|
.badge-freelancer {
|
||||||
background: #d4edda;
|
background: #d1fae5;
|
||||||
color: #155724;
|
color: #065f46;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-success {
|
.text-success {
|
||||||
color: #28a745;
|
color: #10b981;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-muted {
|
.text-muted {
|
||||||
color: #6c757d;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form Actions */
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
padding: 2rem;
|
||||||
flex-wrap: wrap;
|
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 {
|
.danger-zone {
|
||||||
margin-left: auto;
|
background: #fef2f2;
|
||||||
padding: 1rem;
|
border: 2px solid #fecaca;
|
||||||
border: 2px solid #dc3545;
|
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;
|
border-radius: 8px;
|
||||||
background: #f8d7da;
|
font-weight: 600;
|
||||||
max-width: 300px;
|
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 {
|
.btn-primary {
|
||||||
color: #721c24;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
margin-top: 0;
|
color: white;
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-zone p {
|
.btn-primary:hover {
|
||||||
color: #721c24;
|
transform: translateY(-2px);
|
||||||
font-size: 0.875rem;
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -1,98 +1,117 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="health-container">
|
||||||
<div class="header-section">
|
<!-- Header Section -->
|
||||||
<h1>🏥 System Health Check</h1>
|
<div class="page-header">
|
||||||
<p class="subtitle">System diagnostics and event monitoring</p>
|
<div class="header-content">
|
||||||
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
|
<div class="header-left">
|
||||||
|
<h1 class="page-title">
|
||||||
|
<span class="page-icon"><i class="ti ti-heart-rate-monitor"></i></span>
|
||||||
|
System Health
|
||||||
|
</h1>
|
||||||
|
<p class="page-subtitle">System diagnostics and event monitoring</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>
|
</div>
|
||||||
|
|
||||||
<!-- System Health Status -->
|
<!-- Health Status Cards -->
|
||||||
<div class="health-status-section">
|
|
||||||
<h2>🔍 System Status</h2>
|
|
||||||
<div class="health-cards">
|
<div class="health-cards">
|
||||||
<div class="health-card {% if health_summary.health_status == 'healthy' %}healthy{% elif health_summary.health_status == 'issues' %}warning{% else %}critical{% endif %}">
|
<div class="health-card {% if health_status == 'healthy' %}healthy{% elif health_status == 'warning' %}warning{% else %}critical{% endif %}">
|
||||||
<div class="health-icon">
|
<div class="health-icon">
|
||||||
{% if health_summary.health_status == 'healthy' %}
|
{% if health_status == 'healthy' %}
|
||||||
✅
|
<i class="ti ti-circle-check"></i>
|
||||||
{% elif health_summary.health_status == 'issues' %}
|
{% elif health_status == 'warning' %}
|
||||||
⚠️
|
<i class="ti ti-alert-triangle"></i>
|
||||||
{% else %}
|
{% else %}
|
||||||
❌
|
<i class="ti ti-circle-x"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="health-info">
|
<div class="health-content">
|
||||||
<h3>Overall Health</h3>
|
<div class="health-label">Overall Health</div>
|
||||||
<p class="health-status">{{ health_summary.health_status|title }}</p>
|
<div class="health-value">{{ health_status|title }}</div>
|
||||||
<small>
|
<div class="health-detail">
|
||||||
{% if health_summary.health_status == 'healthy' %}
|
{% if health_status == 'healthy' %}
|
||||||
All systems running normally
|
All systems running normally
|
||||||
{% elif health_summary.health_status == 'issues' %}
|
{% elif health_status == 'warning' %}
|
||||||
Minor issues detected
|
Minor issues detected
|
||||||
{% else %}
|
{% else %}
|
||||||
Critical issues require attention
|
Critical issues require attention
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="health-card {% if db_healthy %}healthy{% else %}critical{% endif %}">
|
<div class="health-card {% if db_healthy %}healthy{% else %}critical{% endif %}">
|
||||||
<div class="health-icon">
|
<div class="health-icon">
|
||||||
{% if db_healthy %}✅{% else %}❌{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="health-info">
|
|
||||||
<h3>Database</h3>
|
|
||||||
<p class="health-status">{% if db_healthy %}Connected{% else %}Error{% endif %}</p>
|
|
||||||
<small>
|
|
||||||
{% if db_healthy %}
|
{% if db_healthy %}
|
||||||
PostgreSQL connection active
|
<i class="ti ti-database"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="ti ti-database-off"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="health-content">
|
||||||
|
<div class="health-label">Database</div>
|
||||||
|
<div class="health-value">{% if db_healthy %}Connected{% else %}Error{% endif %}</div>
|
||||||
|
<div class="health-detail">
|
||||||
|
{% if db_healthy %}
|
||||||
|
Database connection active
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ db_error }}
|
{{ db_error }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="health-card info">
|
<div class="health-card info">
|
||||||
<div class="health-icon">⏱️</div>
|
<div class="health-icon">
|
||||||
<div class="health-info">
|
<i class="ti ti-clock"></i>
|
||||||
<h3>Uptime</h3>
|
|
||||||
<p class="health-status">{{ uptime_duration.days }}d {{ uptime_duration.seconds//3600 }}h {{ (uptime_duration.seconds//60)%60 }}m</p>
|
|
||||||
<small>Since first recorded event</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="health-content">
|
||||||
|
<div class="health-label">Uptime</div>
|
||||||
|
<div class="health-value">{{ uptime_duration.days }}d {{ uptime_duration.seconds//3600 }}h</div>
|
||||||
|
<div class="health-detail">Since first recorded event</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Stats -->
|
<!-- Event Statistics -->
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
<h2>📊 Event Statistics</h2>
|
<div class="stat-card {% if error_count_24h > 0 %}error{% endif %}">
|
||||||
<div class="stats-grid">
|
<div class="stat-value">{{ error_count_24h }}</div>
|
||||||
<div class="stat-card {% if health_summary.errors_24h > 0 %}error{% endif %}">
|
<div class="stat-label">Errors (24h)</div>
|
||||||
<h3>{{ health_summary.errors_24h }}</h3>
|
|
||||||
<p>Errors (24h)</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card {% if health_summary.warnings_24h > 0 %}warning{% endif %}">
|
<div class="stat-card {% if warning_count_24h > 0 %}warning{% endif %}">
|
||||||
<h3>{{ health_summary.warnings_24h }}</h3>
|
<div class="stat-value">{{ warning_count_24h }}</div>
|
||||||
<p>Warnings (24h)</p>
|
<div class="stat-label">Warnings (24h)</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{ today_events }}</h3>
|
<div class="stat-value">{{ today_events }}</div>
|
||||||
<p>Events Today</p>
|
<div class="stat-label">Events Today</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{ health_summary.total_events_week }}</h3>
|
<div class="stat-value">{{ total_events_week }}</div>
|
||||||
<p>Events This Week</p>
|
<div class="stat-label">Events This Week</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Errors -->
|
<!-- Recent Errors -->
|
||||||
{% if errors %}
|
{% if errors %}
|
||||||
<div class="events-section error-section">
|
<div class="card error-card">
|
||||||
<h2>🚨 Recent Errors</h2>
|
<div class="card-header">
|
||||||
<div class="events-list">
|
<h2 class="card-title">
|
||||||
|
<span class="icon"><i class="ti ti-alert-circle"></i></span>
|
||||||
|
Recent Errors
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="event-list">
|
||||||
{% for error in errors %}
|
{% for error in errors %}
|
||||||
<div class="event-item error">
|
<div class="event-item error">
|
||||||
<div class="event-header">
|
<div class="event-header">
|
||||||
@@ -100,23 +119,35 @@
|
|||||||
<span class="event-time">{{ error.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
<span class="event-time">{{ error.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-description">{{ error.description }}</div>
|
<div class="event-description">{{ error.description }}</div>
|
||||||
|
<div class="event-meta">
|
||||||
{% if error.user %}
|
{% if error.user %}
|
||||||
<div class="event-meta">User: {{ error.user.username }}</div>
|
<span><i class="ti ti-user"></i> {{ error.user.username }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if error.company %}
|
{% if error.company %}
|
||||||
<div class="event-meta">Company: {{ error.company.name }}</div>
|
<span><i class="ti ti-building"></i> {{ error.company.name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if error.ip_address %}
|
||||||
|
<span><i class="ti ti-world"></i> {{ error.ip_address }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Recent Warnings -->
|
<!-- Recent Warnings -->
|
||||||
{% if warnings %}
|
{% if warnings %}
|
||||||
<div class="events-section warning-section">
|
<div class="card warning-card">
|
||||||
<h2>⚠️ Recent Warnings</h2>
|
<div class="card-header">
|
||||||
<div class="events-list">
|
<h2 class="card-title">
|
||||||
|
<span class="icon"><i class="ti ti-alert-triangle"></i></span>
|
||||||
|
Recent Warnings
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="event-list">
|
||||||
{% for warning in warnings %}
|
{% for warning in warnings %}
|
||||||
<div class="event-item warning">
|
<div class="event-item warning">
|
||||||
<div class="event-header">
|
<div class="event-header">
|
||||||
@@ -124,236 +155,346 @@
|
|||||||
<span class="event-time">{{ warning.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
<span class="event-time">{{ warning.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-description">{{ warning.description }}</div>
|
<div class="event-description">{{ warning.description }}</div>
|
||||||
|
<div class="event-meta">
|
||||||
{% if warning.user %}
|
{% if warning.user %}
|
||||||
<div class="event-meta">User: {{ warning.user.username }}</div>
|
<span><i class="ti ti-user"></i> {{ warning.user.username }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if warning.company %}
|
{% if warning.company %}
|
||||||
<div class="event-meta">Company: {{ warning.company.name }}</div>
|
<span><i class="ti ti-building"></i> {{ warning.company.name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- System Event Log -->
|
<!-- System Event Log -->
|
||||||
<div class="events-section">
|
<div class="card">
|
||||||
<h2>📋 System Event Log (Last 7 Days)</h2>
|
<div class="card-header">
|
||||||
<div class="events-controls">
|
<h2 class="card-title">
|
||||||
|
<span class="icon"><i class="ti ti-clipboard-list"></i></span>
|
||||||
|
System Event Log
|
||||||
|
<span class="card-subtitle">Last 7 days</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="event-controls">
|
||||||
<button class="filter-btn active" data-filter="all">All Events</button>
|
<button class="filter-btn active" data-filter="all">All Events</button>
|
||||||
<button class="filter-btn" data-filter="auth">Authentication</button>
|
<button class="filter-btn" data-filter="auth">Authentication</button>
|
||||||
<button class="filter-btn" data-filter="user_management">User Management</button>
|
<button class="filter-btn" data-filter="user_management">User Management</button>
|
||||||
<button class="filter-btn" data-filter="system">System</button>
|
<button class="filter-btn" data-filter="system">System</button>
|
||||||
<button class="filter-btn" data-filter="error">Errors</button>
|
<button class="filter-btn" data-filter="error">Errors</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="events-list" id="eventsList">
|
<div class="event-list" id="eventsList">
|
||||||
{% for event in recent_events %}
|
{% for event in recent_events %}
|
||||||
<div class="event-item {{ event.severity }} {{ event.event_category }}" data-category="{{ event.event_category }}" data-severity="{{ event.severity }}">
|
<div class="event-item {{ event.severity }}" data-category="{{ event.event_category }}" data-severity="{{ event.severity }}">
|
||||||
<div class="event-header">
|
<div class="event-header">
|
||||||
|
<div class="event-info">
|
||||||
<span class="event-type">{{ event.event_type }}</span>
|
<span class="event-type">{{ event.event_type }}</span>
|
||||||
<span class="event-category-badge">{{ event.event_category }}</span>
|
<span class="event-category">{{ event.event_category }}</span>
|
||||||
|
{% if event.severity != 'info' %}
|
||||||
|
<span class="severity-badge severity-{{ event.severity }}">{{ event.severity|upper }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<span class="event-time">{{ event.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
<span class="event-time">{{ event.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-description">{{ event.description }}</div>
|
<div class="event-description">{{ event.description }}</div>
|
||||||
|
{% if event.user or event.company or event.ip_address %}
|
||||||
|
<div class="event-meta">
|
||||||
{% if event.user %}
|
{% if event.user %}
|
||||||
<div class="event-meta">User: {{ event.user.username }}</div>
|
<span><i class="ti ti-user"></i> {{ event.user.username }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if event.company %}
|
{% if event.company %}
|
||||||
<div class="event-meta">Company: {{ event.company.name }}</div>
|
<span><i class="ti ti-building"></i> {{ event.company.name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if event.ip_address %}
|
{% if event.ip_address %}
|
||||||
<div class="event-meta">IP: {{ event.ip_address }}</div>
|
<span><i class="ti ti-world"></i> {{ event.ip_address }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Last Error Details -->
|
|
||||||
{% if health_summary.last_error %}
|
|
||||||
<div class="events-section error-section">
|
|
||||||
<h2>🔍 Last Critical Error</h2>
|
|
||||||
<div class="last-error-details">
|
|
||||||
<div class="error-card">
|
|
||||||
<div class="error-header">
|
|
||||||
<h3>{{ health_summary.last_error.event_type }}</h3>
|
|
||||||
<span class="error-time">{{ health_summary.last_error.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="error-description">{{ health_summary.last_error.description }}</div>
|
|
||||||
{% if health_summary.last_error.event_metadata %}
|
|
||||||
<div class="error-metadata">
|
|
||||||
<strong>Additional Details:</strong>
|
|
||||||
<pre>{{ health_summary.last_error.event_metadata }}</pre>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.header-section {
|
/* Container */
|
||||||
|
.health-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;
|
margin-bottom: 2rem;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.header-content {
|
||||||
color: #6c757d;
|
display: flex;
|
||||||
margin-bottom: 1rem;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-status-section {
|
.page-title {
|
||||||
margin-bottom: 2rem;
|
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% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Health Cards */
|
||||||
.health-cards {
|
.health-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-card {
|
.health-card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
border-left: 4px solid #dee2e6;
|
border: 1px solid #e5e7eb;
|
||||||
|
border-left: 4px solid #e5e7eb;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-card.healthy {
|
.health-card.healthy {
|
||||||
border-left-color: #28a745;
|
border-left-color: #10b981;
|
||||||
background: #f8fff9;
|
background: #f0fdf4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-card.warning {
|
.health-card.warning {
|
||||||
border-left-color: #ffc107;
|
border-left-color: #f59e0b;
|
||||||
background: #fffdf7;
|
background: #fffbeb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-card.critical {
|
.health-card.critical {
|
||||||
border-left-color: #dc3545;
|
border-left-color: #ef4444;
|
||||||
background: #fff8f8;
|
background: #fef2f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-card.info {
|
.health-card.info {
|
||||||
border-left-color: #17a2b8;
|
border-left-color: #3b82f6;
|
||||||
background: #f8fcfd;
|
background: #eff6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-icon {
|
.health-icon {
|
||||||
font-size: 2.5rem;
|
font-size: 3rem;
|
||||||
line-height: 1;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-info h3 {
|
.health-card.healthy .health-icon {
|
||||||
margin: 0 0 0.25rem 0;
|
color: #10b981;
|
||||||
color: #495057;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-status {
|
.health-card.warning .health-icon {
|
||||||
font-size: 1.1rem;
|
color: #f59e0b;
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-card.healthy .health-status {
|
.health-card.critical .health-icon {
|
||||||
color: #28a745;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-card.warning .health-status {
|
.health-card.info .health-icon {
|
||||||
color: #ffc107;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-card.critical .health-status {
|
.health-content {
|
||||||
color: #dc3545;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-card.info .health-status {
|
.health-label {
|
||||||
color: #17a2b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-info small {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.health-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-card.healthy .health-value {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-card.warning .health-value {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-card.critical .health-value {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-card.info .health-value {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-detail {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Section */
|
||||||
.stats-section {
|
.stats-section {
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-section h2 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-left: 4px solid #dee2e6;
|
border: 1px solid #e5e7eb;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border-left: 4px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.error {
|
.stat-card.error {
|
||||||
border-left-color: #dc3545;
|
border-left-color: #ef4444;
|
||||||
background: #fff8f8;
|
background: #fef2f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.warning {
|
.stat-card.warning {
|
||||||
border-left-color: #ffc107;
|
border-left-color: #f59e0b;
|
||||||
background: #fffdf7;
|
background: #fffbeb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card h3 {
|
.stat-value {
|
||||||
font-size: 2rem;
|
font-size: 2.5rem;
|
||||||
margin: 0 0 0.5rem 0;
|
font-weight: 700;
|
||||||
color: #007bff;
|
margin-bottom: 0.5rem;
|
||||||
|
color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.error h3 {
|
.stat-card.error .stat-value {
|
||||||
color: #dc3545;
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.warning h3 {
|
.stat-card.warning .stat-value {
|
||||||
color: #ffc107;
|
color: #d97706;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card p {
|
.stat-label {
|
||||||
margin: 0;
|
font-size: 0.9rem;
|
||||||
color: #6c757d;
|
color: #6b7280;
|
||||||
font-weight: 500;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.events-section {
|
/* Cards */
|
||||||
|
.card {
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #dee2e6;
|
border-radius: 12px;
|
||||||
border-radius: 8px;
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
padding: 2rem;
|
border: 1px solid #e5e7eb;
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.events-section h2 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
color: #495057;
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.events-controls {
|
.card:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.error-card {
|
||||||
|
border-left: 4px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.warning-card {
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-subtitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event Controls */
|
||||||
|
.event-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
@@ -362,53 +503,62 @@
|
|||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #e5e7eb;
|
||||||
background: white;
|
background: white;
|
||||||
color: #495057;
|
color: #6b7280;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s ease;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn:hover {
|
.filter-btn:hover {
|
||||||
background: #f8f9fa;
|
background: #f3f4f6;
|
||||||
border-color: #adb5bd;
|
border-color: #667eea;
|
||||||
color: #212529;
|
color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn.active {
|
.filter-btn.active {
|
||||||
background: #007bff;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #007bff;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.events-list {
|
/* Event List */
|
||||||
|
.event-list {
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-item {
|
.event-item {
|
||||||
border: 1px solid #dee2e6;
|
background: #f8f9fa;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 0.5rem;
|
border: 1px solid #e5e7eb;
|
||||||
background: white;
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-item.error {
|
.event-item.error {
|
||||||
border-left: 4px solid #dc3545;
|
border-left: 4px solid #ef4444;
|
||||||
background: #fff8f8;
|
background: #fef2f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-item.warning {
|
.event-item.warning {
|
||||||
border-left: 4px solid #ffc107;
|
border-left: 4px solid #f59e0b;
|
||||||
background: #fffdf7;
|
background: #fffbeb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-item.critical {
|
.event-item.critical {
|
||||||
border-left: 4px solid #dc3545;
|
border-left: 4px solid #dc2626;
|
||||||
background: #fff5f5;
|
background: #fee2e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-header {
|
.event-header {
|
||||||
@@ -420,112 +570,155 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-type {
|
.event-info {
|
||||||
font-weight: 600;
|
display: flex;
|
||||||
color: #495057;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-category-badge {
|
.event-type {
|
||||||
background: #e9ecef;
|
font-weight: 600;
|
||||||
color: #495057;
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-category {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #6b7280;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.severity-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-warning {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-error {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-critical {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.event-time {
|
.event-time {
|
||||||
color: #6c757d;
|
color: #6b7280;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-description {
|
.event-description {
|
||||||
|
color: #4b5563;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: #495057;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-meta {
|
.event-meta {
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6c757d;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-section {
|
|
||||||
border-left: 4px solid #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-section {
|
|
||||||
border-left: 4px solid #ffc107;
|
|
||||||
}
|
|
||||||
|
|
||||||
.last-error-details {
|
|
||||||
background: #fff8f8;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
gap: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-meta span {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.5rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-header h3 {
|
.event-meta i {
|
||||||
margin: 0;
|
|
||||||
color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-time {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-description {
|
/* Buttons */
|
||||||
margin-bottom: 0.5rem;
|
.btn {
|
||||||
color: #495057;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-metadata {
|
.btn-secondary {
|
||||||
background: #f8f9fa;
|
background: white;
|
||||||
border: 1px solid #dee2e6;
|
color: #667eea;
|
||||||
border-radius: 4px;
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-metadata pre {
|
.btn-secondary:hover {
|
||||||
margin: 0.5rem 0 0 0;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
font-size: 0.875rem;
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button styles now centralized in main style.css */
|
/* Responsive Design */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.health-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.health-cards {
|
.health-cards {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-header {
|
.event-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.events-controls {
|
.event-controls {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -547,7 +740,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (filter === 'all') {
|
if (filter === 'all') {
|
||||||
item.style.display = 'block';
|
item.style.display = 'block';
|
||||||
} else if (filter === 'error') {
|
} else if (filter === 'error') {
|
||||||
item.style.display = item.classList.contains('error') || item.classList.contains('critical') ? 'block' : 'none';
|
item.style.display = (item.classList.contains('error') || item.classList.contains('critical')) ? 'block' : 'none';
|
||||||
} else {
|
} else {
|
||||||
item.style.display = item.dataset.category === filter ? 'block' : 'none';
|
item.style.display = item.dataset.category === filter ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,51 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="settings-container">
|
||||||
<div class="header-section">
|
<!-- Header Section -->
|
||||||
<h1>⚙️ System Administrator Settings</h1>
|
<div class="page-header">
|
||||||
<p class="subtitle">Global system configuration and management</p>
|
<div class="header-content">
|
||||||
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
|
<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="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>
|
</div>
|
||||||
|
|
||||||
<!-- System Statistics -->
|
<!-- System Statistics -->
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
<h3>📊 System Overview</h3>
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h4>{{ total_companies }}</h4>
|
<div class="stat-value">{{ total_companies }}</div>
|
||||||
<p>Total Companies</p>
|
<div class="stat-label">Total Companies</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h4>{{ total_users }}</h4>
|
<div class="stat-value">{{ total_users }}</div>
|
||||||
<p>Total Users</p>
|
<div class="stat-label">Total Users</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h4>{{ total_system_admins }}</h4>
|
<div class="stat-value">{{ total_system_admins }}</div>
|
||||||
<p>System Administrators</p>
|
<div class="stat-label">System Administrators</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Settings Form -->
|
<!-- System Settings Form -->
|
||||||
<div class="settings-section">
|
<div class="card">
|
||||||
<h3>🔧 System Configuration</h3>
|
<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">
|
<form method="POST" class="settings-form">
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<div class="setting-header">
|
<div class="setting-header">
|
||||||
@@ -112,10 +128,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- System Information -->
|
<!-- System Information -->
|
||||||
<div class="info-section">
|
<div class="card">
|
||||||
<h3>ℹ️ System Information</h3>
|
<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-grid">
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<h4>Application Version</h4>
|
<h4>Application Version</h4>
|
||||||
@@ -131,10 +154,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Danger Zone -->
|
<!-- Danger Zone -->
|
||||||
<div class="danger-section">
|
<div class="danger-zone">
|
||||||
<h3>⚠️ Danger Zone</h3>
|
<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-content">
|
||||||
<div class="danger-item">
|
<div class="danger-item">
|
||||||
<div class="danger-info">
|
<div class="danger-info">
|
||||||
@@ -164,11 +193,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="actions-section">
|
<div class="card">
|
||||||
<h3>🚀 Quick Actions</h3>
|
<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">
|
<div class="actions-grid">
|
||||||
<a href="{{ url_for('users.system_admin_users') }}" class="action-card">
|
<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">
|
<div class="action-content">
|
||||||
<h4>Manage All Users</h4>
|
<h4>Manage All Users</h4>
|
||||||
<p>View, edit, and manage users across all companies</p>
|
<p>View, edit, and manage users across all companies</p>
|
||||||
@@ -176,7 +211,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="{{ url_for('system_admin.system_admin_companies') }}" class="action-card">
|
<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">
|
<div class="action-content">
|
||||||
<h4>Manage Companies</h4>
|
<h4>Manage Companies</h4>
|
||||||
<p>View and manage all companies in the system</p>
|
<p>View and manage all companies in the system</p>
|
||||||
@@ -184,7 +219,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="{{ url_for('system_admin.system_admin_time_entries') }}" class="action-card">
|
<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">
|
<div class="action-content">
|
||||||
<h4>View Time Entries</h4>
|
<h4>View Time Entries</h4>
|
||||||
<p>Browse time tracking data across all companies</p>
|
<p>Browse time tracking data across all companies</p>
|
||||||
@@ -193,68 +228,136 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.header-section {
|
/* Container */
|
||||||
margin-bottom: 2rem;
|
.settings-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
/* Page Header */
|
||||||
color: #6c757d;
|
.page-header {
|
||||||
margin-bottom: 1rem;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
}
|
border-radius: 16px;
|
||||||
|
|
||||||
.stats-section {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-section h3 {
|
.header-content {
|
||||||
margin-top: 0;
|
display: flex;
|
||||||
margin-bottom: 1.5rem;
|
justify-content: space-between;
|
||||||
color: #495057;
|
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;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
text-align: center;
|
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 {
|
.stat-card:hover {
|
||||||
font-size: 2rem;
|
transform: translateY(-2px);
|
||||||
margin: 0 0 0.5rem 0;
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
color: #007bff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card p {
|
.stat-value {
|
||||||
margin: 0;
|
font-size: 2.5rem;
|
||||||
color: #6c757d;
|
font-weight: 700;
|
||||||
font-weight: 500;
|
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;
|
background: white;
|
||||||
border: 1px solid #dee2e6;
|
border-radius: 12px;
|
||||||
border-radius: 8px;
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
padding: 2rem;
|
border: 1px solid #e5e7eb;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section h3 {
|
.card:hover {
|
||||||
margin-top: 0;
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
margin-bottom: 1.5rem;
|
transform: translateY(-2px);
|
||||||
color: #495057;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.settings-form {
|
||||||
@@ -268,8 +371,9 @@
|
|||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border: 1px solid #e9ecef;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
background: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-group.full-width {
|
.setting-group.full-width {
|
||||||
@@ -295,7 +399,8 @@
|
|||||||
|
|
||||||
.setting-header h4 {
|
.setting-header h4 {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
color: #495057;
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-header p {
|
.setting-header p {
|
||||||
@@ -320,7 +425,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background: #ccc;
|
background: #e5e7eb;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
transition: background 0.3s;
|
transition: background 0.3s;
|
||||||
}
|
}
|
||||||
@@ -335,10 +440,11 @@
|
|||||||
top: 2px;
|
top: 2px;
|
||||||
left: 2px;
|
left: 2px;
|
||||||
transition: transform 0.3s;
|
transition: transform 0.3s;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-label input[type="checkbox"]:checked + .toggle-slider {
|
.toggle-label input[type="checkbox"]:checked + .toggle-slider {
|
||||||
background: #007bff;
|
background: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-label input[type="checkbox"]:checked + .toggle-slider::before {
|
.toggle-label input[type="checkbox"]:checked + .toggle-slider::before {
|
||||||
@@ -347,7 +453,7 @@
|
|||||||
|
|
||||||
.toggle-text {
|
.toggle-text {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #495057;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-description {
|
.setting-description {
|
||||||
@@ -359,21 +465,9 @@
|
|||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding-top: 1rem;
|
padding-top: 1.5rem;
|
||||||
border-top: 1px solid #e9ecef;
|
border-top: 1px solid #e5e7eb;
|
||||||
}
|
margin-top: 2rem;
|
||||||
|
|
||||||
.info-section {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-section h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: #495057;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-grid {
|
.info-grid {
|
||||||
@@ -383,41 +477,62 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-card {
|
.info-card {
|
||||||
background: white;
|
background: #f8f9fa;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card h4 {
|
.info-card h4 {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
color: #495057;
|
color: #374151;
|
||||||
font-size: 1rem;
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card p {
|
.info-card p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #6c757d;
|
color: #1f2937;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-section {
|
/* Danger Zone */
|
||||||
background: #f8d7da;
|
.danger-zone {
|
||||||
border: 2px solid #dc3545;
|
margin-top: 3rem;
|
||||||
border-radius: 8px;
|
background: #fef2f2;
|
||||||
padding: 2rem;
|
border: 2px solid #fecaca;
|
||||||
margin-bottom: 2rem;
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-section h3 {
|
.danger-header {
|
||||||
margin-top: 0;
|
background: #fee2e2;
|
||||||
margin-bottom: 1.5rem;
|
padding: 1.5rem;
|
||||||
color: #721c24;
|
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 {
|
.danger-content {
|
||||||
|
padding: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-item {
|
.danger-item {
|
||||||
@@ -427,82 +542,145 @@
|
|||||||
background: white;
|
background: white;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #dc3545;
|
border: 1px solid #fecaca;
|
||||||
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-info h4 {
|
.danger-info h4 {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
color: #721c24;
|
color: #991b1b;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-info p {
|
.danger-info p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #721c24;
|
color: #7f1d1d;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
line-height: 1.5;
|
||||||
|
|
||||||
.actions-section {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-section h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: #495057;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-grid {
|
.actions-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card {
|
.action-card {
|
||||||
background: white;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
gap: 1.5rem;
|
||||||
gap: 1rem;
|
padding: 1.5rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
transition: all 0.2s ease;
|
||||||
transition: all 0.2s;
|
border: 2px solid transparent;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card:hover {
|
.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;
|
text-decoration: none;
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-icon {
|
.action-icon {
|
||||||
font-size: 2rem;
|
font-size: 2.5rem;
|
||||||
width: 60px;
|
flex-shrink: 0;
|
||||||
height: 60px;
|
}
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
.action-icon i {
|
||||||
justify-content: center;
|
font-size: 2.5rem;
|
||||||
background: #f8f9fa;
|
color: #667eea;
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-content h4 {
|
.action-content h4 {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.25rem 0;
|
||||||
color: #495057;
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-content p {
|
.action-content p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #6c757d;
|
color: #6b7280;
|
||||||
font-size: 0.875rem;
|
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) {
|
@media (max-width: 768px) {
|
||||||
|
.settings-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.setting-group {
|
.setting-group {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -513,5 +691,26 @@
|
|||||||
gap: 1rem;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,16 +1,35 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="time-entries-container">
|
||||||
<div class="header-section">
|
<!-- Header Section -->
|
||||||
<h1>⏱️ System Admin - Time Entries</h1>
|
<div class="page-header">
|
||||||
<p class="subtitle">View time entries across all companies</p>
|
<div class="header-content">
|
||||||
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-secondary">← Back to Dashboard</a>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Section -->
|
<!-- Filter Section -->
|
||||||
<div class="filter-section">
|
<div class="card">
|
||||||
<h3>Filter Time Entries</h3>
|
<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">
|
<form method="GET" class="filter-form">
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="company">Company:</label>
|
<label for="company">Company:</label>
|
||||||
@@ -29,10 +48,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Time Entries Table -->
|
<!-- Time Entries Table -->
|
||||||
{% if entries.items %}
|
{% if entries.items %}
|
||||||
<div class="table-section">
|
<div class="card">
|
||||||
|
<div class="card-body no-padding">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -106,13 +127,14 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if entries.pages > 1 %}
|
{% if entries.pages > 1 %}
|
||||||
<div class="pagination-section">
|
<div class="pagination-section">
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% if entries.has_prev %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% for page_num in entries.iter_pages() %}
|
{% for page_num in entries.iter_pages() %}
|
||||||
@@ -128,7 +150,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if entries.has_next %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,55 +174,127 @@
|
|||||||
|
|
||||||
<!-- Summary Statistics -->
|
<!-- Summary Statistics -->
|
||||||
{% if entries.items %}
|
{% if entries.items %}
|
||||||
<div class="summary-section">
|
<div class="stats-section">
|
||||||
<h3>📊 Summary Statistics</h3>
|
<div class="stat-card">
|
||||||
<div class="summary-grid">
|
<div class="stat-value">{{ entries.total }}</div>
|
||||||
<div class="summary-card">
|
<div class="stat-label">Total Entries</div>
|
||||||
<h4>Total Entries</h4>
|
|
||||||
<p class="summary-number">{{ entries.total }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card">
|
<div class="stat-card">
|
||||||
<h4>Active Sessions</h4>
|
<div class="stat-value">{{ entries.items | selectattr('0.departure_time', 'equalto', None) | list | length }}</div>
|
||||||
<p class="summary-number">{{ entries.items | selectattr('0.departure_time', 'equalto', None) | list | length }}</p>
|
<div class="stat-label">Active Sessions</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card">
|
<div class="stat-card">
|
||||||
<h4>Paused Sessions</h4>
|
<div class="stat-value">{{ entries.items | selectattr('0.is_paused', 'equalto', True) | list | length }}</div>
|
||||||
<p class="summary-number">{{ entries.items | selectattr('0.is_paused', 'equalto', True) | list | length }}</p>
|
<div class="stat-label">Paused Sessions</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card">
|
<div class="stat-card">
|
||||||
<h4>Completed Today</h4>
|
<div class="stat-value">
|
||||||
<p class="summary-number">
|
|
||||||
{{ entries.items | selectattr('0.arrival_time') | selectattr('0.departure_time', 'defined') |
|
{{ entries.items | selectattr('0.arrival_time') | selectattr('0.departure_time', 'defined') |
|
||||||
list | length }}
|
list | length }}
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-label">Completed Today</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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;
|
margin-bottom: 2rem;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.header-content {
|
||||||
color: #6c757d;
|
display: flex;
|
||||||
margin-bottom: 1rem;
|
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;
|
background: #f8f9fa;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 8px;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-section h3 {
|
.card-title {
|
||||||
margin-top: 0;
|
font-size: 1.25rem;
|
||||||
margin-bottom: 1rem;
|
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 {
|
.filter-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
@@ -215,24 +309,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-group label {
|
.filter-group label {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: #495057;
|
color: #374151;
|
||||||
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
padding: 0.5rem;
|
padding: 0.625rem 1rem;
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
min-width: 200px;
|
min-width: 250px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-section {
|
.form-control:focus {
|
||||||
background: white;
|
outline: none;
|
||||||
border-radius: 8px;
|
border-color: #667eea;
|
||||||
overflow: hidden;
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
@@ -252,7 +346,10 @@
|
|||||||
.table th {
|
.table th {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #495057;
|
color: #374151;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paused-entry {
|
.paused-entry {
|
||||||
@@ -260,18 +357,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.company-name {
|
.company-name {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: #495057;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-name {
|
.project-name {
|
||||||
color: #007bff;
|
color: #667eea;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration {
|
.duration {
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #28a745;
|
color: #10b981;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notes {
|
.notes {
|
||||||
@@ -284,25 +381,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 20px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-active {
|
.status-active {
|
||||||
background: #d4edda;
|
background: #d1fae5;
|
||||||
color: #155724;
|
color: #065f46;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-paused {
|
.status-paused {
|
||||||
background: #fff3cd;
|
background: #fef3c7;
|
||||||
color: #856404;
|
color: #92400e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-completed {
|
.status-completed {
|
||||||
background: #d1ecf1;
|
background: #dbeafe;
|
||||||
color: #0c5460;
|
color: #1e40af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-section {
|
.pagination-section {
|
||||||
@@ -320,21 +418,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-link {
|
.page-link {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 1rem;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #e5e7eb;
|
||||||
color: #007bff;
|
color: #667eea;
|
||||||
text-decoration: none;
|
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 {
|
.page-link:hover {
|
||||||
background: #e9ecef;
|
background: #f3f4f6;
|
||||||
|
border-color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-link.current {
|
.page-link.current {
|
||||||
background: #007bff;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #007bff;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-info {
|
.pagination-info {
|
||||||
@@ -349,57 +453,130 @@
|
|||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-section {
|
/* Stats Section */
|
||||||
background: #f8f9fa;
|
.stats-section {
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-section h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-card {
|
.stat-card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
text-align: center;
|
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 {
|
.stat-card:hover {
|
||||||
margin: 0 0 0.5rem 0;
|
transform: translateY(-2px);
|
||||||
color: #6c757d;
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-number {
|
.stat-value {
|
||||||
font-size: 2rem;
|
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;
|
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 {
|
.btn-outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #007bff;
|
color: #667eea;
|
||||||
border: 1px solid #007bff;
|
border: 2px solid #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline:hover {
|
.btn-outline:hover {
|
||||||
background: #007bff;
|
background: #667eea;
|
||||||
color: white;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,16 +1,35 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="users-admin-container">
|
||||||
<div class="header-section">
|
<!-- Header Section -->
|
||||||
<h1>👥 System Admin - All Users</h1>
|
<div class="page-header">
|
||||||
<p class="subtitle">Manage users across all companies</p>
|
<div class="header-content">
|
||||||
<a href="{{ url_for('system_admin.system_admin_dashboard') }}" class="btn btn-md btn-secondary">← Back to Dashboard</a>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Options -->
|
<!-- Filter Options -->
|
||||||
<div class="filter-section">
|
<div class="card">
|
||||||
<h3>Filter Users</h3>
|
<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">
|
<div class="filter-buttons">
|
||||||
<a href="{{ url_for('users.system_admin_users') }}"
|
<a href="{{ url_for('users.system_admin_users') }}"
|
||||||
class="btn btn-filter {% if not current_filter %}active{% endif %}">
|
class="btn btn-filter {% if not current_filter %}active{% endif %}">
|
||||||
@@ -38,10 +57,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Users Table -->
|
<!-- Users Table -->
|
||||||
{% if users.items %}
|
{% if users.items %}
|
||||||
<div class="table-section">
|
<div class="card">
|
||||||
|
<div class="card-body no-padding">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -118,13 +139,14 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if users.pages > 1 %}
|
{% if users.pages > 1 %}
|
||||||
<div class="pagination-section">
|
<div class="pagination-section">
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% if users.has_prev %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% for page_num in users.iter_pages() %}
|
{% for page_num in users.iter_pages() %}
|
||||||
@@ -140,7 +162,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if users.has_next %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,60 +182,132 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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;
|
margin-bottom: 2rem;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.header-content {
|
||||||
color: #6c757d;
|
display: flex;
|
||||||
margin-bottom: 1rem;
|
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;
|
background: #f8f9fa;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 8px;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-section h3 {
|
.card-title {
|
||||||
margin-top: 0;
|
font-size: 1.25rem;
|
||||||
margin-bottom: 1rem;
|
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 {
|
.filter-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-filter {
|
.btn-filter {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #e5e7eb;
|
||||||
background: white;
|
background: white;
|
||||||
color: #495057;
|
color: #6b7280;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
transition: all 0.2s;
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-filter:hover {
|
.btn-filter:hover {
|
||||||
background: #e9ecef;
|
background: #f3f4f6;
|
||||||
|
border-color: #667eea;
|
||||||
|
color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-filter.active {
|
.btn-filter.active {
|
||||||
background: #007bff;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #007bff;
|
border-color: transparent;
|
||||||
}
|
|
||||||
|
|
||||||
.table-section {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
.table {
|
.table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -224,120 +318,127 @@
|
|||||||
.table td {
|
.table td {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #dee2e6;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table th {
|
.table th {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
font-weight: 600;
|
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 {
|
.blocked-user {
|
||||||
background-color: #f8d7da !important;
|
background-color: #fef2f2 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.company-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
.badge {
|
.badge {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 20px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-self {
|
.badge-self {
|
||||||
background: #d1ecf1;
|
background: #dbeafe;
|
||||||
color: #0c5460;
|
color: #1e40af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-personal {
|
.badge-personal {
|
||||||
background: #fff3cd;
|
background: #fef3c7;
|
||||||
color: #856404;
|
color: #92400e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-company {
|
.badge-company {
|
||||||
background: #d1ecf1;
|
background: #dbeafe;
|
||||||
color: #0c5460;
|
color: #1e40af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-freelancer {
|
.badge-freelancer {
|
||||||
background: #d4edda;
|
background: #d1fae5;
|
||||||
color: #155724;
|
color: #065f46;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Role Badges */
|
||||||
.role-badge {
|
.role-badge {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 20px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-team_member {
|
.role-team_member {
|
||||||
background: #e2e3e5;
|
background: #e5e7eb;
|
||||||
color: #495057;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-team_leader {
|
.role-team_leader {
|
||||||
background: #d4edda;
|
background: #d1fae5;
|
||||||
color: #155724;
|
color: #065f46;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-supervisor {
|
.role-supervisor {
|
||||||
background: #d1ecf1;
|
background: #dbeafe;
|
||||||
color: #0c5460;
|
color: #1e40af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-admin {
|
.role-admin {
|
||||||
background: #fff3cd;
|
background: #fef3c7;
|
||||||
color: #856404;
|
color: #92400e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-system_admin {
|
.role-system_admin {
|
||||||
background: #f1c0e8;
|
background: #ede9fe;
|
||||||
color: #6a1b99;
|
color: #5b21b6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status Badges */
|
||||||
.status-badge {
|
.status-badge {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 20px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-active {
|
.status-active {
|
||||||
background: #d4edda;
|
background: #d1fae5;
|
||||||
color: #155724;
|
color: #065f46;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-blocked {
|
.status-blocked {
|
||||||
background: #f8d7da;
|
background: #fee2e2;
|
||||||
color: #721c24;
|
color: #991b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-unverified {
|
.status-unverified {
|
||||||
background: #fff3cd;
|
background: #fef3c7;
|
||||||
color: #856404;
|
color: #92400e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
/* Pagination */
|
||||||
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-section {
|
.pagination-section {
|
||||||
margin-top: 2rem;
|
margin: 2rem 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -351,37 +452,156 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-link {
|
.page-link {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 1rem;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #e5e7eb;
|
||||||
color: #007bff;
|
color: #667eea;
|
||||||
text-decoration: none;
|
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 {
|
.page-link:hover {
|
||||||
background: #e9ecef;
|
background: #f3f4f6;
|
||||||
|
border-color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-link.current {
|
.page-link.current {
|
||||||
background: #007bff;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #007bff;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-info {
|
.pagination-info {
|
||||||
color: #6c757d;
|
color: #6b7280;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem;
|
padding: 4rem 2rem;
|
||||||
color: #6c757d;
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-name {
|
.empty-state h3 {
|
||||||
font-weight: 500;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<!-- Basic Information -->
|
<!-- Basic Information -->
|
||||||
<div class="form-section">
|
<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-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="task-name">Task Name *</label>
|
<label for="task-name">Task Name *</label>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
<!-- Assignment & Planning -->
|
<!-- Assignment & Planning -->
|
||||||
<div class="form-section">
|
<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-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="task-project">Project</label>
|
<label for="task-project">Project</label>
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
<div class="hybrid-date-input">
|
<div class="hybrid-date-input">
|
||||||
<input type="date" id="task-due-date-native" class="date-input-native">
|
<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" }}">
|
<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>
|
||||||
<div class="date-error" id="task-due-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
|
<div class="date-error" id="task-due-date-error" style="display: none; color: #dc3545; font-size: 0.8rem; margin-top: 0.25rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,11 +97,11 @@
|
|||||||
|
|
||||||
<!-- Dependencies -->
|
<!-- Dependencies -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3>🔗 Dependencies</h3>
|
<h3><i class="ti ti-link"></i> Dependencies</h3>
|
||||||
<div class="dependencies-grid">
|
<div class="dependencies-grid">
|
||||||
<!-- Blocked By -->
|
<!-- Blocked By -->
|
||||||
<div class="dependency-column">
|
<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>
|
<p class="dependency-help">Tasks that must be completed before this task can start</p>
|
||||||
<div id="blocked-by-container" class="dependency-list">
|
<div id="blocked-by-container" class="dependency-list">
|
||||||
<!-- Blocked by tasks will be populated here -->
|
<!-- Blocked by tasks will be populated here -->
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
|
|
||||||
<!-- Blocks -->
|
<!-- Blocks -->
|
||||||
<div class="dependency-column">
|
<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>
|
<p class="dependency-help">Tasks that cannot start until this task is completed</p>
|
||||||
<div id="blocks-container" class="dependency-list">
|
<div id="blocks-container" class="dependency-list">
|
||||||
<!-- Blocks tasks will be populated here -->
|
<!-- Blocks tasks will be populated here -->
|
||||||
@@ -129,17 +129,17 @@
|
|||||||
|
|
||||||
<!-- Subtasks -->
|
<!-- Subtasks -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3>📋 Subtasks</h3>
|
<h3><i class="ti ti-clipboard-list"></i> Subtasks</h3>
|
||||||
<div id="subtasks-container">
|
<div id="subtasks-container">
|
||||||
<!-- Subtasks will be populated here -->
|
<!-- Subtasks will be populated here -->
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Comments Section (outside form) -->
|
<!-- Comments Section (outside form) -->
|
||||||
<div class="form-section" id="comments-section" style="display: none;">
|
<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">
|
<div id="comments-container">
|
||||||
<!-- Comments will be populated here -->
|
<!-- Comments will be populated here -->
|
||||||
</div>
|
</div>
|
||||||
@@ -147,8 +147,8 @@
|
|||||||
<textarea id="new-comment" placeholder="Add a comment..." rows="2"></textarea>
|
<textarea id="new-comment" placeholder="Add a comment..." rows="2"></textarea>
|
||||||
<div class="comment-form-actions">
|
<div class="comment-form-actions">
|
||||||
<select id="comment-visibility" class="comment-visibility-select" style="display: none;">
|
<select id="comment-visibility" class="comment-visibility-select" style="display: none;">
|
||||||
<option value="COMPANY">🏢 Company</option>
|
<option value="COMPANY"><i class="ti ti-building"></i> Company</option>
|
||||||
<option value="TEAM">👥 Team Only</option>
|
<option value="TEAM"><i class="ti ti-users"></i> Team Only</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn btn-sm btn-primary" onclick="addComment()">Post Comment</button>
|
<button type="button" class="btn btn-sm btn-primary" onclick="addComment()">Post Comment</button>
|
||||||
</div>
|
</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" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="management-container task-management-container">
|
<div class="page-container">
|
||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<div class="management-header task-header">
|
<div class="page-header">
|
||||||
<h1>📋 Task Management</h1>
|
<div class="header-content">
|
||||||
<div class="management-controls task-controls">
|
<div class="header-left">
|
||||||
<!-- Smart Search -->
|
<h1 class="page-title">
|
||||||
|
<span class="page-icon"><i class="ti ti-clipboard-list"></i></span>
|
||||||
|
Task Management
|
||||||
|
</h1>
|
||||||
|
<p class="page-subtitle">Manage and track all tasks across projects</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="refresh-tasks" class="btn btn-secondary">
|
||||||
|
<i class="ti ti-refresh"></i>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button id="toggle-archived" class="btn btn-outline" title="Show/Hide Archived Tasks">
|
||||||
|
<i class="ti ti-archive"></i>
|
||||||
|
Show Archived
|
||||||
|
</button>
|
||||||
|
<button id="add-task-btn" class="btn btn-primary">
|
||||||
|
<i class="ti ti-plus"></i>
|
||||||
|
Add Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Section -->
|
||||||
|
<div class="search-section">
|
||||||
<div class="smart-search-container">
|
<div class="smart-search-container">
|
||||||
<div class="smart-search-box">
|
<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)">
|
<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>
|
<button type="button" class="smart-search-clear" id="smart-search-clear" title="Clear search"><i class="ti ti-x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="smart-search-suggestions" id="smart-search-suggestions" style="display: none;">
|
<div class="smart-search-suggestions" id="smart-search-suggestions" style="display: none;">
|
||||||
<!-- Suggestions will be populated here -->
|
<!-- Suggestions will be populated here -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="management-actions task-actions">
|
|
||||||
<button id="add-task-btn" class="btn btn-primary">+ Add Task</button>
|
|
||||||
<button id="refresh-tasks" class="btn btn-secondary">🔄 Refresh</button>
|
|
||||||
<button id="toggle-archived" class="btn btn-outline" title="Show/Hide Archived Tasks">📦 Show Archived</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task Statistics -->
|
<!-- Task Statistics -->
|
||||||
<div class="management-stats task-stats">
|
<div class="stats-section">
|
||||||
<div class="stat-card">
|
<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 class="stat-label">Total Tasks</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<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 class="stat-label">Completed</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<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 class="stat-label">In Progress</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<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 class="stat-label">Overdue</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card" id="archived-stat-card" style="display: none;">
|
<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 class="stat-label">Archived</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +70,7 @@
|
|||||||
<div class="task-board" id="task-board">
|
<div class="task-board" id="task-board">
|
||||||
<div class="task-column" data-status="TODO">
|
<div class="task-column" data-status="TODO">
|
||||||
<div class="column-header">
|
<div class="column-header">
|
||||||
<h3>📝 To Do</h3>
|
<h3><i class="ti ti-circle"></i> To Do</h3>
|
||||||
<span class="task-count">0</span>
|
<span class="task-count">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column-content" id="column-TODO">
|
<div class="column-content" id="column-TODO">
|
||||||
@@ -64,7 +80,7 @@
|
|||||||
|
|
||||||
<div class="task-column" data-status="IN_PROGRESS">
|
<div class="task-column" data-status="IN_PROGRESS">
|
||||||
<div class="column-header">
|
<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>
|
<span class="task-count">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column-content" id="column-IN_PROGRESS">
|
<div class="column-content" id="column-IN_PROGRESS">
|
||||||
@@ -74,7 +90,7 @@
|
|||||||
|
|
||||||
<div class="task-column" data-status="IN_REVIEW">
|
<div class="task-column" data-status="IN_REVIEW">
|
||||||
<div class="column-header">
|
<div class="column-header">
|
||||||
<h3>🔍 In Review</h3>
|
<h3><i class="ti ti-eye"></i> In Review</h3>
|
||||||
<span class="task-count">0</span>
|
<span class="task-count">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column-content" id="column-IN_REVIEW">
|
<div class="column-content" id="column-IN_REVIEW">
|
||||||
@@ -84,7 +100,7 @@
|
|||||||
|
|
||||||
<div class="task-column" data-status="DONE">
|
<div class="task-column" data-status="DONE">
|
||||||
<div class="column-header">
|
<div class="column-header">
|
||||||
<h3>✅ Done</h3>
|
<h3><i class="ti ti-circle-check"></i> Done</h3>
|
||||||
<span class="task-count">0</span>
|
<span class="task-count">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column-content" id="column-DONE">
|
<div class="column-content" id="column-DONE">
|
||||||
@@ -94,7 +110,7 @@
|
|||||||
|
|
||||||
<div class="task-column" data-status="CANCELLED">
|
<div class="task-column" data-status="CANCELLED">
|
||||||
<div class="column-header">
|
<div class="column-header">
|
||||||
<h3>❌ Cancelled</h3>
|
<h3><i class="ti ti-circle-x"></i> Cancelled</h3>
|
||||||
<span class="task-count">0</span>
|
<span class="task-count">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column-content" id="column-CANCELLED">
|
<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="task-column archived-column" data-status="ARCHIVED" style="display: none;">
|
||||||
<div class="column-header">
|
<div class="column-header">
|
||||||
<h3>📦 Archived</h3>
|
<h3><i class="ti ti-archive"></i> Archived</h3>
|
||||||
<span class="task-count">0</span>
|
<span class="task-count">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column-content" id="column-ARCHIVED">
|
<div class="column-content" id="column-ARCHIVED">
|
||||||
@@ -129,12 +145,26 @@
|
|||||||
|
|
||||||
<!-- Styles -->
|
<!-- Styles -->
|
||||||
<style>
|
<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 Styles */
|
||||||
.smart-search-container {
|
.smart-search-container {
|
||||||
margin-bottom: 1rem;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.smart-search-box {
|
.smart-search-box {
|
||||||
@@ -237,71 +267,9 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Task Management Layout */
|
/* Task Management specific styles removed - using common page styles */
|
||||||
.task-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-controls .smart-search-container {
|
/* Responsive adjustments handled by common page styles */
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subtask progress styles */
|
/* Subtask progress styles */
|
||||||
.task-subtasks {
|
.task-subtasks {
|
||||||
@@ -336,18 +304,6 @@
|
|||||||
white-space: nowrap;
|
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') {
|
if (task.status === 'COMPLETED') {
|
||||||
actionButtons = `
|
actionButtons = `
|
||||||
<div class="task-actions">
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else if (task.status === 'ARCHIVED') {
|
} else if (task.status === 'ARCHIVED') {
|
||||||
actionButtons = `
|
actionButtons = `
|
||||||
<div class="task-actions">
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1727,12 +1683,12 @@ class UnifiedTaskManager {
|
|||||||
const archivedStatCard = document.getElementById('archived-stat-card');
|
const archivedStatCard = document.getElementById('archived-stat-card');
|
||||||
|
|
||||||
if (this.showArchived) {
|
if (this.showArchived) {
|
||||||
toggleBtn.textContent = '📦 Hide Archived';
|
toggleBtn.innerHTML = '<i class="ti ti-archive"></i> Hide Archived';
|
||||||
toggleBtn.classList.add('active');
|
toggleBtn.classList.add('active');
|
||||||
archivedColumn.style.display = 'block';
|
archivedColumn.style.display = 'block';
|
||||||
archivedStatCard.style.display = 'block';
|
archivedStatCard.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
toggleBtn.textContent = '📦 Show Archived';
|
toggleBtn.innerHTML = '<i class="ti ti-archive"></i> Show Archived';
|
||||||
toggleBtn.classList.remove('active');
|
toggleBtn.classList.remove('active');
|
||||||
archivedColumn.style.display = 'none';
|
archivedColumn.style.display = 'none';
|
||||||
archivedStatCard.style.display = 'none';
|
archivedStatCard.style.display = 'none';
|
||||||
@@ -2141,7 +2097,7 @@ class UnifiedTaskManager {
|
|||||||
element.dataset.commentId = comment.id;
|
element.dataset.commentId = comment.id;
|
||||||
|
|
||||||
const visibilityBadge = comment.visibility === 'Team' ?
|
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 ?
|
const editedText = comment.is_edited ?
|
||||||
` <span class="comment-edited">(edited)</span>` : '';
|
` <span class="comment-edited">(edited)</span>` : '';
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<div class="help-section">
|
<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>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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user