mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-08 04:30:20 -06:00
Merge pull request #89 from DRYTRIX/Feat-UIRedesign
feat: Implement Tailwind CSS UI redesign across application
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -185,4 +185,11 @@ nginx/ssl/*.crt
|
||||
# docker-compose.https.yml is now tracked
|
||||
|
||||
# Environment backups
|
||||
.env.backup
|
||||
.env.backup
|
||||
|
||||
# Node.js / Frontend build
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Tailwind CSS build output (keep source in git)
|
||||
app/static/dist/
|
||||
14
Dockerfile
14
Dockerfile
@@ -1,4 +1,14 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
# --- Stage 1: Frontend Build ---
|
||||
FROM node:18-slim as frontend
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build:docker
|
||||
|
||||
# --- Stage 2: Python Application ---
|
||||
FROM python:3.11-slim-bullseye
|
||||
|
||||
# Build-time version argument with safe default
|
||||
@@ -58,6 +68,9 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
# Copy project files
|
||||
COPY . .
|
||||
|
||||
# Copy compiled assets from frontend stage (overwriting the stale one from COPY .)
|
||||
COPY --from=frontend /app/app/static/dist/output.css /app/app/static/dist/output.css
|
||||
|
||||
# Create all directories and set permissions in a single layer
|
||||
RUN mkdir -p \
|
||||
/app/translations \
|
||||
@@ -122,3 +135,4 @@ ENTRYPOINT ["/app/docker/entrypoint_fixed.sh"]
|
||||
# Run the application
|
||||
CMD ["python", "/app/start.py"]
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ Successfully implemented custom kanban board columns functionality for the TimeT
|
||||
### Documentation
|
||||
- `KANBAN_CUSTOMIZATION.md` - Comprehensive feature documentation
|
||||
- `IMPLEMENTATION_SUMMARY.md` - This file
|
||||
- UI polish: Task create/edit pages tips redesigned; unified dark-mode handling for editor
|
||||
|
||||
## Files Modified
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ def create_app(config=None):
|
||||
"img-src 'self' data: https:; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.datatables.net; "
|
||||
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com data:; "
|
||||
"script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; "
|
||||
"script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh; "
|
||||
"connect-src 'self' ws: wss:; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models import User, Project, TimeEntry, Settings, Task
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func, extract
|
||||
from sqlalchemy import func, extract, case
|
||||
import calendar
|
||||
|
||||
analytics_bp = Blueprint('analytics', __name__)
|
||||
@@ -449,7 +449,7 @@ def summary_with_comparison():
|
||||
current_query = db.session.query(
|
||||
func.sum(TimeEntry.duration_seconds).label('total_seconds'),
|
||||
func.count(TimeEntry.id).label('total_entries'),
|
||||
func.sum(func.case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label('billable_seconds')
|
||||
func.sum(case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label('billable_seconds')
|
||||
).filter(
|
||||
TimeEntry.end_time.isnot(None),
|
||||
TimeEntry.start_time >= start_date,
|
||||
@@ -460,7 +460,7 @@ def summary_with_comparison():
|
||||
prev_query = db.session.query(
|
||||
func.sum(TimeEntry.duration_seconds).label('total_seconds'),
|
||||
func.count(TimeEntry.id).label('total_entries'),
|
||||
func.sum(func.case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label('billable_seconds')
|
||||
func.sum(case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label('billable_seconds')
|
||||
).filter(
|
||||
TimeEntry.end_time.isnot(None),
|
||||
TimeEntry.start_time >= prev_start_date,
|
||||
@@ -559,7 +559,7 @@ def task_completion():
|
||||
project_query = db.session.query(
|
||||
Project.name,
|
||||
func.count(Task.id).label('total_tasks'),
|
||||
func.sum(func.case((Task.status == 'done', 1), else_=0)).label('completed_tasks')
|
||||
func.sum(case((Task.status == 'done', 1), else_=0)).label('completed_tasks')
|
||||
).join(Task).filter(
|
||||
Task.created_at >= start_date,
|
||||
Project.status == 'active'
|
||||
@@ -699,7 +699,7 @@ def insights():
|
||||
func.sum(TimeEntry.duration_seconds).label('total_seconds'),
|
||||
func.avg(TimeEntry.duration_seconds).label('avg_seconds'),
|
||||
func.count(TimeEntry.id).label('total_entries'),
|
||||
func.sum(func.case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label('billable_seconds')
|
||||
func.sum(case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label('billable_seconds')
|
||||
).filter(
|
||||
TimeEntry.end_time.isnot(None),
|
||||
TimeEntry.start_time >= start_date,
|
||||
|
||||
@@ -40,7 +40,7 @@ def login():
|
||||
|
||||
if not username:
|
||||
flash(_('Username is required'), 'error')
|
||||
return render_template('auth/login.html')
|
||||
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
|
||||
|
||||
# Normalize admin usernames from config
|
||||
try:
|
||||
@@ -62,12 +62,12 @@ def login():
|
||||
if not safe_commit('self_register_user', {'username': username}):
|
||||
current_app.logger.error("Self-registration failed for '%s' due to DB error", username)
|
||||
flash(_('Could not create your account due to a database error. Please try again later.'), 'error')
|
||||
return render_template('auth/login.html')
|
||||
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
|
||||
current_app.logger.info("Created new user '%s'", username)
|
||||
flash(_('Welcome! Your account has been created.'), 'success')
|
||||
else:
|
||||
flash(_('User not found. Please contact an administrator.'), 'error')
|
||||
return render_template('auth/login.html')
|
||||
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
|
||||
else:
|
||||
# If existing user matches admin usernames, ensure admin role
|
||||
if username in admin_usernames and user.role != 'admin':
|
||||
@@ -75,12 +75,12 @@ def login():
|
||||
if not safe_commit('promote_admin_user', {'username': username}):
|
||||
current_app.logger.error("Failed to promote '%s' to admin due to DB error", username)
|
||||
flash(_('Could not update your account role due to a database error.'), 'error')
|
||||
return render_template('auth/login.html')
|
||||
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
|
||||
|
||||
# Check if user is active
|
||||
if not user.is_active:
|
||||
flash(_('Account is disabled. Please contact an administrator.'), 'error')
|
||||
return render_template('auth/login.html')
|
||||
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
|
||||
|
||||
# Log in the user
|
||||
login_user(user, remember=True)
|
||||
@@ -98,9 +98,9 @@ def login():
|
||||
except Exception as e:
|
||||
current_app.logger.exception("Login error: %s", e)
|
||||
flash(_('Unexpected error during login. Please try again or check server logs.'), 'error')
|
||||
return render_template('auth/login.html')
|
||||
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
|
||||
|
||||
return render_template('auth/login.html')
|
||||
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
|
||||
@@ -7,6 +7,29 @@ from app.utils.db import safe_commit
|
||||
from app.routes.admin import admin_required
|
||||
|
||||
kanban_bp = Blueprint('kanban', __name__)
|
||||
@kanban_bp.route('/kanban')
|
||||
@login_required
|
||||
def board():
|
||||
"""Kanban board page with optional project filter"""
|
||||
project_id = request.args.get('project_id', type=int)
|
||||
query = Task.query
|
||||
if project_id:
|
||||
query = query.filter_by(project_id=project_id)
|
||||
# Order tasks for stable rendering
|
||||
tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all()
|
||||
# Fresh columns
|
||||
db.session.expire_all()
|
||||
columns = KanbanColumn.get_active_columns()
|
||||
# Provide projects for filter dropdown
|
||||
from app.models import Project
|
||||
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
|
||||
# No-cache
|
||||
response = render_template('kanban/board.html', tasks=tasks, kanban_columns=columns, projects=projects, project_id=project_id)
|
||||
resp = make_response(response)
|
||||
resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
|
||||
resp.headers['Pragma'] = 'no-cache'
|
||||
resp.headers['Expires'] = '0'
|
||||
return resp
|
||||
|
||||
@kanban_bp.route('/kanban/columns')
|
||||
@login_required
|
||||
|
||||
@@ -46,13 +46,35 @@ def dashboard():
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
# Build Top Projects (last 30 days) based on user's activity
|
||||
period_start = today - timedelta(days=30)
|
||||
entries_30 = TimeEntry.query.filter(
|
||||
TimeEntry.end_time.isnot(None),
|
||||
TimeEntry.start_time >= period_start,
|
||||
TimeEntry.user_id == current_user.id
|
||||
).all()
|
||||
project_hours = {}
|
||||
for e in entries_30:
|
||||
if not e.project:
|
||||
continue
|
||||
project_hours.setdefault(e.project.id, {
|
||||
'project': e.project,
|
||||
'hours': 0.0,
|
||||
'billable_hours': 0.0
|
||||
})
|
||||
project_hours[e.project.id]['hours'] += e.duration_hours
|
||||
if e.billable and e.project.billable:
|
||||
project_hours[e.project.id]['billable_hours'] += e.duration_hours
|
||||
top_projects = sorted(project_hours.values(), key=lambda x: x['hours'], reverse=True)[:5]
|
||||
|
||||
return render_template('main/dashboard.html',
|
||||
active_timer=active_timer,
|
||||
recent_entries=recent_entries,
|
||||
active_projects=active_projects,
|
||||
today_hours=today_hours,
|
||||
week_hours=week_hours,
|
||||
month_hours=month_hours)
|
||||
month_hours=month_hours,
|
||||
top_projects=top_projects)
|
||||
|
||||
@main_bp.route('/_health')
|
||||
def health_check():
|
||||
|
||||
5144
app/static/base.css
5144
app/static/base.css
File diff suppressed because it is too large
Load Diff
@@ -1,706 +0,0 @@
|
||||
/* ========================================
|
||||
TimeTracker Calendar Styles
|
||||
======================================== */
|
||||
|
||||
/* Calendar Container */
|
||||
#calendar {
|
||||
min-height: 70vh;
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Calendar Header */
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.calendar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.calendar-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.calendar-hours-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--surface-variant);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.calendar-assign,
|
||||
.calendar-filter-project,
|
||||
.calendar-filter-task,
|
||||
.calendar-filter-tags {
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* FullCalendar Customization */
|
||||
.fc-toolbar-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.5rem !important;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fc-event {
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
border-left-width: 4px !important;
|
||||
font-size: 0.85rem;
|
||||
padding: 2px 4px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.fc-event:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
}
|
||||
|
||||
.fc-event-time {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fc-event-title {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Today button */
|
||||
.fc-today-button {
|
||||
background-color: var(--primary-color) !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.fc-today-button:hover {
|
||||
background-color: var(--primary-dark) !important;
|
||||
}
|
||||
|
||||
/* Current time indicator */
|
||||
.fc-timegrid-now-indicator-line {
|
||||
border-color: var(--danger-color);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.fc-timegrid-now-indicator-arrow {
|
||||
border-color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Day cells */
|
||||
.fc-day-today {
|
||||
background-color: var(--primary-50) !important;
|
||||
}
|
||||
|
||||
.fc-day-past {
|
||||
background-color: var(--surface-variant);
|
||||
}
|
||||
|
||||
/* Time grid */
|
||||
.fc-timegrid-slot {
|
||||
height: 3em;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.fc-timegrid-slot-minor {
|
||||
border-style: dotted;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
/* Header cells */
|
||||
.fc-col-header-cell {
|
||||
padding: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: var(--surface-variant);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.fc-col-header-cell-cushion {
|
||||
padding: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Calendar base styles */
|
||||
.fc {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fc-theme-standard td,
|
||||
.fc-theme-standard th {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.fc-theme-standard .fc-scrollgrid {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day-number {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fc .fc-timegrid-slot-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Event Modal Styles */
|
||||
.event-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.event-modal.show {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.event-modal-content {
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--card-shadow-xl);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
.event-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.event-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.event-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.event-modal-close:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.event-modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.event-modal-footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--surface-variant);
|
||||
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg);
|
||||
}
|
||||
|
||||
/* Event Detail View */
|
||||
.event-detail {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.event-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.event-detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.event-detail-value {
|
||||
color: var(--text-primary);
|
||||
padding: 0.5rem;
|
||||
background: var(--surface-variant);
|
||||
border-radius: var(--border-radius-sm);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.event-detail-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: var(--border-radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-detail-badge.billable {
|
||||
background-color: var(--success-light);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.event-detail-badge.non-billable {
|
||||
background-color: var(--danger-light);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Recurring Events Modal */
|
||||
.recurring-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.recurring-item {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--card-bg);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.recurring-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.recurring-item-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.recurring-item-status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recurring-item-status.active {
|
||||
background-color: var(--success-light);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.recurring-item-status.inactive {
|
||||
background-color: var(--surface-variant);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.recurring-item-details {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.recurring-item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Daily Capacity Bar */
|
||||
.daily-capacity-bar {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.capacity-bar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.capacity-bar-container {
|
||||
height: 24px;
|
||||
background: var(--surface-variant);
|
||||
border-radius: var(--border-radius-full);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.capacity-bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease, background-color 0.3s ease;
|
||||
border-radius: var(--border-radius-full);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.capacity-bar-fill.capacity-ok {
|
||||
background: linear-gradient(90deg, #10b981, #34d399);
|
||||
}
|
||||
|
||||
.capacity-bar-fill.capacity-warning {
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
}
|
||||
|
||||
.capacity-bar-fill.capacity-over {
|
||||
background: linear-gradient(90deg, #ef4444, #f87171);
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
.calendar-legend {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem;
|
||||
background: var(--surface-variant);
|
||||
border-radius: var(--border-radius);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Agenda View */
|
||||
.agenda-view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agenda-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agenda-date-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.agenda-date-header {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-primary);
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.agenda-event {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-left: 4px solid;
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 0.75rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agenda-event:hover {
|
||||
transform: translateX(4px);
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
}
|
||||
|
||||
.agenda-event-time {
|
||||
min-width: 100px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.agenda-event-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agenda-event-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.agenda-event-meta {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.calendar-loading {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.calendar-loading.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calendar-spinner {
|
||||
border: 4px solid var(--border-color);
|
||||
border-top: 4px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Keyboard Shortcuts Modal Styles */
|
||||
.shortcuts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.shortcut-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.shortcut-item:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.shortcut-item kbd {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-variant);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.1);
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shortcut-item span {
|
||||
flex: 1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.calendar-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.calendar-controls,
|
||||
.calendar-filters {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar-assign,
|
||||
.calendar-filter-project,
|
||||
.calendar-filter-task,
|
||||
.calendar-filter-tags {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.event-modal-content {
|
||||
width: 95%;
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.event-detail-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.event-detail-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.fc-toolbar {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fc-toolbar-chunk {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode specific styles */
|
||||
[data-theme="dark"] #calendar {
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-col-header-cell {
|
||||
background: var(--surface-variant);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-day-today {
|
||||
background-color: rgba(96, 165, 250, 0.1) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .event-modal-content {
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .event-modal-header {
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .event-modal-header h3 {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .event-modal-footer {
|
||||
background-color: var(--surface-variant);
|
||||
border-top-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .event-detail-value {
|
||||
background: var(--surface-variant);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .recurring-item {
|
||||
border-color: var(--border-color);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .calendar-legend {
|
||||
background: var(--surface-variant);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .agenda-event {
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .agenda-date-header,
|
||||
[data-theme="dark"] .agenda-event-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc .fc-button-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc .fc-button-primary:hover {
|
||||
background-color: var(--primary-dark);
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc .fc-button-primary:disabled {
|
||||
background-color: var(--text-muted);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.calendar-header,
|
||||
.calendar-controls,
|
||||
.event-modal {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#calendar {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.fc-event {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
@@ -12,32 +12,25 @@
|
||||
function $all(sel, root){ return Array.from((root||document).querySelectorAll(sel)); }
|
||||
|
||||
function openModal(){
|
||||
try {
|
||||
const el = $('#commandPaletteModal');
|
||||
if (!el) return;
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(el, { backdrop: 'static' });
|
||||
modal.show();
|
||||
// Focus input after show animation
|
||||
setTimeout(() => $('#commandPaletteInput')?.focus(), 150);
|
||||
refreshCommands();
|
||||
renderList();
|
||||
} catch(e) {}
|
||||
const el = $('#commandPaletteModal');
|
||||
if (!el) return;
|
||||
el.classList.remove('hidden');
|
||||
setTimeout(() => $('#commandPaletteInput')?.focus(), 50);
|
||||
refreshCommands();
|
||||
renderList();
|
||||
}
|
||||
|
||||
function closeModal(){
|
||||
try {
|
||||
const el = $('#commandPaletteModal');
|
||||
if (!el) return;
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(el);
|
||||
modal.hide();
|
||||
clearFilter();
|
||||
} catch(e) {}
|
||||
const el = $('#commandPaletteModal');
|
||||
if (!el) return;
|
||||
el.classList.add('hidden');
|
||||
clearFilter();
|
||||
}
|
||||
|
||||
// Timer helpers
|
||||
async function getActiveTimer(){
|
||||
try {
|
||||
const res = await fetch('/api/timer/status');
|
||||
const res = await fetch('/timer/status', { credentials: 'same-origin' });
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json && json.active ? json.timer : null;
|
||||
@@ -53,7 +46,8 @@
|
||||
try {
|
||||
const active = await getActiveTimer();
|
||||
if (!active) { showToast('No active timer', 'warning'); return; }
|
||||
const res = await fetch('/api/timer/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
|
||||
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
||||
const res = await fetch('/timer/stop', { method: 'POST', headers: { 'X-CSRF-Token': token }, credentials: 'same-origin' });
|
||||
if (res.ok) {
|
||||
showToast('Timer stopped', 'info');
|
||||
} else {
|
||||
@@ -118,12 +112,14 @@
|
||||
const list = $('#commandPaletteList');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
// Ensure container has modern styling
|
||||
list.className = 'flex flex-col max-h-96 overflow-y-auto divide-y divide-border-light dark:divide-border-dark';
|
||||
filtered.forEach((cmd, idx) => {
|
||||
const li = document.createElement('button');
|
||||
li.type = 'button';
|
||||
li.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center';
|
||||
li.className = 'px-3 py-2 text-left flex justify-between items-center hover:bg-background-light dark:hover:bg-background-dark focus:outline-none focus:ring-2 focus:ring-primary';
|
||||
li.setAttribute('data-idx', String(idx));
|
||||
li.innerHTML = `<span>${cmd.title}</span>${cmd.hint ? `<small class="text-muted">${cmd.hint}</small>` : ''}`;
|
||||
li.innerHTML = `<span class="truncate">${cmd.title}</span>${cmd.hint ? `<span class="ml-3 text-xs text-text-muted-light dark:text-text-muted-dark">${cmd.hint}</span>` : ''}`;
|
||||
li.addEventListener('click', () => { closeModal(); setTimeout(() => cmd.action(), 50); });
|
||||
list.appendChild(li);
|
||||
});
|
||||
@@ -131,8 +127,10 @@
|
||||
}
|
||||
|
||||
function highlightSelected(){
|
||||
$all('#commandPaletteList .list-group-item').forEach((el, idx) => {
|
||||
el.classList.toggle('active', idx === selectedIdx);
|
||||
$all('#commandPaletteList > button').forEach((el, idx) => {
|
||||
const isActive = idx === selectedIdx;
|
||||
el.classList.toggle('bg-background-light', isActive);
|
||||
el.classList.toggle('dark:bg-background-dark', isActive);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,7 +183,8 @@
|
||||
|
||||
// Modal-specific keyboard handling
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (!$('#commandPaletteModal')?.classList.contains('show')) return;
|
||||
const modal = $('#commandPaletteModal');
|
||||
if (!modal || modal.classList.contains('hidden')) return;
|
||||
if (ev.key === 'Escape'){ ev.preventDefault(); closeModal(); return; }
|
||||
if (ev.key === 'ArrowDown'){ ev.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, filtered.length - 1); highlightSelected(); return; }
|
||||
if (ev.key === 'ArrowUp'){ ev.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, 0); highlightSelected(); return; }
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Enhanced Empty States
|
||||
Beautiful empty state designs with illustrations and animations
|
||||
========================================================================== */
|
||||
|
||||
/* Empty State Container */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
animation: fade-in-up 0.5s ease;
|
||||
}
|
||||
|
||||
.empty-state-sm {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.empty-state-lg {
|
||||
padding: 6rem 2rem;
|
||||
}
|
||||
|
||||
/* Empty State Icon */
|
||||
.empty-state-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-state-icon-sm {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state-icon-lg {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
/* Animated Icon Container */
|
||||
.empty-state-icon-animated {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Icon Background Circle */
|
||||
.empty-state-icon-circle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary-100), var(--primary-50));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .empty-state-icon-circle {
|
||||
background: linear-gradient(135deg, var(--primary-900), var(--primary-800));
|
||||
}
|
||||
|
||||
/* Pulsing Ring */
|
||||
.empty-state-icon-circle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
right: -10px;
|
||||
bottom: -10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--primary-200);
|
||||
animation: pulse-ring 2s ease-out infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .empty-state-icon-circle::before {
|
||||
border-color: var(--primary-700);
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
.empty-state-icon i {
|
||||
font-size: 3rem;
|
||||
color: var(--primary-500);
|
||||
animation: fade-in-scale 0.6s ease;
|
||||
}
|
||||
|
||||
@keyframes fade-in-scale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* SVG Illustration */
|
||||
.empty-state-illustration {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 0 auto 2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.empty-state-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.empty-state-title-sm {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.empty-state-title-lg {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.empty-state-description {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.empty-state-description-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.empty-state-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.empty-state-actions .btn {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* Specific Empty States */
|
||||
|
||||
/* No Data */
|
||||
.empty-state-no-data .empty-state-icon-circle {
|
||||
background: linear-gradient(135deg, var(--gray-100), var(--gray-50));
|
||||
}
|
||||
|
||||
[data-theme="dark"] .empty-state-no-data .empty-state-icon-circle {
|
||||
background: linear-gradient(135deg, var(--gray-800), var(--gray-900));
|
||||
}
|
||||
|
||||
.empty-state-no-data i {
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* No Results */
|
||||
.empty-state-no-results .empty-state-icon-circle {
|
||||
background: linear-gradient(135deg, var(--warning-light), var(--warning-50));
|
||||
}
|
||||
|
||||
[data-theme="dark"] .empty-state-no-results .empty-state-icon-circle {
|
||||
background: linear-gradient(135deg, var(--warning-900), var(--warning-800));
|
||||
}
|
||||
|
||||
.empty-state-no-results i {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.empty-state-error .empty-state-icon-circle {
|
||||
background: linear-gradient(135deg, var(--danger-light), var(--danger-50));
|
||||
}
|
||||
|
||||
[data-theme="dark"] .empty-state-error .empty-state-icon-circle {
|
||||
background: linear-gradient(135deg, var(--danger-900), var(--danger-800));
|
||||
}
|
||||
|
||||
.empty-state-error i {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Success */
|
||||
.empty-state-success .empty-state-icon-circle {
|
||||
background: linear-gradient(135deg, var(--success-light), var(--success-50));
|
||||
}
|
||||
|
||||
[data-theme="dark"] .empty-state-success .empty-state-icon-circle {
|
||||
background: linear-gradient(135deg, var(--success-900), var(--success-800));
|
||||
}
|
||||
|
||||
.empty-state-success i {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
/* Info */
|
||||
.empty-state-info .empty-state-icon-circle {
|
||||
background: linear-gradient(135deg, var(--info-light), var(--info-50));
|
||||
}
|
||||
|
||||
[data-theme="dark"] .empty-state-info .empty-state-icon-circle {
|
||||
background: linear-gradient(135deg, var(--info-900), var(--info-800));
|
||||
}
|
||||
|
||||
.empty-state-info i {
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
/* Features List */
|
||||
.empty-state-features {
|
||||
text-align: left;
|
||||
max-width: 400px;
|
||||
margin: 2rem auto 2rem;
|
||||
}
|
||||
|
||||
.empty-state-feature {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--surface-variant);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.empty-state-feature-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.empty-state-feature-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-state-feature-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.empty-state-feature-description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Quick Tips */
|
||||
.empty-state-tips {
|
||||
background: var(--info-50);
|
||||
border: 1px solid var(--info-200);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .empty-state-tips {
|
||||
background: var(--info-900);
|
||||
border-color: var(--info-700);
|
||||
}
|
||||
|
||||
.empty-state-tips-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--info-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state-tips-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state-tips-list li {
|
||||
padding: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.empty-state-tips-list li::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--info-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Animated Illustrations */
|
||||
.empty-state-animated-bg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
opacity: 0.05;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.empty-state-animated-bg::before,
|
||||
.empty-state-animated-bg::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.empty-state-animated-bg::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
top: 20%;
|
||||
left: 10%;
|
||||
animation: float-slow 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.empty-state-animated-bg::after {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
bottom: 20%;
|
||||
right: 10%;
|
||||
animation: float-slow 8s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
@keyframes float-slow {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(30px, -30px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact Empty State */
|
||||
.empty-state-compact {
|
||||
padding: 2rem 1rem;
|
||||
background: var(--surface-variant);
|
||||
border-radius: var(--border-radius);
|
||||
border: 2px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.empty-state-compact .empty-state-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state-compact .empty-state-title {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state-compact .empty-state-description {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Inline Empty State */
|
||||
.empty-state-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.empty-state-inline .empty-state-icon {
|
||||
margin: 0;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.empty-state-inline .empty-state-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-state-inline .empty-state-title {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state-inline .empty-state-description {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Card Empty State */
|
||||
.empty-state-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.empty-state {
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.empty-state-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-state-actions .btn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty-state-inline {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-inline .empty-state-content {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
.empty-state {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.empty-state-icon-circle::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Enhanced Search System
|
||||
Instant search, autocomplete, and advanced filtering
|
||||
========================================================================== */
|
||||
|
||||
/* Search Container */
|
||||
.search-enhanced {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--card-bg);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.5rem 1rem;
|
||||
transition: var(--transition);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.search-input-wrapper:focus-within {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1), var(--card-shadow-hover);
|
||||
}
|
||||
|
||||
.search-input-wrapper .search-icon {
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.search-input-wrapper.searching .search-icon {
|
||||
animation: search-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes search-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.search-enhanced input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.search-enhanced input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Search Actions */
|
||||
.search-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-family-mono);
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-variant);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Autocomplete Dropdown */
|
||||
.search-autocomplete {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--card-shadow-lg);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
z-index: var(--z-dropdown);
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.search-autocomplete.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Autocomplete Sections */
|
||||
.search-section {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.search-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-section-title {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Autocomplete Items */
|
||||
.search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.search-item:hover,
|
||||
.search-item.active {
|
||||
background: var(--surface-hover);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.search-item-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-variant);
|
||||
margin-right: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-item:hover .search-item-icon {
|
||||
background: var(--primary-100);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.search-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-item-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-item-title mark {
|
||||
background: var(--warning-light);
|
||||
color: var(--text-primary);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .search-item-title mark {
|
||||
background: var(--warning-900);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.search-item-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.search-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
padding-left: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-item-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--surface-variant);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Recent Searches */
|
||||
.search-recent {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.search-recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.search-recent-item:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-recent-item i {
|
||||
margin-right: 0.75rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.search-recent-clear {
|
||||
padding: 0.5rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-recent-clear button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.search-recent-clear button:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* No Results */
|
||||
.search-no-results {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-no-results i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.search-no-results p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Search Filters */
|
||||
.search-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--surface-variant);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-full);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.search-filter-chip:hover,
|
||||
.search-filter-chip.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.search-filter-chip i {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Search Stats */
|
||||
.search-stats {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-variant);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.search-stats strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.search-loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-loading .loading-spinner {
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
/* Keyboard Navigation Indicator */
|
||||
.search-item.keyboard-focus {
|
||||
background: var(--surface-hover);
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Search Suggestions */
|
||||
.search-suggestions {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.search-suggestion-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.search-suggestion-item:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-suggestion-item i {
|
||||
margin-right: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Advanced Search Toggle */
|
||||
.search-advanced-toggle {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.search-advanced-toggle button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.search-advanced-toggle button:hover {
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .search-advanced-toggle button:hover {
|
||||
background: var(--primary-900);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.search-enhanced {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.search-autocomplete {
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.search-item-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-kbd {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.search-autocomplete::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.search-autocomplete::-webkit-scrollbar-track {
|
||||
background: var(--surface-variant);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.search-autocomplete::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.search-autocomplete::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Animation for dropdown appearance */
|
||||
@keyframes search-dropdown-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.search-autocomplete.show {
|
||||
animation: search-dropdown-in 0.2s ease;
|
||||
}
|
||||
|
||||
/* Highlight active search */
|
||||
.search-input-wrapper.has-value {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .search-input-wrapper.has-value {
|
||||
background: var(--primary-900);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,16 @@
|
||||
init() {
|
||||
this.createSearchUI();
|
||||
this.bindEvents();
|
||||
// Proactively disable native autofill/auto-complete behaviors
|
||||
try {
|
||||
this.input.setAttribute('autocomplete', 'off');
|
||||
this.input.setAttribute('autocapitalize', 'off');
|
||||
this.input.setAttribute('autocorrect', 'off');
|
||||
this.input.setAttribute('spellcheck', 'false');
|
||||
// Trick some Chromium versions
|
||||
this.input.setAttribute('name', 'q_search');
|
||||
this.input.setAttribute('data-lpignore', 'true');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
createSearchUI() {
|
||||
@@ -46,7 +56,7 @@
|
||||
const inputWrapper = document.createElement('div');
|
||||
inputWrapper.className = 'search-input-wrapper';
|
||||
inputWrapper.innerHTML = `
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<i class="fas fa-search search-icon" aria-hidden="true"></i>
|
||||
`;
|
||||
|
||||
// Move input into wrapper
|
||||
@@ -57,8 +67,8 @@
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'search-actions';
|
||||
actions.innerHTML = `
|
||||
<button type="button" class="search-clear-btn" style="display: none;">
|
||||
<i class="fas fa-times"></i>
|
||||
<button type="button" class="search-clear-btn" style="display: none;" aria-label="{{ _('Clear search') if false else 'Clear search' }}">
|
||||
<i class="fas fa-xmark"></i>
|
||||
</button>
|
||||
<span class="search-kbd">Ctrl+K</span>
|
||||
`;
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Enhanced Data Tables
|
||||
Advanced table features: sorting, filtering, inline editing, sticky headers
|
||||
========================================================================== */
|
||||
|
||||
/* Enhanced Table Container */
|
||||
.table-enhanced-wrapper {
|
||||
position: relative;
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Table Toolbar */
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--surface-variant);
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.table-toolbar-left,
|
||||
.table-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.table-search-box {
|
||||
position: relative;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.table-search-box input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem 0.5rem 2.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--card-bg);
|
||||
font-size: 0.9rem;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.table-search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.table-search-box i {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.table-filter-btn,
|
||||
.table-columns-btn,
|
||||
.table-export-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.table-filter-btn:hover,
|
||||
.table-columns-btn:hover,
|
||||
.table-export-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.table-filter-btn.active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Enhanced Table */
|
||||
.table-enhanced {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Sticky Header */
|
||||
.table-enhanced-sticky thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--surface-variant);
|
||||
box-shadow: 0 1px 0 var(--border-color);
|
||||
}
|
||||
|
||||
/* Table Header */
|
||||
.table-enhanced thead th {
|
||||
padding: 0.875rem 1rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-variant);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Sortable Columns */
|
||||
.table-enhanced thead th.sortable {
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.table-enhanced thead th.sortable:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.table-enhanced thead th.sortable::after {
|
||||
content: '\f0dc';
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
margin-left: 0.5rem;
|
||||
opacity: 0.3;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.table-enhanced thead th.sortable.sort-asc::after {
|
||||
content: '\f0de';
|
||||
opacity: 1;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.table-enhanced thead th.sortable.sort-desc::after {
|
||||
content: '\f0dd';
|
||||
opacity: 1;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Resizable Columns */
|
||||
.table-enhanced thead th.resizable {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.column-resizer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
background: transparent;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.column-resizer:hover,
|
||||
.column-resizer.resizing {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Table Body */
|
||||
.table-enhanced tbody td {
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.table-enhanced tbody tr {
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.table-enhanced tbody tr:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.table-enhanced tbody tr.selected {
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .table-enhanced tbody tr.selected {
|
||||
background: var(--primary-900);
|
||||
}
|
||||
|
||||
/* Editable Cells */
|
||||
.table-cell-editable {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-cell-editable:hover {
|
||||
background: var(--surface-hover);
|
||||
outline: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.table-cell-editing {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.table-cell-editing input,
|
||||
.table-cell-editing select,
|
||||
.table-cell-editing textarea {
|
||||
width: 100%;
|
||||
border: 2px solid var(--primary-color);
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--card-bg);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.table-cell-editing textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Cell Actions */
|
||||
.table-cell-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.table-enhanced tbody tr:hover .table-cell-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Checkbox Column */
|
||||
.table-checkbox-cell {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Action Buttons in Cells */
|
||||
.table-action-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: var(--transition);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.table-action-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.table-action-btn.btn-danger:hover {
|
||||
background: var(--danger-light);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.table-loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.table-loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .table-loading-overlay {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--surface-variant);
|
||||
}
|
||||
|
||||
.table-pagination-info {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.table-pagination-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.table-pagination-btn {
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: var(--transition);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.table-pagination-btn:hover:not(:disabled) {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.table-pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.table-pagination-btn.active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Per Page Selector */
|
||||
.table-per-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.table-per-page select {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Column Visibility Dropdown */
|
||||
.table-columns-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--card-shadow-lg);
|
||||
padding: 0.75rem;
|
||||
min-width: 200px;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.table-columns-dropdown.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.table-column-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.table-column-toggle:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.table-column-toggle input {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Export Menu */
|
||||
.table-export-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--card-shadow-lg);
|
||||
min-width: 150px;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.table-export-menu.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.table-export-option {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.table-export-option:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Bulk Actions Bar */
|
||||
.table-bulk-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: var(--primary-50);
|
||||
border-bottom: 1px solid var(--primary-200);
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .table-bulk-actions {
|
||||
background: var(--primary-900);
|
||||
border-color: var(--primary-700);
|
||||
}
|
||||
|
||||
.table-bulk-actions.show {
|
||||
opacity: 1;
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
.table-bulk-actions-info {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-bulk-actions-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.table-empty {
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.table-empty i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.table-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.table-toolbar-left,
|
||||
.table-toolbar-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.table-search-box {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Card view for mobile */
|
||||
.table-enhanced-mobile .table-enhanced thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-enhanced-mobile .table-enhanced tbody tr {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.table-enhanced-mobile .table-enhanced tbody td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.table-enhanced-mobile .table-enhanced tbody td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
.table-toolbar,
|
||||
.table-pagination,
|
||||
.table-cell-actions,
|
||||
.table-checkbox-cell {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.table-enhanced tbody tr:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
129
app/static/form-bridge.css
Normal file
129
app/static/form-bridge.css
Normal file
@@ -0,0 +1,129 @@
|
||||
/* Bridge styles to make legacy .form-control inputs match the new UI
|
||||
Applies generous padding, border, and focus states. */
|
||||
|
||||
/* Base inputs */
|
||||
.form-control,
|
||||
input.form-control,
|
||||
select.form-control,
|
||||
textarea.form-control,
|
||||
.form-select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 2.5rem; /* consistent tap target */
|
||||
padding: 0.625rem 0.875rem; /* py-2.5 px-3.5 */
|
||||
border: 1px solid #E2E8F0; /* border-light */
|
||||
border-radius: 0.5rem; /* rounded-lg */
|
||||
background: #FFFFFF; /* card-light */
|
||||
color: #2D3748; /* text-light */
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: #4A90E2; /* primary */
|
||||
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.35); /* focus ring */
|
||||
}
|
||||
|
||||
/* Placeholder colors */
|
||||
.form-control::placeholder { color: #A0AEC0; }
|
||||
.dark .form-control::placeholder { color: #718096; }
|
||||
|
||||
/* Textarea */
|
||||
textarea.form-control { min-height: 6rem; resize: vertical; }
|
||||
|
||||
/* Sizes */
|
||||
.form-control.form-control-sm { min-height: 2.25rem; padding: 0.5rem 0.75rem; }
|
||||
.form-control.form-control-lg { min-height: 2.875rem; padding: 0.75rem 1rem; }
|
||||
|
||||
/* Dark mode */
|
||||
.dark .form-control,
|
||||
.dark .form-select,
|
||||
.dark input.form-control,
|
||||
.dark select.form-control,
|
||||
.dark textarea.form-control {
|
||||
background: #2D3748; /* card-dark */
|
||||
color: #E2E8F0; /* text-dark */
|
||||
border-color: #4A5568; /* border-dark */
|
||||
}
|
||||
|
||||
.dark .form-control:focus,
|
||||
.dark .form-select:focus {
|
||||
border-color: #4A90E2;
|
||||
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.45);
|
||||
}
|
||||
|
||||
/* Input groups often remove radii; keep padding generous */
|
||||
.input-group .form-control { padding-top: 0.625rem; padding-bottom: 0.625rem; }
|
||||
|
||||
/* =============================
|
||||
Minimal tokens (fallbacks)
|
||||
============================= */
|
||||
:root {
|
||||
--color-primary: #3B82F6;
|
||||
--color-primary-600: #2563EB;
|
||||
--color-bg: #F7F9FB;
|
||||
--color-card: #FFFFFF;
|
||||
--color-border: #E2E8F0;
|
||||
--color-text: #1F2937;
|
||||
--radius-md: 0.5rem;
|
||||
--shadow-md: 0 8px 24px rgba(0,0,0,0.08);
|
||||
}
|
||||
.dark {
|
||||
--color-bg: #0F172A;
|
||||
--color-card: #1F2937;
|
||||
--color-border: #4A5568;
|
||||
--color-text: #E2E8F0;
|
||||
}
|
||||
|
||||
/* =============================
|
||||
Buttons
|
||||
============================= */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.5rem 0.875rem; border-radius: var(--radius-md); border: 1px solid transparent; font-weight: 600; line-height: 1.25; cursor: pointer; }
|
||||
.btn:focus { outline: none; box-shadow: 0 0 0 3px rgba(59,130,246,0.35); }
|
||||
.btn-primary { color: #fff; background-color: var(--color-primary); border-color: var(--color-primary); }
|
||||
.btn-primary:hover { background-color: var(--color-primary-600); border-color: var(--color-primary-600); }
|
||||
.btn-secondary { color: var(--color-text); background-color: var(--color-card); border-color: var(--color-border); }
|
||||
.btn-secondary:hover { background-color: #F3F4F6; }
|
||||
.btn-ghost { color: var(--color-text); background-color: transparent; border-color: transparent; }
|
||||
.btn-ghost:hover { background-color: rgba(148,163,184,0.15); }
|
||||
.btn-sm { padding: 0.375rem 0.625rem; font-size: 0.875rem; }
|
||||
.btn-lg { padding: 0.625rem 1rem; font-size: 1rem; }
|
||||
.btn[disabled], .btn.disabled { opacity: .6; cursor: not-allowed; }
|
||||
|
||||
/* =============================
|
||||
Focus ring utility
|
||||
============================= */
|
||||
.focus-ring { outline: none; box-shadow: 0 0 0 3px rgba(59,130,246,0.35); }
|
||||
|
||||
/* =============================
|
||||
Table enhancements
|
||||
============================= */
|
||||
.table { width: 100%; border-collapse: separate; border-spacing: 0; }
|
||||
.table thead th { position: sticky; top: 0; background: var(--color-card); z-index: 1; }
|
||||
.table thead th, .table tbody td { padding: 1rem; border-bottom: 1px solid var(--color-border); }
|
||||
.table-zebra tbody tr:nth-child(odd) { background-color: rgba(148,163,184,0.07); }
|
||||
.dark .table-zebra tbody tr:nth-child(odd) { background-color: rgba(148,163,184,0.12); }
|
||||
.table-compact thead th, .table-compact tbody td { padding: 0.625rem 0.75rem; }
|
||||
.table-number { text-align: right; }
|
||||
|
||||
/* =============================
|
||||
Badge chips
|
||||
============================= */
|
||||
.chip { display: inline-flex; align-items: center; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; line-height: 1; border: 1px solid transparent; }
|
||||
.chip-neutral { background: #F1F5F9; color: #334155; }
|
||||
.dark .chip-neutral { background: #1F2937; color: #E5E7EB; border-color: #374151; }
|
||||
.chip-success { background: #DCFCE7; color: #166534; }
|
||||
.dark .chip-success { background: rgba(16,185,129,0.15); color: #34D399; }
|
||||
.chip-warning { background: #FEF3C7; color: #92400E; }
|
||||
.dark .chip-warning { background: rgba(245,158,11,0.15); color: #F59E0B; }
|
||||
.chip-danger { background: #FEE2E2; color: #991B1B; }
|
||||
.dark .chip-danger { background: rgba(239,68,68,0.15); color: #F87171; }
|
||||
|
||||
/* =============================
|
||||
Cards & helpers
|
||||
============================= */
|
||||
.card { background: var(--color-card); border: 1px solid var(--color-border); border-radius: var(--radius-md); box-shadow: var(--shadow-md); }
|
||||
.page-bg { background: var(--color-bg); }
|
||||
|
||||
|
||||
15
app/static/images/avatar-default.svg
Normal file
15
app/static/images/avatar-default.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Default avatar">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#4A90E2"/>
|
||||
<stop offset="100%" stop-color="#2D72C4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#g)"/>
|
||||
<g fill="#ffffff" opacity="0.95">
|
||||
<circle cx="32" cy="26" r="10"/>
|
||||
<path d="M12 54c3.5-9.5 12.3-16 20-16s16.5 6.5 20 16" fill-opacity="0.85"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 535 B |
@@ -1,421 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Keyboard Shortcuts & Command Palette
|
||||
Power user features for navigation and actions
|
||||
========================================================================== */
|
||||
|
||||
/* Command Palette */
|
||||
.command-palette {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 10vh 1rem 1rem;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.command-palette.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .command-palette {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.command-palette-container {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
background: var(--card-bg);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.command-palette.show .command-palette-container {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .command-palette-container {
|
||||
background: var(--dark-color);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Search Input */
|
||||
.command-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--surface-variant);
|
||||
}
|
||||
|
||||
.command-search-icon {
|
||||
color: var(--text-muted);
|
||||
margin-right: 1rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.command-search input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.command-search input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Results List */
|
||||
.command-results {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.command-section {
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.command-section-title {
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--surface-variant);
|
||||
}
|
||||
|
||||
/* Command Items */
|
||||
.command-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.875rem 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: var(--text-primary);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.command-item:hover,
|
||||
.command-item.active {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.command-item.active {
|
||||
border-left-color: var(--primary-color);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .command-item.active {
|
||||
background: var(--primary-900);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.command-item-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--surface-variant);
|
||||
margin-right: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.command-item:hover .command-item-icon,
|
||||
.command-item.active .command-item-icon {
|
||||
background: var(--primary-100);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .command-item:hover .command-item-icon,
|
||||
[data-theme="dark"] .command-item.active .command-item-icon {
|
||||
background: var(--primary-900);
|
||||
}
|
||||
|
||||
.command-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.command-item-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.command-item-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.command-item-shortcut {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.command-kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-family-mono), 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-variant);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 1px 0 0 var(--border-color),
|
||||
0 2px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.command-item.active .command-kbd {
|
||||
background: var(--primary-50);
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-300);
|
||||
box-shadow: 0 1px 0 0 var(--primary-300);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .command-item.active .command-kbd {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border-color: var(--primary-700);
|
||||
box-shadow: 0 1px 0 0 var(--primary-700);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.command-empty {
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.command-empty i {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.command-footer {
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--surface-variant);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.command-footer-actions {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.command-footer-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Keyboard Shortcut Hint */
|
||||
.shortcut-hint {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.75rem 1rem;
|
||||
box-shadow: var(--card-shadow-lg);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
z-index: var(--z-toast);
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.shortcut-hint.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.shortcut-hint-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.shortcut-hint-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Help Modal for All Shortcuts */
|
||||
.shortcuts-help-modal .modal-content {
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.shortcuts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.shortcuts-category {
|
||||
background: var(--surface-variant);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.shortcuts-category-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.shortcut-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.shortcut-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.shortcut-label {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.shortcut-keys {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Quick Action Buttons with Shortcuts */
|
||||
.btn-with-shortcut {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-with-shortcut .shortcut-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
/* Keyboard Navigation Indicator */
|
||||
body.keyboard-navigation *:focus {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
body:not(.keyboard-navigation) *:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.command-palette {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.command-palette-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.command-results {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.command-item-shortcut {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.command-footer-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shortcut-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shortcuts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.command-results::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.command-results::-webkit-scrollbar-track {
|
||||
background: var(--surface-variant);
|
||||
}
|
||||
|
||||
.command-results::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.command-results::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
@keyframes command-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.command-item-icon.pulse {
|
||||
animation: command-pulse 0.5s ease;
|
||||
}
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Loading States & Skeleton Screens
|
||||
Modern loading indicators and skeleton components for better UX
|
||||
========================================================================== */
|
||||
|
||||
/* Skeleton Base Styles */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--gray-200) 0%,
|
||||
var(--gray-100) 50%,
|
||||
var(--gray-200) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s ease-in-out infinite;
|
||||
border-radius: var(--border-radius-sm);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--gray-800) 0%,
|
||||
var(--gray-700) 50%,
|
||||
var(--gray-800) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Skeleton Variants */
|
||||
.skeleton-text {
|
||||
height: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.skeleton-text-lg {
|
||||
height: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 2rem;
|
||||
width: 60%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.skeleton-avatar-lg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 200px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.skeleton-button {
|
||||
height: 38px;
|
||||
width: 100px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.skeleton-input {
|
||||
height: 42px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.skeleton-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.skeleton-badge {
|
||||
height: 24px;
|
||||
width: 60px;
|
||||
border-radius: var(--border-radius-full);
|
||||
}
|
||||
|
||||
/* Table Skeleton */
|
||||
.skeleton-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-table-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.skeleton-table-cell {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Card Skeleton */
|
||||
.skeleton-summary-card {
|
||||
padding: 1.5rem;
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.skeleton-summary-card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-summary-card-label {
|
||||
height: 1rem;
|
||||
width: 60%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-summary-card-value {
|
||||
height: 2rem;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid var(--gray-300);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spinner-rotate 0.8s linear infinite;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .loading-spinner {
|
||||
border-color: var(--gray-600);
|
||||
border-top-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.loading-spinner-lg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-width: 4px;
|
||||
}
|
||||
|
||||
.loading-spinner-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
@keyframes spinner-rotate {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading Overlay */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
animation: fade-in 0.3s ease forwards;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .loading-overlay {
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay-content {
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading-overlay-spinner {
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
/* Pulse Animation */
|
||||
.pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer Effect */
|
||||
.shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shimmer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0,
|
||||
rgba(255, 255, 255, 0.3) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .shimmer::after {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-bar-animated {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-animated::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.3),
|
||||
transparent
|
||||
);
|
||||
animation: progress-shine 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-shine {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading Dots */
|
||||
.loading-dots {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.loading-dots span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
animation: loading-dots 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-dots span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.loading-dots span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes loading-dots {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Skeleton List */
|
||||
.skeleton-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.skeleton-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Content Placeholder */
|
||||
.content-placeholder {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-variant);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Loading State Classes */
|
||||
.is-loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.is-loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: -12px 0 0 -12px;
|
||||
border: 3px solid var(--gray-300);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spinner-rotate 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Button Loading State */
|
||||
.btn-loading {
|
||||
position: relative;
|
||||
color: transparent !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: -8px 0 0 -8px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: spinner-rotate 0.6s linear infinite;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Skeleton Chart */
|
||||
.skeleton-chart {
|
||||
height: 300px;
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-chart-bar {
|
||||
flex: 1;
|
||||
background: var(--gray-200);
|
||||
border-radius: var(--border-radius-xs);
|
||||
animation: skeleton-loading 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .skeleton-chart-bar {
|
||||
background: var(--gray-700);
|
||||
}
|
||||
|
||||
.skeleton-chart-bar:nth-child(1) { height: 60%; }
|
||||
.skeleton-chart-bar:nth-child(2) { height: 80%; }
|
||||
.skeleton-chart-bar:nth-child(3) { height: 45%; }
|
||||
.skeleton-chart-bar:nth-child(4) { height: 90%; }
|
||||
.skeleton-chart-bar:nth-child(5) { height: 70%; }
|
||||
.skeleton-chart-bar:nth-child(6) { height: 55%; }
|
||||
.skeleton-chart-bar:nth-child(7) { height: 85%; }
|
||||
|
||||
/* Responsive Skeleton */
|
||||
@media (max-width: 768px) {
|
||||
.skeleton-summary-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-table-row {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,586 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Micro-Interactions & Animations
|
||||
Subtle animations and interactions for enhanced UX
|
||||
========================================================================== */
|
||||
|
||||
/* Ripple Effect */
|
||||
.ripple {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ripple::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.ripple:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ripple::before {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Button Ripple Effect */
|
||||
.btn-ripple {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn-ripple::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.5s, height 0.5s, opacity 0.5s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.btn-ripple:active::after {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
opacity: 1;
|
||||
transition: 0s;
|
||||
}
|
||||
|
||||
/* Smooth Scale on Hover */
|
||||
.scale-hover {
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.scale-hover:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.scale-hover:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Lift Effect on Hover */
|
||||
.lift-hover {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.lift-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
}
|
||||
|
||||
/* Icon Animations */
|
||||
.icon-spin-hover {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.icon-spin-hover:hover {
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
.icon-bounce {
|
||||
animation: icon-bounce 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes icon-bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-pulse {
|
||||
animation: icon-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-shake {
|
||||
animation: icon-shake 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes icon-shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Count Up Animation */
|
||||
.count-up {
|
||||
animation: count-up 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes count-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade Animations */
|
||||
.fade-in {
|
||||
animation: fade-in 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-up {
|
||||
animation: fade-in-up 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-down {
|
||||
animation: fade-in-down 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-left {
|
||||
animation: fade-in-left 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-right {
|
||||
animation: fade-in-right 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide Animations */
|
||||
.slide-in-up {
|
||||
animation: slide-in-up 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes slide-in-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Zoom Animations */
|
||||
.zoom-in {
|
||||
animation: zoom-in 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes zoom-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bounce Animation */
|
||||
.bounce-in {
|
||||
animation: bounce-in 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Card Flip */
|
||||
.card-flip {
|
||||
transition: transform 0.6s;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.card-flip:hover {
|
||||
transform: rotateY(5deg);
|
||||
}
|
||||
|
||||
/* Glow Effect */
|
||||
.glow-hover {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.glow-hover:hover {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
/* Progress Ring Animation */
|
||||
@keyframes progress-ring {
|
||||
0% {
|
||||
stroke-dashoffset: 251.2;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Notification Badge Pulse */
|
||||
.badge-pulse {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.badge-pulse::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: inherit;
|
||||
background: inherit;
|
||||
animation: badge-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes badge-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
transform: scale(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Skeleton Shimmer */
|
||||
.shimmer-effect {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer-move 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer-move {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typewriter Effect */
|
||||
.typewriter {
|
||||
overflow: hidden;
|
||||
border-right: 0.15em solid var(--primary-color);
|
||||
white-space: nowrap;
|
||||
animation: typewriter 3.5s steps(40, end), blink-caret 0.75s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes typewriter {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink-caret {
|
||||
from, to {
|
||||
border-color: transparent;
|
||||
}
|
||||
50% {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Number Counter */
|
||||
.number-counter {
|
||||
display: inline-block;
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.number-counter.updated {
|
||||
color: var(--success-color);
|
||||
transform: scale(1.2);
|
||||
animation: number-pop 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes number-pop {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Success Checkmark */
|
||||
.success-checkmark {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
box-sizing: content-box;
|
||||
border: 4px solid var(--success-color);
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon::before {
|
||||
top: 3px;
|
||||
left: -2px;
|
||||
width: 30px;
|
||||
transform-origin: 100% 50%;
|
||||
border-radius: 100px 0 0 100px;
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon::after {
|
||||
top: 0;
|
||||
left: 30px;
|
||||
width: 60px;
|
||||
transform-origin: 0 50%;
|
||||
border-radius: 0 100px 100px 0;
|
||||
animation: rotate-circle 4.25s ease-in;
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon .icon-line {
|
||||
height: 5px;
|
||||
background-color: var(--success-color);
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon .icon-line.line-tip {
|
||||
top: 46px;
|
||||
left: 14px;
|
||||
width: 25px;
|
||||
transform: rotate(45deg);
|
||||
animation: icon-line-tip 0.75s;
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon .icon-line.line-long {
|
||||
top: 38px;
|
||||
right: 8px;
|
||||
width: 47px;
|
||||
transform: rotate(-45deg);
|
||||
animation: icon-line-long 0.75s;
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon .icon-circle {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
z-index: 10;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
box-sizing: content-box;
|
||||
border: 4px solid rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon .icon-fix {
|
||||
top: 8px;
|
||||
width: 5px;
|
||||
left: 26px;
|
||||
z-index: 1;
|
||||
height: 85px;
|
||||
position: absolute;
|
||||
transform: rotate(-45deg);
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
@keyframes rotate-circle {
|
||||
0% {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
5% {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
12% {
|
||||
transform: rotate(-405deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(-405deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-line-tip {
|
||||
0% {
|
||||
width: 0;
|
||||
left: 1px;
|
||||
top: 19px;
|
||||
}
|
||||
54% {
|
||||
width: 0;
|
||||
left: 1px;
|
||||
top: 19px;
|
||||
}
|
||||
70% {
|
||||
width: 50px;
|
||||
left: -8px;
|
||||
top: 37px;
|
||||
}
|
||||
84% {
|
||||
width: 17px;
|
||||
left: 21px;
|
||||
top: 48px;
|
||||
}
|
||||
100% {
|
||||
width: 25px;
|
||||
left: 14px;
|
||||
top: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-line-long {
|
||||
0% {
|
||||
width: 0;
|
||||
right: 46px;
|
||||
top: 54px;
|
||||
}
|
||||
65% {
|
||||
width: 0;
|
||||
right: 46px;
|
||||
top: 54px;
|
||||
}
|
||||
84% {
|
||||
width: 55px;
|
||||
right: 0px;
|
||||
top: 35px;
|
||||
}
|
||||
100% {
|
||||
width: 47px;
|
||||
right: 8px;
|
||||
top: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus Ring Enhancement */
|
||||
.focus-ring-enhanced:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--primary-color), 0 0 0 5px rgba(59, 130, 246, 0.2);
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
/* Stagger Animation */
|
||||
.stagger-animation > * {
|
||||
opacity: 0;
|
||||
animation: fade-in-up 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.stagger-animation > *:nth-child(1) { animation-delay: 0.05s; }
|
||||
.stagger-animation > *:nth-child(2) { animation-delay: 0.1s; }
|
||||
.stagger-animation > *:nth-child(3) { animation-delay: 0.15s; }
|
||||
.stagger-animation > *:nth-child(4) { animation-delay: 0.2s; }
|
||||
.stagger-animation > *:nth-child(5) { animation-delay: 0.25s; }
|
||||
.stagger-animation > *:nth-child(6) { animation-delay: 0.3s; }
|
||||
.stagger-animation > *:nth-child(7) { animation-delay: 0.35s; }
|
||||
.stagger-animation > *:nth-child(8) { animation-delay: 0.4s; }
|
||||
|
||||
/* Reduce Motion Support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,679 +0,0 @@
|
||||
/**
|
||||
* Reports Enhanced Styling
|
||||
* Modern, clean styles for the reports section
|
||||
*/
|
||||
|
||||
/* Report Container */
|
||||
.reports-container {
|
||||
background: var(--body-bg, #ffffff);
|
||||
min-height: calc(100vh - var(--navbar-height, 72px));
|
||||
}
|
||||
|
||||
/* Summary Cards - Enhanced */
|
||||
.summary-card {
|
||||
border: 1px solid var(--border-light, #f1f5f9) !important;
|
||||
background: var(--card-bg, #ffffff);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.summary-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--border-radius-lg, 12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #475569);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1e293b);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.summary-trend {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-trend.up {
|
||||
color: var(--success-color, #10b981);
|
||||
}
|
||||
|
||||
.summary-trend.down {
|
||||
color: var(--danger-color, #ef4444);
|
||||
}
|
||||
|
||||
/* Filter Section */
|
||||
.filters-card {
|
||||
background: var(--surface-variant, #f8fafc);
|
||||
border: 1px solid var(--border-color, #e2e8f0) !important;
|
||||
}
|
||||
|
||||
.filters-card .card-header {
|
||||
background: var(--card-bg, #ffffff);
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.date-presets-container {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--card-bg, #ffffff);
|
||||
border-radius: var(--border-radius, 8px);
|
||||
border: 1px dashed var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.date-presets-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #475569);
|
||||
margin-bottom: 0.75rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#datePresets .btn-group {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#datePresets .btn {
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: var(--border-radius-sm, 6px);
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#datePresets .btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Report Tables */
|
||||
.report-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.report-table thead th {
|
||||
background: var(--gray-50, #f9fafb);
|
||||
color: var(--text-secondary, #475569);
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 2px solid var(--border-color, #e2e8f0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-table thead th[data-sortable] {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.report-table thead th[data-sortable]:hover {
|
||||
background: var(--gray-100, #f3f4f6);
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.report-table thead th.sort-asc,
|
||||
.report-table thead th.sort-desc {
|
||||
background: var(--primary-50, #eff6ff);
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.report-table tbody tr {
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 1px solid var(--border-light, #f1f5f9);
|
||||
}
|
||||
|
||||
.report-table tbody tr:hover {
|
||||
background: var(--surface-hover, #f8fafc);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.report-table tbody td {
|
||||
padding: 1rem 1.25rem;
|
||||
vertical-align: middle;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.report-table tbody td strong {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
/* Table Actions */
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: var(--border-radius-sm, 6px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.btn-action--view {
|
||||
background: var(--primary-50, #eff6ff);
|
||||
color: var(--primary-color, #3b82f6);
|
||||
border: 1px solid var(--primary-200, #bfdbfe);
|
||||
}
|
||||
|
||||
.btn-action--view:hover {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-action--more {
|
||||
background: var(--gray-50, #f9fafb);
|
||||
color: var(--text-secondary, #475569);
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.btn-action--more:hover {
|
||||
background: var(--gray-100, #f3f4f6);
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
/* Chart Containers */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 350px;
|
||||
margin: 1.5rem 0;
|
||||
padding: 1.5rem;
|
||||
background: var(--card-bg, #ffffff);
|
||||
border-radius: var(--border-radius, 8px);
|
||||
border: 1px solid var(--border-light, #f1f5f9);
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chart-toggle-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
background: var(--card-bg, #ffffff);
|
||||
color: var(--text-secondary, #475569);
|
||||
border-radius: var(--border-radius-sm, 6px);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.chart-toggle-btn:hover,
|
||||
.chart-toggle-btn.active {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
color: white;
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 4rem;
|
||||
color: var(--gray-300, #d1d5db);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state h5 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #475569);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-tertiary, #64748b);
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Pagination Controls */
|
||||
.pagination-controls {
|
||||
border-top: 1px solid var(--border-light, #f1f5f9);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #475569);
|
||||
}
|
||||
|
||||
.pagination .page-link {
|
||||
color: var(--text-secondary, #475569);
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: var(--border-radius-sm, 6px);
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.pagination .page-link:hover {
|
||||
background: var(--primary-50, #eff6ff);
|
||||
color: var(--primary-color, #3b82f6);
|
||||
border-color: var(--primary-200, #bfdbfe);
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination .page-item.disabled .page-link {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Report Export Options */
|
||||
.export-options {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--border-radius, 8px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.export-btn i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Progress Bars */
|
||||
.progress-compact {
|
||||
height: 8px;
|
||||
border-radius: var(--border-radius-full, 9999px);
|
||||
background: var(--gray-100, #f3f4f6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-compact .progress-bar {
|
||||
border-radius: var(--border-radius-full, 9999px);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
/* Report Stats Grid */
|
||||
.report-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.report-stat-item {
|
||||
padding: 1.25rem;
|
||||
background: var(--card-bg, #ffffff);
|
||||
border: 1px solid var(--border-light, #f1f5f9);
|
||||
border-radius: var(--border-radius, 8px);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.report-stat-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #475569);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
/* Hover Card Effects */
|
||||
.hover-lift {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
.filters-card,
|
||||
.export-options,
|
||||
.btn-action,
|
||||
.pagination-controls,
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.report-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.summary-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 280px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.report-table thead th,
|
||||
.report-table tbody td {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
#datePresets .btn-group {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#datePresets .btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.export-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.report-stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.report-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-secondary, #475569);
|
||||
}
|
||||
|
||||
.report-loading .spinner-border {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-width: 0.3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.report-loading-text {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Badges and Tags */
|
||||
.report-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--border-radius-full, 9999px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-badge.badge-billable {
|
||||
background: var(--success-light, #d1fae5);
|
||||
color: var(--success-color, #10b981);
|
||||
}
|
||||
|
||||
.report-badge.badge-non-billable {
|
||||
background: var(--gray-100, #f3f4f6);
|
||||
color: var(--text-tertiary, #64748b);
|
||||
}
|
||||
|
||||
/* Table Search */
|
||||
.table-search-container {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.table-search {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--border-radius, 8px);
|
||||
font-size: 0.9375rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
box-shadow: var(--focus-ring, 0 0 0 3px rgba(59, 130, 246, 0.12));
|
||||
}
|
||||
|
||||
/* Comparison View */
|
||||
.comparison-view {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.comparison-card {
|
||||
padding: 1.5rem;
|
||||
background: var(--card-bg, #ffffff);
|
||||
border: 2px solid var(--border-light, #f1f5f9);
|
||||
border-radius: var(--border-radius-lg, 12px);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.comparison-header {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid var(--border-light, #f1f5f9);
|
||||
}
|
||||
|
||||
.comparison-metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.comparison-metric-label {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary, #475569);
|
||||
}
|
||||
|
||||
.comparison-metric-value {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
/* Reports Grid - Clean Compact Layout */
|
||||
.reports-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.report-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--card-bg, #ffffff);
|
||||
border: 1px solid var(--border-light, #f1f5f9);
|
||||
border-radius: var(--border-radius, 8px);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report-item:hover {
|
||||
background: var(--surface-hover, #f8fafc);
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.report-item-icon {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--border-radius, 8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.report-item-content {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.report-item-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
margin: 0 0 0.25rem 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.report-item-description {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-item-arrow {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
font-size: 0.875rem;
|
||||
margin-left: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.report-item:hover .report-item-arrow {
|
||||
color: var(--primary-color, #3b82f6);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* Purple color utility for analytics */
|
||||
.bg-purple {
|
||||
background-color: #8b5cf6 !important;
|
||||
}
|
||||
|
||||
.text-purple {
|
||||
color: #8b5cf6 !important;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for report grid */
|
||||
@media (max-width: 768px) {
|
||||
.reports-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.report-item {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.report-item-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1.125rem;
|
||||
margin-right: 0.875rem;
|
||||
}
|
||||
|
||||
.report-item-title {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.report-item-description {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
34
app/static/src/input.css
Normal file
34
app/static/src/input.css
Normal file
@@ -0,0 +1,34 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.form-input {
|
||||
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600 px-4 py-3;
|
||||
}
|
||||
}
|
||||
|
||||
.cmdk-root {
|
||||
--cmdk-font-family: 'Inter', sans-serif;
|
||||
--cmdk-background: #fff;
|
||||
--cmdk-border-radius: 8px;
|
||||
--cmdk-box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
--cmdk-color-text: #333;
|
||||
--cmdk-color-placeholder: #999;
|
||||
--cmdk-color-input: #333;
|
||||
--cmdk-color-separator: #ddd;
|
||||
--cmdk-color-item-hover: #f5f5f5;
|
||||
--cmdk-color-item-active: #eee;
|
||||
--cmdk-height: 400px;
|
||||
--cmdk-padding: 12px;
|
||||
}
|
||||
|
||||
[cmdk-theme='dark'] .cmdk-root {
|
||||
--cmdk-background: #1A202C; /* background-dark */
|
||||
--cmdk-color-text: #E2E8F0; /* text-dark */
|
||||
--cmdk-color-placeholder: #718096; /* text-muted-dark */
|
||||
--cmdk-color-input: #E2E8F0;
|
||||
--cmdk-color-separator: #4A5568; /* border-dark */
|
||||
--cmdk-color-item-hover: #2D3748; /* card-dark */
|
||||
--cmdk-color-item-active: #4A5568;
|
||||
}
|
||||
@@ -1,504 +0,0 @@
|
||||
/* TimeTracker Modern Theme Template - Enhanced CSS Variables */
|
||||
/* This file contains the complete modern theme system used in TimeTracker */
|
||||
/* It can be used as a template for future customizations while maintaining consistency */
|
||||
|
||||
/* ===== MODERN LIGHT THEME VARIABLES ===== */
|
||||
:root {
|
||||
/* Primary Color Palette - Enhanced Modern Blue */
|
||||
--primary-color: #3b82f6; /* Main brand color - Modern Blue */
|
||||
--primary-dark: #2563eb; /* Darker shade for hover states */
|
||||
--primary-light: #93c5fd; /* Lighter shade for subtle accents */
|
||||
--primary-50: #eff6ff; /* Ultra light blue */
|
||||
--primary-100: #dbeafe; /* Very light blue */
|
||||
--primary-200: #bfdbfe; /* Light blue */
|
||||
--primary-300: #93c5fd; /* Medium light blue */
|
||||
--primary-400: #60a5fa; /* Medium blue */
|
||||
--primary-500: #3b82f6; /* Base blue */
|
||||
--primary-600: #2563eb; /* Medium dark blue */
|
||||
--primary-700: #1d4ed8; /* Dark blue */
|
||||
--primary-800: #1e40af; /* Very dark blue */
|
||||
--primary-900: #1e3a8a; /* Ultra dark blue */
|
||||
|
||||
/* Semantic Color System */
|
||||
--secondary-color: #64748b; /* Neutral gray for secondary elements */
|
||||
--success-color: #10b981; /* Enhanced green for success states */
|
||||
--success-light: #d1fae5; /* Light success background */
|
||||
--danger-color: #ef4444; /* Enhanced red for error/danger states */
|
||||
--danger-light: #fee2e2; /* Light danger background */
|
||||
--warning-color: #f59e0b; /* Enhanced orange for warning states */
|
||||
--warning-light: #fef3c7; /* Light warning background */
|
||||
--info-color: #06b6d4; /* Enhanced cyan for informational states */
|
||||
--info-light: #cffafe; /* Light info background */
|
||||
|
||||
/* Neutral Color Palette - Enhanced */
|
||||
--gray-50: #f9fafb; /* Ultra light gray */
|
||||
--gray-100: #f3f4f6; /* Very light gray */
|
||||
--gray-200: #e5e7eb; /* Light gray */
|
||||
--gray-300: #d1d5db; /* Medium light gray */
|
||||
--gray-400: #9ca3af; /* Medium gray */
|
||||
--gray-500: #6b7280; /* Base gray */
|
||||
--gray-600: #4b5563; /* Medium dark gray */
|
||||
--gray-700: #374151; /* Dark gray */
|
||||
--gray-800: #1f2937; /* Very dark gray */
|
||||
--gray-900: #111827; /* Ultra dark gray */
|
||||
|
||||
/* Background Colors - Modern Hierarchy */
|
||||
--dark-color: #1e293b; /* Dark backgrounds */
|
||||
--light-color: #f8fafc; /* Light backgrounds */
|
||||
--body-bg: #ffffff; /* Main body background */
|
||||
--surface-color: #ffffff; /* Surface backgrounds */
|
||||
--surface-variant: #f8fafc; /* Variant surface backgrounds */
|
||||
--surface-hover: #f1f5f9; /* Hover surface backgrounds */
|
||||
--surface-pressed: #e2e8f0; /* Pressed surface backgrounds */
|
||||
|
||||
/* Border and Divider Colors - Refined */
|
||||
--border-color: #e2e8f0; /* Standard borders */
|
||||
--border-light: #f1f5f9; /* Light borders */
|
||||
--border-strong: #cbd5e1; /* Strong borders */
|
||||
--divider-color: #e2e8f0; /* Divider lines */
|
||||
|
||||
/* Text Colors - Improved Hierarchy */
|
||||
--text-primary: #1e293b; /* Primary text color */
|
||||
--text-secondary: #475569; /* Secondary text color */
|
||||
--text-tertiary: #64748b; /* Tertiary text color */
|
||||
--text-muted: #9ca3af; /* Muted text color */
|
||||
--text-on-primary: #ffffff; /* Text on primary backgrounds */
|
||||
--text-on-dark: #f8fafc; /* Text on dark backgrounds */
|
||||
|
||||
/* Component Backgrounds - Enhanced */
|
||||
--navbar-bg: rgba(255, 255, 255, 0.95); /* Glass navigation bar */
|
||||
--navbar-border: rgba(226, 232, 240, 0.6); /* Navigation border */
|
||||
--dropdown-bg: #ffffff; /* Dropdown menu background */
|
||||
--card-bg: #ffffff; /* Card background */
|
||||
--input-bg: #ffffff; /* Input background */
|
||||
--input-border: #d1d5db; /* Input border */
|
||||
--input-focus: #3b82f6; /* Input focus color */
|
||||
|
||||
/* Visual Effects - Modern Shadows and Gradients */
|
||||
--bg-gradient: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
--bg-gradient-subtle: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
--card-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
--card-shadow-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
--card-shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
--card-shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
--focus-ring: 0 0 0 3px rgba(59, 130, 246, 0.12);
|
||||
--focus-ring-danger: 0 0 0 3px rgba(239, 68, 68, 0.12);
|
||||
--focus-ring-success: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||
|
||||
/* Spacing and Layout - Refined System */
|
||||
--border-radius: 8px; /* Standard border radius */
|
||||
--border-radius-lg: 12px; /* Large border radius */
|
||||
--border-radius-sm: 6px; /* Small border radius */
|
||||
--border-radius-xs: 4px; /* Extra small border radius */
|
||||
--border-radius-full: 9999px; /* Full border radius (circular) */
|
||||
--section-spacing: 2.5rem; /* Section spacing */
|
||||
--card-spacing: 1.5rem; /* Card spacing */
|
||||
--mobile-section-spacing: 1.5rem; /* Mobile section spacing */
|
||||
--mobile-card-spacing: 1rem; /* Mobile card spacing */
|
||||
--navbar-height: 72px; /* Navigation bar height */
|
||||
--container-padding: 1.5rem; /* Container padding */
|
||||
--grid-gap: 1.5rem; /* Grid gap */
|
||||
--grid-gap-sm: 1rem; /* Small grid gap */
|
||||
|
||||
/* Transitions and Animations - Enhanced */
|
||||
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-fast: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-bounce: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
--animation-duration: 0.3s;
|
||||
--animation-timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Typography - Enhanced Scale */
|
||||
--font-family-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-family-mono: 'SF Mono', Monaco, Inconsolata, 'Roboto Mono', 'Source Code Pro', monospace;
|
||||
--font-weight-light: 300;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
--font-weight-extrabold: 800;
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.625;
|
||||
|
||||
/* Interactive States */
|
||||
--hover-opacity: 0.9;
|
||||
--active-opacity: 0.95;
|
||||
--disabled-opacity: 0.5;
|
||||
--loading-opacity: 0.6;
|
||||
|
||||
/* Z-Index Scale */
|
||||
--z-dropdown: 1000;
|
||||
--z-sticky: 1020;
|
||||
--z-fixed: 1030;
|
||||
--z-modal-backdrop: 1040;
|
||||
--z-modal: 1050;
|
||||
--z-popover: 1060;
|
||||
--z-tooltip: 1070;
|
||||
--z-toast: 1080;
|
||||
|
||||
/* Bootstrap Integration - Enhanced */
|
||||
--bs-body-bg: var(--body-bg);
|
||||
--bs-body-color: var(--text-primary);
|
||||
--bs-body-font-family: var(--font-family-sans);
|
||||
--bs-body-font-size: 0.95rem;
|
||||
--bs-body-line-height: var(--line-height-normal);
|
||||
--bs-card-bg: var(--card-bg);
|
||||
--bs-card-border-color: var(--border-color);
|
||||
--bs-card-border-radius: var(--border-radius);
|
||||
--bs-dropdown-bg: var(--dropdown-bg);
|
||||
--bs-dropdown-border-color: var(--border-color);
|
||||
--bs-dropdown-link-color: var(--text-secondary);
|
||||
--bs-dropdown-link-hover-bg: var(--surface-hover);
|
||||
--bs-dropdown-link-hover-color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== MODERN DARK THEME VARIABLES ===== */
|
||||
[data-theme="dark"] {
|
||||
/* Primary Color Palette - Enhanced for dark theme */
|
||||
--primary-color: #60a5fa; /* Lighter blue for better contrast */
|
||||
--primary-dark: #3b82f6; /* Standard blue for hover */
|
||||
--primary-light: #93c5fd; /* Light blue accent */
|
||||
--primary-50: #1e3a8a; /* Dark blue (reversed) */
|
||||
--primary-100: #1e40af; /* Very dark blue */
|
||||
--primary-200: #1d4ed8; /* Dark blue */
|
||||
--primary-300: #2563eb; /* Medium dark blue */
|
||||
--primary-400: #3b82f6; /* Medium blue */
|
||||
--primary-500: #60a5fa; /* Base blue (lighter for dark) */
|
||||
--primary-600: #93c5fd; /* Medium light blue */
|
||||
--primary-700: #bfdbfe; /* Light blue */
|
||||
--primary-800: #dbeafe; /* Very light blue */
|
||||
--primary-900: #eff6ff; /* Ultra light blue */
|
||||
|
||||
/* Semantic Color System - Dark theme */
|
||||
--secondary-color: #94a3b8; /* Lighter gray for visibility */
|
||||
--success-color: #34d399; /* Brighter green for dark theme */
|
||||
--success-light: #064e3b; /* Dark success background */
|
||||
--danger-color: #f87171; /* Brighter red for dark theme */
|
||||
--danger-light: #7f1d1d; /* Dark danger background */
|
||||
--warning-color: #fbbf24; /* Brighter orange for dark theme */
|
||||
--warning-light: #78350f; /* Dark warning background */
|
||||
--info-color: #38bdf8; /* Brighter cyan for dark theme */
|
||||
--info-light: #164e63; /* Dark info background */
|
||||
|
||||
/* Neutral Color Palette - Dark theme (reversed) */
|
||||
--gray-50: #0f172a; /* Dark gray (reversed) */
|
||||
--gray-100: #1e293b; /* Very dark gray */
|
||||
--gray-200: #334155; /* Dark gray */
|
||||
--gray-300: #475569; /* Medium dark gray */
|
||||
--gray-400: #64748b; /* Medium gray */
|
||||
--gray-500: #94a3b8; /* Base gray (lighter for dark) */
|
||||
--gray-600: #cbd5e1; /* Medium light gray */
|
||||
--gray-700: #e2e8f0; /* Light gray */
|
||||
--gray-800: #f1f5f9; /* Very light gray */
|
||||
--gray-900: #f8fafc; /* Ultra light gray */
|
||||
|
||||
/* Background Colors - Dark theme specific */
|
||||
--dark-color: #0f172a; /* Very dark blue-gray */
|
||||
--light-color: #1e293b; /* Dark blue-gray */
|
||||
--body-bg: #0b1220; /* Main dark background */
|
||||
--surface-color: #0f172a; /* Surface backgrounds */
|
||||
--surface-variant: #1e293b; /* Variant surface backgrounds */
|
||||
--surface-hover: #334155; /* Hover surface backgrounds */
|
||||
--surface-pressed: #475569; /* Pressed surface backgrounds */
|
||||
|
||||
/* Border and Divider Colors - Dark theme */
|
||||
--border-color: #334155; /* Dark borders with better contrast */
|
||||
--border-light: #1e293b; /* Light borders for dark theme */
|
||||
--border-strong: #475569; /* Strong borders for dark theme */
|
||||
--divider-color: #334155; /* Divider lines */
|
||||
|
||||
/* Text Colors - Dark theme */
|
||||
--text-primary: #f1f5f9; /* Light gray for primary text */
|
||||
--text-secondary: #cbd5e1; /* Medium gray for secondary text */
|
||||
--text-tertiary: #94a3b8; /* Tertiary text color */
|
||||
--text-muted: #64748b; /* Muted gray for less important text */
|
||||
--text-on-primary: #ffffff; /* Text on primary backgrounds */
|
||||
--text-on-dark: #0f172a; /* Text on light backgrounds in dark theme */
|
||||
|
||||
/* Component Backgrounds - Dark theme */
|
||||
--navbar-bg: rgba(11, 18, 32, 0.95); /* Glass navigation bar for dark */
|
||||
--navbar-border: rgba(51, 65, 85, 0.6); /* Navigation border for dark */
|
||||
--dropdown-bg: #0f172a; /* Dark dropdown */
|
||||
--card-bg: #0f172a; /* Dark cards */
|
||||
--input-bg: #1e293b; /* Input background for dark */
|
||||
--input-border: #475569; /* Input border for dark */
|
||||
--input-focus: #60a5fa; /* Input focus color for dark */
|
||||
|
||||
/* Visual Effects - Dark theme */
|
||||
--bg-gradient: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 100%);
|
||||
--bg-gradient-subtle: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||||
--card-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px -1px rgba(0, 0, 0, 0.4);
|
||||
--card-shadow-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
|
||||
--card-shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.4);
|
||||
--card-shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
|
||||
--focus-ring: 0 0 0 3px rgba(96, 165, 250, 0.2);
|
||||
--focus-ring-danger: 0 0 0 3px rgba(248, 113, 113, 0.2);
|
||||
--focus-ring-success: 0 0 0 3px rgba(52, 211, 153, 0.2);
|
||||
|
||||
/* Bootstrap Integration - Dark theme */
|
||||
--bs-body-bg: var(--body-bg);
|
||||
--bs-body-color: var(--text-primary);
|
||||
--bs-body-font-family: var(--font-family-sans);
|
||||
--bs-card-bg: var(--card-bg);
|
||||
--bs-card-border-color: var(--border-color);
|
||||
--bs-dropdown-bg: var(--dropdown-bg);
|
||||
--bs-dropdown-border-color: var(--border-color);
|
||||
--bs-dropdown-link-color: var(--text-secondary);
|
||||
--bs-dropdown-link-hover-bg: var(--surface-hover);
|
||||
--bs-dropdown-link-hover-color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== MOBILE-SPECIFIC VARIABLES ===== */
|
||||
:root {
|
||||
/* Mobile Touch Targets and Spacing */
|
||||
--mobile-touch-target: 52px; /* Minimum touch target size */
|
||||
--mobile-nav-height: 70px; /* Mobile navigation height */
|
||||
--mobile-tabbar-height: 64px; /* Bottom tab bar height */
|
||||
--mobile-card-padding: 1.25rem; /* Mobile card padding */
|
||||
--mobile-button-height: 52px; /* Mobile button height */
|
||||
--mobile-input-height: 56px; /* Mobile input height */
|
||||
--mobile-border-radius: 4px; /* Mobile border radius */
|
||||
--mobile-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
--mobile-shadow-hover: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* ===== ENHANCED USAGE EXAMPLES ===== */
|
||||
|
||||
/* Example 1: Modern Themed Card with Glass Effect */
|
||||
/*
|
||||
.modern-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
color: var(--text-primary);
|
||||
transition: var(--transition-slow);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modern-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--primary-color), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.modern-card:hover {
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
border-color: var(--primary-200);
|
||||
}
|
||||
|
||||
.modern-card:hover::before {
|
||||
opacity: 0.8;
|
||||
}
|
||||
*/
|
||||
|
||||
/* Example 2: Enhanced Primary Button with Gradient */
|
||||
/*
|
||||
.btn-modern-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-600) 100%);
|
||||
border: 1px solid var(--primary-color);
|
||||
color: var(--text-on-primary);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.875rem 1.5rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: var(--transition-slow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn-modern-primary::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.btn-modern-primary:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.btn-modern-primary:hover {
|
||||
background: linear-gradient(135deg, var(--primary-600) 0%, var(--primary-700) 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
*/
|
||||
|
||||
/* Example 3: Modern Status Badge System */
|
||||
/*
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: var(--border-radius-full);
|
||||
font-size: 0.8rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
letter-spacing: 0.025em;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.status-badge--success {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--success-color);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.status-badge--warning {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--warning-color);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.status-badge--danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger-color);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
*/
|
||||
|
||||
/* Example 4: Responsive Grid with Modern Spacing */
|
||||
/*
|
||||
.modern-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: var(--grid-gap);
|
||||
margin-bottom: var(--section-spacing);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modern-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--grid-gap-sm);
|
||||
margin-bottom: var(--mobile-section-spacing);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/* Example 5: Modern Form Styling */
|
||||
/*
|
||||
.modern-form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modern-form-label {
|
||||
display: block;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.modern-form-control {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
transition: var(--transition);
|
||||
font-family: var(--font-family-sans);
|
||||
}
|
||||
|
||||
.modern-form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-focus);
|
||||
box-shadow: var(--focus-ring);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
*/
|
||||
|
||||
/* ===== COMPREHENSIVE THEME CUSTOMIZATION GUIDE ===== */
|
||||
/*
|
||||
MODERN TIMETRACKER THEME CUSTOMIZATION GUIDE
|
||||
============================================
|
||||
|
||||
1. COLOR SYSTEM CUSTOMIZATION:
|
||||
- Primary colors: Modify --primary-* variables for brand colors
|
||||
- Semantic colors: Adjust --success-*, --danger-*, --warning-*, --info-* for status colors
|
||||
- Neutral palette: Update --gray-* variables for consistent grayscale
|
||||
- Always maintain both light and dark theme variants
|
||||
|
||||
2. SPACING SYSTEM:
|
||||
- Layout spacing: --section-spacing, --card-spacing for consistent layouts
|
||||
- Component spacing: --grid-gap, --container-padding for internal spacing
|
||||
- Mobile spacing: --mobile-* variables for responsive design
|
||||
- Border radius: --border-radius-* for consistent corner styles
|
||||
|
||||
3. TYPOGRAPHY SYSTEM:
|
||||
- Font families: --font-family-sans, --font-family-mono
|
||||
- Font weights: --font-weight-* for consistent typography hierarchy
|
||||
- Line heights: --line-height-* for optimal readability
|
||||
|
||||
4. VISUAL EFFECTS:
|
||||
- Shadows: --card-shadow-* for depth and elevation
|
||||
- Gradients: --bg-gradient-* for modern visual appeal
|
||||
- Focus rings: --focus-ring-* for accessibility
|
||||
- Transitions: --transition-* for smooth interactions
|
||||
|
||||
5. COMPONENT CUSTOMIZATION:
|
||||
- Navigation: --navbar-* variables for header styling
|
||||
- Cards: --card-* variables for content containers
|
||||
- Forms: --input-* variables for form elements
|
||||
- Interactive states: --hover-*, --active-* for user feedback
|
||||
|
||||
6. MOBILE OPTIMIZATION:
|
||||
- Touch targets: --mobile-touch-target for accessibility
|
||||
- Responsive spacing: --mobile-* variables for smaller screens
|
||||
- Navigation: --mobile-nav-height, --mobile-tabbar-height
|
||||
|
||||
7. ACCESSIBILITY CONSIDERATIONS:
|
||||
- Maintain WCAG contrast ratios (4.5:1 for normal text, 3:1 for large text)
|
||||
- Ensure focus indicators are visible and consistent
|
||||
- Test with screen readers and keyboard navigation
|
||||
- Verify color-blind friendly combinations
|
||||
|
||||
8. DARK THEME BEST PRACTICES:
|
||||
- Increase contrast for better readability
|
||||
- Use appropriate shadow intensities
|
||||
- Ensure interactive elements remain discoverable
|
||||
- Test in various lighting conditions
|
||||
|
||||
9. CUSTOMIZATION WORKFLOW:
|
||||
- Start with color palette modifications
|
||||
- Test in both light and dark themes
|
||||
- Verify mobile responsiveness
|
||||
- Check accessibility compliance
|
||||
- Document any custom additions
|
||||
|
||||
10. INTEGRATION WITH BOOTSTRAP:
|
||||
- Use --bs-* variables for Bootstrap integration
|
||||
- Override Bootstrap defaults through CSS variables
|
||||
- Maintain consistency with custom components
|
||||
|
||||
EXAMPLE CUSTOM COLOR SCHEME:
|
||||
---------------------------
|
||||
For a green-themed variant, replace blue primary colors:
|
||||
--primary-color: #10b981;
|
||||
--primary-dark: #059669;
|
||||
--primary-light: #6ee7b7;
|
||||
|
||||
And adjust the full color scale accordingly in both themes.
|
||||
|
||||
Remember to test all changes thoroughly across different devices,
|
||||
browsers, and accessibility tools before deployment.
|
||||
*/
|
||||
@@ -1,317 +1,107 @@
|
||||
/* Toast Notification System - Professional Design */
|
||||
/* Toast Notifications - styled to match TimeTracker UI (light + dark) */
|
||||
|
||||
/* Container for all toasts - positioned at bottom-right */
|
||||
#toast-notification-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 12px;
|
||||
max-width: 420px;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
z-index: 9999;
|
||||
pointer-events: none; /* clicks pass through except inside toasts */
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 576px) {
|
||||
#toast-notification-container {
|
||||
bottom: 80px; /* Above mobile tab bar */
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
max-width: none;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
#toast-notification-container {
|
||||
top: auto;
|
||||
right: 0.75rem;
|
||||
left: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Individual toast notification */
|
||||
.toast-notification {
|
||||
pointer-events: all;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: 64px;
|
||||
max-width: 100%;
|
||||
animation: toast-slide-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-origin: bottom right;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
width: 24rem;
|
||||
max-width: 90vw;
|
||||
background: #FFFFFF; /* card-light */
|
||||
color: #2D3748; /* text-light */
|
||||
border: 1px solid #E2E8F0; /* border-light */
|
||||
border-left-width: 4px;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 0.75rem;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.12), 0 3px 6px rgba(0,0,0,0.08);
|
||||
opacity: 0;
|
||||
transform: translateX(8px) scale(0.98);
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
pointer-events: auto; /* clickable */
|
||||
}
|
||||
|
||||
.dark .toast-notification {
|
||||
background: #2D3748; /* card-dark */
|
||||
color: #E2E8F0; /* text-dark */
|
||||
border-color: #4A5568; /* border-dark */
|
||||
}
|
||||
|
||||
.toast-notification.hiding {
|
||||
animation: toast-slide-out 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
opacity: 0;
|
||||
transform: translateX(120%) scale(0.8);
|
||||
opacity: 0;
|
||||
transform: translateX(8px) scale(0.98);
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
[data-theme="dark"] .toast-notification {
|
||||
background: #1e293b;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.3),
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Accent bar on the left */
|
||||
.toast-notification::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.toast-notification:hover::before {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
/* Type-based accent colors */
|
||||
.toast-notification.toast-success::before {
|
||||
background: linear-gradient(to bottom, #10b981, #059669);
|
||||
}
|
||||
|
||||
.toast-notification.toast-error::before {
|
||||
background: linear-gradient(to bottom, #ef4444, #dc2626);
|
||||
}
|
||||
|
||||
.toast-notification.toast-warning::before {
|
||||
background: linear-gradient(to bottom, #f59e0b, #d97706);
|
||||
}
|
||||
|
||||
.toast-notification.toast-info::before {
|
||||
background: linear-gradient(to bottom, #3b82f6, #2563eb);
|
||||
}
|
||||
|
||||
/* Icon area */
|
||||
.toast-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.toast-notification.toast-success .toast-icon {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.toast-notification.toast-error .toast-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.toast-notification.toast-warning .toast-icon {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.toast-notification.toast-info .toast-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .toast-notification.toast-success .toast-icon {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .toast-notification.toast-error .toast-icon {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .toast-notification.toast-warning .toast-icon {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .toast-notification.toast-info .toast-icon {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.toast-content {
|
||||
flex: 1;
|
||||
padding: 12px 8px 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
margin: 0 0 4px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .toast-title {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .toast-message {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
/* Icons */
|
||||
.toast-icon { line-height: 1; font-size: 1.125rem; padding-top: 0.125rem; }
|
||||
.toast-title { font-weight: 600; margin-bottom: 0.125rem; }
|
||||
.toast-message { font-size: 0.9375rem; }
|
||||
|
||||
/* Close button */
|
||||
.toast-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: #475569;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.toast-close:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .toast-close {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .toast-close:hover {
|
||||
color: #cbd5e1;
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toast-close:hover { opacity: 1; }
|
||||
|
||||
/* Progress bar */
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
border-bottom-left-radius: 0.5rem;
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .toast-progress {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.toast-progress-bar {
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
transform-origin: left;
|
||||
animation: toast-progress-shrink linear;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
animation-name: toast-progress-shrink;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.toast-notification.toast-success .toast-progress-bar {
|
||||
color: #10b981;
|
||||
}
|
||||
@keyframes toast-progress-shrink { from { width: 100%; } to { width: 0%; } }
|
||||
|
||||
.toast-notification.toast-error .toast-progress-bar {
|
||||
color: #ef4444;
|
||||
}
|
||||
/* Variants */
|
||||
.toast-notification.toast-success { border-left-color: #10B981; }
|
||||
.toast-notification.toast-error { border-left-color: #EF4444; }
|
||||
.toast-notification.toast-warning { border-left-color: #F59E0B; }
|
||||
.toast-notification.toast-info { border-left-color: #3B82F6; }
|
||||
|
||||
.toast-notification.toast-warning .toast-progress-bar {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.toast-notification.toast-success .toast-icon { color: #10B981; }
|
||||
.toast-notification.toast-error .toast-icon { color: #EF4444; }
|
||||
.toast-notification.toast-warning .toast-icon { color: #F59E0B; }
|
||||
.toast-notification.toast-info .toast-icon { color: #3B82F6; }
|
||||
|
||||
.toast-notification.toast-info .toast-progress-bar {
|
||||
color: #3b82f6;
|
||||
}
|
||||
.toast-notification.toast-success .toast-progress-bar { background: #10B981; }
|
||||
.toast-notification.toast-error .toast-progress-bar { background: #EF4444; }
|
||||
.toast-notification.toast-warning .toast-progress-bar { background: #F59E0B; }
|
||||
.toast-notification.toast-info .toast-progress-bar { background: #3B82F6; }
|
||||
|
||||
/* Animations */
|
||||
@keyframes toast-slide-in {
|
||||
from {
|
||||
transform: translateX(120%) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-slide-out {
|
||||
from {
|
||||
transform: translateX(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(120%) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-progress-shrink {
|
||||
from {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
to {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover to pause */
|
||||
.toast-notification:hover .toast-progress-bar {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
|
||||
/* Stacking behavior - limit to 5 visible toasts */
|
||||
.toast-notification:nth-child(n+6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Subtle scaling for stacked toasts */
|
||||
.toast-notification:nth-child(2) {
|
||||
transform: scale(0.98) translateY(2px);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.toast-notification:nth-child(3) {
|
||||
transform: scale(0.96) translateY(4px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
.toast-notification:focus-within {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast-notification {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.toast-notification.hiding {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.toast-progress-bar {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print - hide toasts */
|
||||
@media print {
|
||||
#toast-notification-container {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ class ToastNotificationManager {
|
||||
closeBtn.className = 'toast-close';
|
||||
closeBtn.setAttribute('type', 'button');
|
||||
closeBtn.setAttribute('aria-label', 'Close notification');
|
||||
closeBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||
closeBtn.innerHTML = '<i class="fas fa-xmark"></i>';
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/* Shared UI utilities and extracted page styles */
|
||||
|
||||
/* Skip link accessibility */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -999px;
|
||||
top: 0;
|
||||
background: var(--primary-color);
|
||||
color: var(--text-on-primary);
|
||||
padding: 8px 12px;
|
||||
z-index: var(--z-fixed);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
.skip-link:focus {
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Mobile floating action button (Log Time) */
|
||||
.fab-log-time {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: calc(var(--mobile-tabbar-height, 64px) + 16px);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
color: var(--text-on-primary) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.15);
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
z-index: var(--z-fixed);
|
||||
}
|
||||
.fab-log-time:hover { transform: translateY(-2px); box-shadow: 0 14px 24px rgba(0,0,0,0.2); }
|
||||
|
||||
/* Invoices: extracted styles */
|
||||
.invoice-number-badge {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.payment-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.payment-status-info { min-width: 80px; }
|
||||
.payment-progress { width: 60px; }
|
||||
.payment-date { margin-top: 2px; }
|
||||
|
||||
/* Projects: extracted styles */
|
||||
.project-badge {
|
||||
background: var(--surface-variant);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Projects: table sorting indicators */
|
||||
thead th.sorted-asc::after { content: " \25B2"; color: var(--primary-color); font-weight: bold; }
|
||||
thead th.sorted-desc::after { content: " \25BC"; color: var(--primary-color); font-weight: bold; }
|
||||
|
||||
/* Detail sections (used on project view) */
|
||||
.invoice-section { padding: 12px 0; }
|
||||
.section-title { font-size: 16px; font-weight: 600; border-bottom: 2px solid var(--primary-color); padding-bottom: 8px; }
|
||||
.detail-row { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; padding:8px 0; border-bottom:1px solid var(--border-color); }
|
||||
.detail-label { font-weight:600; color:var(--text-secondary); }
|
||||
.detail-value { font-weight:600; color:var(--text-primary); }
|
||||
.content-box { background: var(--light-color); padding: 16px; border-radius: 8px; border-left: 4px solid var(--primary-color); line-height: 1.6; }
|
||||
@media (max-width:768px){ .detail-row{flex-direction:column; align-items:flex-start; gap:4px;} }
|
||||
|
||||
|
||||
55
app/templates/admin/dashboard.html
Normal file
55
app/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/cards.html" import info_card %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Admin Dashboard</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">System overview and management.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
{{ info_card("Total Users", stats.total_users, "All time") }}
|
||||
{{ info_card("Active Users", stats.active_users, "Currently active") }}
|
||||
{{ info_card("Total Projects", stats.total_projects, "All time") }}
|
||||
{{ info_card("Active Projects", stats.active_projects, "Currently active") }}
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Admin Sections</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a href="{{ url_for('admin.list_users') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">Manage Users</a>
|
||||
<a href="{{ url_for('admin.settings') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">Settings</a>
|
||||
<a href="{{ url_for('admin.system_info') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">System Info</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
|
||||
<h2 class="text-lg font-semibold mb-4">Recent Activity</h2>
|
||||
<table class="w-full text-left">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-4">User</th>
|
||||
<th class="p-4">Project</th>
|
||||
<th class="p-4">Duration</th>
|
||||
<th class="p-4">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in recent_entries %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-4">{{ entry.user.username }}</td>
|
||||
<td class="p-4">{{ entry.project.name }}</td>
|
||||
<td class="p-4">{{ entry.duration }}</td>
|
||||
<td class="p-4">{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">No recent activity.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
56
app/templates/admin/settings.html
Normal file
56
app/templates/admin/settings.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Settings</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Configure system-wide application settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- General Settings -->
|
||||
<div class="border-b border-border-light dark:border-border-dark pb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">General</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="timezone" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Timezone</label>
|
||||
<input type="text" name="timezone" id="timezone" value="{{ settings.timezone }}" required class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="currency" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Currency</label>
|
||||
<input type="text" name="currency" id="currency" value="{{ settings.currency }}" required class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timer Settings -->
|
||||
<div class="border-b border-border-light dark:border-border-dark pb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Timers</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="rounding_minutes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Rounding (Minutes)</label>
|
||||
<input type="number" name="rounding_minutes" id="rounding_minutes" value="{{ settings.rounding_minutes }}" required class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="idle_timeout_minutes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Idle Timeout (Minutes)</label>
|
||||
<input type="number" name="idle_timeout_minutes" id="idle_timeout_minutes" value="{{ settings.idle_timeout_minutes }}" required class="form-input">
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-center">
|
||||
<input type="checkbox" name="single_active_timer" id="single_active_timer" {% if settings.single_active_timer %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<label for="single_active_timer" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">Allow only one active timer per user</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 flex justify-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
19
app/templates/admin/system_info.html
Normal file
19
app/templates/admin/system_info.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/cards.html" import info_card %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">System Information</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Key metrics and statistics about the application.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{{ info_card("Total Users", total_users, "All time") }}
|
||||
{{ info_card("Total Projects", total_projects, "All time") }}
|
||||
{{ info_card("Total Time Entries", total_entries, "All time") }}
|
||||
{{ info_card("Active Timers", active_timers, "Currently running") }}
|
||||
{{ info_card("Database Size (MB)", db_size_mb, "Current size") }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
41
app/templates/admin/user_form.html
Normal file
41
app/templates/admin/user_form.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ 'Edit User' if user else 'Create User' }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ 'Update the details for %s.'|format(user.username) if user else 'Create a new user account.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Username</label>
|
||||
<input type="text" name="username" id="username" value="{{ user.username if user else '' }}" required class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="role" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Role</label>
|
||||
<select name="role" id="role" class="form-input">
|
||||
<option value="user" {% if user and user.role == 'user' %}selected{% endif %}>User</option>
|
||||
<option value="admin" {% if user and user.role == 'admin' %}selected{% endif %}>Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
{% if user %}
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="is_active" id="is_active" {% if user.is_active %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">Active</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-8 border-t border-border-light dark:border-border-dark pt-6 flex justify-end">
|
||||
<a href="{{ url_for('admin.list_users') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mr-4">Cancel</a>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
44
app/templates/admin/users.html
Normal file
44
app/templates/admin/users.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Manage Users</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Add, edit, or remove user accounts.</p>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.create_user') }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Create User</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-4">Username</th>
|
||||
<th class="p-4">Role</th>
|
||||
<th class="p-4">Status</th>
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-4">{{ user.username }}</td>
|
||||
<td class="p-4">{{ user.role | capitalize }}</td>
|
||||
<td class="p-4">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ 'bg-green-100 text-green-800' if user.is_active else 'bg-red-100 text-red-800' }}">
|
||||
{{ 'Active' if user.is_active else 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}" class="text-primary hover:underline">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">No users found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,61 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Edit Profile') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% from "_components.html" import page_header %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% set actions %}
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Back to Profile') }}
|
||||
</a>
|
||||
{% endset %}
|
||||
{{ page_header('fas fa-user-cog', _('Edit Profile'), _('Update your personal information'), actions) }}
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Edit Profile</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Update your personal information.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="fas fa-id-card me-2"></i> {{ _('Profile Details') }}</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ _('Username') }}</label>
|
||||
<input type="text" class="form-control" value="{{ current_user.username }}" disabled>
|
||||
<div class="form-text">{{ _('Usernames cannot be changed.') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ _('Full name') }}</label>
|
||||
<input type="text" name="full_name" class="form-control" value="{{ current_user.full_name or '' }}" placeholder="{{ _('Enter your real name') }}">
|
||||
<div class="form-text">{{ _('Shown in tasks and reports when provided.') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ _('Language') }}</label>
|
||||
<select name="preferred_language" class="form-select">
|
||||
{% for code, label in config['LANGUAGES'].items() %}
|
||||
<option value="{{ code }}" {% if (current_user.preferred_language or current_language_code) == code %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">{{ _('Choose your interface language.') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ _('Role') }}</label>
|
||||
<input type="text" class="form-control" value="{{ current_user.role|capitalize }}" disabled>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-1"></i>{{ _('Save Changes') }}
|
||||
</button>
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-primary ms-2">{{ _('Cancel') }}</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Username</label>
|
||||
<input type="text" name="username" id="username" value="{{ current_user.username }}" disabled class="form-input bg-gray-100 dark:bg-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Full Name</label>
|
||||
<input type="text" name="full_name" id="full_name" value="{{ current_user.full_name or '' }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="preferred_language" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Language</label>
|
||||
<select name="preferred_language" id="preferred_language" class="form-input">
|
||||
{% for code, label in config['LANGUAGES'].items() %}
|
||||
<option value="{{ code }}" {% if (current_user.preferred_language or current_language_code) == code %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">New Password</label>
|
||||
<input type="password" name="password" id="password" class="form-input" placeholder="Leave blank to keep current password">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password_confirm" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm New Password</label>
|
||||
<input type="password" name="password_confirm" id="password_confirm" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 border-t border-border-light dark:border-border-dark pt-6 flex justify-end">
|
||||
<a href="{{ url_for('auth.profile') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mr-4">Cancel</a>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,800 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ current_locale or 'en' }}">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#3b82f6" id="meta-theme-color">
|
||||
<title>{% block title %}{{ _('Login') }} - {{ app_name }}{% endblock %}</title>
|
||||
{% if csrf_token %}
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
{% endif %}
|
||||
|
||||
<!-- Favicon -->
|
||||
{% if settings and settings.has_logo() %}
|
||||
<link rel="icon" type="image/x-icon" href="{{ settings.get_logo_url() }}">
|
||||
{% else %}
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/drytrix-logo.svg') }}">
|
||||
{% endif %}
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Font Awesome -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<!-- Google Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<!-- Toast Notifications -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='toast-notifications.css') }}?v={{ app_version }}">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #3b82f6;
|
||||
--primary-dark: #2563eb;
|
||||
--primary-light: #60a5fa;
|
||||
--primary-50: #eff6ff;
|
||||
--primary-100: #dbeafe;
|
||||
--gray-50: #f9fafb;
|
||||
--gray-100: #f3f4f6;
|
||||
--gray-200: #e5e7eb;
|
||||
--gray-300: #d1d5db;
|
||||
--gray-400: #9ca3af;
|
||||
--gray-500: #6b7280;
|
||||
--gray-600: #4b5563;
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1f2937;
|
||||
--gray-900: #111827;
|
||||
--success-color: #10b981;
|
||||
--danger-color: #ef4444;
|
||||
--warning-color: #f59e0b;
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
--border-radius: 12px;
|
||||
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
<title>{{ _('Login') }} - {{ app_name }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='dist/output.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='toast-notifications.css') }}">
|
||||
<script>
|
||||
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
|
||||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 50%, #1e40af 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Animated background particles */
|
||||
.bg-particles {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
animation: float linear infinite;
|
||||
}
|
||||
|
||||
.particle:nth-child(1) { width: 80px; height: 80px; left: 10%; animation-duration: 25s; animation-delay: 0s; }
|
||||
.particle:nth-child(2) { width: 60px; height: 60px; left: 25%; animation-duration: 30s; animation-delay: 2s; }
|
||||
.particle:nth-child(3) { width: 100px; height: 100px; left: 50%; animation-duration: 35s; animation-delay: 4s; }
|
||||
.particle:nth-child(4) { width: 50px; height: 50px; left: 70%; animation-duration: 28s; animation-delay: 1s; }
|
||||
.particle:nth-child(5) { width: 70px; height: 70px; left: 85%; animation-duration: 32s; animation-delay: 3s; }
|
||||
|
||||
@keyframes float {
|
||||
0% {
|
||||
transform: translateY(100vh) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
90% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-100px) rotate(360deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.login-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow-xl), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
padding: 3rem 2.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.login-card {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 1.5rem;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-lg), 0 0 20px rgba(59, 130, 246, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: logoFloat 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes logoFloat {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
.brand-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 800;
|
||||
color: var(--gray-900);
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
color: var(--gray-600);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
text-align: center;
|
||||
color: var(--gray-600);
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.925rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--gray-400);
|
||||
font-size: 1.125rem;
|
||||
z-index: 2;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem 0.875rem 3rem;
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.95rem;
|
||||
transition: var(--transition);
|
||||
background: var(--gray-50);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
background: white;
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-control:focus ~ .input-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
transition: var(--transition);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md), 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg), 0 8px 20px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: white;
|
||||
color: var(--gray-700);
|
||||
border: 2px solid var(--gray-300);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--gray-50);
|
||||
border-color: var(--gray-400);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 1.75rem 0;
|
||||
color: var(--gray-500);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--gray-300);
|
||||
}
|
||||
|
||||
.divider span {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.info-banner {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.info-banner i {
|
||||
color: var(--primary-color);
|
||||
font-size: 1.125rem;
|
||||
margin-top: 0.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-banner-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-banner-title {
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.info-banner-text {
|
||||
color: var(--gray-700);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.feature-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
text-align: center;
|
||||
margin-top: 1.75rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
color: var(--gray-600);
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: var(--transition);
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: var(--primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 576px) {
|
||||
.brand-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility improvements */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Focus visible for keyboard navigation */
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Language Selector */
|
||||
.language-selector {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.language-selector {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.language-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--gray-700);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: var(--transition);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.language-btn:hover {
|
||||
background: white;
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.language-btn i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.language-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-xl);
|
||||
min-width: 160px;
|
||||
padding: 0.5rem 0;
|
||||
display: none;
|
||||
animation: slideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.language-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.language-menu-header {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.language-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.625rem 1rem;
|
||||
color: var(--gray-700);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.language-menu-item:hover {
|
||||
background: var(--gray-50);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.language-menu-item.active {
|
||||
background: var(--primary-50);
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.language-menu-item i {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Toast adjustments for login page (no mobile tab bar) */
|
||||
@media (max-width: 576px) {
|
||||
#toast-notification-container {
|
||||
bottom: 24px !important; /* Override mobile tab bar offset */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Toast Notification Container -->
|
||||
<div id="toast-notification-container"></div>
|
||||
|
||||
<!-- Animated background particles -->
|
||||
<div class="bg-particles">
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
</div>
|
||||
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<!-- Language Selector -->
|
||||
<div class="language-selector">
|
||||
<div class="language-dropdown">
|
||||
<button type="button" class="language-btn" id="languageBtn" aria-label="{{ _('Language') }}" aria-expanded="false">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span>{{ current_language_label }}</span>
|
||||
<i class="fas fa-chevron-down" style="font-size: 0.75rem;"></i>
|
||||
</button>
|
||||
<div class="language-menu" id="languageMenu">
|
||||
<div class="language-menu-header">{{ _('Language') }}</div>
|
||||
{% for code, label in config['LANGUAGES'].items() %}
|
||||
<a href="{{ url_for('main.set_language') }}?lang={{ code }}&next={{ request.url }}"
|
||||
class="language-menu-item {% if current_language_code == code %}active{% endif %}"
|
||||
{% if current_language_code == code %}aria-current="true"{% endif %}>
|
||||
{% if current_language_code == code %}<i class="fas fa-check"></i>{% endif %}{{ label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<body class="bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark">
|
||||
<div class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 gap-0 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg overflow-hidden">
|
||||
<div class="hidden md:flex items-center justify-center p-10 bg-background-light dark:bg-background-dark">
|
||||
<div class="text-center">
|
||||
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="logo" class="w-24 h-24 mx-auto">
|
||||
<h1 class="text-3xl font-bold mt-4 text-primary">TimeTracker</h1>
|
||||
<p class="mt-2 text-text-muted-light dark:text-text-muted-dark">{{ _('Track time. Stay organized.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages (converted to toasts by JavaScript) -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div id="flash-messages-container" style="display: none;">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }}" role="alert" data-toast-message="{{ message }}" data-toast-type="{{ 'error' if category == 'error' else category }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="p-8">
|
||||
<h2 class="text-2xl font-bold tracking-tight">{{ _('Sign in to your account') }}</h2>
|
||||
<form class="mt-6" method="POST" action="{{ url_for('auth.login') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label for="username" class="block mb-2 text-sm font-medium">{{ _('Username') }}</label>
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-user absolute left-3 top-1/2 -translate-y-1/2 text-text-muted-light dark:text-text-muted-dark"></i>
|
||||
<input type="text" name="username" id="username" autocomplete="username" class="w-full pl-10 bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg px-3 py-2 text-text-light dark:text-text-dark placeholder-text-muted-light dark:placeholder-text-muted-dark focus:outline-none focus:ring-2 focus:ring-primary" placeholder="your-username" required>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Brand Header -->
|
||||
<div class="brand-header">
|
||||
<div class="brand-logo">
|
||||
{% if settings and settings.has_logo() %}
|
||||
<img src="{{ settings.get_logo_url() }}" alt="{{ _('Company Logo') }}">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="{{ _('DryTrix Logo') }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="brand-title">{{ _('TimeTracker') }}</h1>
|
||||
<p class="brand-subtitle">{{ _('Professional Time Management') }}</p>
|
||||
</div>
|
||||
|
||||
<p class="welcome-text">
|
||||
{{ _('Sign in to your account to start tracking your time') }}
|
||||
</p>
|
||||
|
||||
{% set auth_method = (config.get('AUTH_METHOD') or 'local') | lower %}
|
||||
|
||||
<!-- Local Login Form -->
|
||||
{% if auth_method != 'oidc' %}
|
||||
<form method="POST" action="{{ url_for('auth.login') }}" autocomplete="on" novalidate id="loginForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">{{ _('Username') }}</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="{{ _('Enter your username') }}"
|
||||
required
|
||||
autofocus
|
||||
aria-required="true">
|
||||
<i class="fas fa-user input-icon"></i>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full mt-6">{{ _('Sign in') }}</button>
|
||||
|
||||
{% if allow_self_register %}
|
||||
<p class="mt-3 text-xs text-text-muted-light dark:text-text-muted-dark text-center">{{ _('Tip: Enter a new username to create your account.') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if auth_method and auth_method|string|lower == 'both' %}
|
||||
<div class="relative my-6">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-border-light dark:border-border-dark"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-card-light dark:bg-card-dark text-text-muted-light dark:text-text-muted-dark">{{ _('Or continue with') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
<span id="btnText">{{ _('Sign In') }}</span>
|
||||
</button>
|
||||
<a href="{{ url_for('auth.login_oidc') }}" class="btn btn-secondary w-full">
|
||||
<i class="fa-solid fa-right-to-bracket"></i>
|
||||
{{ _('Single Sign-On') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<!-- Divider -->
|
||||
{% if auth_method == 'both' %}
|
||||
<div class="divider">
|
||||
<span>{{ _('or') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- SSO Login -->
|
||||
{% if auth_method in ['oidc', 'both'] %}
|
||||
<a class="btn btn-outline" href="{{ url_for('auth.login_oidc', next=request.args.get('next')) }}">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<span>{{ _('Sign in with SSO') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Info Banner -->
|
||||
<div class="info-banner">
|
||||
<i class="fas fa-shield-check"></i>
|
||||
<div class="info-banner-content">
|
||||
<div class="info-banner-title">{{ _('Internal Tool') }}</div>
|
||||
<div class="info-banner-text">
|
||||
{{ _('This is a private time tracking application for internal use only.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Self-Registration Badge -->
|
||||
{% if settings and settings.allow_self_register %}
|
||||
<div style="text-align: center;">
|
||||
<span class="feature-badge">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
{{ _('New users will be created automatically') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Footer Links -->
|
||||
<div class="footer-links">
|
||||
<div class="footer-text">
|
||||
{{ _('Version') }} {{ app_version }} • {{ _('Powered by') }} <strong>DryTrix</strong>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('main.about') }}">{{ _('About') }}</a>
|
||||
<a href="{{ url_for('main.help') }}">{{ _('Help') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Toast Notifications -->
|
||||
<script src="{{ url_for('static', filename='toast-notifications.js') }}?v={{ app_version }}"></script>
|
||||
|
||||
<script>
|
||||
// Language Selector
|
||||
(function() {
|
||||
const languageBtn = document.getElementById('languageBtn');
|
||||
const languageMenu = document.getElementById('languageMenu');
|
||||
|
||||
if (languageBtn && languageMenu) {
|
||||
// Toggle dropdown
|
||||
languageBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const isOpen = languageMenu.classList.contains('show');
|
||||
languageMenu.classList.toggle('show');
|
||||
languageBtn.setAttribute('aria-expanded', !isOpen);
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!languageBtn.contains(e.target) && !languageMenu.contains(e.target)) {
|
||||
languageMenu.classList.remove('show');
|
||||
languageBtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdown on Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && languageMenu.classList.contains('show')) {
|
||||
languageMenu.classList.remove('show');
|
||||
languageBtn.setAttribute('aria-expanded', 'false');
|
||||
languageBtn.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
<!-- Flash Messages (hidden; converted to toasts) -->
|
||||
<div id="flash-messages-container" class="hidden">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert {% if category == 'success' %}alert-success{% elif category == 'error' %}alert-danger{% elif category == 'warning' %}alert-warning{% else %}alert-info{% endif %}" data-toast-message="{{ message }}" data-toast-type="{{ category }}"></div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
// Auto-focus on username field
|
||||
try {
|
||||
const usernameField = document.getElementById('username');
|
||||
if (usernameField) {
|
||||
usernameField.focus();
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Handle form submission
|
||||
const form = document.getElementById('loginForm');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
const usernameEl = document.getElementById('username');
|
||||
const username = (usernameEl && usernameEl.value ? usernameEl.value : '').trim();
|
||||
|
||||
if (!username) {
|
||||
e.preventDefault();
|
||||
|
||||
// Show error styling
|
||||
if (usernameEl) {
|
||||
usernameEl.style.borderColor = 'var(--danger-color)';
|
||||
usernameEl.focus();
|
||||
|
||||
setTimeout(() => {
|
||||
usernameEl.style.borderColor = '';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const btnText = document.getElementById('btnText');
|
||||
|
||||
if (submitBtn && btnText) {
|
||||
btnText.innerHTML = '{{ _('Signing in...') }}';
|
||||
submitBtn.querySelector('i').className = 'fas fa-spinner spinner';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
// Fallback: re-enable after 8s in case of network issues
|
||||
setTimeout(() => {
|
||||
if (submitBtn.disabled) {
|
||||
submitBtn.disabled = false;
|
||||
btnText.innerHTML = '{{ _('Sign In') }}';
|
||||
submitBtn.querySelector('i').className = 'fas fa-sign-in-alt';
|
||||
}
|
||||
}, 8000);
|
||||
}
|
||||
});
|
||||
|
||||
// Add smooth input animations
|
||||
const usernameInput = document.getElementById('username');
|
||||
if (usernameInput) {
|
||||
usernameInput.addEventListener('input', function() {
|
||||
this.style.borderColor = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcut: Enter to submit
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && form && document.activeElement && document.activeElement.id === 'username') {
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='toast-notifications.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,104 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Profile') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% from "_components.html" import page_header %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% set actions %}
|
||||
<a href="{{ url_for('auth.edit_profile') }}" class="btn-header btn-outline-primary">
|
||||
<i class="fas fa-edit"></i> {{ _('Edit Profile') }}
|
||||
</a>
|
||||
<button id="theme-toggle" class="btn-header btn-outline-primary" type="button">
|
||||
<i class="fas fa-moon"></i> {{ _('Toggle Theme') }}
|
||||
</button>
|
||||
{% endset %}
|
||||
{{ page_header('fas fa-user-circle', _('Your Profile'), _('Manage your account details'), actions) }}
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Profile</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">View your account details below.</p>
|
||||
</div>
|
||||
<a href="{{ url_for('auth.edit_profile') }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Edit Profile</a>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="fas fa-id-card me-2"></i> {{ _('Profile Details') }}</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 text-muted">{{ _('Username') }}</div>
|
||||
<div class="col-sm-8"><strong>{{ current_user.username }}</strong></div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 text-muted">{{ _('Full name') }}</div>
|
||||
<div class="col-sm-8">{{ current_user.full_name or '—' }}</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 text-muted">{{ _('Role') }}</div>
|
||||
<div class="col-sm-8">
|
||||
<span class="badge bg-{{ 'primary' if current_user.is_admin else 'secondary' }}">
|
||||
{{ current_user.role|capitalize }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 text-muted">{{ _('Member since') }}</div>
|
||||
<div class="col-sm-8">{{ current_user.created_at.strftime('%Y-%m-%d %H:%M') if current_user.created_at else '—' }}</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 text-muted">{{ _('Last login') }}</div>
|
||||
<div class="col-sm-8">{{ current_user.last_login.strftime('%Y-%m-%d %H:%M') if current_user.last_login else '—' }}</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 text-muted">{{ _('Language') }}</div>
|
||||
<div class="col-sm-8">{{ current_language_label }}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-4 text-muted">{{ _('Total hours') }}</div>
|
||||
<div class="col-sm-8"><span class="badge bg-success">{{ current_user.total_hours }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-8 rounded-lg shadow">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Username</h2>
|
||||
<p class="mt-1 text-lg">{{ current_user.username }}</p>
|
||||
</div>
|
||||
<div class="border-t border-border-light dark:border-border-dark"></div>
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Role</h2>
|
||||
<p class="mt-1 text-lg">{{ current_user.role | capitalize }}</p>
|
||||
</div>
|
||||
<div class="border-t border-border-light dark:border-border-dark"></div>
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Member Since</h2>
|
||||
<p class="mt-1 text-lg">{{ current_user.created_at.strftime('%B %d, %Y') }}</p>
|
||||
</div>
|
||||
<div class="border-t border-border-light dark:border-border-dark"></div>
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Last Login</h2>
|
||||
<p class="mt-1 text-lg">{{ current_user.last_login.strftime('%B %d, %Y at %I:%M %p') if current_user.last_login else 'Never' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const storageKey = 'tt-theme';
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
const meta = document.getElementById('meta-theme-color');
|
||||
if (!btn) return;
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
if (meta) meta.setAttribute('content', theme === 'dark' ? '#0b1220' : '#3b82f6');
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) icon.className = theme === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
|
||||
}
|
||||
|
||||
function currentTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') || 'light';
|
||||
}
|
||||
|
||||
async function saveThemeToUser(value) {
|
||||
try {
|
||||
await fetch('{{ url_for("auth.update_theme_preference") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ theme: value })
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Initialize icon state
|
||||
applyTheme(currentTheme());
|
||||
|
||||
btn.addEventListener('click', async function() {
|
||||
const next = currentTheme() === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem(storageKey, next);
|
||||
applyTheme(next);
|
||||
await saveThemeToUser(next);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
75
app/templates/clients/list.html
Normal file
75
app/templates/clients/list.html
Normal file
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Clients</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Manage your clients here.</p>
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('clients.create_client') }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Create Client</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Filter Clients</h2>
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||
<input type="text" name="search" id="search" value="{{ search or '' }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
|
||||
<select name="status" id="status" class="form-input">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All</option>
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>Active</option>
|
||||
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="self-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-4">Name</th>
|
||||
<th class="p-4">Contact Person</th>
|
||||
<th class="p-4">Email</th>
|
||||
<th class="p-4">Status</th>
|
||||
<th class="p-4">Projects</th>
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for client in clients %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-4">{{ client.name }}</td>
|
||||
<td class="p-4">{{ client.contact_person }}</td>
|
||||
<td class="p-4">{{ client.email }}</td>
|
||||
<td class="p-4">
|
||||
{% if client.status == 'active' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">Active</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ client.active_projects }}/{{ client.total_projects }}</span>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-primary hover:underline">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">No clients found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
73
app/templates/clients/view.html
Normal file
73
app/templates/clients/view.html
Normal file
@@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ client.name }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Client details and associated projects.</p>
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('clients.edit_client', client_id=client.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Edit Client</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Left Column: Client Details -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">Contact Information</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Contact Person</h3>
|
||||
<p>{{ client.contact_person or 'N/A' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Email</h3>
|
||||
<p>{{ client.email or 'N/A' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Phone</h3>
|
||||
<p>{{ client.phone or 'N/A' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Address</h3>
|
||||
<p>{{ client.address or 'N/A' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Projects -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">Projects</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-4">Name</th>
|
||||
<th class="p-4">Status</th>
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-4">{{ project.name }}</td>
|
||||
<td class="p-4">{{ project.status | capitalize }}</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-primary hover:underline">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">No projects found for this client.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
14
app/templates/components/cards.html
Normal file
14
app/templates/components/cards.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% macro stat_card(title, value, change, change_color) %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
|
||||
<h3 class="text-text-muted-light dark:text-text-muted-dark">{{ title }}</h3>
|
||||
<p class="text-2xl font-bold">{{ value }} <span class="text-sm text-{{ change_color }}">{{ change }}</span></p>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro info_card(title, value, subtext) %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
|
||||
<h3 class="text-text-muted-light dark:text-text-muted-dark">{{ title }}</h3>
|
||||
<p class="text-3xl font-bold">{{ value }}</p>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ subtext }}</p>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
47
app/templates/invoices/create.html
Normal file
47
app/templates/invoices/create.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Create Invoice</h1>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||
<select name="project_id" id="project_id" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<option value="">Select a project</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}">{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Name</label>
|
||||
<input type="text" name="client_name" id="client_name" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Email</label>
|
||||
<input type="email" name="client_email" id="client_email" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div>
|
||||
<label for="due_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Due Date</label>
|
||||
<input type="date" name="due_date" id="due_date" value="{{ default_due_date }}" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="client_address" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Address</label>
|
||||
<textarea name="client_address" id="client_address" rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tax_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Tax Rate (%)</label>
|
||||
<input type="number" name="tax_rate" id="tax_rate" value="{{ settings.tax_rate or 0 }}" step="0.01" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Create Invoice</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
69
app/templates/invoices/edit.html
Normal file
69
app/templates/invoices/edit.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Edit Invoice {{ invoice.invoice_number }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="client_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Name</label>
|
||||
<input type="text" name="client_name" id="client_name" value="{{ invoice.client_name }}" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Email</label>
|
||||
<input type="email" name="client_email" id="client_email" value="{{ invoice.client_email }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div>
|
||||
<label for="due_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Due Date</label>
|
||||
<input type="date" name="due_date" id="due_date" value="{{ invoice.due_date.strftime('%Y-%m-%d') }}" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="client_address" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Address</label>
|
||||
<textarea name="client_address" id="client_address" rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">{{ invoice.client_address }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tax_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Tax Rate (%)</label>
|
||||
<input type="number" name="tax_rate" id="tax_rate" value="{{ invoice.tax_rate }}" step="0.01" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Items</h2>
|
||||
<div id="invoice-items">
|
||||
{% for item in invoice.items %}
|
||||
<div class="grid grid-cols-4 gap-4 mb-4">
|
||||
<input type="text" name="description[]" placeholder="Description" value="{{ item.description }}" class="col-span-2 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<input type="number" name="quantity[]" placeholder="Quantity" value="{{ item.quantity }}" step="0.01" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<input type="number" name="unit_price[]" placeholder="Unit Price" value="{{ item.unit_price }}" step="0.01" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" id="add-item" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">Add Item</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts_extra %}
|
||||
<script>
|
||||
document.getElementById('add-item').addEventListener('click', function() {
|
||||
var itemsContainer = document.getElementById('invoice-items');
|
||||
var newItem = document.createElement('div');
|
||||
newItem.className = 'grid grid-cols-4 gap-4 mb-4';
|
||||
newItem.innerHTML = `
|
||||
<input type="text" name="description[]" placeholder="Description" class="col-span-2 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<input type="number" name="quantity[]" placeholder="Quantity" step="0.01" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<input type="number" name="unit_price[]" placeholder="Unit Price" step="0.01" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
`;
|
||||
itemsContainer.appendChild(newItem);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
47
app/templates/invoices/list.html
Normal file
47
app/templates/invoices/list.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/cards.html" import info_card %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Invoices</h1>
|
||||
<a href="{{ url_for('invoices.create_invoice') }}" class="bg-primary text-white px-4 py-2 rounded-lg">Create Invoice</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
{{ info_card("Total Invoiced", "%.2f"|format(summary.total_amount), "All time") }}
|
||||
{{ info_card("Total Paid", "%.2f"|format(summary.paid_amount), "All time") }}
|
||||
{{ info_card("Outstanding", "%.2f"|format(summary.outstanding_amount), "All time") }}
|
||||
{{ info_card("Overdue", "%.2f"|format(summary.overdue_amount), "All time") }}
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="p-2">Number</th>
|
||||
<th class="p-2">Client</th>
|
||||
<th class="p-2">Status</th>
|
||||
<th class="p-2">Total</th>
|
||||
<th class="p-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invoice in invoices %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-2">{{ invoice.invoice_number }}</td>
|
||||
<td class="p-2">{{ invoice.client_name }}</td>
|
||||
<td class="p-2">{{ invoice.status }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(invoice.total_amount) }}</td>
|
||||
<td class="p-2">
|
||||
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="text-primary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="p-4 text-center">No invoices found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
30
app/templates/invoices/record_payment.html
Normal file
30
app/templates/invoices/record_payment.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Record Payment for Invoice {{ invoice.invoice_number }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Amount</label>
|
||||
<input type="number" name="amount" id="amount" value="{{ invoice.outstanding_amount }}" step="0.01" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div>
|
||||
<label for="payment_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Payment Date</label>
|
||||
<input type="date" name="payment_date" id="payment_date" value="{{ today }}" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div>
|
||||
<label for="payment_method" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Payment Method</label>
|
||||
<input type="text" name="payment_method" id="payment_method" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Record Payment</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
70
app/templates/invoices/view.html
Normal file
70
app/templates/invoices/view.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Invoice {{ invoice.invoice_number }}</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg">Edit</a>
|
||||
<a href="{{ url_for('invoices.export_invoice_pdf', invoice_id=invoice.id) }}" class="bg-secondary text-white px-4 py-2 rounded-lg">Export PDF</a>
|
||||
<a href="{{ url_for('invoices.record_payment', invoice_id=invoice.id) }}" class="bg-green-500 text-white px-4 py-2 rounded-lg">Record Payment</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Client</h2>
|
||||
<p>{{ invoice.client_name }}</p>
|
||||
<p>{{ invoice.client_email }}</p>
|
||||
<p>{{ invoice.client_address }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Details</h2>
|
||||
<p><strong>Issue Date:</strong> {{ invoice.issue_date.strftime('%Y-%m-%d') }}</p>
|
||||
<p><strong>Due Date:</strong> {{ invoice.due_date.strftime('%Y-%m-%d') }}</p>
|
||||
<p><strong>Status:</strong> {{ invoice.status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Items</h2>
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="p-2">Description</th>
|
||||
<th class="p-2">Quantity</th>
|
||||
<th class="p-2">Unit Price</th>
|
||||
<th class="p-2">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in invoice.items %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-2">{{ item.description }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(item.quantity) }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(item.unit_price) }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(item.total_amount) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="flex justify-between">
|
||||
<span>Subtotal</span>
|
||||
<span>{{ "%.2f"|format(invoice.subtotal) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Tax ({{ "%.2f"|format(invoice.tax_rate) }}%)</span>
|
||||
<span>{{ "%.2f"|format(invoice.tax_amount) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between font-bold text-lg">
|
||||
<span>Total</span>
|
||||
<span>{{ "%.2f"|format(invoice.total_amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
171
app/templates/kanban/board.html
Normal file
171
app/templates/kanban/board.html
Normal file
@@ -0,0 +1,171 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Kanban') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold"><i class="fas fa-columns mr-2"></i>{{ _('Kanban') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Drag tasks between columns. Filter by project.') }}</p>
|
||||
</div>
|
||||
<div class="mt-3 md:mt-0 flex items-center gap-3">
|
||||
<form method="get" class="flex items-center gap-2">
|
||||
<label for="project_id" class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}</label>
|
||||
<select id="project_id" name="project_id" class="bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 px-3 text-sm text-text-light dark:text-text-dark" onchange="this.form.submit()">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {% if project_id == p.id %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 bg-primary text-white text-sm px-3 py-2 rounded hover:opacity-90">
|
||||
<i class="fas fa-sliders-h"></i> {{ _('Manage Kanban Columns') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow" role="application" aria-label="{{ _('Kanban board') }}">
|
||||
{% include 'projects/_kanban_tailwind.html' %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts_extra %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const board = document.getElementById('kanbanBoard');
|
||||
if (!board) return;
|
||||
|
||||
let dragCard = null;
|
||||
board.addEventListener('dragstart', (e) => {
|
||||
const card = e.target.closest('.kanban-card');
|
||||
if (card) {
|
||||
dragCard = card;
|
||||
card.classList.add('opacity-50');
|
||||
card.setAttribute('aria-grabbed', 'true');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', card.dataset.taskId);
|
||||
}
|
||||
});
|
||||
board.addEventListener('dragend', () => {
|
||||
if (dragCard) {
|
||||
dragCard.classList.remove('opacity-50');
|
||||
dragCard.setAttribute('aria-grabbed', 'false');
|
||||
}
|
||||
dragCard = null;
|
||||
document.querySelectorAll('.kanban-column-body').forEach(b => b.classList.remove('bg-blue-100','dark:bg-blue-900'));
|
||||
});
|
||||
document.querySelectorAll('.kanban-column-body').forEach(body => {
|
||||
body.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; body.classList.add('bg-blue-100','dark:bg-blue-900'); body.setAttribute('aria-dropeffect', 'move'); });
|
||||
body.addEventListener('dragleave', () => { body.classList.remove('bg-blue-100','dark:bg-blue-900'); body.removeAttribute('aria-dropeffect'); });
|
||||
body.addEventListener('drop', async (e) => {
|
||||
e.preventDefault(); body.classList.remove('bg-blue-100','dark:bg-blue-900');
|
||||
body.removeAttribute('aria-dropeffect');
|
||||
const targetStatus = body.dataset.status;
|
||||
const taskId = e.dataTransfer.getData('text/plain');
|
||||
const card = dragCard || board.querySelector(`[data-task-id="${taskId}"]`);
|
||||
if (card && card.dataset.status !== targetStatus) {
|
||||
const originalParent = card.parentElement;
|
||||
// Hide target empty placeholder before inserting
|
||||
const targetEmpty = body.querySelector('.kanban-empty');
|
||||
if (targetEmpty) targetEmpty.style.display = 'none';
|
||||
body.appendChild(card);
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}/status`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin', body: JSON.stringify({ status: targetStatus }) });
|
||||
if (!res.ok) throw new Error('Failed'); card.dataset.status = targetStatus;
|
||||
updateColumnCounts();
|
||||
toggleEmptyStatesForBodies([body, originalParent]);
|
||||
announce(`${card.querySelector('h4, .kanban-card-title')?.textContent || 'Task'} {{ _('moved to') }} ${targetStatus}`);
|
||||
} catch (err) {
|
||||
// Revert DOM move and empty states
|
||||
originalParent.appendChild(card);
|
||||
toggleEmptyStatesForBodies([body, originalParent]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Inline status select changes
|
||||
document.querySelectorAll('.kanban-status').forEach(sel => {
|
||||
sel.addEventListener('change', async (e) => {
|
||||
const select = e.target;
|
||||
const taskId = select.dataset.taskId;
|
||||
const newStatus = select.value;
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}/status`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin', body: JSON.stringify({ status: newStatus }) });
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
// Move the card to target column
|
||||
const card = document.querySelector(`.kanban-card[data-task-id="${taskId}"]`);
|
||||
const originalParent = card ? card.parentElement : null;
|
||||
const target = document.querySelector(`.kanban-column-body[data-status="${newStatus}"]`);
|
||||
if (card && target) {
|
||||
// Hide target empty placeholder
|
||||
const targetEmpty = target.querySelector('.kanban-empty');
|
||||
if (targetEmpty) targetEmpty.style.display = 'none';
|
||||
target.appendChild(card);
|
||||
card.dataset.status = newStatus;
|
||||
updateColumnCounts();
|
||||
toggleEmptyStatesForBodies([target, originalParent]);
|
||||
announce(`${card.querySelector('h4, .kanban-card-title')?.textContent || 'Task'} {{ _('moved to') }} ${newStatus}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// revert select silently
|
||||
const card = document.querySelector(`.kanban-card[data-task-id="${taskId}"]`);
|
||||
if (card) select.value = card.dataset.status;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function updateColumnCounts(){
|
||||
document.querySelectorAll('.kanban-count').forEach(span => {
|
||||
const status = span.getAttribute('data-status');
|
||||
const count = document.querySelectorAll(`.kanban-card[data-status="${status}"]`).length;
|
||||
span.textContent = count;
|
||||
});
|
||||
// Also toggle empty placeholders after any count recalculation
|
||||
document.querySelectorAll('.kanban-column-body').forEach(body => toggleEmptyStateForBody(body));
|
||||
}
|
||||
|
||||
function toggleEmptyStatesForBodies(bodies){
|
||||
if (!Array.isArray(bodies)) return;
|
||||
bodies.forEach(b => { if (b) toggleEmptyStateForBody(b); });
|
||||
}
|
||||
|
||||
function toggleEmptyStateForBody(body){
|
||||
const cards = body.querySelectorAll('.kanban-card');
|
||||
let empty = body.querySelector('.kanban-empty');
|
||||
if (cards.length === 0) {
|
||||
if (!empty) {
|
||||
empty = document.createElement('div');
|
||||
empty.className = 'kanban-empty text-center text-text-muted-light dark:text-text-muted-dark py-10';
|
||||
empty.innerHTML = '<p>{{ _('No tasks in this column.') }}</p>';
|
||||
body.appendChild(empty);
|
||||
}
|
||||
empty.style.display = '';
|
||||
} else if (empty) {
|
||||
empty.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Simple aria-live region for announcements
|
||||
function announce(message){
|
||||
try {
|
||||
let live = document.getElementById('kanban-live-region');
|
||||
if (!live) {
|
||||
live = document.createElement('div');
|
||||
live.id = 'kanban-live-region';
|
||||
live.setAttribute('aria-live', 'polite');
|
||||
live.setAttribute('aria-atomic', 'true');
|
||||
live.className = 'sr-only';
|
||||
document.body.appendChild(live);
|
||||
}
|
||||
live.textContent = message;
|
||||
} catch(_) {}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -9,100 +9,85 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2>
|
||||
<i class="fas fa-columns text-primary"></i> {{ _('Manage Kanban Columns') }}
|
||||
</h2>
|
||||
<p class="text-muted">{{ _('Customize your kanban board columns and task states') }}</p>
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||
<span class="w-9 h-9 rounded-lg bg-primary flex items-center justify-center text-white"><i class="fas fa-columns"></i></span>
|
||||
{{ _('Manage Kanban Columns') }}
|
||||
</h2>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Customize your kanban board columns and task states') }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('kanban.create_column') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white hover:opacity-90">
|
||||
<i class="fas fa-plus"></i> {{ _('Add Column') }}
|
||||
</a>
|
||||
</div>
|
||||
<a href="{{ url_for('kanban.create_column') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> {{ _('Add Column') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Flash messages are globally converted to toasts in base.html -->
|
||||
<!-- Flash messages are globally converted to toasts in base.html -->
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-xl shadow ring-1 ring-border-light/60 dark:ring-border-dark/60">
|
||||
<div class="p-0 overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-border-light dark:divide-border-dark" role="table" aria-label="{{ _('Kanban columns list') }}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">#</th>
|
||||
<th width="120">{{ _('Key') }}</th>
|
||||
<th>{{ _('Label') }}</th>
|
||||
<th width="150">{{ _('Icon') }}</th>
|
||||
<th width="100">{{ _('Color') }}</th>
|
||||
<th width="100">{{ _('Status') }}</th>
|
||||
<th width="120">{{ _('Complete?') }}</th>
|
||||
<th width="100">{{ _('System') }}</th>
|
||||
<th width="200">{{ _('Actions') }}</th>
|
||||
<tr class="text-left text-sm">
|
||||
<th scope="col" class="px-4 py-3 w-12">#</th>
|
||||
<th scope="col" class="px-4 py-3 w-32">{{ _('Key') }}</th>
|
||||
<th scope="col" class="px-4 py-3">{{ _('Label') }}</th>
|
||||
<th scope="col" class="px-4 py-3 w-40">{{ _('Icon') }}</th>
|
||||
<th scope="col" class="px-4 py-3 w-28">{{ _('Color') }}</th>
|
||||
<th scope="col" class="px-4 py-3 w-28">{{ _('Status') }}</th>
|
||||
<th scope="col" class="px-4 py-3 w-32">{{ _('Complete?') }}</th>
|
||||
<th scope="col" class="px-4 py-3 w-28">{{ _('System') }}</th>
|
||||
<th scope="col" class="px-4 py-3 w-52">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="columnsList">
|
||||
<tbody id="columnsList" role="rowgroup" aria-live="polite">
|
||||
{% for column in columns %}
|
||||
<tr data-column-id="{{ column.id }}">
|
||||
<td>
|
||||
<i class="fas fa-grip-vertical text-muted" style="cursor: move;"></i>
|
||||
<tr data-column-id="{{ column.id }}" role="row">
|
||||
<td class="px-4 py-3 align-middle" aria-label="Reorder">
|
||||
<button type="button" class="text-text-muted-light dark:text-text-muted-dark" aria-label="{{ _('Drag to reorder') }}" title="{{ _('Drag to reorder') }}">
|
||||
<i class="fas fa-grip-vertical" style="cursor: move;"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ column.key }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ column.label }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<i class="{{ column.icon }}"></i>
|
||||
<span class="text-muted small">{{ column.icon }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ column.color }}">{{ column.color }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-4 py-3"><code>{{ column.key }}</code></td>
|
||||
<td class="px-4 py-3"><strong>{{ column.label }}</strong></td>
|
||||
<td class="px-4 py-3"><i class="{{ column.icon }}"></i> <span class="text-text-muted-light dark:text-text-muted-dark text-xs">{{ column.icon }}</span></td>
|
||||
<td class="px-4 py-3"><span class="inline-flex items-center px-2 py-1 rounded text-xs bg-background-light dark:bg-background-dark border border-border-light dark:border-border-dark">{{ column.color }}</span></td>
|
||||
<td class="px-4 py-3">
|
||||
{% if column.is_active %}
|
||||
<span class="badge bg-success">{{ _('Active') }}</span>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Active') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ _('Inactive') }}</span>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">{{ _('Inactive') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-4 py-3">
|
||||
{% if column.is_complete_state %}
|
||||
<i class="fas fa-check-circle text-success"></i> {{ _('Yes') }}
|
||||
<span class="inline-flex items-center gap-1 text-sm text-green-700 dark:text-green-300"><i class="fas fa-check-circle"></i> {{ _('Yes') }}</span>
|
||||
{% else %}
|
||||
<i class="fas fa-circle text-muted"></i> {{ _('No') }}
|
||||
<span class="inline-flex items-center gap-1 text-sm text-text-muted-light dark:text-text-muted-dark"><i class="fas fa-circle"></i> {{ _('No') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-4 py-3">
|
||||
{% if column.is_system %}
|
||||
<span class="badge bg-info">{{ _('System') }}</span>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">{{ _('System') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark">{{ _('Custom') }}</span>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">{{ _('Custom') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('kanban.edit_column', column_id=column.id) }}"
|
||||
class="btn btn-outline-primary"
|
||||
title="{{ _('Edit') }}">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ url_for('kanban.edit_column', column_id=column.id) }}" class="inline-flex items-center gap-1 px-2 py-1 rounded border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark" title="{{ _('Edit') }}" aria-label="{{ _('Edit column') }}">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<form method="POST"
|
||||
action="{{ url_for('kanban.toggle_column', column_id=column.id) }}"
|
||||
class="d-inline">
|
||||
<form method="POST" action="{{ url_for('kanban.toggle_column', column_id=column.id) }}" class="inline" aria-label="{{ _('Toggle active state') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
class="btn btn-outline-{{ 'secondary' if column.is_active else 'success' }}"
|
||||
title="{{ _('Deactivate') if column.is_active else _('Activate') }}">
|
||||
<button type="submit" class="inline-flex items-center gap-1 px-2 py-1 rounded border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark" title="{{ _('Deactivate') if column.is_active else _('Activate') }}">
|
||||
<i class="fas fa-{{ 'eye-slash' if column.is_active else 'eye' }}"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% if not column.is_system %}
|
||||
<button type="button"
|
||||
class="btn btn-outline-danger"
|
||||
title="{{ _('Delete') }}"
|
||||
onclick="showDeleteModal({{ column.id }}, '{{ column.label }}', '{{ column.key }}')">
|
||||
<button type="button" class="inline-flex items-center gap-1 px-2 py-1 rounded border border-red-300 text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30" title="{{ _('Delete') }}" onclick="showDeleteModal({{ column.id }}, '{{ column.label }}', '{{ column.key }}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -115,23 +100,59 @@
|
||||
</div>
|
||||
|
||||
{% if not columns %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-columns fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">{{ _('No kanban columns found. Create your first column to get started.') }}</p>
|
||||
<div class="text-center py-8">
|
||||
<i class="fas fa-columns fa-3x text-text-muted-light dark:text-text-muted-dark mb-3"></i>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No kanban columns found. Create your first column to get started.') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-4">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>{{ _('Tips:') }}</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>{{ _('Drag and drop rows to reorder columns') }}</li>
|
||||
<li>{{ _('System columns (todo, in_progress, done) cannot be deleted but can be customized') }}</li>
|
||||
<li>{{ _('Columns marked as "Complete" will mark tasks as completed when dragged to that column') }}</li>
|
||||
<li>{{ _('Inactive columns are hidden from the kanban board but tasks with that status remain accessible') }}</li>
|
||||
</ul>
|
||||
<div class="mt-4 bg-blue-50 dark:bg-blue-900/30 text-blue-900 dark:text-blue-100 rounded-lg p-4 ring-1 ring-blue-200/60 dark:ring-blue-700/50">
|
||||
<p class="flex items-start gap-2"><i class="fas fa-info-circle mt-1"></i> <span><strong>{{ _('Tips:') }}</strong></span></p>
|
||||
<ul class="list-disc pl-6 mt-2 text-sm">
|
||||
<li>{{ _('Drag and drop rows to reorder columns') }}</li>
|
||||
<li>{{ _('System columns (todo, in_progress, done) cannot be deleted but can be customized') }}</li>
|
||||
<li>{{ _('Columns marked as "Complete" will mark tasks as completed when dragged to that column') }}</li>
|
||||
<li>{{ _('Inactive columns are hidden from the kanban board but tasks with that status remain accessible') }}</li>
|
||||
</ul>
|
||||
<!-- Accessible Tailwind Modal for Delete Confirmation -->
|
||||
<div id="deleteColumnModal" class="hidden fixed inset-0 z-50" role="dialog" aria-modal="true" aria-labelledby="deleteModalTitle">
|
||||
<div class="absolute inset-0 bg-black/50" onclick="hideDeleteModal()"></div>
|
||||
<div class="relative max-w-lg mx-auto mt-24 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-xl ring-1 ring-border-light/60 dark:ring-border-dark/60">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark flex items-center justify-between">
|
||||
<h5 id="deleteModalTitle" class="text-lg font-semibold flex items-center gap-2">
|
||||
<i class="fas fa-trash text-danger"></i> {{ _('Delete Kanban Column') }}
|
||||
</h5>
|
||||
<button type="button" class="px-2 py-1 hover:bg-background-light dark:hover:bg-background-dark rounded" aria-label="{{ _('Close') }}" onclick="hideDeleteModal()">×</button>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/30 text-yellow-900 dark:text-yellow-100 rounded-lg p-3 ring-1 ring-yellow-200/60 dark:ring-yellow-700/50 flex items-start gap-2">
|
||||
<i class="fas fa-exclamation-triangle mt-1"></i>
|
||||
<div>
|
||||
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4">{{ _('Are you sure you want to delete the column?') }}</p>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mt-2">
|
||||
<small>{{ _('Key:') }} <code id="deleteColumnKey"></code></small>
|
||||
</p>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mt-2">
|
||||
<small>{{ _('Note: Tasks with this status will remain accessible but the column will no longer appear on the kanban board.') }}</small>
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4 border-t border-border-light dark:border-border-dark flex items-center justify-between">
|
||||
<button type="button" class="inline-flex items-center gap-2 px-3 py-2 rounded border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark" onclick="hideDeleteModal()">
|
||||
<i class="fas fa-times"></i> {{ _('Cancel') }}
|
||||
</button>
|
||||
<form method="POST" id="deleteColumnForm" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-3 py-2 rounded bg-danger text-white hover:opacity-90">
|
||||
<i class="fas fa-trash"></i> {{ _('Delete Column') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -177,30 +198,22 @@
|
||||
<script>
|
||||
// Show delete modal
|
||||
function showDeleteModal(columnId, label, key) {
|
||||
const labelEl = document.getElementById('deleteColumnLabel');
|
||||
const keyEl = document.getElementById('deleteColumnKey');
|
||||
const formEl = document.getElementById('deleteColumnForm');
|
||||
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
if (keyEl) keyEl.textContent = key;
|
||||
if (keyEl) keyEl.textContent = key || '';
|
||||
if (formEl) formEl.action = "{{ url_for('kanban.delete_column', column_id=0) }}".replace('0', columnId);
|
||||
|
||||
const modal = document.getElementById('deleteColumnModal');
|
||||
if (modal) new bootstrap.Modal(modal).show();
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Loading state on delete submit
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteForm = document.getElementById('deleteColumnForm');
|
||||
if (deleteForm) {
|
||||
deleteForm.addEventListener('submit', function() {
|
||||
const btn = deleteForm.querySelector('button[type="submit"]');
|
||||
if (btn) {
|
||||
btn.innerHTML = '<div class="spinner-border spinner-border-sm me-2" role="status"><span class="visually-hidden">Loading...</span></div>Deleting...';
|
||||
btn.disabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
function hideDeleteModal(){
|
||||
const modal = document.getElementById('deleteColumnModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Close on Escape
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key === 'Escape') hideDeleteModal();
|
||||
});
|
||||
|
||||
// Enable drag-and-drop reordering
|
||||
@@ -237,6 +250,11 @@ if (tbody) {
|
||||
} else {
|
||||
try { showToast(data.message || '{{ _('Columns reordered successfully') }}', 'success'); } catch(_) {}
|
||||
}
|
||||
// Announce to screen readers
|
||||
try {
|
||||
const live = document.getElementById('columnsList');
|
||||
if (live) live.setAttribute('aria-busy', 'false');
|
||||
} catch(_) {}
|
||||
// Broadcast to other tabs as a fallback (if Socket.IO not connected there)
|
||||
try {
|
||||
if (window._kanbanBroadcastChannel) {
|
||||
|
||||
@@ -2,128 +2,83 @@
|
||||
{% block title %}{{ _('Create Kanban Column') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-4">
|
||||
<h2>
|
||||
<i class="fas fa-plus-circle text-primary"></i> {{ _('Create Kanban Column') }}
|
||||
</h2>
|
||||
<p class="text-muted">{{ _('Add a new column to your kanban board') }}</p>
|
||||
</div>
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||
<span class="w-9 h-9 rounded-lg bg-primary flex items-center justify-center text-white"><i class="fas fa-plus-circle"></i></span>
|
||||
{{ _('Create Kanban Column') }}
|
||||
</h2>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Add a new column to your kanban board') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Flash messages are globally converted to toasts in base.html -->
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-xl shadow ring-1 ring-border-light/60 dark:ring-border-dark/60">
|
||||
<div class="p-6">
|
||||
<form method="POST" action="{{ url_for('kanban.create_column') }}" role="form" aria-labelledby="formTitle">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('kanban.create_column') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="label" class="form-label">
|
||||
{{ _('Column Label') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="label"
|
||||
name="label"
|
||||
required
|
||||
placeholder="{{ _('e.g., In Review, Blocked, Testing') }}"
|
||||
autofocus>
|
||||
<small class="form-text text-muted">
|
||||
{{ _('The display name shown on the kanban board') }}
|
||||
</small>
|
||||
<h3 id="formTitle" class="sr-only">{{ _('Create Kanban Column form') }}</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="label" class="block text-sm font-medium mb-1">{{ _('Column Label') }} <span aria-hidden="true" class="text-red-600">*</span></label>
|
||||
<input type="text" id="label" name="label" required aria-required="true" autocomplete="off" placeholder="{{ _('e.g., In Review, Blocked, Testing') }}" class="w-full rounded-lg border border-border-light dark:border-border-dark bg-background-light dark:bg-gray-700 px-3 py-2" autofocus>
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('The display name shown on the kanban board') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="key" class="block text-sm font-medium mb-1">{{ _('Column Key') }} <span aria-hidden="true" class="text-red-600">*</span></label>
|
||||
<input type="text" id="key" name="key" required pattern="[a-z0-9_]+" aria-describedby="keyHelp" class="w-full rounded-lg border border-border-light dark:border-border-dark bg-background-light dark:bg-gray-700 px-3 py-2" placeholder="{{ _('e.g., in_review, blocked, testing') }}">
|
||||
<p id="keyHelp" class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Unique identifier (lowercase, no spaces, use underscores). Auto-generated from label if empty.') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="icon" class="block text-sm font-medium mb-1">{{ _('Icon Class') }}</label>
|
||||
<input type="text" id="icon" name="icon" value="fas fa-circle" placeholder="fas fa-circle" class="w-full rounded-lg border border-border-light dark:border-border-dark bg-background-light dark:bg-gray-700 px-3 py-2">
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ _('Font Awesome icon class') }}
|
||||
<a href="https://fontawesome.com/icons" target="_blank" class="underline">{{ _('Browse icons') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="key" class="form-label">
|
||||
{{ _('Column Key') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="key"
|
||||
name="key"
|
||||
required
|
||||
pattern="[a-z0-9_]+"
|
||||
placeholder="{{ _('e.g., in_review, blocked, testing') }}">
|
||||
<small class="form-text text-muted">
|
||||
{{ _('Unique identifier (lowercase, no spaces, use underscores). Auto-generated from label if empty.') }}
|
||||
</small>
|
||||
<div>
|
||||
<label for="color" class="block text-sm font-medium mb-1">{{ _('Color') }}</label>
|
||||
<select id="color" name="color" class="w-full rounded-lg border border-border-light dark:border-border-dark bg-background-light dark:bg-gray-700 px-3 py-2">
|
||||
<option value="primary">{{ _('Primary (Blue)') }}</option>
|
||||
<option value="secondary" selected>{{ _('Secondary (Gray)') }}</option>
|
||||
<option value="success">{{ _('Success (Green)') }}</option>
|
||||
<option value="danger">{{ _('Danger (Red)') }}</option>
|
||||
<option value="warning">{{ _('Warning (Yellow)') }}</option>
|
||||
<option value="info">{{ _('Info (Cyan)') }}</option>
|
||||
<option value="dark">{{ _('Dark') }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Bootstrap color class for styling') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="icon" class="form-label">
|
||||
{{ _('Icon Class') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="icon"
|
||||
name="icon"
|
||||
value="fas fa-circle"
|
||||
placeholder="fas fa-circle">
|
||||
<small class="form-text text-muted">
|
||||
{{ _('Font Awesome icon class') }}
|
||||
<a href="https://fontawesome.com/icons" target="_blank">{{ _('Browse icons') }}</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="color" class="form-label">
|
||||
{{ _('Color') }}
|
||||
</label>
|
||||
<select class="form-select" id="color" name="color">
|
||||
<option value="primary">{{ _('Primary (Blue)') }}</option>
|
||||
<option value="secondary" selected>{{ _('Secondary (Gray)') }}</option>
|
||||
<option value="success">{{ _('Success (Green)') }}</option>
|
||||
<option value="danger">{{ _('Danger (Red)') }}</option>
|
||||
<option value="warning">{{ _('Warning (Yellow)') }}</option>
|
||||
<option value="info">{{ _('Info (Cyan)') }}</option>
|
||||
<option value="dark">{{ _('Dark') }}</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
{{ _('Bootstrap color class for styling') }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<input type="checkbox" id="is_complete_state" name="is_complete_state" class="mt-1" aria-describedby="completeHelp">
|
||||
<div>
|
||||
<label for="is_complete_state" class="text-sm font-medium">{{ _('Mark as Complete State') }}</label>
|
||||
<p id="completeHelp" class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Tasks moved to this column will be marked as completed') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
id="is_complete_state"
|
||||
name="is_complete_state">
|
||||
<label class="form-check-label" for="is_complete_state">
|
||||
{{ _('Mark as Complete State') }}
|
||||
</label>
|
||||
<small class="form-text text-muted d-block">
|
||||
{{ _('Tasks moved to this column will be marked as completed') }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('kanban.list_columns') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> {{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> {{ _('Create Column') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<a href="{{ url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
|
||||
<i class="fas fa-times"></i> {{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white hover:opacity-90">
|
||||
<i class="fas fa-save"></i> {{ _('Create Column') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-4">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>{{ _('Note:') }}</strong>
|
||||
{{ _('The column will be added at the end of the board. You can reorder columns later from the management page.') }}
|
||||
</div>
|
||||
<div class="mt-4 bg-blue-50 dark:bg-blue-900/30 text-blue-900 dark:text-blue-100 rounded-lg p-4 ring-1 ring-blue-200/60 dark:ring-blue-700/50">
|
||||
<p class="flex items-start gap-2"><i class="fas fa-info-circle mt-1"></i> <span><strong>{{ _('Note:') }}</strong> {{ _('The column will be added at the end of the board. You can reorder columns later from the management page.') }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,151 +2,99 @@
|
||||
{% block title %}{{ _('Edit Kanban Column') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-4">
|
||||
<h2>
|
||||
<i class="fas fa-edit text-primary"></i> {{ _('Edit Kanban Column') }}
|
||||
</h2>
|
||||
<p class="text-muted">{{ _('Modify column settings') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Flash messages are globally converted to toasts in base.html -->
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('kanban.edit_column', column_id=column.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="key" class="form-label">
|
||||
{{ _('Column Key') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="key"
|
||||
value="{{ column.key }}"
|
||||
disabled>
|
||||
<small class="form-text text-muted">
|
||||
{{ _('The key cannot be changed after creation') }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="label" class="form-label">
|
||||
{{ _('Column Label') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="label"
|
||||
name="label"
|
||||
value="{{ column.label }}"
|
||||
required
|
||||
autofocus>
|
||||
<small class="form-text text-muted">
|
||||
{{ _('The display name shown on the kanban board') }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="icon" class="form-label">
|
||||
{{ _('Icon Class') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="icon"
|
||||
name="icon"
|
||||
value="{{ column.icon }}"
|
||||
placeholder="fas fa-circle">
|
||||
<small class="form-text text-muted">
|
||||
{{ _('Font Awesome icon class') }}
|
||||
<a href="https://fontawesome.com/icons" target="_blank">{{ _('Browse icons') }}</a>
|
||||
</small>
|
||||
<div class="mt-2">
|
||||
<span class="badge bg-light text-dark">
|
||||
<i class="{{ column.icon }}"></i> {{ _('Preview') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="color" class="form-label">
|
||||
{{ _('Color') }}
|
||||
</label>
|
||||
<select class="form-select" id="color" name="color">
|
||||
<option value="primary" {% if column.color == 'primary' %}selected{% endif %}>{{ _('Primary (Blue)') }}</option>
|
||||
<option value="secondary" {% if column.color == 'secondary' %}selected{% endif %}>{{ _('Secondary (Gray)') }}</option>
|
||||
<option value="success" {% if column.color == 'success' %}selected{% endif %}>{{ _('Success (Green)') }}</option>
|
||||
<option value="danger" {% if column.color == 'danger' %}selected{% endif %}>{{ _('Danger (Red)') }}</option>
|
||||
<option value="warning" {% if column.color == 'warning' %}selected{% endif %}>{{ _('Warning (Yellow)') }}</option>
|
||||
<option value="info" {% if column.color == 'info' %}selected{% endif %}>{{ _('Info (Cyan)') }}</option>
|
||||
<option value="dark" {% if column.color == 'dark' %}selected{% endif %}>{{ _('Dark') }}</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
{{ _('Bootstrap color class for styling') }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
id="is_complete_state"
|
||||
name="is_complete_state"
|
||||
{% if column.is_complete_state %}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_complete_state">
|
||||
{{ _('Mark as Complete State') }}
|
||||
</label>
|
||||
<small class="form-text text-muted d-block">
|
||||
{{ _('Tasks moved to this column will be marked as completed') }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
{% if column.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_active">
|
||||
{{ _('Active') }}
|
||||
</label>
|
||||
<small class="form-text text-muted d-block">
|
||||
{{ _('Inactive columns are hidden from the kanban board') }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('kanban.list_columns') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> {{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> {{ _('Save Changes') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if column.is_system %}
|
||||
<div class="alert alert-warning mt-4">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>{{ _('System Column:') }}</strong>
|
||||
{{ _('This is a system column. You can customize its appearance but cannot delete it.') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||
<span class="w-9 h-9 rounded-lg bg-primary flex items-center justify-center text-white"><i class="fas fa-edit"></i></span>
|
||||
{{ _('Edit Kanban Column') }}
|
||||
</h2>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Modify column settings') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-xl shadow ring-1 ring-border-light/60 dark:ring-border-dark/60">
|
||||
<div class="p-6">
|
||||
<form method="POST" action="{{ url_for('kanban.edit_column', column_id=column.id) }}" role="form" aria-labelledby="editFormTitle">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h3 id="editFormTitle" class="sr-only">{{ _('Edit Kanban Column form') }}</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="key" class="block text-sm font-medium mb-1">{{ _('Column Key') }}</label>
|
||||
<input type="text" id="key" value="{{ column.key }}" disabled class="w-full rounded-lg border border-border-light dark:border-border-dark bg-background-light dark:bg-gray-700 px-3 py-2 opacity-75 cursor-not-allowed">
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('The key cannot be changed after creation') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="label" class="block text-sm font-medium mb-1">{{ _('Column Label') }} <span aria-hidden="true" class="text-red-600">*</span></label>
|
||||
<input type="text" id="label" name="label" value="{{ column.label }}" required aria-required="true" class="w-full rounded-lg border border-border-light dark:border-border-dark bg-background-light dark:bg-gray-700 px-3 py-2" autofocus>
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('The display name shown on the kanban board') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="icon" class="block text-sm font-medium mb-1">{{ _('Icon Class') }}</label>
|
||||
<input type="text" id="icon" name="icon" value="{{ column.icon }}" placeholder="fas fa-circle" class="w-full rounded-lg border border-border-light dark:border-border-dark bg-background-light dark:bg-gray-700 px-3 py-2">
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ _('Font Awesome icon class') }}
|
||||
<a href="https://fontawesome.com/icons" target="_blank" class="underline">{{ _('Browse icons') }}</a>
|
||||
</p>
|
||||
<div class="mt-2 inline-flex items-center gap-2 text-sm bg-background-light dark:bg-background-dark border border-border-light dark:border-border-dark rounded px-2 py-1">
|
||||
<i class="{{ column.icon }}"></i> <span>{{ _('Preview') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="color" class="block text-sm font-medium mb-1">{{ _('Color') }}</label>
|
||||
<select id="color" name="color" class="w-full rounded-lg border border-border-light dark:border-border-dark bg-background-light dark:bg-gray-700 px-3 py-2">
|
||||
<option value="primary" {% if column.color == 'primary' %}selected{% endif %}>{{ _('Primary (Blue)') }}</option>
|
||||
<option value="secondary" {% if column.color == 'secondary' %}selected{% endif %}>{{ _('Secondary (Gray)') }}</option>
|
||||
<option value="success" {% if column.color == 'success' %}selected{% endif %}>{{ _('Success (Green)') }}</option>
|
||||
<option value="danger" {% if column.color == 'danger' %}selected{% endif %}>{{ _('Danger (Red)') }}</option>
|
||||
<option value="warning" {% if column.color == 'warning' %}selected{% endif %}>{{ _('Warning (Yellow)') }}</option>
|
||||
<option value="info" {% if column.color == 'info' %}selected{% endif %}>{{ _('Info (Cyan)') }}</option>
|
||||
<option value="dark" {% if column.color == 'dark' %}selected{% endif %}>{{ _('Dark') }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Bootstrap color class for styling') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<input type="checkbox" id="is_complete_state" name="is_complete_state" {% if column.is_complete_state %}checked{% endif %} class="mt-1" aria-describedby="completeHelp">
|
||||
<div>
|
||||
<label for="is_complete_state" class="text-sm font-medium">{{ _('Mark as Complete State') }}</label>
|
||||
<p id="completeHelp" class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Tasks moved to this column will be marked as completed') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<input type="checkbox" id="is_active" name="is_active" {% if column.is_active %}checked{% endif %} class="mt-1" aria-describedby="activeHelp">
|
||||
<div>
|
||||
<label for="is_active" class="text-sm font-medium">{{ _('Active') }}</label>
|
||||
<p id="activeHelp" class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Inactive columns are hidden from the kanban board') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<a href="{{ url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
|
||||
<i class="fas fa-times"></i> {{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white hover:opacity-90">
|
||||
<i class="fas fa-save"></i> {{ _('Save Changes') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if column.is_system %}
|
||||
<div class="mt-4 bg-yellow-50 dark:bg-yellow-900/30 text-yellow-900 dark:text-yellow-100 rounded-lg p-4 ring-1 ring-yellow-200/60 dark:ring-yellow-700/50">
|
||||
<p class="flex items-start gap-2"><i class="fas fa-exclamation-triangle mt-1"></i> <span><strong>{{ _('System Column:') }}</strong> {{ _('This is a system column. You can customize its appearance but cannot delete it.') }}</span></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,620 +1,190 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Dashboard') }} - {{ app_name }}{% endblock %}
|
||||
{% from "components/cards.html" import info_card, stat_card %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
{% from "_components.html" import page_header %}
|
||||
<!-- Toast notifications handled by base template -->
|
||||
|
||||
<!-- Template meta for JS flags -->
|
||||
<div id="dashboard-meta" data-has-active-timer="{{ 1 if active_timer else 0 }}" style="display:none;"></div>
|
||||
|
||||
<!-- Page Header matching admin style -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% set actions %}
|
||||
<a href="{{ url_for('timer.manual_entry') }}" class="btn btn-primary me-2">
|
||||
<i class="fas fa-plus me-2"></i>{{ _('Log Time') }}
|
||||
</a>
|
||||
<a href="{{ url_for('reports.reports') }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-chart-line me-2"></i>{{ _('Reports') }}
|
||||
</a>
|
||||
{% endset %}
|
||||
{{ page_header('fas fa-tachometer-alt', _('Dashboard'), _('Welcome back,') ~ ' ' ~ current_user.display_name ~ ' • ' ~ (today_hours|round(1)) ~ _('h today'), actions) }}
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ _('Dashboard') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _("Here's a quick overview of your work.") }}</p>
|
||||
</div>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark mt-2 md:mt-0">
|
||||
<i class="fas fa-tachometer-alt"></i> / {{ _('Dashboard') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timer Section -->
|
||||
<div class="row section-spacing">
|
||||
<div class="col-12">
|
||||
<div class="card hover-lift">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0 d-flex align-items-center">
|
||||
<i class="fas fa-clock me-2 text-primary"></i>{{ _('Timer Status') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-lg-8 col-md-7 mb-4 mb-md-0">
|
||||
{% if active_timer %}
|
||||
<div class="d-flex align-items-center flex-column flex-md-row">
|
||||
<div class="me-0 me-md-4 mb-4 mb-md-0">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center timer-status-icon shadow-sm" style="backdrop-filter: blur(8px);">
|
||||
<i class="fas fa-play text-success fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center text-md-start">
|
||||
<h4 class="mb-2 text-success">{{ _('Timer Running') }}</h4>
|
||||
<p class="mb-3 text-muted">
|
||||
<i class="fas fa-project-diagram me-2"></i>{{ active_timer.project.name }}{% if active_timer.task %} <span class="text-muted">•</span> <i class="fas fa-tasks ms-2 me-1"></i>{{ active_timer.task.name }}{% endif %}
|
||||
</p>
|
||||
<div class="timer-display mb-2" id="timer-display">
|
||||
{{ active_timer.duration_formatted }}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{ _('Started at') }} {{ active_timer.start_time.strftime('%H:%M') }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center flex-column flex-md-row">
|
||||
<div class="me-0 me-md-4 mb-4 mb-md-0">
|
||||
<div class="bg-light rounded-circle d-flex align-items-center justify-content-center timer-status-icon shadow-sm" style="backdrop-filter: blur(8px);">
|
||||
<i class="fas fa-stop text-muted fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center text-md-start">
|
||||
<h4 class="mb-2 text-muted">{{ _('No Active Timer') }}</h4>
|
||||
<p class="text-muted mb-1">{{ _('Choose a project or one of its tasks to start tracking.') }}</p>
|
||||
<div class="mt-2">
|
||||
<span class="status-badge bg-light text-dark">{{ _('Idle') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-5 text-center">
|
||||
{% if active_timer %}
|
||||
<form method="POST" action="{{ url_for('timer.stop_timer') }}" class="d-inline w-100">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger px-4 w-100 w-md-auto">
|
||||
<i class="fas fa-stop me-2"></i>{{ _('Stop Timer') }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-success px-4 w-100 w-md-auto" data-bs-toggle="modal" data-bs-target="#startTimerModal">
|
||||
<i class="fas fa-play me-2"></i>{{ _('Start Timer') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Key Stats -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
|
||||
{{ info_card("Today's Hours", "%.2f"|format(today_hours), "Hours logged today") }}
|
||||
{{ info_card("Week's Hours", "%.2f"|format(week_hours), "Hours logged this week") }}
|
||||
{{ info_card("Month's Hours", "%.2f"|format(month_hours), "Hours logged this month") }}
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row section-spacing stagger-animation">
|
||||
<div class="col-lg-4 col-md-6 mb-3">
|
||||
{% from "_components.html" import summary_card %}
|
||||
<div class="scale-hover">
|
||||
{{ summary_card('fas fa-calendar-day', 'primary', _('Hours Today'), "%.1f"|format(today_hours)) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6 mb-3">
|
||||
<div class="scale-hover">
|
||||
{{ summary_card('fas fa-calendar-week', 'success', _('Hours This Week'), "%.1f"|format(week_hours)) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6 mb-3">
|
||||
<div class="scale-hover">
|
||||
{{ summary_card('fas fa-calendar-alt', 'info', _('Hours This Month'), "%.1f"|format(month_hours)) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Quick Actions -->
|
||||
<div class="row section-spacing">
|
||||
<div class="col-12">
|
||||
<div class="card mobile-card hover-lift fade-in-up">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0 d-flex align-items-center">
|
||||
<i class="fas fa-bolt me-3 text-warning icon-pulse"></i>{{ _('Quick Actions') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-4 stagger-animation">
|
||||
<div class="col-lg-3 col-md-6 col-sm-6">
|
||||
<a href="{{ url_for('timer.manual_entry') }}" class="card h-100 text-decoration-none mobile-card lift-hover border-0 shadow-sm">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3 shadow-sm icon-spin-hover" style="width: 56px; height: 56px; backdrop-filter: blur(8px);">
|
||||
<i class="fas fa-plus text-primary fa-lg"></i>
|
||||
</div>
|
||||
<h6 class="fw-semibold mb-2 fs-5">{{ _('Log Time') }}</h6>
|
||||
<small class="text-muted fs-6">{{ _('Manual entry') }}</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-6">
|
||||
<a href="{{ url_for('timer.bulk_entry') }}" class="card h-100 text-decoration-none mobile-card lift-hover border-0 shadow-sm">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3 shadow-sm icon-spin-hover" style="width: 56px; height: 56px; backdrop-filter: blur(8px);">
|
||||
<i class="fas fa-calendar-plus text-success fa-lg"></i>
|
||||
</div>
|
||||
<h6 class="fw-semibold mb-2 fs-5">{{ _('Bulk Entry') }}</h6>
|
||||
<small class="text-muted fs-6">{{ _('Multi-day time entry') }}</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-6">
|
||||
<a href="{{ url_for('projects.list_projects') }}" class="card h-100 text-decoration-none mobile-card lift-hover border-0 shadow-sm">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="bg-secondary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3 shadow-sm icon-spin-hover" style="width: 56px; height: 56px; backdrop-filter: blur(8px);">
|
||||
<i class="fas fa-project-diagram text-secondary fa-lg"></i>
|
||||
</div>
|
||||
<h6 class="fw-semibold mb-2 fs-5">{{ _('Projects') }}</h6>
|
||||
<small class="text-muted fs-6">{{ _('Manage projects') }}</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-6">
|
||||
<a href="{{ url_for('reports.reports') }}" class="card h-100 text-decoration-none mobile-card lift-hover border-0 shadow-sm">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3 shadow-sm icon-spin-hover" style="width: 56px; height: 56px; backdrop-filter: blur(8px);">
|
||||
<i class="fas fa-chart-bar text-info fa-lg"></i>
|
||||
</div>
|
||||
<h6 class="fw-semibold mb-2 fs-5">{{ _('Reports') }}</h6>
|
||||
<small class="text-muted fs-6">{{ _('View analytics') }}</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-sm-6">
|
||||
<a href="{{ url_for('main.search') }}" class="card h-100 text-decoration-none mobile-card lift-hover border-0 shadow-sm">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3 shadow-sm icon-spin-hover" style="width: 56px; height: 56px; backdrop-filter: blur(8px);">
|
||||
<i class="fas fa-search text-warning fa-lg"></i>
|
||||
</div>
|
||||
<h6 class="fw-semibold mb-2 fs-5">{{ _('Search') }}</h6>
|
||||
<small class="text-muted fs-6">{{ _('Find entries') }}</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today by Task -->
|
||||
<div class="row section-spacing">
|
||||
<div class="col-12">
|
||||
<div class="card mobile-card hover-lift">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<h5 class="mb-0 d-flex align-items-center">
|
||||
<i class="fas fa-list-check me-3 text-secondary"></i>{{ _('Today by Task') }}
|
||||
</h5>
|
||||
<small class="text-muted" id="today-by-task-date"></small>
|
||||
</div>
|
||||
<div class="card-body" id="today-by-task">
|
||||
<div class="text-muted">{{ _('Loading...') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Recent Entries -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card mobile-card hover-lift">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-column flex-md-row">
|
||||
<h5 class="mb-0 mb-3 mb-md-0 d-flex align-items-center">
|
||||
<i class="fas fa-history me-3 text-primary"></i>{{ _('Recent Entries') }}
|
||||
</h5>
|
||||
<a href="{{ url_for('reports.reports') }}" class="btn btn-sm btn-outline-primary touch-target">
|
||||
<i class="fas fa-external-link-alt me-2"></i>{{ _('View All') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if recent_entries %}
|
||||
<div class="table-responsive">
|
||||
<div class="d-flex align-items-center justify-content-between px-3 pt-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input type="checkbox" id="selectAllEntries" class="form-check-input">
|
||||
<label for="selectAllEntries" class="form-check-label">{{ _('Select all') }}</label>
|
||||
</div>
|
||||
<div class="btn-group" role="group" aria-label="Bulk actions">
|
||||
<button id="bulkDelete" class="btn btn-sm btn-outline-danger" disabled>
|
||||
<i class="fas fa-trash me-1"></i>{{ _('Delete') }}
|
||||
</button>
|
||||
<button id="bulkBillable" class="btn btn-sm btn-outline-primary" disabled>
|
||||
<i class="fas fa-dollar-sign me-1"></i>{{ _('Set Billable') }}
|
||||
</button>
|
||||
<button id="bulkNonBillable" class="btn btn-sm btn-outline-secondary" disabled>
|
||||
<i class="fas fa-ban me-1"></i>{{ _('Set Non-billable') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-4" style="width:36px"></th>
|
||||
<th class="ps-4">{{ _('Project') }}</th>
|
||||
<th>{{ _('Duration') }}</th>
|
||||
<th>{{ _('Date') }}</th>
|
||||
<th>{{ _('Notes') }}</th>
|
||||
<th class="text-end pe-4">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in recent_entries %}
|
||||
<tr class="hover-lift">
|
||||
<td class="ps-4">
|
||||
<input type="checkbox" class="form-check-input entry-select" value="{{ entry.id }}">
|
||||
</td>
|
||||
<td class="ps-4" data-label="Project">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
|
||||
<i class="fas fa-project-diagram text-primary"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong class="d-block fs-6">{{ entry.project.name }}</strong>
|
||||
<small class="text-muted fs-6">{{ entry.project.client }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Duration">
|
||||
<span class="status-badge bg-primary text-white">{{ entry.duration_formatted }}</span>
|
||||
</td>
|
||||
<td data-label="Date">
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fw-semibold fs-6">{{ entry.start_time.strftime('%b %d') }}</span>
|
||||
<small class="text-muted fs-6">{{ entry.start_time.strftime('%H:%M') }}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Notes">
|
||||
{% if entry.notes %}
|
||||
<div class="text-truncate" style="max-width: 200px;" title="{{ entry.notes }}">
|
||||
{{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted fst-italic fs-6">{{ _('No notes') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-4 actions-cell" data-label="Actions">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}"
|
||||
class="btn btn-sm btn-action btn-action--edit touch-target" title="{{ _('Edit entry') }}">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% if current_user.is_admin or entry.user_id == current_user.id %}
|
||||
<button type="button" class="btn btn-sm btn-action btn-action--danger touch-target" title="{{ _('Delete entry') }}"
|
||||
onclick="showDeleteEntryModal('{{ entry.id }}', '{{ entry.project.name }}', '{{ entry.duration_formatted }}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{% from "_components.html" import empty_state %}
|
||||
{% set actions %}
|
||||
<a href="{{ url_for('timer.manual_entry') }}" class="btn btn-primary touch-target fw-bold">
|
||||
<i class="fas fa-plus me-2"></i>{{ _('Log Your First Entry') }}
|
||||
</a>
|
||||
{% endset %}
|
||||
{{ empty_state('fas fa-clock', _('No recent entries'), _('Start tracking your time to see entries here'), actions) }}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Left Column: Active Timer & Recent Entries -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Active Timer -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-lg font-semibold">{{ _('Timer') }}</h2>
|
||||
{% if not active_timer %}
|
||||
<button type="button" id="openStartTimer" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Start Timer') }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if active_timer %}
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="font-semibold">{{ active_timer.project.name }}</p>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Started at') }} {{ active_timer.start_time.strftime('%I:%M %p') }}</p>
|
||||
</div>
|
||||
<form action="{{ url_for('timer.stop_timer') }}" method="POST" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="bg-red-500 text-white px-4 py-2 rounded-lg">{{ _('Stop Timer') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No active timer.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Recent Entries -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
|
||||
<h2 class="text-lg font-semibold mb-4">Recent Entries</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-4">{{ _('Project') }}</th>
|
||||
<th class="p-4">{{ _('Task') }}</th>
|
||||
<th class="p-4">{{ _('Notes') }}</th>
|
||||
<th class="p-4">{{ _('Tags') }}</th>
|
||||
<th class="p-4">{{ _('Duration') }}</th>
|
||||
<th class="p-4">{{ _('Date') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in recent_entries %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-4">{{ entry.project.name }}</td>
|
||||
<td class="p-4">{{ entry.task.name if entry.task else '-' }}</td>
|
||||
<td class="p-4">{% if entry.notes %}<span title="{{ entry.notes }}">{{ entry.notes[:60] }}{% if entry.notes|length > 60 %}...{% endif %}</span>{% else %}-{% endif %}</td>
|
||||
<td class="p-4">{{ entry.tags or '-' }}</td>
|
||||
<td class="p-4">{{ entry.duration_formatted }}</td>
|
||||
<td class="p-4">{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">{{ _('No recent entries found.') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Start Timer Modal -->
|
||||
<div class="modal fade" id="startTimerModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title d-flex align-items-center">
|
||||
<i class="fas fa-play me-3 text-success"></i>{{ _('Start Timer') }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ _('Close') }}"></button>
|
||||
<!-- Right Column: Real Insights -->
|
||||
<div class="space-y-6">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Top Projects (30 days)') }}</h2>
|
||||
<ul class="space-y-3">
|
||||
{% for item in top_projects %}
|
||||
<li class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium">{{ item.project.name }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Billable') }}: {{ '%.1f'|format(item.billable_hours) }}h</div>
|
||||
</div>
|
||||
<div class="text-right font-semibold">{{ '%.1f'|format(item.hours) }}h</div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="text-text-muted-light dark:text-text-muted-dark">{{ _('No activity in the last 30 days.') }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Start Timer Modal -->
|
||||
<div id="startTimerModal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="absolute inset-0 bg-black/50" data-overlay></div>
|
||||
<div class="relative max-w-lg mx-auto mt-24 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-lg">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark flex items-center justify-between">
|
||||
<div class="text-lg font-semibold">{{ _('Start Timer') }}</div>
|
||||
<button type="button" data-close class="px-2 py-1 text-sm hover:bg-background-light dark:hover:bg-background-dark rounded">{{ _('Close') }}</button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('timer.start_timer') }}">
|
||||
<form method="POST" action="{{ url_for('timer.start_timer') }}" class="p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-body">
|
||||
<div class="mb-4">
|
||||
<label for="project_id" class="form-label fw-semibold d-flex align-items-center">
|
||||
<i class="fas fa-project-diagram me-2"></i>{{ _('Select Project') }}
|
||||
</label>
|
||||
<select class="form-select form-select-lg" id="project_id" name="project_id" required>
|
||||
<option value="">{{ _('Choose a project...') }}</option>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="startTimerProject" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Project') }}</label>
|
||||
<select id="startTimerProject" name="project_id" required class="form-input">
|
||||
<option value="">{{ _('Select a project') }}</option>
|
||||
{% for project in active_projects %}
|
||||
<option value="{{ project.id }}">
|
||||
{{ project.name }} ({{ project.client }})
|
||||
</option>
|
||||
<option value="{{ project.id }}">{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="task_id" class="form-label fw-semibold d-flex align-items-center">
|
||||
<i class="fas fa-tasks me-2"></i>{{ _('Select Task (Optional)') }}
|
||||
</label>
|
||||
<select class="form-select" id="task_id" name="task_id" disabled>
|
||||
<option value="">{{ _('Choose a task...') }}</option>
|
||||
<div>
|
||||
<label for="startTimerTask" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Task (optional)') }}</label>
|
||||
<select id="startTimerTask" name="task_id" class="form-input">
|
||||
<option value="">—</option>
|
||||
</select>
|
||||
<small class="text-muted">{{ _('Tasks list updates after choosing a project. Leave empty to log at project level.') }}</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="timer_notes" class="form-label fw-semibold d-flex align-items-center">
|
||||
<i class="fas fa-sticky-note me-2"></i>{{ _('Notes (Optional)') }}
|
||||
</label>
|
||||
<textarea class="form-control" id="timer_notes" name="notes" rows="3"
|
||||
placeholder="{{ _('What are you working on?') }}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer d-flex flex-column flex-md-row">
|
||||
<button type="button" class="btn btn-secondary mb-2 mb-md-0 me-md-2" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-2"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success fw-bold">
|
||||
<i class="fas fa-play me-2"></i>{{ _('Start Timer') }}
|
||||
</button>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Start') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Delete Time Entry Modal -->
|
||||
<div class="modal fade" id="deleteEntryModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title d-flex align-items-center">
|
||||
<i class="fas fa-trash me-3 text-danger"></i>{{ _('Delete Time Entry') }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ _('Close') }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
|
||||
</div>
|
||||
<p class="mb-2">{{ _('Are you sure you want to delete the time entry for') }} <strong id="deleteEntryProjectName"></strong>?</p>
|
||||
<p class="text-muted mb-0 fs-6">{{ _('Duration:') }} <strong id="deleteEntryDuration"></strong></p>
|
||||
</div>
|
||||
<div class="modal-footer d-flex flex-column flex-md-row">
|
||||
<button type="button" class="btn btn-secondary mb-2 mb-md-0 me-md-2" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-2"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
<form method="POST" id="deleteEntryForm" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger fw-bold">
|
||||
<i class="fas fa-trash me-2"></i>{{ _('Delete Entry') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script id="i18n-dashboard" type="application/json">{{ {
|
||||
'choose_task': _('Choose a task...'),
|
||||
'please_select_project': _('Please select a project'),
|
||||
'starting': _('Starting...'),
|
||||
'deleting': _('Deleting...'),
|
||||
'today_by_task': _('Today by Task'),
|
||||
'no_data': _('No time tracked yet today'),
|
||||
'hours_suffix': _('h')
|
||||
}|tojson }}</script>
|
||||
|
||||
{% block scripts_extra %}
|
||||
<script>
|
||||
// Parse page i18n
|
||||
var i18nDash = (function(){
|
||||
try {
|
||||
var el = document.getElementById('i18n-dashboard');
|
||||
return el ? JSON.parse(el.textContent) : {};
|
||||
} catch (e) { return {}; }
|
||||
})();
|
||||
|
||||
let timerInterval;
|
||||
const HAS_ACTIVE_TIMER = !!Number(document.getElementById('dashboard-meta')?.getAttribute('data-has-active-timer') || '0');
|
||||
if (HAS_ACTIVE_TIMER) {
|
||||
// Enhanced timer update
|
||||
function updateTimer() {
|
||||
fetch('/api/timer/status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.active && data.timer) {
|
||||
const display = document.getElementById('timer-display');
|
||||
if (display) {
|
||||
// Prefer server-provided current duration; fallback to computing from start_time
|
||||
let totalSeconds = typeof data.timer.current_duration === 'number'
|
||||
? data.timer.current_duration
|
||||
: Math.floor((new Date() - new Date(data.timer.start_time)) / 1000);
|
||||
if (totalSeconds < 0 || Number.isNaN(totalSeconds)) totalSeconds = 0;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
display.textContent = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
} else {
|
||||
// Timer stopped, reload page
|
||||
clearInterval(timerInterval);
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error updating timer:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Update timer immediately and then every second
|
||||
updateTimer();
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
|
||||
// Clean up on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Populate tasks when project changes
|
||||
const projectSelect = document.getElementById('project_id');
|
||||
const taskSelect = document.getElementById('task_id');
|
||||
if (projectSelect && taskSelect) {
|
||||
projectSelect.addEventListener('change', async function() {
|
||||
const pid = this.value;
|
||||
taskSelect.innerHTML = `<option value="">${i18nDash.choose_task || 'Choose a task...'}</option>`;
|
||||
taskSelect.disabled = true;
|
||||
if (!pid) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/tasks?project_id=${encodeURIComponent(pid)}`);
|
||||
const data = await resp.json();
|
||||
if (Array.isArray(data.tasks)) {
|
||||
for (const t of data.tasks) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.id;
|
||||
opt.textContent = t.name;
|
||||
taskSelect.appendChild(opt);
|
||||
}
|
||||
taskSelect.disabled = data.tasks.length === 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load tasks', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch and render Today by Task
|
||||
(function(){
|
||||
const container = document.getElementById('today-by-task');
|
||||
const dateEl = document.getElementById('today-by-task-date');
|
||||
if (!container) return;
|
||||
function renderRows(rows){
|
||||
if (!rows || rows.length === 0) {
|
||||
container.innerHTML = `<div class="text-muted">${i18nDash.no_data || 'No time tracked yet today'}</div>`;
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
list.className = 'list-group list-group-flush';
|
||||
rows.forEach(r => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
const left = document.createElement('div');
|
||||
left.className = 'd-flex align-items-center';
|
||||
left.innerHTML = `<div class="bg-secondary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width:32px;height:32px;"><i class="fas fa-tasks text-secondary"></i></div><div><div class="fw-semibold">${(r.task_name ? r.project_name + ' • ' + r.task_name : r.project_name + ' • ' + (i18nDash.no_task || 'No task'))}</div></div>`;
|
||||
const right = document.createElement('div');
|
||||
right.className = 'badge bg-primary rounded-pill';
|
||||
right.textContent = `${(r.total_hours ?? 0).toFixed(2)} ${(i18nDash.hours_suffix || 'h')}`;
|
||||
item.appendChild(left);
|
||||
item.appendChild(right);
|
||||
list.appendChild(item);
|
||||
});
|
||||
container.innerHTML = '';
|
||||
container.appendChild(list);
|
||||
}
|
||||
fetch('/api/analytics/today-by-task').then(r => r.json()).then(data => {
|
||||
if (data && data.date) {
|
||||
try { dateEl.textContent = new Date(data.date + 'T00:00:00').toLocaleDateString(); } catch(e) {}
|
||||
}
|
||||
renderRows((data && data.rows) || []);
|
||||
}).catch(() => {
|
||||
container.innerHTML = `<div class="text-muted">${i18nDash.no_data || 'No time tracked yet today'}</div>`;
|
||||
});
|
||||
})();
|
||||
|
||||
// Validate start timer submission
|
||||
const startForm = document.querySelector('#startTimerModal form');
|
||||
if (startForm) {
|
||||
startForm.addEventListener('submit', function(e) {
|
||||
const pid = projectSelect.value;
|
||||
if (!pid) {
|
||||
e.preventDefault();
|
||||
showToast(i18nDash.please_select_project || 'Please select a project', 'warning');
|
||||
return false;
|
||||
}
|
||||
const btn = this.querySelector('button[type="submit"]');
|
||||
if (btn) {
|
||||
btn.innerHTML = `<div class="loading-spinner me-2"></div>${i18nDash.starting || 'Starting...'}`;
|
||||
btn.disabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enhanced function to show delete time entry modal
|
||||
function showDeleteEntryModal(entryId, projectName, duration) {
|
||||
document.getElementById('deleteEntryProjectName').textContent = projectName;
|
||||
document.getElementById('deleteEntryDuration').textContent = duration;
|
||||
document.getElementById('deleteEntryForm').action = "{{ url_for('timer.delete_timer', timer_id=0) }}".replace('0', entryId);
|
||||
new bootstrap.Modal(document.getElementById('deleteEntryModal')).show();
|
||||
}
|
||||
|
||||
// Enhanced loading state to delete entry form
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteForm = document.getElementById('deleteEntryForm');
|
||||
if (deleteForm) {
|
||||
deleteForm.addEventListener('submit', function(e) {
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
submitBtn.innerHTML = `<div class="loading-spinner me-2"></div>${i18nDash.deleting || 'Deleting...'}`;
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
try {
|
||||
if (typeof anime !== 'undefined') {
|
||||
anime({
|
||||
targets: '.animated-card',
|
||||
translateY: [20, 0],
|
||||
opacity: [0, 1],
|
||||
delay: (window.anime && anime.stagger) ? anime.stagger(100) : undefined,
|
||||
duration: 500,
|
||||
easing: 'easeOutQuad'
|
||||
});
|
||||
}
|
||||
} catch(e) { /* no-op if animation lib missing */ }
|
||||
|
||||
const modal = document.getElementById('startTimerModal');
|
||||
// Open via event delegation to avoid missed binding
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('#openStartTimer');
|
||||
if (btn && modal) {
|
||||
e.preventDefault();
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
if (modal) {
|
||||
modal.querySelector('[data-close]')?.addEventListener('click', () => modal.classList.add('hidden'));
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal || e.target.hasAttribute('data-overlay')) modal.classList.add('hidden'); });
|
||||
const projectSelect = document.getElementById('startTimerProject');
|
||||
const taskSelect = document.getElementById('startTimerTask');
|
||||
if (projectSelect && taskSelect) {
|
||||
projectSelect.addEventListener('change', async () => {
|
||||
const pid = projectSelect.value;
|
||||
taskSelect.innerHTML = '<option value="">—</option>';
|
||||
if (!pid) return;
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${pid}/tasks`, { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (data && data.tasks) {
|
||||
data.tasks.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.id; opt.textContent = t.name; taskSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk selection and actions
|
||||
(function(){
|
||||
const selectAll = document.getElementById('selectAllEntries');
|
||||
const checkboxes = Array.from(document.querySelectorAll('.entry-select'));
|
||||
const btnDelete = document.getElementById('bulkDelete');
|
||||
const btnBillable = document.getElementById('bulkBillable');
|
||||
const btnNonBillable = document.getElementById('bulkNonBillable');
|
||||
|
||||
function selectedIds(){ return checkboxes.filter(cb => cb.checked).map(cb => Number(cb.value)); }
|
||||
function updateButtons(){
|
||||
const any = selectedIds().length > 0;
|
||||
[btnDelete, btnBillable, btnNonBillable].forEach(btn => { if (btn) btn.disabled = !any; });
|
||||
}
|
||||
if (selectAll){
|
||||
selectAll.addEventListener('change', function(){
|
||||
checkboxes.forEach(cb => { cb.checked = selectAll.checked; });
|
||||
updateButtons();
|
||||
});
|
||||
}
|
||||
checkboxes.forEach(cb => cb.addEventListener('change', updateButtons));
|
||||
|
||||
async function bulkAction(action, value){
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
const res = await fetch('/api/entries/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entry_ids: ids, action, value })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success){ throw new Error(json.error || 'Bulk action failed'); }
|
||||
showToast(`{{ _('Bulk action completed') }}`, 'success');
|
||||
location.reload();
|
||||
} catch(e){
|
||||
showToast(`{{ _('Bulk action failed') }}`, 'danger');
|
||||
}
|
||||
}
|
||||
if (btnDelete) btnDelete.addEventListener('click', () => bulkAction('delete'));
|
||||
if (btnBillable) btnBillable.addEventListener('click', () => bulkAction('set_billable', true));
|
||||
if (btnNonBillable) btnNonBillable.addEventListener('click', () => bulkAction('set_billable', false));
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
86
app/templates/projects/_kanban_tailwind.html
Normal file
86
app/templates/projects/_kanban_tailwind.html
Normal file
@@ -0,0 +1,86 @@
|
||||
{# Reusable Kanban board for tasks. Expects `tasks` and `kanban_columns` in context. #}
|
||||
|
||||
<div class="kanban-board-wrapper p-4">
|
||||
<div id="kanbanBoard" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" role="list" aria-label="{{ _('Kanban board columns') }}">
|
||||
{% for col in kanban_columns %}
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-xl shadow-sm ring-1 ring-border-light/60 dark:ring-border-dark/60 flex flex-col" role="region" aria-labelledby="col-{{ col.key }}-title">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark flex justify-between items-center bg-gradient-to-b from-background-light/60 dark:from-background-dark/40 to-transparent rounded-t-xl">
|
||||
<h3 id="col-{{ col.key }}-title" class="text-base font-semibold flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full" style="background-color: {{ col.color or '#4A90E2' }}"></span>
|
||||
<span>{{ col.label }}</span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="kanban-count bg-gray-200 dark:bg-gray-700 text-xs font-semibold px-2 py-1 rounded-full" data-status="{{ col.key }}" aria-live="polite" aria-atomic="true">
|
||||
{{ tasks|selectattr('status', 'equalto', col.key)|list|length }}
|
||||
</span>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('kanban.edit_column', column_id=col.id) }}" class="text-text-muted-light dark:text-text-muted-dark hover:text-primary transition-colors" title="{{ _('Edit swimlane') }}">
|
||||
<i class="fas fa-pen"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="kanban-column-body p-3 space-y-3 overflow-y-auto h-96 flex-grow" data-status="{{ col.key }}" role="list" aria-label="{{ _('Column') }}: {{ col.label }}" tabindex="0">
|
||||
{% set column_tasks = tasks|selectattr('status', 'equalto', col.key)|list %}
|
||||
{% if column_tasks %}
|
||||
{% for task in column_tasks %}
|
||||
<div class="kanban-card group bg-background-light dark:bg-background-dark p-4 rounded-lg shadow-sm border border-border-light dark:border-border-dark hover:shadow-md transition-all cursor-grab active:cursor-grabbing" draggable="true" data-task-id="{{ task.id }}" data-status="{{ task.status }}" data-project-id="{{ task.project_id }}" role="listitem" aria-grabbed="false" aria-label="{{ _('Task') }}: {{ task.name }} — {{ _('Status') }}: {{ col.label }}">
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="block">
|
||||
<h4 class="font-semibold mb-2 group-hover:text-primary transition-colors">{{ task.name }}</h4>
|
||||
{% if task.description %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">{{ task.description | truncate(80) }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
<span class="text-sm font-semibold px-2 py-1 rounded-full
|
||||
{% if task.priority == 'urgent' %} bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
|
||||
{% elif task.priority == 'high' %} bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
|
||||
{% elif task.priority == 'medium' %} bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||
{% else %} bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200 {% endif %}">
|
||||
{{ task.priority | capitalize }}
|
||||
</span>
|
||||
{% if task.due_date %}
|
||||
<span class="{{ 'text-red-500' if task.is_overdue else '' }}">
|
||||
<i class="fas fa-calendar-alt mr-1"></i>
|
||||
{{ task.due_date.strftime('%b %d') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
{% if task.assigned_user %}
|
||||
<img src="https://i.pravatar.cc/40?u={{ task.assigned_user.id }}" alt="{{ task.assigned_user.display_name }}" class="w-8 h-8 rounded-full" title="{{ task.assigned_user.display_name }}">
|
||||
{% else %}
|
||||
<div class="w-8"></div> <!-- Placeholder for alignment -->
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-text-muted-light dark:text-text-muted-dark" for="status-{{ task.id }}">{{ _('Status') }}</label>
|
||||
<select id="status-{{ task.id }}" class="kanban-status bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded px-2 py-1 text-xs" data-task-id="{{ task.id }}">
|
||||
{% for st in kanban_columns %}
|
||||
<option value="{{ st.key }}" {% if task.status == st.key %}selected{% endif %}>{{ st.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project_id) }}?task_id={{ task.id }}" class="text-xs px-2 py-1 rounded bg-primary text-white hover:opacity-90">{{ _('Start') }}</a>
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-xs px-2 py-1 rounded border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">{{ _('View') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="kanban-empty text-center text-text-muted-light dark:text-text-muted-dark py-10 border border-dashed border-border-light dark:border-border-dark rounded-lg" aria-live="polite">
|
||||
<p>{{ _('No tasks in this column.') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
112
app/templates/projects/list.html
Normal file
112
app/templates/projects/list.html
Normal file
@@ -0,0 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Projects</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Manage your projects here.</p>
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('projects.create_project') }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Create Project</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Filter Projects</h2>
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||
<input type="text" name="search" id="search" value="{{ search or '' }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
|
||||
<select name="status" id="status" class="form-input">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All</option>
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>Active</option>
|
||||
<option value="archived" {% if status == 'archived' %}selected{% endif %}>Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client</label>
|
||||
<select name="client" id="client" class="form-input">
|
||||
<option value="">All</option>
|
||||
{% for client_name in clients %}
|
||||
<option value="{{ client_name }}" {% if request.args.get('client') == client_name %}selected{% endif %}>{{ client_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="self-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-4">Name</th>
|
||||
<th class="p-4">Client</th>
|
||||
<th class="p-4">Status</th>
|
||||
<th class="p-4">Billable</th>
|
||||
<th class="p-4">Rate</th>
|
||||
<th class="p-4">Budget</th>
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-4">{{ project.name }}</td>
|
||||
<td class="p-4">{{ project.client.name }}</td>
|
||||
<td class="p-4">
|
||||
{% if project.status == 'active' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">Active</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Archived</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if project.billable %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">Billable</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">Non‑billable</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if project.hourly_rate %}
|
||||
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ '%.2f'|format(project.hourly_rate|float) }}/h</span>
|
||||
{% else %}
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% if project.budget_amount %}
|
||||
{% set consumed = (project.budget_consumed_amount or 0.0) %}
|
||||
{% set total = project.budget_amount|float %}
|
||||
{% set pct = (consumed / total * 100) if total > 0 else 0 %}
|
||||
{% if pct >= 90 %}
|
||||
{% set badge_classes = 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' %}
|
||||
{% elif pct >= 70 %}
|
||||
{% set badge_classes = 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' %}
|
||||
{% else %}
|
||||
{% set badge_classes = 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' %}
|
||||
{% endif %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium {{ badge_classes }}">{{ pct|round(0) }}%</span>
|
||||
{% else %}
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-primary hover:underline">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">No projects found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
115
app/templates/projects/view.html
Normal file
115
app/templates/projects/view.html
Normal file
@@ -0,0 +1,115 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ project.name }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ project.client.name }}</p>
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Edit Project</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Left Column: Project Details -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">Details</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Description</h3>
|
||||
<p>{{ project.description or 'No description provided.' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Status</h3>
|
||||
<p class="{{ 'text-green-500' if project.status == 'active' else 'text-red-500' }}">{{ project.status | capitalize }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Billing</h3>
|
||||
<p>{{ 'Billable' if project.billable else 'Not Billable' }} {% if project.hourly_rate %}({{ "%.2f"|format(project.hourly_rate) }}/hr){% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">User Contributions</h2>
|
||||
<ul>
|
||||
{% for user_total in user_totals %}
|
||||
<li class="flex justify-between py-2 border-b border-border-light dark:border-border-dark">
|
||||
<span>{{ user_total.username }}</span>
|
||||
<span class="font-semibold">{{ "%.2f"|format(user_total.total_hours) }} hrs</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>No hours logged yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Tabs for Tasks, Entries, etc. -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow p-6 overflow-x-auto">
|
||||
<h3 class="text-lg font-semibold mb-4">{{ _('Tasks for this project') }}</h3>
|
||||
<table class="w-full text-left">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-3">{{ _('Name') }}</th>
|
||||
<th class="p-3">{{ _('Priority') }}</th>
|
||||
<th class="p-3">{{ _('Status') }}</th>
|
||||
<th class="p-3">{{ _('Due') }}</th>
|
||||
<th class="p-3">{{ _('Progress') }}</th>
|
||||
<th class="p-3">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-3">{{ task.name }}</td>
|
||||
<td class="p-3">
|
||||
{% set p = task.priority %}
|
||||
{% set pcls = {'low':'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
'medium':'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300',
|
||||
'high':'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
'urgent':'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'}[p] if p in ['low','medium','high','urgent'] else 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium {{ pcls }}">{{ task.priority_display }}</span>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
{% set s = task.status %}
|
||||
{% set scls = {'todo':'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||
'in_progress':'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||
'review':'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
'done':'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
'cancelled':'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'}[s] if s in ['todo','in_progress','review','done','cancelled'] else 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium {{ scls }}">{{ task.status_display }}</span>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
{% if task.due_date %}
|
||||
{% set overdue = task.is_overdue %}
|
||||
<span class="px-2 py-1 rounded-md text-xs font-medium {{ 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' if overdue else 'bg-primary/10 text-primary' }}">{{ task.due_date.strftime('%Y-%m-%d') }}</span>
|
||||
{% else %}
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-3">
|
||||
{% set pct = task.progress_percentage or 0 %}
|
||||
<div class="w-28 h-2 bg-gray-200 dark:bg-gray-700 rounded">
|
||||
<div class="h-2 rounded {{ 'bg-emerald-500' if pct>=100 else 'bg-primary' }}" style="width: {{ [pct,100]|min }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">{{ _('View') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="p-3 text-center text-text-muted-light dark:text-text-muted-dark">{{ _('No tasks for this project.') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts_extra %}{% endblock %}
|
||||
52
app/templates/reports/index.html
Normal file
52
app/templates/reports/index.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/cards.html" import info_card %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Reports</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
{{ info_card("Total Hours", "%.2f"|format(summary.total_hours), "All time") }}
|
||||
{{ info_card("Billable Hours", "%.2f"|format(summary.billable_hours), "All time") }}
|
||||
{{ info_card("Active Projects", summary.active_projects, "Currently active") }}
|
||||
{{ info_card("Active Users", summary.total_users, "Currently active") }}
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Report Types</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a href="{{ url_for('reports.project_report') }}" class="bg-primary text-white p-4 rounded-lg text-center">Project Report</a>
|
||||
<a href="{{ url_for('reports.user_report') }}" class="bg-primary text-white p-4 rounded-lg text-center">User Report</a>
|
||||
<a href="{{ url_for('reports.summary_report') }}" class="bg-primary text-white p-4 rounded-lg text-center">Summary Report</a>
|
||||
<a href="{{ url_for('reports.task_report') }}" class="bg-primary text-white p-4 rounded-lg text-center">Task Report</a>
|
||||
<a href="{{ url_for('reports.export_csv') }}" class="bg-secondary text-white p-4 rounded-lg text-center">Export CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">Recent Entries</h2>
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="p-2">Project</th>
|
||||
<th class="p-2">Duration</th>
|
||||
<th class="p-2">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in recent_entries %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-2">{{ entry.project.name }}</td>
|
||||
<td class="p-2">{{ entry.duration }}</td>
|
||||
<td class="p-2">{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="p-4 text-center">No recent entries.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
68
app/templates/reports/project_report.html
Normal file
68
app/templates/reports/project_report.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Project Report</h1>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||
<select name="project_id" id="project_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<option value="">All Projects</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if selected_project == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">User</label>
|
||||
<select name="user_id" id="user_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if selected_user == user.id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Start Date</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ start_date }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div>
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">End Date</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ end_date }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div class="self-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="p-2">Project</th>
|
||||
<th class="p-2">Total Hours</th>
|
||||
<th class="p-2">Billable Hours</th>
|
||||
<th class="p-2">Billable Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for project in projects_data %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-2">{{ project.name }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(project.total_hours) }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(project.billable_hours) }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(project.billable_amount) }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="p-4 text-center">No data for the selected period.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
38
app/templates/reports/summary.html
Normal file
38
app/templates/reports/summary.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/cards.html" import info_card %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Summary Report</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
{{ info_card("Today's Hours", "%.2f"|format(today_hours), "Logged today") }}
|
||||
{{ info_card("Week's Hours", "%.2f"|format(week_hours), "Logged this week") }}
|
||||
{{ info_card("Month's Hours", "%.2f"|format(month_hours), "Logged this month") }}
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">Top Projects (Last 30 Days)</h2>
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="p-2">Project</th>
|
||||
<th class="p-2">Total Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for stat in project_stats %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-2">{{ stat.project.name }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(stat.hours) }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2" class="p-4 text-center">No project data for the last 30 days.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
68
app/templates/reports/task_report.html
Normal file
68
app/templates/reports/task_report.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Task Report</h1>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||
<select name="project_id" id="project_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<option value="">All Projects</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if selected_project == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">User</label>
|
||||
<select name="user_id" id="user_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if selected_user == user.id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Start Date</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ start_date }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div>
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">End Date</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ end_date }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div class="self-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="p-2">Task</th>
|
||||
<th class="p-2">Project</th>
|
||||
<th class="p-2">Completed At</th>
|
||||
<th class="p-2">Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task_row in tasks %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-2">{{ task_row.task.name }}</td>
|
||||
<td class="p-2">{{ task_row.project.name }}</td>
|
||||
<td class="p-2">{{ task_row.completed_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(task_row.hours) }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="p-4 text-center">No completed tasks for the selected period.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
66
app/templates/reports/user_report.html
Normal file
66
app/templates/reports/user_report.html
Normal file
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">User Report</h1>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">User</label>
|
||||
<select name="user_id" id="user_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if selected_user == user.id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||
<select name="project_id" id="project_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
<option value="">All Projects</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if selected_project == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Start Date</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ start_date }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div>
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">End Date</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ end_date }}" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div class="self-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="p-2">User</th>
|
||||
<th class="p-2">Total Hours</th>
|
||||
<th class="p-2">Billable Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for username, totals in user_totals.items() %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-2">{{ username }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(totals.hours) }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(totals.billable_hours) }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="p-4 text-center">No data for the selected period.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -8,95 +8,65 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<!-- Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-body py-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 48px; height: 48px;">
|
||||
<i class="fas fa-plus text-primary fa-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="h2 mb-1">{{ _('Create New Task') }}</h1>
|
||||
<p class="text-muted mb-0">{{ _('Add a new task to your project to break down work into manageable components') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ _('Create Task') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Add a new task to your project to break down work into manageable components') }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('tasks.list_tasks') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Tasks') }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Create Task Form -->
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-edit me-2 text-primary"></i>{{ _('Task Information') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" id="createTaskForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Task Name -->
|
||||
<div class="mb-4">
|
||||
<label for="name" class="form-label fw-semibold">
|
||||
<i class="fas fa-tag me-2 text-primary"></i>{{ _('Task Name') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-lg" id="name" name="name"
|
||||
value="{{ request.form.get('name', '') }}" placeholder="{{ _('Enter a descriptive task name') }}" required>
|
||||
<small class="form-text text-muted">{{ _('Choose a clear, descriptive name that explains what needs to be done') }}</small>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Task Name') }} *</label>
|
||||
<input type="text" id="name" name="name" value="{{ request.form.get('name', '') }}" placeholder="{{ _('Enter a descriptive task name') }}" required class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Choose a clear, descriptive name that explains what needs to be done') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-4">
|
||||
<label for="description" class="form-label fw-semibold d-flex align-items-center justify-content-between">
|
||||
<i class="fas fa-align-left me-2 text-primary"></i>{{ _('Description') }}
|
||||
<small class="text-muted">{{ _('Supports Markdown') }}</small>
|
||||
</label>
|
||||
<div class="flex items-center justify-between">
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Description') }}</label>
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark">{{ _('Supports Markdown') }}</small>
|
||||
</div>
|
||||
<div class="markdown-editor-wrapper">
|
||||
<textarea class="form-control d-none" id="description" name="description" rows="12" placeholder="{{ _('Provide detailed information about the task, requirements, and any specific instructions...') }}">{{ request.form.get('description', '') }}</textarea>
|
||||
<textarea class="form-input d-none" id="description" name="description" rows="12" placeholder="{{ _('Provide detailed information about the task, requirements, and any specific instructions...') }}">{{ request.form.get('description', '') }}</textarea>
|
||||
<div id="description_editor"></div>
|
||||
</div>
|
||||
<small class="form-text text-muted">{{ _('Optional: Add context, requirements, or specific instructions for the task') }}</small>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Add context, requirements, or specific instructions for the task') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Project Selection -->
|
||||
<div class="mb-4">
|
||||
<label for="project_id" class="form-label fw-semibold">
|
||||
<i class="fas fa-project-diagram me-2 text-primary"></i>{{ _('Project') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select class="form-select form-select-lg" id="project_id" name="project_id" required>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Project') }} *</label>
|
||||
<select id="project_id" name="project_id" required class="form-input">
|
||||
<option value="">{{ _('Select a project') }}</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if request.form.get('project_id')|int == project.id or request.args.get('project_id')|int == project.id %}selected{% endif %}>
|
||||
{{ project.name }} ({{ project.client }})
|
||||
</option>
|
||||
<option value="{{ project.id }}" {% if request.form.get('project_id')|int == project.id or request.args.get('project_id')|int == project.id %}selected{% endif %}>{{ project.name }} ({{ project.client }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">{{ _('Select the project this task belongs to') }}</small>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Select the project this task belongs to') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Priority and Status -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label for="priority" class="form-label fw-semibold">
|
||||
<i class="fas fa-flag me-2 text-warning"></i>{{ _('Priority') }}
|
||||
</label>
|
||||
<select class="form-select" id="priority" name="priority">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div>
|
||||
<label for="priority" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Priority') }}</label>
|
||||
<select id="priority" name="priority" class="form-input">
|
||||
<option value="low" {% if request.form.get('priority') == 'low' %}selected{% endif %}>{{ _('Low') }}</option>
|
||||
<option value="medium" {% if request.form.get('priority') == 'medium' or not request.form.get('priority') %}selected{% endif %}>{{ _('Medium') }}</option>
|
||||
<option value="high" {% if request.form.get('priority') == 'high' %}selected{% endif %}>{{ _('High') }}</option>
|
||||
<option value="urgent" {% if request.form.get('priority') == 'urgent' %}selected{% endif %}>{{ _('Urgent') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="status" class="form-label fw-semibold">
|
||||
<i class="fas fa-tasks me-2 text-info"></i>{{ _('Initial Status') }}
|
||||
</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Initial Status') }}</label>
|
||||
<select id="status" name="status" class="form-input">
|
||||
<option value="todo" {% if request.form.get('status') == 'todo' or not request.form.get('status') %}selected{% endif %}>{{ _('To Do') }}</option>
|
||||
<option value="in_progress" {% if request.form.get('status') == 'in_progress' %}selected{% endif %}>{{ _('In Progress') }}</option>
|
||||
<option value="review" {% if request.form.get('status') == 'review' %}selected{% endif %}>{{ _('Review') }}</option>
|
||||
@@ -105,191 +75,92 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-4 text-xs">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Preview') }}:</span>
|
||||
<span id="priorityPreview" class="priority-badge priority-{{ request.form.get('priority', 'medium') }}">
|
||||
{% if request.form.get('priority') == 'low' %}{{ _('Low') }}{% elif request.form.get('priority') == 'high' %}{{ _('High') }}{% elif request.form.get('priority') == 'urgent' %}{{ _('Urgent') }}{% else %}{{ _('Medium') }}{% endif %}
|
||||
</span>
|
||||
<span id="statusPreview" class="status-badge status-{{ request.form.get('status', 'todo') }}">
|
||||
{% if request.form.get('status') == 'in_progress' %}{{ _('In Progress') }}{% elif request.form.get('status') == 'review' %}{{ _('Review') }}{% elif request.form.get('status') == 'done' %}{{ _('Done') }}{% elif request.form.get('status') == 'cancelled' %}{{ _('Cancelled') }}{% else %}{{ _('To Do') }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Due Date and Estimated Hours -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label for="due_date" class="form-label fw-semibold">
|
||||
<i class="fas fa-calendar me-2 text-secondary"></i>{{ _('Due Date') }}
|
||||
</label>
|
||||
<input type="date" class="form-control" id="due_date" name="due_date"
|
||||
value="{{ request.form.get('due_date', '') }}">
|
||||
<small class="form-text text-muted">{{ _('Optional: Set a deadline for this task') }}</small>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="due_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Due Date') }}</label>
|
||||
<input type="date" id="due_date" name="due_date" value="{{ request.form.get('due_date', '') }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Set a deadline for this task') }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="estimated_hours" class="form-label fw-semibold">
|
||||
<i class="fas fa-clock me-2 text-warning"></i>{{ _('Estimated Hours') }}
|
||||
</label>
|
||||
<input type="number" class="form-control" id="estimated_hours" name="estimated_hours"
|
||||
value="{{ request.form.get('estimated_hours', '') }}" step="0.5" min="0" placeholder="0.0">
|
||||
<small class="form-text text-muted">{{ _('Optional: Estimate how long this task will take') }}</small>
|
||||
<div>
|
||||
<label for="estimated_hours" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Estimated Hours') }}</label>
|
||||
<input type="number" id="estimated_hours" name="estimated_hours" value="{{ request.form.get('estimated_hours', '') }}" step="0.5" min="0" placeholder="0.0" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Estimate how long this task will take') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignment -->
|
||||
<div class="mb-4">
|
||||
<label for="assigned_to" class="form-label fw-semibold">
|
||||
<i class="fas fa-user me-2 text-info"></i>{{ _('Assign To') }}
|
||||
</label>
|
||||
<select class="form-select" id="assigned_to" name="assigned_to">
|
||||
<label for="assigned_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Assign To') }}</label>
|
||||
<select id="assigned_to" name="assigned_to" class="form-input">
|
||||
<option value="">{{ _('Unassigned') }}</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if request.form.get('assigned_to')|int == user.id %}selected{% endif %}>
|
||||
{{ user.display_name }}
|
||||
</option>
|
||||
<option value="{{ user.id }}" {% if request.form.get('assigned_to')|int == user.id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">{{ _('Optional: Assign this task to a team member') }}</small>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Assign this task to a team member') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex gap-3 pt-3 border-top">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-save me-2"></i>{{ _('Create Task') }}
|
||||
</button>
|
||||
<a href="{{ url_for('tasks.list_tasks') }}" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="fas fa-times me-2"></i>{{ _('Cancel') }}
|
||||
</a>
|
||||
<div class="mt-6 flex justify-end gap-3 border-t border-border-light dark:border-border-dark pt-4">
|
||||
<a href="{{ url_for('tasks.list_tasks') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">{{ _('Cancel') }}</a>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Create Task') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Help -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Task Creation Tips -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-lightbulb me-2 text-warning"></i>{{ _('Task Creation Tips') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tip-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-check text-primary fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Clear Naming') }}</small>
|
||||
<small class="text-muted">{{ _('Use action verbs and be specific about what needs to be done') }}</small>
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow space-y-4 text-sm" data-testid="task-create-tips">
|
||||
<h3 class="text-lg font-semibold">{{ _('Task Creation Tips') }}</h3>
|
||||
<ul class="space-y-2" role="list">
|
||||
<li class="tip-item flex items-start gap-3">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-sky-500/10 text-sky-600 dark:text-sky-400" aria-hidden="true">
|
||||
<i class="fas fa-bullseye text-[13px]"></i>
|
||||
</span>
|
||||
<div>
|
||||
<strong class="block">{{ _('Clear Naming') }}</strong>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Use action verbs and be specific about what needs to be done') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-clock text-success fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Realistic Estimates') }}</small>
|
||||
<small class="text-muted">{{ _('Consider complexity and dependencies when estimating time') }}</small>
|
||||
</div>
|
||||
</li>
|
||||
<li class="tip-item flex items-start gap-3">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400" aria-hidden="true">
|
||||
<i class="fas fa-hourglass-half text-[13px]"></i>
|
||||
</span>
|
||||
<div>
|
||||
<strong class="block">{{ _('Realistic Estimates') }}</strong>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Consider complexity and dependencies when estimating time') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-calendar text-info fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Set Deadlines') }}</small>
|
||||
<small class="text-muted">{{ _('Due dates help prioritize work and track progress') }}</small>
|
||||
</div>
|
||||
</li>
|
||||
<li class="tip-item flex items-start gap-3">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-indigo-500/10 text-indigo-600 dark:text-indigo-400" aria-hidden="true">
|
||||
<i class="fas fa-calendar-alt text-[13px]"></i>
|
||||
</span>
|
||||
<div>
|
||||
<strong class="block">{{ _('Set Deadlines') }}</strong>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Due dates help prioritize work and track progress') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-flag text-warning fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Priority Matters') }}</small>
|
||||
<small class="text-muted">{{ _('Use priority levels to help team members focus on what\'s most important') }}</small>
|
||||
</div>
|
||||
</li>
|
||||
<li class="tip-item flex items-start gap-3">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-rose-500/10 text-rose-600 dark:text-rose-400" aria-hidden="true">
|
||||
<i class="fas fa-flag text-[13px]"></i>
|
||||
</span>
|
||||
<div>
|
||||
<strong class="block">{{ _('Priority Matters') }}</strong>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _("Use priority levels to help team members focus on what's most important") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Guide -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-info-circle me-2 text-info"></i>{{ _('Priority Guide') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="priority-guide-item mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="priority-badge priority-low me-2">{{ _('Low') }}</span>
|
||||
<small class="text-muted">{{ _('Non-urgent, can be done later') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="priority-guide-item mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="priority-badge priority-medium me-2">{{ _('Medium') }}</span>
|
||||
<small class="text-muted">{{ _('Normal priority, standard timeline') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="priority-guide-item mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="priority-badge priority-high me-2">{{ _('High') }}</span>
|
||||
<small class="text-muted">{{ _('Important, needs attention soon') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="priority-guide-item">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="priority-badge priority-urgent me-2">{{ _('Urgent') }}</span>
|
||||
<small class="text-muted">{{ _('Critical, immediate attention required') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Guide -->
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-list me-2 text-secondary"></i>{{ _('Status Guide') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="status-guide-item mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="status-badge status-todo me-2">{{ _('To Do') }}</span>
|
||||
<small class="text-muted">{{ _('Task is planned but not started') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-guide-item mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="status-badge status-in_progress me-2">{{ _('In Progress') }}</span>
|
||||
<small class="text-muted">{{ _('Work has begun on the task') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-guide-item mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="status-badge status-review me-2">{{ _('Review') }}</span>
|
||||
<small class="text-muted">{{ _('Task is ready for review/testing') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-guide-item">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="status-badge status-done me-2">{{ _('Done') }}</span>
|
||||
<small class="text-muted">{{ _('Task is completed successfully') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,6 +227,11 @@
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Tip Items */
|
||||
.tip-item {
|
||||
padding: 0.75rem;
|
||||
@@ -369,6 +245,14 @@
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Dark mode for tip items */
|
||||
.dark .tip-item {
|
||||
background-color: #0f172a; /* slate-900 */
|
||||
}
|
||||
.dark .tip-item:hover {
|
||||
background-color: #0b1220; /* slightly lighter hover */
|
||||
}
|
||||
|
||||
/* Priority and Status Guide Items */
|
||||
.priority-guide-item,
|
||||
.status-guide-item {
|
||||
@@ -439,11 +323,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('createTaskForm');
|
||||
const nameInput = document.getElementById('name');
|
||||
const descriptionInput = document.getElementById('description');
|
||||
const prioritySelect = document.getElementById('priority');
|
||||
const statusSelect = document.getElementById('status');
|
||||
const priorityPreview = document.getElementById('priorityPreview');
|
||||
const statusPreview = document.getElementById('statusPreview');
|
||||
let mdEditor = null;
|
||||
|
||||
// Initialize Toast UI Editor
|
||||
if (descriptionInput && window.toastui && window.toastui.Editor) {
|
||||
const theme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
mdEditor = new toastui.Editor({
|
||||
el: document.getElementById('description_editor'),
|
||||
height: '380px',
|
||||
@@ -467,8 +355,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Apply theme changes dynamically if supported
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme' && mdEditor) {
|
||||
const nextTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class' && mdEditor) {
|
||||
const nextTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
try {
|
||||
if (typeof mdEditor.setTheme === 'function') {
|
||||
mdEditor.setTheme(nextTheme);
|
||||
@@ -477,7 +365,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// Image upload hook
|
||||
mdEditor.removeHook && mdEditor.removeHook('addImageBlobHook');
|
||||
@@ -518,11 +406,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
nameInput.classList.add('is-invalid');
|
||||
return false;
|
||||
}
|
||||
// Sync markdown content back to hidden textarea
|
||||
if (mdEditor && descriptionInput) {
|
||||
try { descriptionInput.value = mdEditor.getMarkdown(); } catch (err) {}
|
||||
}
|
||||
|
||||
// Sync markdown content back to hidden textarea
|
||||
if (mdEditor && descriptionInput) {
|
||||
try { descriptionInput.value = mdEditor.getMarkdown(); } catch (err) {}
|
||||
@@ -549,6 +432,36 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
this.classList.remove('is-valid');
|
||||
}
|
||||
});
|
||||
// Live badge previews
|
||||
function updateBadge(element, value, type) {
|
||||
if (!element) return;
|
||||
const map = type === 'priority' ? {
|
||||
low: { text: '{{ _('Low') }}', class: 'priority-low' },
|
||||
medium: { text: '{{ _('Medium') }}', class: 'priority-medium' },
|
||||
high: { text: '{{ _('High') }}', class: 'priority-high' },
|
||||
urgent: { text: '{{ _('Urgent') }}', class: 'priority-urgent' },
|
||||
} : {
|
||||
todo: { text: '{{ _('To Do') }}', class: 'status-todo' },
|
||||
in_progress: { text: '{{ _('In Progress') }}', class: 'status-in_progress' },
|
||||
review: { text: '{{ _('Review') }}', class: 'status-review' },
|
||||
done: { text: '{{ _('Done') }}', class: 'status-done' },
|
||||
cancelled: { text: '{{ _('Cancelled') }}', class: 'status-cancelled' },
|
||||
};
|
||||
const def = map[value] || Object.values(map)[0];
|
||||
// Reset classes preserving base class
|
||||
element.className = element.className.split(' ').filter(c => !c.startsWith(type === 'priority' ? 'priority-' : 'status-')).join(' ').trim();
|
||||
element.classList.add(def.class);
|
||||
element.textContent = def.text;
|
||||
}
|
||||
|
||||
function handlePreviewChange(){
|
||||
updateBadge(priorityPreview, prioritySelect?.value || 'medium', 'priority');
|
||||
updateBadge(statusPreview, statusSelect?.value || 'todo', 'status');
|
||||
}
|
||||
|
||||
prioritySelect && prioritySelect.addEventListener('change', handlePreviewChange);
|
||||
statusSelect && statusSelect.addEventListener('change', handlePreviewChange);
|
||||
handlePreviewChange();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -8,67 +8,68 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ _('Edit Task') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Update task details and settings for "%(task)s"', task=task.name) }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Task') }}</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<!-- Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-body py-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 48px; height: 48px;">
|
||||
<i class="fas fa-edit text-warning fa-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="h2 mb-1">{{ _('Edit Task') }}</h1>
|
||||
<p class="text-muted mb-0">{{ _('Update task details and settings for "%(task)s"', task=task.name) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg mb-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center justify-center mr-3 rounded-full bg-yellow-100 dark:bg-yellow-900/30" style="width:48px;height:48px;">
|
||||
<i class="fas fa-edit text-yellow-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">{{ _('Edit Task') }}</h2>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Update task details and settings for "%(task)s"', task=task.name) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Task Form -->
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-edit me-2 text-warning"></i>{{ _('Task Information') }}
|
||||
</h6>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow">
|
||||
<div class="border-b border-border-light dark:border-border-dark p-4">
|
||||
<h6 class="font-semibold flex items-center gap-2"><i class="fas fa-edit text-yellow-600"></i>{{ _('Task Information') }}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-4">
|
||||
<form method="POST" id="editTaskForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Task Name -->
|
||||
<div class="mb-4">
|
||||
<label for="name" class="form-label fw-semibold">
|
||||
<i class="fas fa-tag me-2 text-primary"></i>{{ _('Task Name') }} <span class="text-danger">*</span>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-tag mr-2 text-primary"></i>{{ _('Task Name') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-lg" id="name" name="name"
|
||||
<input type="text" class="form-input" id="name" name="name"
|
||||
value="{{ task.name }}" placeholder="{{ _('Enter a descriptive task name') }}" required>
|
||||
<small class="form-text text-muted">{{ _('Choose a clear, descriptive name that explains what needs to be done') }}</small>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Choose a clear, descriptive name that explains what needs to be done') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-4">
|
||||
<label for="description" class="form-label fw-semibold d-flex align-items-center justify-content-between">
|
||||
<span><i class="fas fa-align-left me-2 text-primary"></i>{{ _('Description') }}</span>
|
||||
<small class="text-muted">{{ _('Markdown supported') }}</small>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<span><i class="fas fa-align-left mr-2 text-primary"></i>{{ _('Description') }}</span>
|
||||
</label>
|
||||
<div class="markdown-editor-wrapper">
|
||||
<textarea class="form-control d-none" id="description" name="description" rows="12" placeholder="{{ _('Provide detailed information about the task, requirements, and any specific instructions...') }}">{{ task.description or '' }}</textarea>
|
||||
<textarea class="form-input d-none" id="description" name="description" rows="12" placeholder="{{ _('Provide detailed information about the task, requirements, and any specific instructions...') }}">{{ task.description or '' }}</textarea>
|
||||
<div id="description_editor"></div>
|
||||
</div>
|
||||
<small class="form-text text-muted">{{ _('Optional: Add context, requirements, or specific instructions for the task') }}</small>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Add context, requirements, or specific instructions for the task') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Project Selection -->
|
||||
<div class="mb-4">
|
||||
<label for="project_id" class="form-label fw-semibold">
|
||||
<i class="fas fa-project-diagram me-2 text-primary"></i>{{ _('Project') }} <span class="text-danger">*</span>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-project-diagram mr-2 text-primary"></i>{{ _('Project') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select class="form-select form-select-lg" id="project_id" name="project_id" required>
|
||||
<select class="form-input" id="project_id" name="project_id" required>
|
||||
<option value="">{{ _('Select a project') }}</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if task.project_id == project.id %}selected{% endif %}>
|
||||
@@ -76,27 +77,23 @@
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">{{ _('Select the project this task belongs to') }}</small>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Select the project this task belongs to') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Priority and Status -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label for="priority" class="form-label fw-semibold">
|
||||
<i class="fas fa-flag me-2 text-warning"></i>{{ _('Priority') }}
|
||||
</label>
|
||||
<select class="form-select" id="priority" name="priority">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div>
|
||||
<label for="priority" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Priority') }}</label>
|
||||
<select id="priority" name="priority" class="form-input">
|
||||
<option value="low" {% if task.priority == 'low' %}selected{% endif %}>{{ _('Low') }}</option>
|
||||
<option value="medium" {% if task.priority == 'medium' %}selected{% endif %}>{{ _('Medium') }}</option>
|
||||
<option value="high" {% if task.priority == 'high' %}selected{% endif %}>{{ _('High') }}</option>
|
||||
<option value="urgent" {% if task.priority == 'urgent' %}selected{% endif %}>{{ _('Urgent') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="status" class="form-label fw-semibold">
|
||||
<i class="fas fa-tasks me-2 text-info"></i>{{ _('Status') }}
|
||||
</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Status') }}</label>
|
||||
<select id="status" name="status" class="form-input">
|
||||
<option value="todo" {% if task.status == 'todo' %}selected{% endif %}>{{ _('To Do') }}</option>
|
||||
<option value="in_progress" {% if task.status == 'in_progress' %}selected{% endif %}>{{ _('In Progress') }}</option>
|
||||
<option value="review" {% if task.status == 'review' %}selected{% endif %}>{{ _('Review') }}</option>
|
||||
@@ -105,86 +102,103 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-4 text-xs">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Preview') }}:</span>
|
||||
<span id="priorityPreview" class="priority-badge priority-{{ task.priority }}">
|
||||
{{ task.priority_display }}
|
||||
</span>
|
||||
<span id="statusPreview" class="status-badge status-{{ task.status }}">
|
||||
{{ task.status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Due Date and Estimated Hours -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label for="due_date" class="form-label fw-semibold">
|
||||
<i class="fas fa-calendar me-2 text-secondary"></i>{{ _('Due Date') }}
|
||||
</label>
|
||||
<input type="date" class="form-control" id="due_date" name="due_date"
|
||||
value="{{ task.due_date.strftime('%Y-%m-%d') if task.due_date else '' }}">
|
||||
<small class="form-text text-muted">{{ _('Optional: Set a deadline for this task') }}</small>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="due_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Due Date') }}</label>
|
||||
<input type="date" id="due_date" name="due_date" value="{{ task.due_date.strftime('%Y-%m-%d') if task.due_date else '' }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Set a deadline for this task') }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="estimated_hours" class="form-label fw-semibold">
|
||||
<i class="fas fa-clock me-2 text-warning"></i>{{ _('Estimated Hours') }}
|
||||
</label>
|
||||
<input type="number" class="form-control" id="estimated_hours" name="estimated_hours"
|
||||
value="{{ task.estimated_hours or '' }}" step="0.5" min="0" placeholder="0.0">
|
||||
<small class="form-text text-muted">{{ _('Optional: Estimate how long this task will take') }}</small>
|
||||
<div>
|
||||
<label for="estimated_hours" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Estimated Hours') }}</label>
|
||||
<input type="number" id="estimated_hours" name="estimated_hours" value="{{ task.estimated_hours or '' }}" step="0.5" min="0" placeholder="0.0" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Estimate how long this task will take') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignment -->
|
||||
<div class="mb-4">
|
||||
<label for="assigned_to" class="form-label fw-semibold">
|
||||
<i class="fas fa-user me-2 text-info"></i>{{ _('Assign To') }}
|
||||
</label>
|
||||
<select class="form-select" id="assigned_to" name="assigned_to">
|
||||
<label for="assigned_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Assign To') }}</label>
|
||||
<select id="assigned_to" name="assigned_to" class="form-input">
|
||||
<option value="">{{ _('Unassigned') }}</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if task.assigned_to == user.id %}selected{% endif %}>
|
||||
{{ user.display_name }}
|
||||
</option>
|
||||
<option value="{{ user.id }}" {% if task.assigned_to == user.id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">{{ _('Optional: Assign this task to a team member') }}</small>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Assign this task to a team member') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex gap-3 pt-3 border-top">
|
||||
<button type="submit" class="btn btn-warning btn-lg">
|
||||
<i class="fas fa-save me-2"></i>{{ _('Update Task') }}
|
||||
</button>
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="fas fa-times me-2"></i>{{ _('Cancel') }}
|
||||
</a>
|
||||
<div class="mt-6 flex justify-end gap-3 border-t border-border-light dark:border-border-dark pt-4">
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">{{ _('Cancel') }}</a>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Update Task') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Information -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Current Task Info -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-info-circle me-2 text-info"></i>{{ _('Current Task Info') }}
|
||||
</h6>
|
||||
<!-- Sidebar -->
|
||||
<div class="lg:col-span-1 space-y-4">
|
||||
<!-- Progress -->
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow">
|
||||
<div class="border-b border-border-light dark:border-border-dark p-4">
|
||||
<h6 class="font-semibold flex items-center gap-2"><i class="fas fa-chart-line text-primary"></i>{{ _('Progress') }}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-4" data-testid="task-edit-progress">
|
||||
<div class="flex items-center justify-between text-sm mb-2">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Estimate used') }}</span>
|
||||
<span class="font-medium">{{ task.progress_percentage }}%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded bg-border-light dark:bg-border-dark overflow-hidden">
|
||||
<div class="h-2 bg-primary rounded" style="width: {{ task.progress_percentage }}%"></div>
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
<span>{{ _('Actual') }}: {{ task.total_hours }} {{ _('h') }}</span>
|
||||
{% if task.estimated_hours %}
|
||||
<span class="ml-2">· {{ _('Estimated') }}: {{ task.estimated_hours }} {{ _('h') }}</span>
|
||||
{% endif %}
|
||||
{% if task.total_billable_hours %}
|
||||
<span class="ml-2">· {{ _('Billable') }}: {{ task.total_billable_hours }} {{ _('h') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Current Task Info -->
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow mb-4">
|
||||
<div class="border-b border-border-light dark:border-border-dark p-4">
|
||||
<h6 class="font-semibold flex items-center gap-2"><i class="fas fa-info-circle text-sky-600"></i>{{ _('Current Task Info') }}</h6>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="task-info-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Current Status') }}</small>
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark block mb-1">{{ _('Current Status') }}</small>
|
||||
<span class="status-badge status-{{ task.status }}">
|
||||
{{ task.status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="task-info-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Current Priority') }}</small>
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark block mb-1">{{ _('Current Priority') }}</small>
|
||||
<span class="priority-badge priority-{{ task.priority }}">
|
||||
{{ task.priority_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="task-info-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Project') }}</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-project-diagram text-primary fa-xs"></i>
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark block mb-1">{{ _('Project') }}</small>
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-full flex items-center justify-center mr-2 bg-sky-500/10" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-project-diagram text-sky-600 fa-xs"></i>
|
||||
</div>
|
||||
<span>{{ task.project.name }}</span>
|
||||
</div>
|
||||
@@ -192,10 +206,10 @@
|
||||
|
||||
{% if task.assigned_user %}
|
||||
<div class="task-info-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Currently Assigned To') }}</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height:24px;">
|
||||
<i class="fas fa-user text-info fa-xs"></i>
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark block mb-1">{{ _('Currently Assigned To') }}</small>
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-full flex items-center justify-content-center mr-2 bg-cyan-500/10" style="width: 24px; height:24px;">
|
||||
<i class="fas fa-user text-cyan-600 fa-xs"></i>
|
||||
</div>
|
||||
<span>{{ task.assigned_user.display_name }}</span>
|
||||
</div>
|
||||
@@ -204,12 +218,12 @@
|
||||
|
||||
{% if task.due_date %}
|
||||
<div class="task-info-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Current Due Date') }}</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-{% if task.is_overdue %}danger{% else %}secondary{% endif %} bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-calendar text-{% if task.is_overdue %}danger{% else %}secondary{% endif %} fa-xs"></i>
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark block mb-1">{{ _('Current Due Date') }}</small>
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-full flex items-center justify-content-center mr-2 {% if task.is_overdue %}bg-rose-500/10{% else %}bg-slate-500/10{% endif %}" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-calendar {% if task.is_overdue %}text-rose-600{% else %}text-slate-500{% endif %} fa-xs"></i>
|
||||
</div>
|
||||
<span class="{% if task.is_overdue %}text-danger fw-bold{% endif %}">
|
||||
<span class="{% if task.is_overdue %}text-rose-600 font-semibold{% endif %}">
|
||||
{{ task.due_date.strftime('%B %d, %Y') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -218,10 +232,10 @@
|
||||
|
||||
{% if task.estimated_hours %}
|
||||
<div class="task-info-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Current Estimate') }}</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-clock text-warning fa-xs"></i>
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark block mb-1">{{ _('Current Estimate') }}</small>
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-full flex items-center justify-content-center mr-2 bg-amber-500/10" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-clock text-amber-600 fa-xs"></i>
|
||||
</div>
|
||||
<span>{{ task.estimated_hours }} {{ _('hours') }}</span>
|
||||
</div>
|
||||
@@ -230,84 +244,90 @@
|
||||
|
||||
{% if task.total_hours > 0 %}
|
||||
<div class="task-info-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Actual Hours') }}</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-stopwatch text-success fa-xs"></i>
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark block mb-1">{{ _('Actual Hours') }}</small>
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-full flex items-center justify-content-center mr-2 bg-emerald-500/10" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-stopwatch text-emerald-600 fa-xs"></i>
|
||||
</div>
|
||||
<span>{{ task.total_hours }} {{ _('hours') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="task-info-item">
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
<div><span class="block">{{ _('Created') }}</span><span class="text-text-light dark:text-text-dark">{{ task.created_at.strftime('%b %d, %Y %H:%M') if task.created_at else '-' }}</span></div>
|
||||
<div><span class="block">{{ _('Updated') }}</span><span class="text-text-light dark:text-text-dark">{{ task.updated_at.strftime('%b %d, %Y %H:%M') if task.updated_at else '-' }}</span></div>
|
||||
<div><span class="block">{{ _('Started') }}</span><span class="text-text-light dark:text-text-dark">{{ task.started_at.strftime('%b %d, %Y %H:%M') if task.started_at else '-' }}</span></div>
|
||||
<div><span class="block">{{ _('Completed') }}</span><span class="text-text-light dark:text-text-dark">{{ task.completed_at.strftime('%b %d, %Y %H:%M') if task.completed_at else '-' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-bolt me-2 text-warning"></i>{{ _('Quick Actions') }}
|
||||
</h6>
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow mb-4">
|
||||
<div class="border-b border-border-light dark:border-border-dark p-4">
|
||||
<h6 class="font-semibold flex items-center gap-2"><i class="fas fa-bolt text-yellow-600"></i>{{ _('Quick Actions') }}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-eye me-2"></i>{{ _('View Task') }}
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-primary text-primary hover:bg-primary/10">
|
||||
<i class="fas fa-eye mr-2"></i>{{ _('View Task') }}
|
||||
</a>
|
||||
|
||||
{% if task.status == 'todo' or task.status == 'in_progress' %}
|
||||
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project_id, task_id=task.id) }}" class="btn btn-success btn-sm">
|
||||
<i class="fas fa-play me-2"></i>{{ _('Start Timer') }}
|
||||
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project_id, task_id=task.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-700">
|
||||
<i class="fas fa-play mr-2"></i>{{ _('Start Timer') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('tasks.list_tasks') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-list me-2"></i>{{ _('Back to Tasks') }}
|
||||
<a href="{{ url_for('tasks.list_tasks') }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
|
||||
<i class="fas fa-list mr-2"></i>{{ _('Back to Tasks') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Tips -->
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-lightbulb me-2 text-warning"></i>{{ _('Edit Tips') }}
|
||||
</h6>
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow" data-testid="task-edit-tips">
|
||||
<div class="border-b border-border-light dark:border-border-dark p-4">
|
||||
<h6 class="font-semibold flex items-center gap-2"><i class="fas fa-lightbulb text-yellow-600"></i>{{ _('Edit Tips') }}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-4">
|
||||
<div class="tip-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-info text-info fa-xs"></i>
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-info text-sky-600 dark:text-sky-400 fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Status Changes') }}</small>
|
||||
<small class="text-muted">{{ _('Changing status may affect time tracking and progress calculations') }}</small>
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark">{{ _('Changing status may affect time tracking and progress calculations') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-exclamation-triangle text-warning fa-xs"></i>
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-exclamation-triangle text-amber-600 dark:text-amber-400 fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Due Date Updates') }}</small>
|
||||
<small class="text-muted">{{ _('Consider team workload when adjusting deadlines') }}</small>
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark">{{ _('Consider team workload when adjusting deadlines') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-check text-success fa-xs"></i>
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-check text-emerald-600 dark:text-emerald-400 fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Assignment Changes') }}</small>
|
||||
<small class="text-muted">{{ _('Notify team members when reassigning tasks') }}</small>
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark">{{ _('Notify team members when reassigning tasks') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -410,12 +430,12 @@
|
||||
}
|
||||
|
||||
/* Dark mode for task info blocks to match view page */
|
||||
[data-theme="dark"] .task-info-item,
|
||||
[data-theme="dark"] .tip-item {
|
||||
.dark .task-info-item,
|
||||
.dark .tip-item {
|
||||
background-color: #0f172a;
|
||||
}
|
||||
[data-theme="dark"] .task-info-item:hover,
|
||||
[data-theme="dark"] .tip-item:hover {
|
||||
.dark .task-info-item:hover,
|
||||
.dark .tip-item:hover {
|
||||
background-color: #0b1220;
|
||||
}
|
||||
|
||||
@@ -473,11 +493,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('editTaskForm');
|
||||
const nameInput = document.getElementById('name');
|
||||
const descriptionInput = document.getElementById('description');
|
||||
const prioritySelect = document.getElementById('priority');
|
||||
const statusSelect = document.getElementById('status');
|
||||
const priorityPreview = document.getElementById('priorityPreview');
|
||||
const statusPreview = document.getElementById('statusPreview');
|
||||
let mdEditor = null;
|
||||
|
||||
// Initialize Toast UI Editor
|
||||
if (descriptionInput && window.toastui && window.toastui.Editor) {
|
||||
const theme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
mdEditor = new toastui.Editor({
|
||||
el: document.getElementById('description_editor'),
|
||||
height: '380px',
|
||||
@@ -501,8 +525,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Apply theme changes dynamically if supported
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme' && mdEditor) {
|
||||
const nextTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class' && mdEditor) {
|
||||
const nextTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
try {
|
||||
if (typeof mdEditor.setTheme === 'function') {
|
||||
mdEditor.setTheme(nextTheme);
|
||||
@@ -511,7 +535,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// Image upload hook
|
||||
mdEditor.removeHook && mdEditor.removeHook('addImageBlobHook');
|
||||
@@ -586,9 +610,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const currentPriority = '{{ task.priority }}';
|
||||
|
||||
// Add visual indicators for current values
|
||||
const statusSelect = document.getElementById('status');
|
||||
const prioritySelect = document.getElementById('priority');
|
||||
|
||||
if (statusSelect) {
|
||||
statusSelect.addEventListener('change', function() {
|
||||
this.classList.remove('border-success', 'border-warning');
|
||||
@@ -610,6 +631,34 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Live badge previews
|
||||
function updateBadge(element, value, type) {
|
||||
if (!element) return;
|
||||
const map = type === 'priority' ? {
|
||||
low: { text: '{{ _('Low') }}', class: 'priority-low' },
|
||||
medium: { text: '{{ _('Medium') }}', class: 'priority-medium' },
|
||||
high: { text: '{{ _('High') }}', class: 'priority-high' },
|
||||
urgent: { text: '{{ _('Urgent') }}', class: 'priority-urgent' },
|
||||
} : {
|
||||
todo: { text: '{{ _('To Do') }}', class: 'status-todo' },
|
||||
in_progress: { text: '{{ _('In Progress') }}', class: 'status-in_progress' },
|
||||
review: { text: '{{ _('Review') }}', class: 'status-review' },
|
||||
done: { text: '{{ _('Done') }}', class: 'status-done' },
|
||||
cancelled: { text: '{{ _('Cancelled') }}', class: 'status-cancelled' },
|
||||
};
|
||||
const def = map[value] || Object.values(map)[0];
|
||||
element.className = element.className.split(' ').filter(c => !c.startsWith(type === 'priority' ? 'priority-' : 'status-')).join(' ').trim();
|
||||
element.classList.add(def.class);
|
||||
element.textContent = def.text;
|
||||
}
|
||||
function handlePreviewChange(){
|
||||
updateBadge(priorityPreview, prioritySelect?.value || currentPriority, 'priority');
|
||||
updateBadge(statusPreview, statusSelect?.value || currentStatus, 'status');
|
||||
}
|
||||
prioritySelect && prioritySelect.addEventListener('change', handlePreviewChange);
|
||||
statusSelect && statusSelect.addEventListener('change', handlePreviewChange);
|
||||
handlePreviewChange();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,351 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Tasks') }} - Time Tracker{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<!-- Prevent page caching for kanban board -->
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate, max-age=0">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
{% from "_components.html" import page_header %}
|
||||
{% set skeleton = request.args.get('loading') %}
|
||||
{% set view = request.args.get('view', 'board') %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% set actions %}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<a href="{{ url_for('tasks.list_tasks', view='board', search=search, status=status, priority=priority, project_id=project_id, assigned_to=assigned_to, overdue=1 if overdue else None) }}" class="btn-header btn-outline-primary {% if view != 'table' %}active{% endif %}">
|
||||
<i class="fas fa-columns"></i> {{ _('Board') }}
|
||||
</a>
|
||||
<a href="{{ url_for('tasks.list_tasks', view='table', search=search, status=status, priority=priority, project_id=project_id, assigned_to=assigned_to, overdue=1 if overdue else None) }}" class="btn-header btn-outline-primary {% if view == 'table' %}active{% endif %}">
|
||||
<i class="fas fa-table"></i> {{ _('Table') }}
|
||||
</a>
|
||||
<a href="{{ url_for('tasks.create_task') }}" class="btn-header btn-primary">
|
||||
<i class="fas fa-plus"></i> {{ _('New Task') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endset %}
|
||||
{{ page_header('fas fa-tasks', _('Tasks'), _('Plan and track work') ~ ' • ' ~ (tasks|length) ~ ' ' ~ _('total'), actions) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Summary Cards (Invoices-style) -->
|
||||
<div class="row mb-4 stagger-animation">
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100 summary-card scale-hover">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="summary-icon bg-primary bg-opacity-10 text-primary">
|
||||
<i class="fas fa-list-check"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="summary-label">{{ _('To Do') }}</div>
|
||||
<div class="summary-value" data-count-up="{{ tasks|selectattr('status', 'equalto', 'todo')|list|length }}">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100 summary-card scale-hover">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="summary-icon bg-warning bg-opacity-10 text-warning">
|
||||
<i class="fas fa-spinner"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="summary-label">{{ _('In Progress') }}</div>
|
||||
<div class="summary-value" data-count-up="{{ tasks|selectattr('status', 'equalto', 'in_progress')|list|length }}">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100 summary-card scale-hover">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="summary-icon bg-info bg-opacity-10 text-info">
|
||||
<i class="fas fa-user-check"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="summary-label">{{ _('Review') }}</div>
|
||||
<div class="summary-value" data-count-up="{{ tasks|selectattr('status', 'equalto', 'review')|list|length }}">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100 summary-card scale-hover">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="summary-icon bg-success bg-opacity-10 text-success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="summary-label">{{ _('Completed') }}</div>
|
||||
<div class="summary-value" data-count-up="{{ tasks|selectattr('status', 'equalto', 'done')|list|length }}">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-filter me-2 text-muted"></i>{{ _('Filter Tasks') }}
|
||||
</h6>
|
||||
<button type="button" class="btn btn-sm" id="toggleFilters" onclick="toggleFilterVisibility()" title="{{ _('Toggle Filters') }}">
|
||||
<i class="fas fa-chevron-up" id="filterToggleIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" id="filterBody">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-4 col-sm-6">
|
||||
<label for="search" class="form-label">{{ _('Search') }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||||
<input type="text" class="form-control" id="search" name="search"
|
||||
value="{{ search }}" placeholder="{{ _('Task name or description') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6">
|
||||
<label for="status" class="form-label">{{ _('Status') }}</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<option value="">{{ _('All Statuses') }}</option>
|
||||
<option value="todo" {% if status == 'todo' %}selected{% endif %}>{{ _('To Do') }}</option>
|
||||
<option value="in_progress" {% if status == 'in_progress' %}selected{% endif %}>{{ _('In Progress') }}</option>
|
||||
<option value="review" {% if status == 'review' %}selected{% endif %}>{{ _('Review') }}</option>
|
||||
<option value="done" {% if status == 'done' %}selected{% endif %}>{{ _('Done') }}</option>
|
||||
<option value="cancelled" {% if status == 'cancelled' %}selected{% endif %}>{{ _('Cancelled') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6">
|
||||
<label for="priority" class="form-label">{{ _('Priority') }}</label>
|
||||
<select class="form-select" id="priority" name="priority">
|
||||
<option value="">{{ _('All Priorities') }}</option>
|
||||
<option value="low" {% if priority == 'low' %}selected{% endif %}>{{ _('Low') }}</option>
|
||||
<option value="medium" {% if priority == 'medium' %}selected{% endif %}>{{ _('Medium') }}</option>
|
||||
<option value="high" {% if priority == 'high' %}selected{% endif %}>{{ _('High') }}</option>
|
||||
<option value="urgent" {% if priority == 'urgent' %}selected{% endif %}>{{ _('Urgent') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6">
|
||||
<label for="project_id" class="form-label">{{ _('Project') }}</label>
|
||||
<select class="form-select" id="project_id" name="project_id">
|
||||
<option value="">{{ _('All Projects') }}</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>
|
||||
{{ project.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6">
|
||||
<label for="assigned_to" class="form-label">{{ _('Assigned To') }}</label>
|
||||
<select class="form-select" id="assigned_to" name="assigned_to">
|
||||
<option value="">{{ _('All Users') }}</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if assigned_to == user.id %}selected{% endif %}>
|
||||
{{ user.display_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="1" id="overdue" name="overdue" {% if overdue %}checked{% endif %}>
|
||||
<label class="form-check-label" for="overdue">
|
||||
{{ _('Show overdue only') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-search me-2"></i>{{ _('Apply Filters') }}
|
||||
</button>
|
||||
<a href="{{ url_for('tasks.list_tasks') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-2"></i>{{ _('Clear') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-body">
|
||||
{% if tasks %}
|
||||
{% if view == 'table' %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('ID') }}</th>
|
||||
<th>{{ _('Task') }}</th>
|
||||
<th>{{ _('Project') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th>{{ _('Priority') }}</th>
|
||||
<th>{{ _('Assignee') }}</th>
|
||||
<th>{{ _('Due') }}</th>
|
||||
<th>{{ _('Est.') }}</th>
|
||||
<th>{{ _('Progress') }}</th>
|
||||
<th class="text-end">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr class="{% if current_user.active_timer and current_user.active_timer.task_id == task.id %}table-active{% endif %}">
|
||||
<td>#{{ task.id }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="fw-semibold text-decoration-none">{{ task.name }}</a>
|
||||
{% if task.description %}
|
||||
<div class="text-muted small">{{ task.description[:90] }}{% if task.description|length > 90 %}...{% endif %}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ task.project.name if task.project else '-' }}</td>
|
||||
<td><span class="status-badge status-{{ task.status }}">{{ task.status_display }}</span></td>
|
||||
<td><span class="priority-badge priority-{{ task.priority }}">{{ task.priority_display }}</span></td>
|
||||
<td>{{ task.assigned_user.display_name if task.assigned_user else '-' }}</td>
|
||||
<td>{% if task.due_date %}{{ task.due_date.strftime('%Y-%m-%d') }}{% else %}-{% endif %}</td>
|
||||
<td>{% if task.estimated_hours %}{{ '%.1f'|format(task.estimated_hours) }}h{% else %}-{% endif %}</td>
|
||||
<td style="min-width:140px;">
|
||||
<div class="progress progress-thin">
|
||||
<div class="progress-bar" role="progressbar" style="width: {{ task.progress_percentage }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group" role="group">
|
||||
{% if current_user.active_timer and current_user.active_timer.task_id == task.id %}
|
||||
<form method="POST" action="{{ url_for('timer.stop_timer') }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-action btn-action--danger touch-target" title="{{ _('Stop Timer') }}"><i class="fas fa-stop"></i></button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="{{ url_for('timer.start_timer') }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="project_id" value="{{ task.project_id }}">
|
||||
<input type="hidden" name="task_id" value="{{ task.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-action btn-action--success touch-target" title="{{ _('Start Timer') }}"><i class="fas fa-play"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="btn btn-sm btn-action btn-action--view touch-target" title="{{ _('View Task') }}"><i class="fas fa-eye"></i></a>
|
||||
<a href="{{ url_for('tasks.edit_task', task_id=task.id) }}" class="btn btn-sm btn-action btn-action--edit touch-target" title="{{ _('Edit Task') }}"><i class="fas fa-pen"></i></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{% include 'tasks/_kanban.html' %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="bg-light rounded-circle d-flex align-items-center justify-content-center mx-auto mb-4" style="width: 80px; height: 80px;">
|
||||
<i class="fas fa-tasks fa-2x text-muted"></i>
|
||||
</div>
|
||||
<h3 class="text-muted mb-3">{{ _('No tasks found') }}</h3>
|
||||
<p class="text-muted mb-4">{{ _('Try adjusting your filters or create your first task to get started.') }}</p>
|
||||
<div class="d-flex flex-column flex-sm-row gap-2 justify-content-center">
|
||||
<a href="{{ url_for('tasks.create_task') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus me-2"></i>{{ _('Create Your First Task') }}
|
||||
</a>
|
||||
<a href="{{ url_for('tasks.list_tasks') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-2"></i>{{ _('Clear Filters') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Tasks</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Manage your tasks here.</p>
|
||||
</div>
|
||||
<a href="{{ url_for('tasks.create_task') }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Create Task</a>
|
||||
</div>
|
||||
|
||||
<!-- Modern styling now handled by global CSS in base.css -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Filter Tasks</h2>
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="lg:col-span-1">
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||
<input type="text" name="search" id="search" value="{{ search or '' }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
|
||||
<select name="status" id="status" class="form-input">
|
||||
<option value="">All</option>
|
||||
<option value="todo" {% if status == 'todo' %}selected{% endif %}>To Do</option>
|
||||
<option value="in_progress" {% if status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="review" {% if status == 'review' %}selected{% endif %}>Review</option>
|
||||
<option value="done" {% if status == 'done' %}selected{% endif %}>Done</option>
|
||||
<option value="cancelled" {% if status == 'cancelled' %}selected{% endif %}>Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="priority" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
|
||||
<select name="priority" id="priority" class="form-input">
|
||||
<option value="">All</option>
|
||||
<option value="low" {% if priority == 'low' %}selected{% endif %}>Low</option>
|
||||
<option value="medium" {% if priority == 'medium' %}selected{% endif %}>Medium</option>
|
||||
<option value="high" {% if priority == 'high' %}selected{% endif %}>High</option>
|
||||
<option value="urgent" {% if priority == 'urgent' %}selected{% endif %}>Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||
<select name="project_id" id="project_id" class="form-input">
|
||||
<option value="">All</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="assigned_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Assigned To</label>
|
||||
<select name="assigned_to" id="assigned_to" class="form-input">
|
||||
<option value="">All</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if assigned_to == user.id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center pt-5">
|
||||
<input type="checkbox" name="overdue" id="overdue" value="1" {% if overdue %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<label for="overdue" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">Overdue only</label>
|
||||
</div>
|
||||
<div class="col-span-full flex justify-end">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Task list page-specific styles */
|
||||
.filter-collapsed {
|
||||
display: none !important;
|
||||
}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
|
||||
<table class="table table-zebra w-full text-left">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-4">Name</th>
|
||||
<th class="p-4">Project</th>
|
||||
<th class="p-4">Priority</th>
|
||||
<th class="p-4">Status</th>
|
||||
<th class="p-4 table-number">Due</th>
|
||||
<th class="p-4 table-number">Progress</th>
|
||||
<th class="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-4">{{ task.name }}</td>
|
||||
<td class="p-4">{{ task.project.name }}</td>
|
||||
<td class="p-4">
|
||||
{% set p = task.priority %}
|
||||
{% set pcls = {'low':'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
'medium':'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300',
|
||||
'high':'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
'urgent':'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'}[p] if p in ['low','medium','high','urgent'] else 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium {{ pcls }}">{{ task.priority_display }}</span>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
{% set s = task.status %}
|
||||
{% set scls = {'todo':'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||
'in_progress':'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||
'review':'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
'done':'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
'cancelled':'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'}[s] if s in ['todo','in_progress','review','done','cancelled'] else 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium {{ scls }}">{{ task.status_display }}</span>
|
||||
</td>
|
||||
<td class="p-4 table-number">
|
||||
{% if task.due_date %}
|
||||
{% set overdue = task.is_overdue %}
|
||||
<span class="chip {{ 'chip-danger' if overdue else 'chip-neutral' }}">{{ task.due_date.strftime('%Y-%m-%d') }}</span>
|
||||
{% else %}
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 table-number">
|
||||
{% set pct = task.progress_percentage or 0 %}
|
||||
<div class="w-28 h-2 bg-gray-200 dark:bg-gray-700 rounded">
|
||||
<div class="h-2 rounded {{ 'bg-emerald-500' if pct>=100 else 'bg-primary' }}" style="width: {{ [pct,100]|min }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">No tasks found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
.filter-toggle-transition {
|
||||
transition: var(--transition-slow);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function toggleFilterVisibility() {
|
||||
const filterBody = document.getElementById('filterBody');
|
||||
const toggleIcon = document.getElementById('filterToggleIcon');
|
||||
const toggleButton = document.getElementById('toggleFilters');
|
||||
|
||||
if (filterBody.classList.contains('filter-collapsed')) {
|
||||
// Show filters
|
||||
filterBody.classList.remove('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-up';
|
||||
toggleButton.title = '{{ _('Hide Filters') }}';
|
||||
localStorage.setItem('taskFiltersVisible', 'true');
|
||||
} else {
|
||||
// Hide filters
|
||||
filterBody.classList.add('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-down';
|
||||
toggleButton.title = '{{ _('Show Filters') }}';
|
||||
localStorage.setItem('taskFiltersVisible', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize filter visibility on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const filterBody = document.getElementById('filterBody');
|
||||
const toggleIcon = document.getElementById('filterToggleIcon');
|
||||
const toggleButton = document.getElementById('toggleFilters');
|
||||
|
||||
// Get saved state (default to visible)
|
||||
const filtersVisible = localStorage.getItem('taskFiltersVisible') || 'true';
|
||||
|
||||
if (filtersVisible === 'false') {
|
||||
filterBody.classList.add('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-down';
|
||||
toggleButton.title = '{{ _('Show Filters') }}';
|
||||
}
|
||||
|
||||
// Add transition class after initial setup
|
||||
setTimeout(() => {
|
||||
filterBody.classList.add('filter-toggle-transition');
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Kanban column updates are handled by global socket in base.html
|
||||
console.log('Task list page loaded - listening for kanban updates via global socket');
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block content_after %}{% endblock %}
|
||||
@@ -1,896 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ task.name }} - Time Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('tasks.list_tasks') }}">{{ _('Tasks') }}</a></li>
|
||||
<li class="breadcrumb-item active">{{ task.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Task Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-body py-4">
|
||||
<div class="row align-items-start">
|
||||
<div class="col-lg-8 col-md-7 mb-3 mb-md-0">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 48px; height: 48px;">
|
||||
<i class="fas fa-tasks text-primary fa-lg"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h1 class="h2 mb-2">{{ task.name }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status and Priority -->
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<span class="status-badge status-{{ task.status }}">
|
||||
{{ task.status_display }}
|
||||
</span>
|
||||
<span class="priority-badge priority-{{ task.priority }}">
|
||||
{{ task.priority_display }}
|
||||
</span>
|
||||
{% if task.is_overdue %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>Overdue
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4 col-md-5">
|
||||
<div class="d-flex flex-column gap-2">
|
||||
{% if current_user.active_timer and current_user.active_timer.task_id == task.id %}
|
||||
<form method="POST" action="{{ url_for('timer.stop_timer') }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-stop me-2"></i>{{ _('Stop Timer') }}
|
||||
</button>
|
||||
</form>
|
||||
{% elif not current_user.active_timer %}
|
||||
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project_id, task_id=task.id) }}" class="btn btn-success">
|
||||
<i class="fas fa-play me-2"></i>{{ _('Start Timer') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.is_admin or task.created_by == current_user.id %}
|
||||
<a href="{{ url_for('tasks.edit_task', task_id=task.id) }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-edit me-2"></i>{{ _('Edit Task') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('tasks.list_tasks') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-2"></i>{{ _('Back to Tasks') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Details Grid -->
|
||||
<div class="row g-4">
|
||||
<!-- Main Task Information -->
|
||||
<div class="col-lg-8">
|
||||
<!-- Task Description -->
|
||||
{% if task.description %}
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-align-left me-2 text-primary"></i>{{ _('Description') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="task-description-content">
|
||||
{{ task.description | markdown | safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Project Information -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-project-diagram me-2 text-primary"></i>Project Information
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
|
||||
<i class="fas fa-project-diagram text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-1">{{ task.project.name }}</h6>
|
||||
<p class="text-muted mb-0">{{ task.project.description or _('No description available') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time Tracking -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-clock me-2 text-warning"></i>{{ _('Time Tracking') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{% if task.estimated_hours %}
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="text-center">
|
||||
<div class="h4 text-warning mb-1">{{ task.estimated_hours }}</div>
|
||||
<small class="text-muted">{{ _('Estimated Hours') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if task.total_hours > 0 %}
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="text-center">
|
||||
<div class="h4 text-success mb-1">{{ task.total_hours }}</div>
|
||||
<small class="text-muted">{{ _('Actual Hours') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if task.total_billable_hours > 0 %}
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="text-center">
|
||||
<div class="h4 text-success mb-1">{{ task.total_billable_hours }}</div>
|
||||
<small class="text-muted">{{ _('Billable Hours') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if task.estimated_hours and task.total_hours > 0 %}
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="text-center">
|
||||
<div class="h4 text-info mb-1">{{ task.progress_percentage }}%</div>
|
||||
<small class="text-muted">{{ _('Progress') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="text-center">
|
||||
<div class="h4 text-primary mb-1">{{ task.time_entries.count() }}</div>
|
||||
<small class="text-muted">{{ _('Time Entries') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if task.estimated_hours and task.total_hours > 0 %}
|
||||
<div class="mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<small class="text-muted">{{ _('Progress Overview') }}</small>
|
||||
<small class="text-muted fw-bold">{{ task.progress_percentage }}% {{ _('Complete') }}</small>
|
||||
</div>
|
||||
<div class="progress" style="height: 8px;">
|
||||
<div class="progress-bar bg-primary" role="progressbar"
|
||||
style="width: {{ task.progress_percentage }}%"
|
||||
aria-valuenow="{{ task.progress_percentage }}"
|
||||
aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Time Entries -->
|
||||
{% if task.time_entries.count() > 0 %}
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-history me-2 text-info"></i>{{ _('Recent Time Entries') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Date') }}</th>
|
||||
<th>{{ _('Duration') }}</th>
|
||||
<th>{{ _('Notes') }}</th>
|
||||
<th>{{ _('User') }}</th>
|
||||
<th class="text-end">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="timeEntriesTableBody">
|
||||
{% for entry in time_entries %}
|
||||
<tr>
|
||||
<td>{{ entry.start_time.strftime('%b %d, %Y') }}</td>
|
||||
<td>
|
||||
{% if entry.duration_seconds %}
|
||||
{{ (entry.duration_seconds / 3600)|round(2) }}h
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.notes[:50] if entry.notes else '-' }}</td>
|
||||
<td>{{ entry.user.display_name if entry.user else '-' }}</td>
|
||||
<td class="text-end pe-4 actions-cell">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}"
|
||||
class="btn btn-sm btn-action btn-action--edit touch-target" title="{{ _('Edit entry') }}">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% if current_user.is_admin or entry.user_id == current_user.id %}
|
||||
<button type="button" class="btn btn-sm btn-action btn-action--danger touch-target" title="{{ _('Delete entry') }}"
|
||||
onclick="showDeleteEntryModal('{{ entry.id }}', '{{ task.project.name }}', '{{ entry.duration_formatted }}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if task.time_entries.count() > 10 %}
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<small class="text-muted" id="entriesInfo">{{ _('Showing') }} 10 {{ _('of') }} {{ task.time_entries.count() }} {{ _('entries') }}</small>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="showMoreBtn" onclick="showMoreEntries()">
|
||||
<i class="fas fa-chevron-down me-2"></i>{{ _('Show more') }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary d-none" id="showLessBtn" onclick="showLessEntries()">
|
||||
<i class="fas fa-chevron-up me-2"></i>{{ _('Show less') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Information -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Task Details -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-info-circle me-2 text-secondary"></i>{{ _('Task Details') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="task-detail-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Status') }}</small>
|
||||
<span class="status-badge status-{{ task.status }}">
|
||||
{{ task.status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="task-detail-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Priority') }}</small>
|
||||
<span class="priority-badge priority-{{ task.priority }}">
|
||||
{{ task.priority_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if task.assigned_user %}
|
||||
<div class="task-detail-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Assigned To') }}</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-user text-info fa-xs"></i>
|
||||
</div>
|
||||
<span>{{ task.assigned_user.display_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="task-detail-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Created By') }}</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-secondary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-user-plus text-secondary fa-xs"></i>
|
||||
</div>
|
||||
<span>{{ task.creator.display_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if task.due_date %}
|
||||
<div class="task-detail-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Due Date') }}</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-{% if task.is_overdue %}danger{% else %}secondary{% endif %} bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-calendar text-{% if task.is_overdue %}danger{% else %}secondary{% endif %} fa-xs"></i>
|
||||
</div>
|
||||
<span class="{% if task.is_overdue %}text-danger fw-bold{% endif %}">
|
||||
{{ task.due_date.strftime('%B %d, %Y') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if task.started_at %}
|
||||
<div class="task-detail-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Started') }}</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-play text-warning fa-xs"></i>
|
||||
</div>
|
||||
<span>{{ task.started_at.strftime('%B %d, %Y %H:%M') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if task.completed_at %}
|
||||
<div class="task-detail-item mb-3">
|
||||
<small class="text-muted d-block mb-1">{{ _('Completed') }}</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-check text-success fa-xs"></i>
|
||||
</div>
|
||||
<span>{{ task.completed_at.strftime('%B %d, %Y %H:%M') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-bolt me-2 text-warning"></i>Quick Actions
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="quick-actions d-flex flex-wrap gap-2">
|
||||
{% if task.status == 'todo' %}
|
||||
<button class="btn btn-sm btn-action btn-action--success" onclick="openStatusModal('in_progress')" title="{{ _('Start Task') }}" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% elif task.status == 'in_progress' %}
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-action btn-action--view" onclick="openStatusModal('review')" title="{{ _('Mark for Review') }}" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-action btn-action--warning" onclick="openStatusModal('todo')" title="{{ _('Pause Task') }}" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-pause"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% elif task.status == 'review' %}
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-action btn-action--success" onclick="openStatusModal('done')" title="{{ _('Complete Task') }}" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-action btn-action--edit" onclick="openStatusModal('in_progress')" title="{{ _('Back to Progress') }}" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% elif task.status == 'done' %}
|
||||
<button class="btn btn-sm btn-action btn-action--warning" onclick="openStatusModal('todo')" title="{{ _('Reopen Task') }}" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if task.status != 'done' and task.status != 'cancelled' %}
|
||||
<button class="btn btn-sm btn-action btn-action--danger" onclick="openStatusModal('cancelled')" title="{{ _('Cancel Task') }}" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Timeline -->
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-history me-2 text-info"></i>{{ _('Task Timeline') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker bg-primary"></div>
|
||||
<div class="timeline-content">
|
||||
<small class="text-muted">{{ task.created_at.strftime('%b %d, %Y') }}</small>
|
||||
<p class="mb-0">{{ _('Task created') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if task.started_at %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker bg-warning"></div>
|
||||
<div class="timeline-content">
|
||||
<small class="text-muted">{{ task.started_at.strftime('%b %d, %Y') }}</small>
|
||||
<p class="mb-0">{{ _('Task started') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if task.completed_at %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker bg-success"></div>
|
||||
<div class="timeline-content">
|
||||
<small class="text-muted">{{ task.completed_at.strftime('%b %d, %Y') }}</small>
|
||||
<p class="mb-0">{{ _('Task completed') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker bg-secondary"></div>
|
||||
<div class="timeline-content">
|
||||
<small class="text-muted">{{ task.updated_at.strftime('%b %d, %Y') }}</small>
|
||||
<p class="mb-0">{{ _('Last updated') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="card mobile-card mt-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-comments me-2 text-primary"></i>{{ _('Task Comments') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% include 'comments/_comments_section.html' with context %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Log -->
|
||||
{% if task.activities.count() > 0 %}
|
||||
<div class="card mobile-card mt-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-clipboard-list me-2 text-secondary"></i>{{ _('Activity Log') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('When') }}</th>
|
||||
<th>{{ _('Event') }}</th>
|
||||
<th>{{ _('User') }}</th>
|
||||
<th>{{ _('Details') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for act in activities %}
|
||||
<tr>
|
||||
<td>{{ act.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td class="text-capitalize">{{ act.event.replace('_', ' ') }}</td>
|
||||
<td>{{ act.user.display_name if act.user else '-' }}</td>
|
||||
<td class="text-muted">{{ act.details or '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ task.name }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Task details and history.</p>
|
||||
</div>
|
||||
{% if current_user.is_admin or task.created_by == current_user.id %}
|
||||
<a href="{{ url_for('tasks.edit_task', task_id=task.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Edit Task</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Status and Priority Badges */
|
||||
.status-badge, .priority-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Left Column: Task Details -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
{% if task.description %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">Description</h2>
|
||||
<p>{{ task.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
.status-todo {
|
||||
background-color: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.status-in_progress {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-review {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.status-done {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
background-color: #fed7aa;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.priority-urgent {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Task Detail Items */
|
||||
.task-detail-item {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background-color: #f8fafc;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.task-detail-item:hover {
|
||||
background-color: #f1f5f9;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Timeline Styling */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
left: -0.75rem;
|
||||
top: 0.25rem;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 0 0 2px #e2e8f0;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.timeline-content small {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.timeline-content p {
|
||||
font-size: 0.875rem;
|
||||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.progress {
|
||||
background-color: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: linear-gradient(90deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Mobile Optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
padding: 1rem 1rem 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
left: -0.625rem;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover Effects */
|
||||
.task-detail-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Dark mode for task details */
|
||||
[data-theme="dark"] .task-detail-item {
|
||||
background-color: #0f172a;
|
||||
}
|
||||
[data-theme="dark"] .task-detail-item:hover {
|
||||
background-color: #0b1220;
|
||||
}
|
||||
[data-theme="dark"] .timeline::before { background-color: #1f2937; }
|
||||
[data-theme="dark"] .timeline-marker { box-shadow: 0 0 0 2px #1f2937; }
|
||||
[data-theme="dark"] .card.mobile-card .card-header h6 { color: #e5e7eb; }
|
||||
|
||||
.btn:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
/* Quick actions: prevent full-width buttons, improve spacing */
|
||||
.quick-actions .btn { width: auto; }
|
||||
@media (max-width: 768px) {
|
||||
.quick-actions { gap: 0.5rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function updateTaskStatus(status) {
|
||||
// Build and submit form without default confirm
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '{{ url_for("tasks.update_task_status", task_id=task.id) }}';
|
||||
|
||||
const statusInput = document.createElement('input');
|
||||
statusInput.type = 'hidden';
|
||||
statusInput.name = 'status';
|
||||
statusInput.value = status;
|
||||
form.appendChild(statusInput);
|
||||
|
||||
// Add CSRF token
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function toggleEntries() { /* deprecated - replaced by showMoreEntries/showLessEntries */ }
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const rows = document.querySelectorAll('#timeEntriesTableBody tr');
|
||||
const total = rows.length;
|
||||
const initial = Math.min(10, total);
|
||||
rows.forEach((row, idx) => row.style.display = (idx < initial ? '' : 'none'));
|
||||
const info = document.getElementById('entriesInfo');
|
||||
if (info && total > 10) info.textContent = `Showing ${initial} of ${total} entries`;
|
||||
|
||||
// Initialize tooltips
|
||||
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
|
||||
new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
})
|
||||
});
|
||||
|
||||
// Quick Action Modal
|
||||
let pendingStatus = null;
|
||||
function openStatusModal(status) {
|
||||
pendingStatus = status;
|
||||
const titleMap = {
|
||||
'in_progress': 'Start Task',
|
||||
'review': 'Mark for Review',
|
||||
'todo': 'Pause / Reopen Task',
|
||||
'done': 'Complete Task',
|
||||
'cancelled': 'Cancel Task'
|
||||
};
|
||||
const bodyMap = {
|
||||
'in_progress': 'Are you sure you want to start this task now?',
|
||||
'review': 'Move this task to Review?',
|
||||
'todo': 'Move this task to To Do?',
|
||||
'done': 'Mark this task as Completed?',
|
||||
'cancelled': 'Cancel this task? You can reopen it later.'
|
||||
};
|
||||
|
||||
const modalEl = document.getElementById('statusConfirmModal');
|
||||
modalEl.querySelector('.modal-title').textContent = titleMap[status] || 'Confirm Action';
|
||||
modalEl.querySelector('.modal-body-text').textContent = bodyMap[status] || 'Are you sure?';
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function confirmStatusChange() {
|
||||
if (!pendingStatus) return;
|
||||
const btn = document.getElementById('statusConfirmBtn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Working...';
|
||||
}
|
||||
updateTaskStatus(pendingStatus);
|
||||
}
|
||||
|
||||
// Progressive show more/less for time entries
|
||||
function showMoreEntries() {
|
||||
const rows = Array.from(document.querySelectorAll('#timeEntriesTableBody tr'));
|
||||
const info = document.getElementById('entriesInfo');
|
||||
const showMoreBtn = document.getElementById('showMoreBtn');
|
||||
const showLessBtn = document.getElementById('showLessBtn');
|
||||
const currentlyVisible = rows.filter(r => r.style.display !== 'none').length;
|
||||
const toShow = Math.min(currentlyVisible + 10, rows.length);
|
||||
rows.forEach((row, idx) => row.style.display = (idx < toShow ? '' : 'none'));
|
||||
if (info) info.textContent = (toShow < rows.length) ? `Showing ${toShow} of ${rows.length} entries` : `Showing all ${rows.length} entries`;
|
||||
if (toShow >= rows.length) {
|
||||
showMoreBtn.classList.add('d-none');
|
||||
showLessBtn.classList.remove('d-none');
|
||||
} else {
|
||||
showLessBtn.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function showLessEntries() {
|
||||
const rows = Array.from(document.querySelectorAll('#timeEntriesTableBody tr'));
|
||||
const info = document.getElementById('entriesInfo');
|
||||
const showMoreBtn = document.getElementById('showMoreBtn');
|
||||
const showLessBtn = document.getElementById('showLessBtn');
|
||||
const initial = Math.min(10, rows.length);
|
||||
rows.forEach((row, idx) => row.style.display = (idx < initial ? '' : 'none'));
|
||||
if (info) info.textContent = `Showing ${initial} of ${rows.length} entries`;
|
||||
showMoreBtn.classList.remove('d-none');
|
||||
showLessBtn.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Function to show delete time entry modal
|
||||
function showDeleteEntryModal(entryId, projectName, duration) {
|
||||
const nameEl = document.getElementById('deleteEntryProjectName');
|
||||
const durationEl = document.getElementById('deleteEntryDuration');
|
||||
const formEl = document.getElementById('deleteEntryForm');
|
||||
if (nameEl) nameEl.textContent = projectName || '';
|
||||
if (durationEl) durationEl.textContent = duration || '';
|
||||
if (formEl) formEl.action = "{{ url_for('timer.delete_timer', timer_id=0) }}".replace('0', entryId);
|
||||
const modal = document.getElementById('deleteEntryModal');
|
||||
if (modal) new bootstrap.Modal(modal).show();
|
||||
}
|
||||
|
||||
// Loading state on delete submit
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteForm = document.getElementById('deleteEntryForm');
|
||||
if (deleteForm) {
|
||||
deleteForm.addEventListener('submit', function() {
|
||||
const btn = deleteForm.querySelector('button[type="submit"]');
|
||||
if (btn) {
|
||||
btn.innerHTML = '<div class="loading-spinner me-2"></div>Deleting...';
|
||||
btn.disabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Status Confirm Modal -->
|
||||
<div class="modal fade" id="statusConfirmModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ _('Confirm Action') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ _('Close') }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-body-text">{{ _('Are you sure?') }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-2"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
<button type="button" id="statusConfirmBtn" class="btn btn-primary" onclick="confirmStatusChange()">
|
||||
<i class="fas fa-check me-2"></i>{{ _('Confirm') }}
|
||||
</button>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">Time Entries</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-4">Date</th>
|
||||
<th class="p-4">Duration</th>
|
||||
<th class="p-4">User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in time_entries %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-4">{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="p-4">{{ entry.duration_formatted }}</td>
|
||||
<td class="p-4">{{ entry.user.display_name }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">No time has been logged for this task.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Time Entry Modal -->
|
||||
<div class="modal fade" id="deleteEntryModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Time Entry') }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
|
||||
<!-- Right Column: Metadata -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">Details</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Status</h3>
|
||||
<p>{{ task.status_display }}</p>
|
||||
</div>
|
||||
<p>{{ _('Are you sure you want to delete the time entry for') }} <strong id="deleteEntryProjectName"></strong>?</p>
|
||||
<p class="text-muted mb-0">{{ _('Duration:') }} <strong id="deleteEntryDuration"></strong></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
<form method="POST" id="deleteEntryForm" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash me-2"></i>{{ _('Delete Entry') }}
|
||||
</button>
|
||||
</form>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Priority</h3>
|
||||
<p>{{ task.priority_display }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Project</h3>
|
||||
<p>{{ task.project.name }}</p>
|
||||
</div>
|
||||
{% if task.assigned_user %}
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Assigned To</h3>
|
||||
<p>{{ task.assigned_user.display_name }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if task.due_date %}
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Due Date</h3>
|
||||
<p class="{{ 'text-red-500' if task.is_overdue else '' }}">{{ task.due_date.strftime('%Y-%m-%d') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Comment Modal -->
|
||||
<div class="modal fade" id="deleteCommentModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Comment') }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
|
||||
</div>
|
||||
<p>{{ _('Are you sure you want to delete this comment?') }}</p>
|
||||
<div id="comment-preview" class="bg-light p-3 rounded mt-3" style="display: none;">
|
||||
<div class="text-muted small mb-1">{{ _('Comment preview:') }}</div>
|
||||
<div id="comment-preview-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
<form method="POST" id="deleteCommentForm" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash me-2"></i>{{ _('Delete Comment') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
136
app/templates/timer/manual_entry.html
Normal file
136
app/templates/timer/manual_entry.html
Normal file
@@ -0,0 +1,136 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Log Time Manually</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Create a new time entry.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow max-w-3xl mx-auto">
|
||||
<form method="POST" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
|
||||
<select name="project_id" id="project_id" required class="form-input">
|
||||
<option value="">Select a project</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if selected_project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="task_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Task (optional)</label>
|
||||
<select name="task_id" id="task_id" class="form-input" data-selected-task-id="{{ selected_task_id or '' }}" {% if not selected_project_id %}disabled{% endif %}>
|
||||
<option value="">No task</option>
|
||||
</select>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">Tasks load after selecting a project</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Start Date</label>
|
||||
<input type="date" name="start_date" id="start_date" required class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="start_time" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Start Time</label>
|
||||
<input type="time" name="start_time" id="start_time" required class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">End Date</label>
|
||||
<input type="date" name="end_date" id="end_date" required class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="end_time" class="block text-sm font-medium text-gray-700 dark:text-gray-300">End Time</label>
|
||||
<input type="time" name="end_time" id="end_time" required class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="md:col-span-2">
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
|
||||
<textarea name="notes" id="notes" rows="4" class="form-input" placeholder="What did you work on?"></textarea>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="tags" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</label>
|
||||
<input type="text" name="tags" id="tags" class="form-input" placeholder="tag1, tag2">
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="billable" name="billable" class="h-5 w-5 rounded border-gray-300 text-primary focus:ring-0" checked>
|
||||
<label for="billable" class="text-sm text-gray-700 dark:text-gray-300">Billable</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-border-light dark:border-border-dark pt-6 flex justify-end gap-3">
|
||||
<button type="reset" class="px-4 py-2 rounded-lg border border-border-light dark:border-border-dark text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">Clear</button>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Log Time</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
// Default values for date/time to now
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const now = new Date();
|
||||
const hh = String(now.getHours()).padStart(2,'0');
|
||||
const mm = String(now.getMinutes()).padStart(2,'0');
|
||||
const startDate = document.getElementById('start_date');
|
||||
const endDate = document.getElementById('end_date');
|
||||
const startTime = document.getElementById('start_time');
|
||||
const endTime = document.getElementById('end_time');
|
||||
if (startDate && !startDate.value) startDate.value = today;
|
||||
if (endDate && !endDate.value) endDate.value = today;
|
||||
if (startTime && !startTime.value) startTime.value = `${hh}:${mm}`;
|
||||
if (endTime && !endTime.value) endTime.value = `${hh}:${mm}`;
|
||||
|
||||
// Dynamic task loading when a project is chosen
|
||||
const projectSelect = document.getElementById('project_id');
|
||||
const taskSelect = document.getElementById('task_id');
|
||||
async function loadTasks(projectId){
|
||||
if (!taskSelect) return;
|
||||
if (!projectId){
|
||||
taskSelect.innerHTML = '<option value="">No task</option>';
|
||||
taskSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
try{
|
||||
const resp = await fetch(`/api/tasks?project_id=${projectId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load tasks');
|
||||
const data = await resp.json();
|
||||
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
||||
taskSelect.innerHTML = '<option value="">No task</option>';
|
||||
tasks.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(t.id);
|
||||
opt.textContent = t.name;
|
||||
taskSelect.appendChild(opt);
|
||||
});
|
||||
const pre = taskSelect.getAttribute('data-selected-task-id');
|
||||
if (pre){
|
||||
const found = Array.from(taskSelect.options).some(o => o.value === pre);
|
||||
if (found) taskSelect.value = pre;
|
||||
taskSelect.setAttribute('data-selected-task-id','');
|
||||
}
|
||||
taskSelect.disabled = false;
|
||||
}catch(e){
|
||||
taskSelect.innerHTML = '<option value="">No task</option>';
|
||||
taskSelect.disabled = true;
|
||||
}
|
||||
}
|
||||
if (projectSelect){
|
||||
if (projectSelect.value){ loadTasks(projectSelect.value); }
|
||||
projectSelect.addEventListener('change', () => loadTasks(projectSelect.value));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
25
docs/QUICK_WINS_UI.md
Normal file
25
docs/QUICK_WINS_UI.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# UI Quick Wins (October 2025)
|
||||
|
||||
This document summarizes the lightweight improvements applied to the new UI.
|
||||
|
||||
## What changed
|
||||
|
||||
- Added minimal design tokens, button classes, focus ring utility, table helpers, and chips in `app/static/form-bridge.css`.
|
||||
- Added an accessible skip link and a main content anchor in `app/templates/base.html`.
|
||||
- Enhanced `app/templates/tasks/list.html` with sticky header treatment (CSS-only), zebra rows, and numeric alignment for date/progress columns.
|
||||
- Polished `app/templates/auth/login.html` with primary button styling and an inline user icon for the username field.
|
||||
- Added smoke tests in `tests/test_ui_quick_wins.py` to ensure presence of these enhancements.
|
||||
|
||||
## How to use
|
||||
|
||||
- Buttons: use `btn btn-primary`, `btn btn-secondary`, or `btn btn-ghost`. Sizes: add `btn-sm` or `btn-lg`.
|
||||
- Focus: add `focus-ring` to any interactive element that needs a consistent visible focus.
|
||||
- Tables: add `table table-zebra` to tables; use `table-compact` for denser rows and `table-number` on numeric cells/headers.
|
||||
- Chips: use `chip` plus variant like `chip-neutral`, `chip-success`, `chip-warning`, `chip-danger`.
|
||||
|
||||
## Notes
|
||||
|
||||
- The sticky header effect relies on `position: sticky` applied to the `<th>` elements via `.table` class. Ensure the table is inside a scrolling container (already true for the list view wrapper).
|
||||
- Token values are minimal fallbacks; prefer Tailwind theme tokens when available. These helpers are safe to remove once the templates are fully converted to Tailwind component primitives.
|
||||
|
||||
|
||||
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "timetracker-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Frontend assets for TimeTracker",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"install:all": "npm install && npm install -D tailwindcss postcss autoprefixer",
|
||||
"install:cmdk": "npm install cmdk",
|
||||
"build:css": "tailwindcss -i ./app/static/src/input.css -o ./app/static/dist/output.css --watch",
|
||||
"build:docker": "npx tailwindcss -i ./app/static/src/input.css -o ./app/static/dist/output.css"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"cmdk": "^1.1.1",
|
||||
"framer-motion": "^12.23.24"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
2
setup.py
2
setup.py
@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name='timetracker',
|
||||
version='2.3.7',
|
||||
version='3.0.0',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
|
||||
27
tailwind.config.js
Normal file
27
tailwind.config.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./app/templates/**/*.html',
|
||||
'./app/static/src/**/*.js',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'primary': '#4A90E2',
|
||||
'secondary': '#50E3C2',
|
||||
'background-light': '#F7F9FB',
|
||||
'background-dark': '#1A202C',
|
||||
'card-light': '#FFFFFF',
|
||||
'card-dark': '#2D3748',
|
||||
'text-light': '#2D3748',
|
||||
'text-dark': '#E2E8F0',
|
||||
'text-muted-light': '#A0AEC0',
|
||||
'text-muted-dark': '#718096',
|
||||
'border-light': '#E2E8F0',
|
||||
'border-dark': '#4A5568',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -3,462 +3,198 @@
|
||||
{% block title %}{{ _('OIDC Debug Dashboard') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-shield-alt text-primary"></i> {{ _('OIDC Debug Dashboard') }}
|
||||
</h1>
|
||||
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn-header btn-outline-primary">
|
||||
<i class="fas fa-arrow-left me-2"></i>{{ _('Back to Dashboard') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold"><i class="fas fa-shield-alt mr-2"></i>{{ _('OIDC Debug Dashboard') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Inspect configuration, provider metadata and OIDC users') }}</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row">
|
||||
<!-- Configuration Status -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-cog me-1"></i> {{ _('OIDC Configuration') }}</h5>
|
||||
<a href="{{ url_for('admin.oidc_test') }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-vial me-1"></i> {{ _('Test Configuration') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th width="40%">{{ _('Status') }}</th>
|
||||
<td>
|
||||
{% if oidc_config.enabled %}
|
||||
<span class="badge bg-success"><i class="fas fa-check-circle me-1"></i> {{ _('Enabled') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning"><i class="fas fa-exclamation-circle me-1"></i> {{ _('Disabled') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Auth Method') }}</th>
|
||||
<td><code>{{ oidc_config.auth_method }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Issuer') }}</th>
|
||||
<td>
|
||||
{% if oidc_config.issuer %}
|
||||
<code class="text-break">{{ oidc_config.issuer }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ _('Not configured') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Client ID') }}</th>
|
||||
<td>
|
||||
{% if oidc_config.client_id %}
|
||||
<code>{{ oidc_config.client_id }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ _('Not configured') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Client Secret') }}</th>
|
||||
<td>
|
||||
{% if oidc_config.client_secret_set %}
|
||||
<span class="text-success"><i class="fas fa-check-circle me-1"></i> {{ _('Set') }}</span>
|
||||
{% else %}
|
||||
<span class="text-danger"><i class="fas fa-times-circle me-1"></i> {{ _('Not set') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Redirect URI') }}</th>
|
||||
<td>
|
||||
{% if oidc_config.redirect_uri %}
|
||||
<code class="text-break">{{ oidc_config.redirect_uri }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ _('Auto-generated') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Scopes') }}</th>
|
||||
<td><code>{{ oidc_config.scopes }}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claim Mapping -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-id-card me-1"></i> {{ _('Claim Mapping') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th width="40%">{{ _('Username Claim') }}</th>
|
||||
<td><code>{{ oidc_config.username_claim }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Email Claim') }}</th>
|
||||
<td><code>{{ oidc_config.email_claim }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Full Name Claim') }}</th>
|
||||
<td><code>{{ oidc_config.full_name_claim }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Groups Claim') }}</th>
|
||||
<td><code>{{ oidc_config.groups_claim }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Admin Group') }}</th>
|
||||
<td>
|
||||
{% if oidc_config.admin_group %}
|
||||
<code>{{ oidc_config.admin_group }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ _('Not configured') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Admin Emails') }}</th>
|
||||
<td>
|
||||
{% if oidc_config.admin_emails %}
|
||||
{% for email in oidc_config.admin_emails %}
|
||||
<code class="d-block">{{ email }}</code>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted">{{ _('Not configured') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Post-Logout URI') }}</th>
|
||||
<td>
|
||||
{% if oidc_config.post_logout_redirect %}
|
||||
<code class="text-break">{{ oidc_config.post_logout_redirect }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ _('Auto-generated') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 md:mt-0">
|
||||
<a href="{{ url_for('admin.admin_dashboard') }}" class="px-3 py-2 rounded-lg border border-border-light dark:border-border-dark text-sm hover:bg-background-light dark:hover:bg-background-dark">
|
||||
<i class="fas fa-arrow-left mr-1"></i>{{ _('Back to Dashboard') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider Metadata -->
|
||||
{% if oidc_config.enabled and oidc_config.issuer %}
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-server me-1"></i> {{ _('Provider Metadata') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if metadata_error %}
|
||||
<div class="alert alert-danger mb-3">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>{{ _('Error loading metadata:') }}</strong> {{ metadata_error }}
|
||||
</div>
|
||||
{% if well_known_url %}
|
||||
<p class="mb-0">
|
||||
<small class="text-muted">
|
||||
{{ _('Discovery endpoint:') }} <code>{{ well_known_url }}</code>
|
||||
</small>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% elif metadata %}
|
||||
<div class="alert alert-success mb-3">
|
||||
<i class="fas fa-check-circle me-2"></i>
|
||||
{{ _('Successfully loaded provider metadata') }}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted mb-2">{{ _('Endpoints') }}</h6>
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
{% if metadata.authorization_endpoint %}
|
||||
<tr>
|
||||
<th width="40%">{{ _('Authorization') }}</th>
|
||||
<td><code class="text-break small">{{ metadata.authorization_endpoint }}</code></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if metadata.token_endpoint %}
|
||||
<tr>
|
||||
<th>{{ _('Token') }}</th>
|
||||
<td><code class="text-break small">{{ metadata.token_endpoint }}</code></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if metadata.userinfo_endpoint %}
|
||||
<tr>
|
||||
<th>{{ _('UserInfo') }}</th>
|
||||
<td><code class="text-break small">{{ metadata.userinfo_endpoint }}</code></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if metadata.end_session_endpoint %}
|
||||
<tr>
|
||||
<th>{{ _('End Session') }}</th>
|
||||
<td><code class="text-break small">{{ metadata.end_session_endpoint }}</code></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if metadata.jwks_uri %}
|
||||
<tr>
|
||||
<th>{{ _('JWKS URI') }}</th>
|
||||
<td><code class="text-break small">{{ metadata.jwks_uri }}</code></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted mb-2">{{ _('Supported Features') }}</h6>
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
{% if metadata.scopes_supported %}
|
||||
<tr>
|
||||
<th width="40%">{{ _('Scopes') }}</th>
|
||||
<td><small>{{ metadata.scopes_supported|join(', ') }}</small></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if metadata.response_types_supported %}
|
||||
<tr>
|
||||
<th>{{ _('Response Types') }}</th>
|
||||
<td><small>{{ metadata.response_types_supported|join(', ') }}</small></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if metadata.grant_types_supported %}
|
||||
<tr>
|
||||
<th>{{ _('Grant Types') }}</th>
|
||||
<td><small>{{ metadata.grant_types_supported|join(', ') }}</small></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if metadata.token_endpoint_auth_methods_supported %}
|
||||
<tr>
|
||||
<th>{{ _('Auth Methods') }}</th>
|
||||
<td><small>{{ metadata.token_endpoint_auth_methods_supported|join(', ') }}</small></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if metadata.claims_supported %}
|
||||
<tr>
|
||||
<th>{{ _('Claims') }}</th>
|
||||
<td><small>{{ metadata.claims_supported|join(', ') }}</small></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if well_known_url %}
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">
|
||||
{{ _('Discovery endpoint:') }} <code>{{ well_known_url }}</code>
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Configuration and Claims -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- OIDC Configuration -->
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold"><i class="fas fa-cog mr-2"></i>{{ _('OIDC Configuration') }}</h2>
|
||||
<a href="{{ url_for('admin.oidc_test') }}" class="px-3 py-2 rounded-lg bg-primary text-white text-sm hover:opacity-90"><i class="fas fa-vial mr-1"></i>{{ _('Test Configuration') }}</a>
|
||||
</div>
|
||||
<div class="divide-y divide-border-light dark:divide-border-dark">
|
||||
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
||||
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Status') }}</div>
|
||||
<div>
|
||||
{% if oidc_config.enabled %}
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 bg-green-100 text-green-700 text-xs"><i class="fas fa-check-circle mr-1"></i>{{ _('Enabled') }}</span>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
{{ _('Provider metadata not loaded. Click "Test Configuration" to fetch.') }}
|
||||
</p>
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 bg-amber-100 text-amber-700 text-xs"><i class="fas fa-exclamation-circle mr-1"></i>{{ _('Disabled') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- OIDC Users -->
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-users me-1"></i> {{ _('OIDC Users') }} ({{ oidc_users|length }})</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if oidc_users %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Username') }}</th>
|
||||
<th>{{ _('Email') }}</th>
|
||||
<th>{{ _('Full Name') }}</th>
|
||||
<th>{{ _('Role') }}</th>
|
||||
<th>{{ _('Last Login') }}</th>
|
||||
<th>{{ _('OIDC Subject') }}</th>
|
||||
<th>{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in oidc_users %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ user.username }}
|
||||
{% if not user.is_active %}
|
||||
<span class="badge bg-secondary ms-1">{{ _('Inactive') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.email or '-' }}</td>
|
||||
<td>{{ user.full_name or '-' }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-danger">{{ _('Admin') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">{{ _('User') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.last_login %}
|
||||
{{ user.last_login.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-muted">{{ _('Never') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small>
|
||||
<code class="text-break">{{ user.oidc_sub[:20] }}...</code>
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.oidc_user_detail', user_id=user.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-info-circle me-1"></i> {{ _('Details') }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
{{ _('No users have logged in via OIDC yet.') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
||||
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Auth Method') }}</div>
|
||||
<div><code>{{ oidc_config.auth_method }}</code></div>
|
||||
</div>
|
||||
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
||||
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Issuer') }}</div>
|
||||
<div>{% if oidc_config.issuer %}<code class="break-all">{{ oidc_config.issuer }}</code>{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Not configured') }}</span>{% endif %}</div>
|
||||
</div>
|
||||
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
||||
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Client ID') }}</div>
|
||||
<div>{% if oidc_config.client_id %}<code>{{ oidc_config.client_id }}</code>{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Not configured') }}</span>{% endif %}</div>
|
||||
</div>
|
||||
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
||||
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Client Secret') }}</div>
|
||||
<div>{% if oidc_config.client_secret_set %}<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>{{ _('Set') }}</span>{% else %}<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>{{ _('Not set') }}</span>{% endif %}</div>
|
||||
</div>
|
||||
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
||||
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Redirect URI') }}</div>
|
||||
<div>{% if oidc_config.redirect_uri %}<code class="break-all">{{ oidc_config.redirect_uri }}</code>{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Auto-generated') }}</span>{% endif %}</div>
|
||||
</div>
|
||||
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
||||
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Scopes') }}</div>
|
||||
<div><code>{{ oidc_config.scopes }}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Variables Reference -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-book me-1"></i> {{ _('Environment Variables Reference') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">{{ _('Configure OIDC using these environment variables:') }}</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Variable') }}</th>
|
||||
<th>{{ _('Description') }}</th>
|
||||
<th>{{ _('Example') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>AUTH_METHOD</code></td>
|
||||
<td>{{ _('Authentication method') }}</td>
|
||||
<td><code>oidc</code> or <code>both</code> or <code>local</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>OIDC_ISSUER</code></td>
|
||||
<td>{{ _('OIDC provider issuer URL') }}</td>
|
||||
<td><code>https://auth.example.com</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>OIDC_CLIENT_ID</code></td>
|
||||
<td>{{ _('Client ID from OIDC provider') }}</td>
|
||||
<td><code>timetracker</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>OIDC_CLIENT_SECRET</code></td>
|
||||
<td>{{ _('Client secret from OIDC provider') }}</td>
|
||||
<td><code>secret123</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>OIDC_REDIRECT_URI</code></td>
|
||||
<td>{{ _('Callback URL (optional, auto-generated)') }}</td>
|
||||
<td><code>https://app.example.com/auth/oidc/callback</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>OIDC_SCOPES</code></td>
|
||||
<td>{{ _('Requested scopes') }}</td>
|
||||
<td><code>openid profile email groups</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>OIDC_USERNAME_CLAIM</code></td>
|
||||
<td>{{ _('Claim containing username') }}</td>
|
||||
<td><code>preferred_username</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>OIDC_EMAIL_CLAIM</code></td>
|
||||
<td>{{ _('Claim containing email') }}</td>
|
||||
<td><code>email</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>OIDC_FULL_NAME_CLAIM</code></td>
|
||||
<td>{{ _('Claim containing full name') }}</td>
|
||||
<td><code>name</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>OIDC_GROUPS_CLAIM</code></td>
|
||||
<td>{{ _('Claim containing groups') }}</td>
|
||||
<td><code>groups</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>OIDC_ADMIN_GROUP</code></td>
|
||||
<td>{{ _('Group name for admin role (optional)') }}</td>
|
||||
<td><code>timetracker_admin</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>OIDC_ADMIN_EMAILS</code></td>
|
||||
<td>{{ _('Comma-separated admin emails (optional)') }}</td>
|
||||
<td><code>admin@example.com,boss@example.com</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Claim Mapping -->
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-id-card mr-2"></i>{{ _('Claim Mapping') }}</h2>
|
||||
<div class="divide-y divide-border-light dark:divide-border-dark text-sm">
|
||||
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Username Claim') }}</div><div><code>{{ oidc_config.username_claim }}</code></div></div>
|
||||
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Email Claim') }}</div><div><code>{{ oidc_config.email_claim }}</code></div></div>
|
||||
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Full Name Claim') }}</div><div><code>{{ oidc_config.full_name_claim }}</code></div></div>
|
||||
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Groups Claim') }}</div><div><code>{{ oidc_config.groups_claim }}</code></div></div>
|
||||
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Admin Group') }}</div><div>{% if oidc_config.admin_group %}<code>{{ oidc_config.admin_group }}</code>{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Not configured') }}</span>{% endif %}</div></div>
|
||||
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Admin Emails') }}</div><div>{% if oidc_config.admin_emails %}{% for email in oidc_config.admin_emails %}<code class="block">{{ email }}</code>{% endfor %}{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Not configured') }}</span>{% endif %}</div></div>
|
||||
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Post-Logout URI') }}</div><div>{% if oidc_config.post_logout_redirect %}<code class="break-all">{{ oidc_config.post_logout_redirect }}</code>{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Auto-generated') }}</span>{% endif %}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.text-break {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
<!-- Provider Metadata -->
|
||||
{% if oidc_config.enabled and oidc_config.issuer %}
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-server mr-2"></i>{{ _('Provider Metadata') }}</h2>
|
||||
{% if metadata_error %}
|
||||
<div class="mb-3 text-sm inline-flex items-center rounded px-3 py-2 bg-red-100 text-red-700">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>{{ _('Error loading metadata:') }} {{ metadata_error }}
|
||||
</div>
|
||||
{% if well_known_url %}
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Discovery endpoint:') }} <code>{{ well_known_url }}</code></p>
|
||||
{% endif %}
|
||||
{% elif metadata %}
|
||||
<div class="mb-4 text-sm inline-flex items-center rounded px-3 py-2 bg-green-100 text-green-700">
|
||||
<i class="fas fa-check-circle mr-2"></i>{{ _('Successfully loaded provider metadata') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm">
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2 text-text-muted-light dark:text-text-muted-dark">{{ _('Endpoints') }}</h3>
|
||||
<div class="space-y-2">
|
||||
{% if metadata.authorization_endpoint %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Authorization') }}</div><div class="flex-1"><code class="break-all">{{ metadata.authorization_endpoint }}</code></div></div>{% endif %}
|
||||
{% if metadata.token_endpoint %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Token') }}</div><div class="flex-1"><code class="break-all">{{ metadata.token_endpoint }}</code></div></div>{% endif %}
|
||||
{% if metadata.userinfo_endpoint %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('UserInfo') }}</div><div class="flex-1"><code class="break-all">{{ metadata.userinfo_endpoint }}</code></div></div>{% endif %}
|
||||
{% if metadata.end_session_endpoint %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('End Session') }}</div><div class="flex-1"><code class="break-all">{{ metadata.end_session_endpoint }}</code></div></div>{% endif %}
|
||||
{% if metadata.jwks_uri %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('JWKS URI') }}</div><div class="flex-1"><code class="break-all">{{ metadata.jwks_uri }}</code></div></div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2 text-text-muted-light dark:text-text-muted-dark">{{ _('Supported Features') }}</h3>
|
||||
<div class="space-y-2">
|
||||
{% if metadata.scopes_supported %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Scopes') }}</div><div class="flex-1"><small>{{ metadata.scopes_supported|join(', ') }}</small></div></div>{% endif %}
|
||||
{% if metadata.response_types_supported %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Response Types') }}</div><div class="flex-1"><small>{{ metadata.response_types_supported|join(', ') }}</small></div></div>{% endif %}
|
||||
{% if metadata.grant_types_supported %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Grant Types') }}</div><div class="flex-1"><small>{{ metadata.grant_types_supported|join(', ') }}</small></div></div>{% endif %}
|
||||
{% if metadata.token_endpoint_auth_methods_supported %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Auth Methods') }}</div><div class="flex-1"><small>{{ metadata.token_endpoint_auth_methods_supported|join(', ') }}</small></div></div>{% endif %}
|
||||
{% if metadata.claims_supported %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Claims') }}</div><div class="flex-1"><small>{{ metadata.claims_supported|join(', ') }}</small></div></div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if well_known_url %}
|
||||
<div class="mt-3 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Discovery endpoint:') }} <code>{{ well_known_url }}</code></div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-sm"><i class="fas fa-info-circle mr-1"></i>{{ _('Provider metadata not loaded. Click "Test Configuration" to fetch.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- OIDC Users -->
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-users mr-2"></i>{{ _('OIDC Users') }} ({{ oidc_users|length }})</h2>
|
||||
{% if oidc_users %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-text-muted-light dark:text-text-muted-dark">
|
||||
<th class="py-2 pr-4">{{ _('Username') }}</th>
|
||||
<th class="py-2 pr-4">{{ _('Email') }}</th>
|
||||
<th class="py-2 pr-4">{{ _('Full Name') }}</th>
|
||||
<th class="py-2 pr-4">{{ _('Role') }}</th>
|
||||
<th class="py-2 pr-4">{{ _('Last Login') }}</th>
|
||||
<th class="py-2 pr-4">{{ _('OIDC Subject') }}</th>
|
||||
<th class="py-2 pr-0">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in oidc_users %}
|
||||
<tr class="border-t border-border-light dark:border-border-dark">
|
||||
<td class="py-2 pr-4">
|
||||
{{ user.username }}
|
||||
{% if not user.is_active %}<span class="ml-1 inline-flex items-center rounded px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700 text-xs">{{ _('Inactive') }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="py-2 pr-4">{{ user.email or '-' }}</td>
|
||||
<td class="py-2 pr-4">{{ user.full_name or '-' }}</td>
|
||||
<td class="py-2 pr-4">{% if user.is_admin %}<span class="inline-flex items-center rounded px-1.5 py-0.5 bg-red-100 text-red-700 text-xs">{{ _('Admin') }}</span>{% else %}<span class="inline-flex items-center rounded px-1.5 py-0.5 bg-sky-100 text-sky-700 text-xs">{{ _('User') }}</span>{% endif %}</td>
|
||||
<td class="py-2 pr-4">{% if user.last_login %}{{ user.last_login.strftime('%Y-%m-%d %H:%M') }}{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Never') }}</span>{% endif %}</td>
|
||||
<td class="py-2 pr-4"><small><code class="break-all">{{ user.oidc_sub[:20] }}...</code></small></td>
|
||||
<td class="py-2 pr-0">
|
||||
<a href="{{ url_for('admin.oidc_user_detail', user_id=user.id) }}" class="px-3 py-1.5 rounded-lg border border-border-light dark:border-border-dark text-sm hover:bg-background-light dark:hover:bg-background-dark">
|
||||
<i class="fas fa-info-circle mr-1"></i>{{ _('Details') }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-sm"><i class="fas fa-info-circle mr-1"></i>{{ _('No users have logged in via OIDC yet.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Env Vars Reference -->
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-book mr-2"></i>{{ _('Environment Variables Reference') }}</h2>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-sm mb-3">{{ _('Configure OIDC using these environment variables:') }}</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-text-muted-light dark:text-text-muted-dark">
|
||||
<th class="py-2 pr-4">{{ _('Variable') }}</th>
|
||||
<th class="py-2 pr-4">{{ _('Description') }}</th>
|
||||
<th class="py-2 pr-0">{{ _('Example') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>AUTH_METHOD</code></td><td class="py-2 pr-4">{{ _('Authentication method') }}</td><td class="py-2 pr-0"><code>oidc</code> / <code>both</code> / <code>local</code></td></tr>
|
||||
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_ISSUER</code></td><td class="py-2 pr-4">{{ _('OIDC provider issuer URL') }}</td><td class="py-2 pr-0"><code>https://auth.example.com</code></td></tr>
|
||||
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_CLIENT_ID</code></td><td class="py-2 pr-4">{{ _('Client ID from OIDC provider') }}</td><td class="py-2 pr-0"><code>timetracker</code></td></tr>
|
||||
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_CLIENT_SECRET</code></td><td class="py-2 pr-4">{{ _('Client secret from OIDC provider') }}</td><td class="py-2 pr-0"><code>secret123</code></td></tr>
|
||||
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_REDIRECT_URI</code></td><td class="py-2 pr-4">{{ _('Callback URL (optional, auto-generated)') }}</td><td class="py-2 pr-0"><code>https://app.example.com/auth/oidc/callback</code></td></tr>
|
||||
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_SCOPES</code></td><td class="py-2 pr-4">{{ _('Requested scopes') }}</td><td class="py-2 pr-0"><code>openid profile email groups</code></td></tr>
|
||||
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_USERNAME_CLAIM</code></td><td class="py-2 pr-4">{{ _('Claim containing username') }}</td><td class="py-2 pr-0"><code>preferred_username</code></td></tr>
|
||||
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_EMAIL_CLAIM</code></td><td class="py-2 pr-4">{{ _('Claim containing email') }}</td><td class="py-2 pr-0"><code>email</code></td></tr>
|
||||
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_FULL_NAME_CLAIM</code></td><td class="py-2 pr-4">{{ _('Claim containing full name') }}</td><td class="py-2 pr-0"><code>name</code></td></tr>
|
||||
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_GROUPS_CLAIM</code></td><td class="py-2 pr-4">{{ _('Claim containing groups') }}</td><td class="py-2 pr-0"><code>groups</code></td></tr>
|
||||
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_ADMIN_GROUP</code></td><td class="py-2 pr-4">{{ _('Group name for admin role (optional)') }}</td><td class="py-2 pr-0"><code>timetracker_admin</code></td></tr>
|
||||
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_ADMIN_EMAILS</code></td><td class="py-2 pr-4">{{ _('Comma-separated admin emails (optional)') }}</td><td class="py-2 pr-0"><code>admin@example.com,boss@example.com</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -3,228 +3,92 @@
|
||||
{% block title %}{{ _('OIDC User Details') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-user-shield text-primary"></i> {{ _('OIDC User Details') }}
|
||||
</h1>
|
||||
<a href="{{ url_for('admin.oidc_debug') }}" class="btn-header btn-outline-primary">
|
||||
<i class="fas fa-arrow-left me-2"></i>{{ _('Back to OIDC Debug') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold"><i class="fas fa-user-shield mr-2"></i>{{ _('OIDC User Details') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Profile and OIDC identity for this user') }}</p>
|
||||
</div>
|
||||
<div class="mt-3 md:mt-0">
|
||||
<a href="{{ url_for('admin.oidc_debug') }}" class="px-3 py-2 rounded-lg border border-border-light dark:border-border-dark text-sm hover:bg-background-light dark:hover:bg-background-dark">
|
||||
<i class="fas fa-arrow-left mr-1"></i>{{ _('Back to OIDC Debug') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- User Profile -->
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-user mr-2"></i>{{ _('User Profile') }}</h2>
|
||||
<div class="text-sm divide-y divide-border-light dark:divide-border-dark">
|
||||
<div class="py-2 flex justify-between gap-6"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Username') }}</div><div class="flex-1 font-semibold">{{ user.username }}</div></div>
|
||||
<div class="py-2 flex justify-between gap-6"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Email') }}</div><div class="flex-1">{{ user.email or '-' }}</div></div>
|
||||
<div class="py-2 flex justify-between gap-6"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Full Name') }}</div><div class="flex-1">{{ user.full_name or '-' }}</div></div>
|
||||
<div class="py-2 flex justify-between gap-6"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Role') }}</div><div class="flex-1">{% if user.is_admin %}<span class="inline-flex items-center rounded px-1.5 py-0.5 bg-red-100 text-red-700 text-xs">{{ _('Admin') }}</span>{% else %}<span class="inline-flex items-center rounded px-1.5 py-0.5 bg-sky-100 text-sky-700 text-xs">{{ _('User') }}</span>{% endif %}</div></div>
|
||||
<div class="py-2 flex justify-between gap-6"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Status') }}</div><div class="flex-1">{% if user.is_active %}<span class="inline-flex items-center rounded px-1.5 py-0.5 bg-green-100 text-green-700 text-xs">{{ _('Active') }}</span>{% else %}<span class="inline-flex items-center rounded px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700 text-xs">{{ _('Inactive') }}</span>{% endif %}</div></div>
|
||||
<div class="py-2 flex justify-between gap-6"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Preferred Language') }}</div><div class="flex-1">{{ user.preferred_language or 'en' }}</div></div>
|
||||
<div class="py-2 flex justify-between gap-6"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Theme') }}</div><div class="flex-1">{{ user.theme_preference or 'system' }}</div></div>
|
||||
<div class="py-2 flex justify-between gap-6"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Created At') }}</div><div class="flex-1">{% if user.created_at %}{{ user.created_at.strftime('%Y-%m-%d %H:%M:%S') }}{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Unknown') }}</span>{% endif %}</div></div>
|
||||
<div class="py-2 flex justify-between gap-6"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Last Login') }}</div><div class="flex-1">{% if user.last_login %}{{ user.last_login.strftime('%Y-%m-%d %H:%M:%S') }}{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Never') }}</span>{% endif %}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- User Profile -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-user me-1"></i> {{ _('User Profile') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th width="40%">{{ _('Username') }}</th>
|
||||
<td><strong>{{ user.username }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Email') }}</th>
|
||||
<td>{{ user.email or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Full Name') }}</th>
|
||||
<td>{{ user.full_name or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Role') }}</th>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-danger">{{ _('Admin') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">{{ _('User') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
<span class="badge bg-success">{{ _('Active') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ _('Inactive') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Preferred Language') }}</th>
|
||||
<td>{{ user.preferred_language or 'en' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Theme') }}</th>
|
||||
<td>{{ user.theme_preference or 'system' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Created At') }}</th>
|
||||
<td>
|
||||
{% if user.created_at %}
|
||||
{{ user.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
<span class="text-muted">{{ _('Unknown') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Last Login') }}</th>
|
||||
<td>
|
||||
{% if user.last_login %}
|
||||
{{ user.last_login.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
<span class="text-muted">{{ _('Never') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- OIDC Information -->
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-shield-alt mr-2"></i>{{ _('OIDC Information') }}</h2>
|
||||
<div class="text-sm divide-y divide-border-light dark:divide-border-dark">
|
||||
<div class="py-2 flex justify-between gap-6"><div class="w-48 text-text-muted-light dark:text-text-muted-dark">{{ _('OIDC Issuer') }}</div><div class="flex-1">{% if user.oidc_issuer %}<code class="break-all">{{ user.oidc_issuer }}</code>{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Not set') }}</span>{% endif %}</div></div>
|
||||
<div class="py-2 flex justify-between gap-6"><div class="w-48 text-text-muted-light dark:text-text-muted-dark">{{ _('OIDC Subject (sub)') }}</div><div class="flex-1">{% if user.oidc_sub %}<code class="break-all">{{ user.oidc_sub }}</code>{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Not set') }}</span>{% endif %}</div></div>
|
||||
<div class="py-2 flex justify-between gap-6"><div class="w-48 text-text-muted-light dark:text-text-muted-dark">{{ _('Authentication Method') }}</div><div class="flex-1">{% if user.oidc_issuer and user.oidc_sub %}<span class="inline-flex items-center rounded px-1.5 py-0.5 bg-primary/10 text-primary text-xs">OIDC</span>{% else %}<span class="inline-flex items-center rounded px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700 text-xs">{{ _('Local') }}</span>{% endif %}</div></div>
|
||||
</div>
|
||||
|
||||
<!-- OIDC Information -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-shield-alt me-1"></i> {{ _('OIDC Information') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th width="40%">{{ _('OIDC Issuer') }}</th>
|
||||
<td>
|
||||
{% if user.oidc_issuer %}
|
||||
<code class="text-break">{{ user.oidc_issuer }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ _('Not set') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('OIDC Subject (sub)') }}</th>
|
||||
<td>
|
||||
{% if user.oidc_sub %}
|
||||
<code class="text-break">{{ user.oidc_sub }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ _('Not set') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ _('Authentication Method') }}</th>
|
||||
<td>
|
||||
{% if user.oidc_issuer and user.oidc_sub %}
|
||||
<span class="badge bg-primary">OIDC</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ _('Local') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if user.oidc_issuer and user.oidc_sub %}
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
<small>
|
||||
{{ _('This user was created or linked via OIDC. The issuer and subject are used to uniquely identify the user across login sessions.') }}
|
||||
</small>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning mt-3 mb-0">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<small>
|
||||
{{ _('This user has no OIDC information. They may have been created manually or via self-registration without OIDC.') }}
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if user.oidc_issuer and user.oidc_sub %}
|
||||
<div class="mt-3 p-3 rounded border border-sky-600/30 bg-sky-50 dark:bg-sky-900/20 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
<i class="fas fa-info-circle mr-1"></i>{{ _('This user was created or linked via OIDC. The issuer and subject are used to uniquely identify the user across login sessions.') }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-3 p-3 rounded border border-amber-600/30 bg-amber-50 dark:bg-amber-900/20 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>{{ _('This user has no OIDC information. They may have been created manually or via self-registration without OIDC.') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Statistics -->
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-chart-line me-1"></i> {{ _('Activity Statistics') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-center">
|
||||
<h3 class="mb-0">{{ user.projects.count() }}</h3>
|
||||
<small class="text-muted">{{ _('Projects') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-center">
|
||||
<h3 class="mb-0">{{ user.time_entries.count() }}</h3>
|
||||
<small class="text-muted">{{ _('Time Entries') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-center">
|
||||
<h3 class="mb-0">
|
||||
{% set active_timer = user.time_entries.filter_by(end_time=None).first() %}
|
||||
{% if active_timer %}
|
||||
<span class="badge bg-success">{{ _('Active') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ _('None') }}</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<small class="text-muted">{{ _('Active Timer') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-center">
|
||||
<h3 class="mb-0">{{ user.created_tasks.count() }}</h3>
|
||||
<small class="text-muted">{{ _('Tasks Created') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Activity Statistics -->
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-chart-line mr-2"></i>{{ _('Activity Statistics') }}</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-semibold">{{ user.projects.count() }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Projects') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-tools me-1"></i> {{ _('Actions') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}" class="btn btn-primary">
|
||||
<i class="fas fa-edit me-2"></i>{{ _('Edit User') }}
|
||||
</a>
|
||||
<a href="{{ url_for('admin.list_users') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-users me-2"></i>{{ _('View All Users') }}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-semibold">{{ user.time_entries.count() }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Time Entries') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-semibold">
|
||||
{% set active_timer = user.time_entries.filter_by(end_time=None).first() %}
|
||||
{% if active_timer %}
|
||||
<span class="inline-flex items-center rounded px-1.5 py-0.5 bg-green-100 text-green-700 text-xs">{{ _('Active') }}</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center rounded px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700 text-xs">{{ _('None') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Active Timer') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-semibold">{{ user.created_tasks.count() }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Tasks Created') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.text-break {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
<!-- Actions -->
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-tools mr-2"></i>{{ _('Actions') }}</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}" class="px-4 py-2 rounded-lg bg-primary text-white text-sm hover:opacity-90"><i class="fas fa-edit mr-1"></i>{{ _('Edit User') }}</a>
|
||||
<a href="{{ url_for('admin.list_users') }}" class="px-4 py-2 rounded-lg border border-border-light dark:border-border-dark text-sm hover:bg-background-light dark:hover:bg-background-dark"><i class="fas fa-users mr-1"></i>{{ _('View All Users') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -3,124 +3,88 @@
|
||||
{% block title %}{{ _('Create Client') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-plus text-primary"></i> {{ _('Create New Client') }}
|
||||
</h1>
|
||||
<a href="{{ url_for('clients.list_clients') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Back to Clients') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ _('Create Client') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Add a new client to manage related projects and billing.') }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('clients.list_clients') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Clients') }}</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" action="{{ url_for('clients.create_client') }}" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client Name') }} *</label>
|
||||
<input type="text" id="name" name="name" required value="{{ request.form.get('name','') }}" placeholder="{{ _('Enter client name') }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="default_hourly_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Default Hourly Rate') }}</label>
|
||||
<input type="number" step="0.01" min="0" id="default_hourly_rate" name="default_hourly_rate" value="{{ request.form.get('default_hourly_rate','') }}" placeholder="{{ _('e.g. 75.00') }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('This rate will be automatically filled when creating projects for this client') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Description') }}</label>
|
||||
<textarea id="description" name="description" rows="3" placeholder="{{ _('Brief description of the client or project scope') }}" class="form-input">{{ request.form.get('description','') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="contact_person" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Contact Person') }}</label>
|
||||
<input type="text" id="contact_person" name="contact_person" value="{{ request.form.get('contact_person','') }}" placeholder="{{ _('Primary contact name') }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Email') }}</label>
|
||||
<input type="email" id="email" name="email" value="{{ request.form.get('email','') }}" placeholder="{{ _('contact@client.com') }}" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Phone') }}</label>
|
||||
<input type="tel" id="phone" name="phone" value="{{ request.form.get('phone','') }}" placeholder="{{ _('+1 (555) 123-4567') }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="address" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Address') }}</label>
|
||||
<textarea id="address" name="address" rows="2" placeholder="{{ _('Client address') }}" class="form-input">{{ request.form.get('address','') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3 border-t border-border-light dark:border-border-dark pt-4">
|
||||
<a href="{{ url_for('clients.list_clients') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">{{ _('Cancel') }}</a>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Create Client') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> {{ _('Client Information') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('clients.create_client') }}" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">{{ _('Client Name') }} *</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required
|
||||
value="{{ request.form.get('name','') }}" placeholder="{{ _('Enter client name') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="default_hourly_rate" class="form-label">{{ _('Default Hourly Rate') }}</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control" id="default_hourly_rate"
|
||||
name="default_hourly_rate" value="{{ request.form.get('default_hourly_rate','') }}"
|
||||
placeholder="{{ _('e.g. 75.00') }}">
|
||||
<div class="form-text">{{ _('This rate will be automatically filled when creating projects for this client') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">{{ _('Description') }}</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3"
|
||||
placeholder="{{ _('Brief description of the client or project scope') }}">{{ request.form.get('description','') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="contact_person" class="form-label">{{ _('Contact Person') }}</label>
|
||||
<input type="text" class="form-control" id="contact_person" name="contact_person"
|
||||
value="{{ request.form.get('contact_person','') }}" placeholder="{{ _('Primary contact name') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">{{ _('Email') }}</label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
value="{{ request.form.get('email','') }}" placeholder="{{ _('contact@client.com') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="phone" class="form-label">{{ _('Phone') }}</label>
|
||||
<input type="tel" class="form-control" id="phone" name="phone"
|
||||
value="{{ request.form.get('phone','') }}" placeholder="{{ _('+1 (555) 123-4567') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="address" class="form-label">{{ _('Address') }}</label>
|
||||
<textarea class="form-control" id="address" name="address" rows="2"
|
||||
placeholder="{{ _('Client address') }}">{{ request.form.get('address','') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('clients.list_clients') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> {{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> {{ _('Create Client') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> {{ _('Help') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6>{{ _('Client Name') }}</h6>
|
||||
<p class="text-muted small">{{ _('Choose a clear, descriptive name for the client organization.') }}</p>
|
||||
|
||||
<h6>{{ _('Default Hourly Rate') }}</h6>
|
||||
<p class="text-muted small">{{ _('Set the standard hourly rate for this client. This will automatically populate when creating new projects.') }}</p>
|
||||
|
||||
<h6>{{ _('Contact Information') }}</h6>
|
||||
<p class="text-muted small">{{ _('Add contact details for easy communication and record keeping.') }}</p>
|
||||
|
||||
<h6>{{ _('Description') }}</h6>
|
||||
<p class="text-muted small">{{ _('Provide context about the client relationship or typical project types.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-3">{{ _('Help') }}</h3>
|
||||
<ul class="space-y-3 text-sm">
|
||||
<li>
|
||||
<strong>{{ _('Client Name') }}</strong>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Choose a clear, descriptive name for the client organization.') }}</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{{ _('Default Hourly Rate') }}</strong>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Set the standard hourly rate for this client. This will automatically populate when creating new projects.') }}</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{{ _('Contact Information') }}</strong>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Add contact details for easy communication and record keeping.') }}</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{{ _('Description') }}</strong>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Provide context about the client relationship or typical project types.') }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,156 +3,88 @@
|
||||
{% block title %}{{ _('Edit Client') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-edit text-primary"></i> {{ _('Edit Client') }}: {{ client.name }}
|
||||
</h1>
|
||||
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Back to Client') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ _('Edit Client') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ client.name }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Client') }}</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" action="{{ url_for('clients.edit_client', client_id=client.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client Name') }} *</label>
|
||||
<input type="text" id="name" name="name" required value="{{ request.form.get('name', client.name) }}" placeholder="{{ _('Enter client name') }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="default_hourly_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Default Hourly Rate') }}</label>
|
||||
<input type="number" step="0.01" min="0" id="default_hourly_rate" name="default_hourly_rate" value="{{ request.form.get('default_hourly_rate', client.default_hourly_rate or '') }}" placeholder="{{ _('e.g. 75.00') }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('This rate will be automatically filled when creating projects for this client') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Description') }}</label>
|
||||
<textarea id="description" name="description" rows="3" placeholder="{{ _('Brief description of the client or project scope') }}" class="form-input">{{ request.form.get('description', client.description or '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="contact_person" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Contact Person') }}</label>
|
||||
<input type="text" id="contact_person" name="contact_person" value="{{ request.form.get('contact_person', client.contact_person or '') }}" placeholder="{{ _('Primary contact name') }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Email') }}</label>
|
||||
<input type="email" id="email" name="email" value="{{ request.form.get('email', client.email or '') }}" placeholder="{{ _('contact@client.com') }}" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Phone') }}</label>
|
||||
<input type="tel" id="phone" name="phone" value="{{ request.form.get('phone', client.phone or '') }}" placeholder="{{ _('+1 (555) 123-4567') }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="address" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Address') }}</label>
|
||||
<textarea id="address" name="address" rows="2" placeholder="{{ _('Client address') }}" class="form-input">{{ request.form.get('address', client.address or '') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3 border-t border-border-light dark:border-border-dark pt-4">
|
||||
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">{{ _('Cancel') }}</a>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Update Client') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> {{ _('Client Information') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('clients.edit_client', client_id=client.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">{{ _('Client Name') }} *</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required
|
||||
value="{{ request.form.get('name', client.name) }}" placeholder="{{ _('Enter client name') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="default_hourly_rate" class="form-label">{{ _('Default Hourly Rate') }}</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control" id="default_hourly_rate"
|
||||
name="default_hourly_rate"
|
||||
value="{{ request.form.get('default_hourly_rate', client.default_hourly_rate or '') }}"
|
||||
placeholder="{{ _('e.g. 75.00') }}">
|
||||
<div class="form-text">{{ _('This rate will be automatically filled when creating projects for this client') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">{{ _('Description') }}</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3"
|
||||
placeholder="{{ _('Brief description of the client or project scope') }}">{{ request.form.get('description', client.description or '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="contact_person" class="form-label">{{ _('Contact Person') }}</label>
|
||||
<input type="text" class="form-control" id="contact_person" name="contact_person"
|
||||
value="{{ request.form.get('contact_person', client.contact_person or '') }}" placeholder="{{ _('Primary contact name') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">{{ _('Email') }}</label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
value="{{ request.form.get('email', client.email or '') }}" placeholder="{{ _('contact@client.com') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="phone" class="form-label">{{ _('Phone') }}</label>
|
||||
<input type="tel" class="form-control" id="phone" name="phone"
|
||||
value="{{ request.form.get('phone', client.phone or '') }}" placeholder="{{ _('+1 (555) 123-4567') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="address" class="form-label">{{ _('Address') }}</label>
|
||||
<textarea class="form-control" id="address" name="address" rows="2"
|
||||
placeholder="{{ _('Client address') }}">{{ request.form.get('address', client.address or '') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> {{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> {{ _('Update Client') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> {{ _('Help') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6>{{ _('Client Name') }}</h6>
|
||||
<p class="text-muted small">{{ _('Choose a clear, descriptive name for the client organization.') }}</p>
|
||||
|
||||
<h6>{{ _('Default Hourly Rate') }}</h6>
|
||||
<p class="text-muted small">{{ _('Set the standard hourly rate for this client. This will automatically populate when creating new projects.') }}</p>
|
||||
|
||||
<h6>{{ _('Contact Information') }}</h6>
|
||||
<p class="text-muted small">{{ _('Add contact details for easy communication and record keeping.') }}</p>
|
||||
|
||||
<h6>{{ _('Description') }}</h6>
|
||||
<p class="text-muted small">{{ _('Provide context about the client relationship or typical project types.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-bar"></i> {{ _('Client Statistics') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<h4 class="text-primary">{{ client.total_projects }}</h4>
|
||||
<small class="text-muted">{{ _('Total Projects') }}</small>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h4 class="text-success">{{ client.active_projects }}</h4>
|
||||
<small class="text-muted">{{ _('Active Projects') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<h4 class="text-info">{{ "%.1f"|format(client.total_hours) }}</h4>
|
||||
<small class="text-muted">{{ _('Total Hours') }}</small>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h4 class="text-warning">{{ "%.2f"|format(client.estimated_total_cost) }}</h4>
|
||||
<small class="text-muted">{{ _('Est. Total Cost') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-3">{{ _('Client Statistics') }}</h3>
|
||||
<ul class="grid grid-cols-2 gap-4 text-center">
|
||||
<li>
|
||||
<div class="text-primary text-xl font-bold">{{ client.total_projects }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Total Projects') }}</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="text-green-600 text-xl font-bold">{{ client.active_projects }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Active Projects') }}</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="text-blue-600 text-xl font-bold">{{ "%.1f"|format(client.total_hours) }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Total Hours') }}</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="text-amber-600 text-xl font-bold">{{ "%.2f"|format(client.estimated_total_cost) }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Est. Total Cost') }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,317 +3,221 @@
|
||||
{% block title %}{{ _('About') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% from "_components.html" import page_header, info_card, summary_card %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{{ page_header('fas fa-info-circle', _('About'), _('Professional time tracking and project management'), None) }}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ _('About') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Professional time tracking and project management') }}</p>
|
||||
</div>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark mt-2 md:mt-0">
|
||||
<i class="fas fa-circle-info"></i> / {{ _('About') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-8 rounded-lg shadow mb-8 text-center">
|
||||
<h2 class="text-xl font-semibold mb-2">TimeTracker</h2>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark max-w-2xl mx-auto">
|
||||
{{ _('A comprehensive web-based time tracking application built with Flask, featuring project management, client organization, task management, invoicing, and advanced analytics.') }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-4 justify-center">
|
||||
<span class="px-3 py-1 rounded-full border border-primary text-primary">
|
||||
<i class="fas fa-clock mr-1"></i>{{ _('Time Tracking') }}
|
||||
</span>
|
||||
<span class="px-3 py-1 rounded-full border border-green-600 text-green-600">
|
||||
<i class="fas fa-project-diagram mr-1"></i>{{ _('Project Management') }}
|
||||
</span>
|
||||
<span class="px-3 py-1 rounded-full border border-sky-600 text-sky-600">
|
||||
<i class="fas fa-file-invoice-dollar mr-1"></i>{{ _('Invoicing') }}
|
||||
</span>
|
||||
<span class="px-3 py-1 rounded-full border border-amber-600 text-amber-600">
|
||||
<i class="fas fa-chart-line mr-1"></i>{{ _('Analytics') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highlights -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<i class="fas fa-stopwatch text-primary"></i>
|
||||
<h3 class="font-semibold">{{ _('Smart Timers') }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Real-time tracking with idle detection and live updates') }}</p>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<i class="fas fa-building text-green-600"></i>
|
||||
<h3 class="font-semibold">{{ _('Client Management') }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Organize clients with contacts, rates, and project relations') }}</p>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<i class="fas fa-tasks text-sky-600"></i>
|
||||
<h3 class="font-semibold">{{ _('Task System') }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Break down projects into tasks with progress tracking') }}</p>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<i class="fas fa-file-pdf text-amber-600"></i>
|
||||
<h3 class="font-semibold">{{ _('PDF Invoices') }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Generate professional invoices from tracked time') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core vs Advanced -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark">
|
||||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-star text-amber-500 mr-2"></i>{{ _('Core Features') }}</h3>
|
||||
<ul class="space-y-2 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i><span>{{ _('Start/stop timers with project and task association') }}</span></li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i><span>{{ _('Manual time entry with notes and tags') }}</span></li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i><span>{{ _('Client and project organization') }}</span></li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i><span>{{ _('Role-based access and user profiles') }}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark">
|
||||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-rocket text-primary mr-2"></i>{{ _('Advanced Features') }}</h3>
|
||||
<ul class="space-y-2 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i><span>{{ _('Branded PDF invoicing with tax and payment tracking') }}</span></li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i><span>{{ _('Visual analytics and detailed reporting') }}</span></li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i><span>{{ _('REST API for integrations') }}</span></li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i><span>{{ _('PWA capabilities and mobile-friendly UI') }}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Platform Support -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-globe mr-2"></i>{{ _('Platform Support') }}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 text-sm">
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">{{ _('Web Application') }}</h4>
|
||||
<ul class="space-y-1 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i>{{ _('Desktop browsers (Chrome, Firefox, Safari, Edge)') }}</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i>{{ _('Mobile responsive design') }}</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i>{{ _('Progressive Web App (PWA)') }}</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i>{{ _('Touch-friendly tablet interface') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">{{ _('Operating Systems') }}</h4>
|
||||
<ul class="space-y-1 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i>{{ _('Windows, macOS, Linux') }}</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i>{{ _('Android and iOS (browser)') }}</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i>{{ _('Raspberry Pi support') }}</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i>{{ _('Dockerized deployment') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">{{ _('Database Support') }}</h4>
|
||||
<ul class="space-y-1 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i>{{ _('PostgreSQL (recommended)') }}</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i>{{ _('SQLite (dev/test)') }}</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i>{{ _('Automatic migrations with Flask-Migrate') }}</li>
|
||||
<li class="flex items-start gap-2"><i class="fas fa-check text-green-600 mt-1"></i>{{ _('Backup and restoration tools') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<!-- Hero Section -->
|
||||
<div class="text-center mb-5">
|
||||
{% if settings and settings.has_logo() %}
|
||||
<img src="{{ settings.get_logo_url() }}" alt="{{ _('Company Logo') }}" class="mb-4" width="80" height="80">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="{{ _('DryTrix Logo') }}" class="mb-4" width="80" height="80">
|
||||
{% endif %}
|
||||
<h1 class="h2 mb-3">{{ _('TimeTracker') }}</h1>
|
||||
<p class="lead text-muted mb-4">
|
||||
{{ _('A comprehensive web-based time tracking application built with Flask, featuring project management, client organization, task management, invoicing, and advanced analytics.') }}
|
||||
</p>
|
||||
<div class="d-flex justify-content-center gap-3 flex-wrap">
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 px-3 py-2">
|
||||
<i class="fas fa-clock me-1"></i> {{ _('Time Tracking') }}
|
||||
</span>
|
||||
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25 px-3 py-2">
|
||||
<i class="fas fa-project-diagram me-1"></i> {{ _('Project Management') }}
|
||||
</span>
|
||||
<span class="badge bg-info bg-opacity-10 text-info border border-info border-opacity-25 px-3 py-2">
|
||||
<i class="fas fa-file-invoice-dollar me-1"></i> {{ _('Invoicing') }}
|
||||
</span>
|
||||
<span class="badge bg-warning bg-opacity-10 text-warning border border-warning border-opacity-25 px-3 py-2">
|
||||
<i class="fas fa-chart-line me-1"></i> {{ _('Analytics') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-muted mt-3">
|
||||
<strong>{{ _('Developed by DryTrix') }}</strong> • {{ _('Open Source') }} • {{ _('GPL v3.0 License') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Features Overview -->
|
||||
<div class="row mb-5">
|
||||
<div class="col-md-3 col-sm-6 mb-4">
|
||||
{{ summary_card('fas fa-stopwatch', 'primary', _('Smart Timers'), _('Real-time tracking with idle detection and WebSocket updates')) }}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 mb-4">
|
||||
{{ summary_card('fas fa-building', 'success', _('Client Management'), _('Organize clients with contacts, rates, and project relationships')) }}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 mb-4">
|
||||
{{ summary_card('fas fa-tasks', 'info', _('Task System'), _('Break down projects into manageable tasks with progress tracking')) }}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 mb-4">
|
||||
{{ summary_card('fas fa-file-pdf', 'warning', _('PDF Invoices'), _('Generate professional branded invoices from tracked time')) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Technical Specifications -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-cogs mr-2"></i>{{ _('Technical Specifications') }}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm">
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">{{ _('Technology Stack') }}</h4>
|
||||
<ul class="space-y-1 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li><i class="fas fa-code text-primary mr-2"></i><strong>{{ _('Backend') }}:</strong> Python, Flask, SQLAlchemy</li>
|
||||
<li><i class="fas fa-palette text-green-600 mr-2"></i><strong>{{ _('Frontend') }}:</strong> Tailwind CSS, JS, Font Awesome</li>
|
||||
<li><i class="fas fa-database text-sky-600 mr-2"></i><strong>{{ _('Database') }}:</strong> PostgreSQL, SQLite</li>
|
||||
<li><i class="fas fa-server text-amber-600 mr-2"></i><strong>{{ _('Deployment') }}:</strong> Docker, Docker Compose</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">{{ _('Key Capabilities') }}</h4>
|
||||
<ul class="space-y-1 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li><i class="fas fa-bolt text-primary mr-2"></i><strong>{{ _('Real-time') }}:</strong> WebSocket updates, live timers</li>
|
||||
<li><i class="fas fa-language text-green-600 mr-2"></i><strong>{{ _('i18n') }}:</strong> Multi-language support</li>
|
||||
<li><i class="fas fa-shield-alt text-sky-600 mr-2"></i><strong>{{ _('Security') }}:</strong> CSRF, session management</li>
|
||||
<li><i class="fas fa-mobile-alt text-amber-600 mr-2"></i><strong>{{ _('Mobile') }}:</strong> Responsive, PWA</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Feature Sections -->
|
||||
<div class="row">
|
||||
<!-- Core Features -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-star text-warning"></i> {{ _('Core Features') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
{{ info_card(_('Time Tracking'), _('Start/stop timers with project association, idle detection, and real-time WebSocket updates'), 'fas fa-play-circle', 'primary') }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ info_card(_('Project Management'), _('Create and manage projects with client relationships, billing rates, and status tracking'), 'fas fa-project-diagram', 'success') }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ info_card(_('Client Organization'), _('Manage client contacts, default rates, and project relationships with error prevention'), 'fas fa-building', 'info') }}
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
{{ info_card(_('Task Management'), _('Break projects into tasks with priorities, due dates, and progress tracking'), 'fas fa-tasks', 'warning') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Open Source & Community -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-code-branch mr-2"></i>{{ _('Open Source & Community') }}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">{{ _('Open Source Benefits') }}</h4>
|
||||
<ul class="space-y-1">
|
||||
<li><i class="fas fa-code text-primary mr-2"></i>{{ _('Full source code available on GitHub') }}</li>
|
||||
<li><i class="fas fa-balance-scale text-primary mr-2"></i>{{ _('Licensed under GPL v3.0') }}</li>
|
||||
<li><i class="fas fa-users text-primary mr-2"></i>{{ _('Community-driven development') }}</li>
|
||||
<li><i class="fas fa-bug text-primary mr-2"></i>{{ _('Transparent issue tracking and bug reports') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">{{ _('Deployment Options') }}</h4>
|
||||
<ul class="space-y-1">
|
||||
<li><i class="fas fa-docker text-sky-600 mr-2"></i>{{ _('Docker images (GHCR)') }}</li>
|
||||
<li><i class="fas fa-server text-sky-600 mr-2"></i>{{ _('Self-hosted deployment with full control') }}</li>
|
||||
<li><i class="fas fa-cloud text-sky-600 mr-2"></i>{{ _('Cloud-ready with Compose configs') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<a href="https://github.com/drytrix/TimeTracker" target="_blank" rel="noopener" class="px-4 py-2 rounded-lg border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
|
||||
<i class="fab fa-github mr-2"></i>{{ _('View on GitHub') }}
|
||||
</a>
|
||||
<a href="https://buymeacoffee.com/DryTrix" target="_blank" rel="noopener" class="px-4 py-2 rounded-lg border border-amber-600 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20">
|
||||
<i class="fas fa-mug-saucer mr-2"></i>{{ _('Support Development') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Features -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-rocket text-primary"></i> {{ _('Advanced Features') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
{{ info_card(_('Professional Invoicing'), _('Generate branded PDF invoices with company logos, tax calculations, and payment tracking'), 'fas fa-file-invoice-dollar', 'primary') }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ info_card(_('Visual Analytics'), _('Comprehensive reporting with charts, trends, and export capabilities'), 'fas fa-chart-line', 'success') }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ info_card(_('Multi-User System'), _('Role-based access control with admin panel and user management'), 'fas fa-users', 'info') }}
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
{{ info_card(_('API Integration'), _('RESTful API endpoints with JSON responses for third-party integrations'), 'fas fa-plug', 'warning') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Getting Help -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border border-border-light dark:border-border-dark">
|
||||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-question-circle mr-2"></i>{{ _('Getting Help & Resources') }}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="text-center p-4 rounded-lg border border-border-light dark:border-border-dark">
|
||||
<div class="mx-auto mb-3 w-14 h-14 rounded-full flex items-center justify-center bg-primary/10">
|
||||
<i class="fas fa-book text-primary"></i>
|
||||
</div>
|
||||
|
||||
<!-- Platform Support -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-globe"></i> {{ _('Platform Support') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<h6 class="fw-semibold">{{ _('Web Application') }}</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><i class="fas fa-check text-success me-2"></i>{{ _('Desktop browsers (Chrome, Firefox, Safari, Edge)') }}</li>
|
||||
<li><i class="fas fa-check text-success me-2"></i>{{ _('Mobile responsive design') }}</li>
|
||||
<li><i class="fas fa-check text-success me-2"></i>{{ _('Progressive Web App (PWA) capabilities') }}</li>
|
||||
<li><i class="fas fa-check text-success me-2"></i>{{ _('Touch-friendly tablet interface') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<h6 class="fw-semibold">{{ _('Operating Systems') }}</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><i class="fas fa-check text-success me-2"></i>{{ _('Windows, macOS, Linux') }}</li>
|
||||
<li><i class="fas fa-check text-success me-2"></i>{{ _('Android and iOS (web browser)') }}</li>
|
||||
<li><i class="fas fa-check text-success me-2"></i>{{ _('Raspberry Pi support') }}</li>
|
||||
<li><i class="fas fa-check text-success me-2"></i>{{ _('Docker containerized deployment') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<h6 class="fw-semibold">{{ _('Database Support') }}</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><i class="fas fa-check text-success me-2"></i>{{ _('PostgreSQL (recommended)') }}</li>
|
||||
<li><i class="fas fa-check text-success me-2"></i>{{ _('SQLite (development/testing)') }}</li>
|
||||
<li><i class="fas fa-check text-success me-2"></i>{{ _('Automatic migrations with Flask-Migrate') }}</li>
|
||||
<li><i class="fas fa-check text-success me-2"></i>{{ _('Backup and restoration tools') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="font-semibold mb-1">{{ _('Documentation') }}</h4>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Step-by-step guides for all features.') }}</p>
|
||||
<a href="{{ url_for('main.help') }}" class="px-3 py-2 text-sm rounded-lg bg-primary text-white hover:opacity-90">
|
||||
<i class="fas fa-book mr-1"></i>{{ _('View Help') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg border border-border-light dark:border-border-dark">
|
||||
<div class="mx-auto mb-3 w-14 h-14 rounded-full flex items-center justify-center bg-green-600/10">
|
||||
<i class="fas fa-info-circle text-green-600"></i>
|
||||
</div>
|
||||
|
||||
<!-- Technical Specifications -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-cogs"></i> {{ _('Technical Specifications') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<h6 class="fw-semibold">{{ _('Technology Stack') }}</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><i class="fas fa-code text-primary me-2"></i><strong>{{ _('Backend') }}:</strong> Python 3.8+, Flask, SQLAlchemy</li>
|
||||
<li><i class="fas fa-palette text-success me-2"></i><strong>{{ _('Frontend') }}:</strong> Bootstrap 5, jQuery, Chart.js</li>
|
||||
<li><i class="fas fa-database text-info me-2"></i><strong>{{ _('Database') }}:</strong> PostgreSQL, SQLite</li>
|
||||
<li><i class="fas fa-server text-warning me-2"></i><strong>{{ _('Deployment') }}:</strong> Docker, Docker Compose</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<h6 class="fw-semibold">{{ _('Key Capabilities') }}</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><i class="fas fa-bolt text-primary me-2"></i><strong>{{ _('Real-time') }}:</strong> WebSocket updates, live timers</li>
|
||||
<li><i class="fas fa-language text-success me-2"></i><strong>{{ _('i18n') }}:</strong> Multi-language support (Flask-Babel)</li>
|
||||
<li><i class="fas fa-shield-alt text-info me-2"></i><strong>{{ _('Security') }}:</strong> Session management, CSRF protection</li>
|
||||
<li><i class="fas fa-mobile-alt text-warning me-2"></i><strong>{{ _('Mobile') }}:</strong> Responsive design, touch optimized</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security & Privacy -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-shield-alt"></i> {{ _('Security & Privacy') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-4">
|
||||
{{ _('TimeTracker is designed with security and privacy as core principles:') }}
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<h6 class="fw-semibold">{{ _('Authentication & Access') }}</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><i class="fas fa-user-shield text-success me-2"></i>{{ _('Username-only authentication (no password required)') }}</li>
|
||||
<li><i class="fas fa-users-cog text-success me-2"></i>{{ _('Role-based access control (user/admin)') }}</li>
|
||||
<li><i class="fas fa-key text-success me-2"></i>{{ _('Secure session management with CSRF protection') }}</li>
|
||||
<li><i class="fas fa-lock text-success me-2"></i>{{ _('Configurable secure cookie settings') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<h6 class="fw-semibold">{{ _('Data Protection') }}</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><i class="fas fa-database text-info me-2"></i>{{ _('Data stored locally on your infrastructure') }}</li>
|
||||
<li><i class="fas fa-user-secret text-info me-2"></i>{{ _('No external data sharing or tracking') }}</li>
|
||||
<li><i class="fas fa-backup text-info me-2"></i>{{ _('Built-in backup and restoration capabilities') }}</li>
|
||||
<li><i class="fas fa-eye-slash text-info me-2"></i>{{ _('Optional metrics collection (fully configurable)') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Open Source & Community -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-code-branch"></i> {{ _('Open Source & Community') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<h6 class="fw-semibold">{{ _('Open Source Benefits') }}</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><i class="fas fa-code text-primary me-2"></i>{{ _('Full source code available on GitHub') }}</li>
|
||||
<li><i class="fas fa-balance-scale text-primary me-2"></i>{{ _('Licensed under GPL v3.0') }}</li>
|
||||
<li><i class="fas fa-users text-primary me-2"></i>{{ _('Community-driven development') }}</li>
|
||||
<li><i class="fas fa-bug text-primary me-2"></i>{{ _('Transparent issue tracking and bug reports') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<h6 class="fw-semibold">{{ _('Deployment Options') }}</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><i class="fas fa-docker text-info me-2"></i>{{ _('Docker images available on GitHub Container Registry') }}</li>
|
||||
<li><i class="fas fa-server text-info me-2"></i>{{ _('Self-hosted deployment with full control') }}</li>
|
||||
<li><i class="fas fa-raspberry-pi text-info me-2"></i>{{ _('Raspberry Pi compatible for edge deployment') }}</li>
|
||||
<li><i class="fas fa-cloud text-info me-2"></i>{{ _('Cloud-ready with Docker Compose configurations') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-4">
|
||||
<a href="https://github.com/drytrix/TimeTracker" target="_blank" rel="noopener" class="btn btn-outline-primary me-2">
|
||||
<i class="fab fa-github me-2"></i>{{ _('View on GitHub') }}
|
||||
</a>
|
||||
<a href="https://buymeacoffee.com/DryTrix" target="_blank" rel="noopener" class="btn btn-outline-warning">
|
||||
<i class="fas fa-mug-hot me-2"></i>{{ _('Support Development') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Getting Help -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-question-circle"></i> {{ _('Getting Help & Resources') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="text-center">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 60px; height: 60px;">
|
||||
<i class="fas fa-book text-primary fa-2x"></i>
|
||||
</div>
|
||||
<h6 class="fw-semibold">{{ _('Documentation') }}</h6>
|
||||
<p class="text-muted small mb-3">
|
||||
{{ _('Comprehensive help documentation with step-by-step guides for all features.') }}
|
||||
</p>
|
||||
<a href="{{ url_for('main.help') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-book me-1"></i>{{ _('View Help') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="text-center">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 60px; height: 60px;">
|
||||
<i class="fas fa-info-circle text-success fa-2x"></i>
|
||||
</div>
|
||||
<h6 class="fw-semibold">{{ _('System Information') }}</h6>
|
||||
<p class="text-muted small mb-3">
|
||||
{{ _('View system status, version information, and configuration details.') }}
|
||||
</p>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.system_info') }}" class="btn btn-outline-success btn-sm">
|
||||
<i class="fas fa-info-circle me-1"></i>{{ _('System Info') }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted small">{{ _('Admin access required') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="text-center">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" style="width: 60px; height: 60px;">
|
||||
<i class="fab fa-github text-warning fa-2x"></i>
|
||||
</div>
|
||||
<h6 class="fw-semibold">{{ _('Community Support') }}</h6>
|
||||
<p class="text-muted small mb-3">
|
||||
{{ _('Report issues, request features, and contribute to the project on GitHub.') }}
|
||||
</p>
|
||||
<a href="https://github.com/drytrix/TimeTracker/issues" target="_blank" rel="noopener" class="btn btn-outline-warning btn-sm">
|
||||
<i class="fab fa-github me-1"></i>{{ _('GitHub Issues') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="font-semibold mb-1">{{ _('System Information') }}</h4>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Status, versions, and configuration details.') }}</p>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.system_info') }}" class="px-3 py-2 text-sm rounded-lg border border-green-600 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20">
|
||||
<i class="fas fa-info-circle mr-1"></i>{{ _('System Info') }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Admin access required') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg border border-border-light dark:border-border-dark">
|
||||
<div class="mx-auto mb-3 w-14 h-14 rounded-full flex items-center justify-center bg-amber-600/10">
|
||||
<i class="fab fa-github text-amber-600"></i>
|
||||
</div>
|
||||
<h4 class="font-semibold mb-1">{{ _('Community Support') }}</h4>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Report issues, request features, contribute.') }}</p>
|
||||
<a href="https://github.com/drytrix/TimeTracker/issues" target="_blank" rel="noopener" class="px-3 py-2 text-sm rounded-lg border border-amber-600 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20">
|
||||
<i class="fab fa-github mr-1"></i>{{ _('GitHub Issues') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,293 +3,95 @@
|
||||
{% block title %}{{ _('Create Project') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<!-- Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-body py-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 48px; height: 48px;">
|
||||
<i class="fas fa-project-diagram text-primary fa-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="h2 mb-1">{{ _('Create New Project') }}</h1>
|
||||
<p class="text-muted mb-0">{{ _('Set up a new project to organize your work and track time effectively') }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ _('Create Project') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Set up a new project to organize your work and track time effectively') }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('projects.list_projects') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Projects') }}</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" action="{{ url_for('projects.create_project') }}" novalidate id="createProjectForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Project Name') }} *</label>
|
||||
<input type="text" id="name" name="name" required value="{{ request.form.get('name','') }}" placeholder="{{ _('Enter a descriptive project name') }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Choose a clear, descriptive name that explains the project scope') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client') }} *</label>
|
||||
<select id="client_id" name="client_id" required class="form-input">
|
||||
<option value="">{{ _('Select a client...') }}</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}" {% if request.form.get('client_id') == client.id|string %}selected{% endif %} data-default-rate="{{ client.default_hourly_rate or '' }}">{{ client.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="text-xs mt-1"><a href="{{ url_for('clients.create_client') }}" class="text-primary hover:underline">{{ _('Create new client') }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Description') }}</label>
|
||||
<textarea id="description" name="description" rows="4" placeholder="{{ _('Provide detailed information about the project, objectives, and deliverables...') }}" class="form-input">{{ request.form.get('description','') }}</textarea>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Add context, objectives, or specific requirements for the project') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="inline-flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<input type="checkbox" id="billable" name="billable" {% if request.form.get('billable') %}checked{% endif %} class="rounded border-gray-300 text-primary shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
{{ _('Billable Project') }}
|
||||
</label>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Enable billing for this project') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="hourly_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Hourly Rate') }}</label>
|
||||
<input type="number" step="0.01" min="0" id="hourly_rate" name="hourly_rate" value="{{ request.form.get('hourly_rate','') }}" placeholder="{{ _('e.g. 75.00') }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Leave empty for non-billable projects') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billing_ref" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Billing Reference') }}</label>
|
||||
<input type="text" id="billing_ref" name="billing_ref" value="{{ request.form.get('billing_ref','') }}" placeholder="{{ _('PO number, contract reference, etc.') }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Add a reference number or identifier for billing purposes') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="budget_amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Budget Amount') }}</label>
|
||||
<input type="number" step="0.01" min="0" id="budget_amount" name="budget_amount" value="{{ request.form.get('budget_amount','') }}" placeholder="{{ _('e.g. 10000.00') }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Set a total project budget to monitor spend') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="budget_threshold_percent" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Alert Threshold (%)') }}</label>
|
||||
<input type="number" step="1" min="0" max="100" id="budget_threshold_percent" name="budget_threshold_percent" value="{{ request.form.get('budget_threshold_percent','80') }}" placeholder="80" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Notify when consumed budget exceeds this threshold') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3 border-t border-border-light dark:border-border-dark pt-4">
|
||||
<a href="{{ url_for('projects.list_projects') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">{{ _('Cancel') }}</a>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Create Project') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Project Form -->
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-edit me-2 text-primary"></i>{{ _('Project Information') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('projects.create_project') }}" novalidate id="createProjectForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Project Name and Client -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-semibold">
|
||||
<i class="fas fa-tag me-2 text-primary"></i>{{ _('Project Name') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-lg" id="name" name="name" required
|
||||
value="{{ request.form.get('name','') }}" placeholder="{{ _('Enter a descriptive project name') }}">
|
||||
<small class="form-text text-muted">{{ _('Choose a clear, descriptive name that explains the project scope') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="client_id" class="form-label fw-semibold">
|
||||
<i class="fas fa-user me-2 text-info"></i>{{ _('Client') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select class="form-select form-select-lg" id="client_id" name="client_id" required>
|
||||
<option value="">{{ _('Select a client...') }}</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}"
|
||||
{% if request.form.get('client_id') == client.id|string %}selected{% endif %}
|
||||
data-default-rate="{{ client.default_hourly_rate or '' }}">
|
||||
{{ client.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
<a href="{{ url_for('clients.create_client') }}" class="text-decoration-none">
|
||||
<i class="fas fa-plus me-2"></i>{{ _('Create new client') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-4">
|
||||
<label for="description" class="form-label fw-semibold">
|
||||
<i class="fas fa-align-left me-2 text-primary"></i>{{ _('Description') }}
|
||||
</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="4"
|
||||
placeholder="{{ _('Provide detailed information about the project, objectives, and deliverables...') }}">{{ request.form.get('description','') }}</textarea>
|
||||
<small class="form-text text-muted">{{ _('Optional: Add context, objectives, or specific requirements for the project') }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Billing Settings -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if request.form.get('billable') %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="billable">
|
||||
<i class="fas fa-dollar-sign me-2 text-success"></i>{{ _('Billable Project') }}
|
||||
</label>
|
||||
<small class="form-text text-muted d-block">{{ _('Enable billing for this project') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="hourly_rate" class="form-label fw-semibold">
|
||||
<i class="fas fa-clock me-2 text-warning"></i>{{ _('Hourly Rate') }}
|
||||
</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control" id="hourly_rate" name="hourly_rate"
|
||||
value="{{ request.form.get('hourly_rate','') }}" placeholder="{{ _('e.g. 75.00') }}">
|
||||
<small class="form-text text-muted">{{ _('Leave empty for non-billable projects') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing Reference -->
|
||||
<div class="mb-4">
|
||||
<label for="billing_ref" class="form-label fw-semibold">
|
||||
<i class="fas fa-receipt me-2 text-secondary"></i>{{ _('Billing Reference') }}
|
||||
</label>
|
||||
<input type="text" class="form-control" id="billing_ref" name="billing_ref"
|
||||
value="{{ request.form.get('billing_ref','') }}" placeholder="{{ _('PO number, contract reference, etc.') }}">
|
||||
<small class="form-text text-muted">{{ _('Optional: Add a reference number or identifier for billing purposes') }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Budget Settings -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="budget_amount" class="form-label fw-semibold">
|
||||
<i class="fas fa-wallet me-2 text-success"></i>{{ _('Budget Amount') }}
|
||||
</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control" id="budget_amount" name="budget_amount"
|
||||
value="{{ request.form.get('budget_amount','') }}" placeholder="{{ _('e.g. 10000.00') }}">
|
||||
<small class="form-text text-muted">{{ _('Optional: Set a total project budget to monitor spend') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="budget_threshold_percent" class="form-label fw-semibold">
|
||||
<i class="fas fa-bell me-2 text-danger"></i>{{ _('Alert Threshold (%)') }}
|
||||
</label>
|
||||
<input type="number" step="1" min="0" max="100" class="form-control" id="budget_threshold_percent" name="budget_threshold_percent"
|
||||
value="{{ request.form.get('budget_threshold_percent','80') }}" placeholder="80">
|
||||
<small class="form-text text-muted">{{ _('Notify when consumed budget exceeds this threshold') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex gap-3 pt-3 border-top">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-save me-2"></i>{{ _('Create Project') }}
|
||||
</button>
|
||||
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="fas fa-times me-2"></i>{{ _('Cancel') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Help -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Project Creation Tips -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-lightbulb me-2 text-warning"></i>{{ _('Project Creation Tips') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tip-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-check text-primary fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Clear Naming') }}</small>
|
||||
<small class="text-muted">{{ _("Use descriptive names that clearly indicate the project's purpose") }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-dollar-sign text-success fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Billing Setup') }}</small>
|
||||
<small class="text-muted">{{ _('Set appropriate hourly rates based on project complexity and client budget') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-info-circle text-info fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Detailed Description') }}</small>
|
||||
<small class="text-muted">{{ _('Include project objectives, deliverables, and key requirements') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-user text-warning fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Client Selection') }}</small>
|
||||
<small class="text-muted">{{ _('Choose the right client to ensure proper project organization') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing Guide -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-info-circle me-2 text-info"></i>{{ _('Billing Guide') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="billing-guide-item mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="billing-badge billing-billable me-2">{{ _('Billable') }}</span>
|
||||
<small class="text-muted">{{ _('Track time and bill client') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="billing-guide-item mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="billing-badge billing-non-billable me-2">{{ _('Non-Billable') }}</span>
|
||||
<small class="text-muted">{{ _('Track time without billing') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="billing-guide-item">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="billing-badge billing-rate me-2">{{ _('Rate Setting') }}</span>
|
||||
<small class="text-muted">{{ _('Set appropriate hourly rates') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Management -->
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-tasks me-2 text-secondary"></i>{{ _('Project Management') }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="management-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-clock text-primary fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Time Tracking') }}</small>
|
||||
<small class="text-muted">{{ _('Log time entries for accurate project tracking') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="management-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-list text-success fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Task Creation') }}</small>
|
||||
<small class="text-muted">{{ _('Break down projects into manageable tasks') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="management-item">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-chart-bar text-info fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">{{ _('Progress Monitoring') }}</small>
|
||||
<small class="text-muted">{{ _('Track project progress and time allocation') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-3">{{ _('Project Creation Tips') }}</h3>
|
||||
<ul class="space-y-3 text-sm">
|
||||
<li><strong>{{ _('Clear Naming') }}</strong><p class="text-text-muted-light dark:text-text-muted-dark">{{ _("Use descriptive names that clearly indicate the project's purpose") }}</p></li>
|
||||
<li><strong>{{ _('Billing Setup') }}</strong><p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Set appropriate hourly rates based on project complexity and client budget') }}</p></li>
|
||||
<li><strong>{{ _('Detailed Description') }}</strong><p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Include project objectives, deliverables, and key requirements') }}</p></li>
|
||||
<li><strong>{{ _('Client Selection') }}</strong><p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Choose the right client to ensure proper project organization') }}</p></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,125 +3,77 @@
|
||||
{% block title %}{{ _('Edit Project') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('projects.list_projects') }}">{{ _('Projects') }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a></li>
|
||||
<li class="breadcrumb-item active">{{ _('Edit') }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-project-diagram text-primary"></i> {{ _('Edit Project') }}
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> {{ _('Back to Project') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ _('Edit Project') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ project.name }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Project') }}</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> {{ _('Project Information') }}
|
||||
</h5>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" action="{{ url_for('projects.edit_project', project_id=project.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Project Name') }} *</label>
|
||||
<input type="text" id="name" name="name" required value="{{ request.form.get('name', project.name) }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Client') }} *</label>
|
||||
<select id="client_id" name="client_id" required class="form-input">
|
||||
<option value="">{{ _('Select a client...') }}</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}" {% if request.form.get('client_id', project.client_id) == client.id %}selected{% endif %} data-default-rate="{{ client.default_hourly_rate or '' }}">{{ client.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="text-xs mt-1"><a href="{{ url_for('clients.create_client') }}" class="text-primary hover:underline">{{ _('Create new client') }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('projects.edit_project', project_id=project.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">{{ _('Project Name') }} *</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required value="{{ request.form.get('name', project.name) }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="client_id" class="form-label">{{ _('Client') }} *</label>
|
||||
<select class="form-control" id="client_id" name="client_id" required>
|
||||
<option value="">{{ _('Select a client...') }}</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}"
|
||||
{% if request.form.get('client_id', project.client_id) == client.id %}selected{% endif %}
|
||||
data-default-rate="{{ client.default_hourly_rate or '' }}">
|
||||
{{ client.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
<a href="{{ url_for('clients.create_client') }}" class="text-decoration-none">
|
||||
<i class="fas fa-plus"></i> {{ _('Create new client') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">{{ _('Description') }}</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3">{{ request.form.get('description', project.description or '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if (request.form and request.form.get('billable')) or (not request.form and project.billable) %}checked{% endif %}>
|
||||
<label class="form-check-label" for="billable">{{ _('Billable') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="hourly_rate" class="form-label">{{ _('Hourly Rate') }}</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control" id="hourly_rate" name="hourly_rate" value="{{ request.form.get('hourly_rate', project.hourly_rate or '') }}" placeholder="e.g. 75.00">
|
||||
<div class="form-text">{{ _('Leave empty for non-billable projects') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="billing_ref" class="form-label">{{ _('Billing Reference') }}</label>
|
||||
<input type="text" class="form-control" id="billing_ref" name="billing_ref" value="{{ request.form.get('billing_ref', project.billing_ref or '') }}" placeholder="Optional">
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="budget_amount" class="form-label">{{ _('Budget Amount') }}</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control" id="budget_amount" name="budget_amount" value="{{ request.form.get('budget_amount', project.budget_amount or '') }}" placeholder="{{ _('e.g. 10000.00') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="budget_threshold_percent" class="form-label">{{ _('Alert Threshold (%)') }}</label>
|
||||
<input type="number" step="1" min="0" max="100" class="form-control" id="budget_threshold_percent" name="budget_threshold_percent" value="{{ request.form.get('budget_threshold_percent', project.budget_threshold_percent or 80) }}" placeholder="80">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> {{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> {{ _('Save Changes') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Description') }}</label>
|
||||
<textarea id="description" name="description" rows="3" class="form-input">{{ request.form.get('description', project.description or '') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="inline-flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<input type="checkbox" id="billable" name="billable" {% if (request.form and request.form.get('billable')) or (not request.form and project.billable) %}checked{% endif %} class="rounded border-gray-300 text-primary shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
{{ _('Billable') }}
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label for="hourly_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Hourly Rate') }}</label>
|
||||
<input type="number" step="0.01" min="0" id="hourly_rate" name="hourly_rate" value="{{ request.form.get('hourly_rate', project.hourly_rate or '') }}" placeholder="e.g. 75.00" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Leave empty for non-billable projects') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billing_ref" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Billing Reference') }}</label>
|
||||
<input type="text" id="billing_ref" name="billing_ref" value="{{ request.form.get('billing_ref', project.billing_ref or '') }}" placeholder="Optional" class="form-input">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="budget_amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Budget Amount') }}</label>
|
||||
<input type="number" step="0.01" min="0" id="budget_amount" name="budget_amount" value="{{ request.form.get('budget_amount', project.budget_amount or '') }}" placeholder="{{ _('e.g. 10000.00') }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="budget_threshold_percent" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Alert Threshold (%)') }}</label>
|
||||
<input type="number" step="1" min="0" max="100" id="budget_threshold_percent" name="budget_threshold_percent" value="{{ request.form.get('budget_threshold_percent', project.budget_threshold_percent or 80) }}" placeholder="80" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3 border-t border-border-light dark:border-border-dark pt-4">
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">{{ _('Cancel') }}</a>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Save Changes') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,11 +82,9 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const clientSelect = document.getElementById('client_id');
|
||||
const hourlyRateInput = document.getElementById('hourly_rate');
|
||||
|
||||
clientSelect.addEventListener('change', function() {
|
||||
const selectedOption = this.options[this.selectedIndex];
|
||||
const defaultRate = selectedOption.getAttribute('data-default-rate');
|
||||
|
||||
if (defaultRate && !hourlyRateInput.value) {
|
||||
hourlyRateInput.value = defaultRate;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import pytest
|
||||
from app import db
|
||||
from app.models import User, Project, TimeEntry
|
||||
from datetime import datetime, timedelta
|
||||
from app.models import Task
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(app):
|
||||
@@ -33,6 +34,18 @@ def sample_data(app):
|
||||
)
|
||||
db.session.add(entry)
|
||||
|
||||
# Create some tasks for task-completion endpoint
|
||||
t1 = Task(project_id=project_id, name='T1', created_by=user_id, assigned_to=user_id)
|
||||
t1.status = 'done'
|
||||
t1.completed_at = datetime.now() - timedelta(days=1)
|
||||
db.session.add(t1)
|
||||
t2 = Task(project_id=project_id, name='T2', created_by=user_id, assigned_to=user_id)
|
||||
t2.status = 'in_progress'
|
||||
db.session.add(t2)
|
||||
t3 = Task(project_id=project_id, name='T3', created_by=user_id, assigned_to=user_id)
|
||||
t3.status = 'todo'
|
||||
db.session.add(t3)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return {'user_id': user_id, 'project_id': project_id}
|
||||
@@ -142,6 +155,25 @@ def test_weekly_trends_api(client, app, sample_data):
|
||||
assert 'labels' in data
|
||||
assert 'datasets' in data
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
def test_task_completion_api(client, app, sample_data):
|
||||
"""Test task completion analytics API endpoint structure"""
|
||||
with app.app_context():
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(sample_data['user_id'])
|
||||
sess['_fresh'] = True
|
||||
|
||||
response = client.get('/api/analytics/task-completion?days=7')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert 'status_breakdown' in data
|
||||
sb = data['status_breakdown'] or {}
|
||||
# Ensure essential keys exist
|
||||
for key in ['done', 'in_progress', 'todo', 'review', 'cancelled']:
|
||||
assert key in sb
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
def test_project_efficiency_api(client, app, sample_data):
|
||||
|
||||
@@ -217,6 +217,26 @@ def test_analytics_page(authenticated_client):
|
||||
response = authenticated_client.get('/analytics')
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.routes
|
||||
def test_dashboard_contains_start_timer_modal(authenticated_client):
|
||||
"""Dashboard should render Start Timer modal container in new UI."""
|
||||
response = authenticated_client.get('/dashboard')
|
||||
assert response.status_code == 200
|
||||
html = response.get_data(as_text=True)
|
||||
assert 'id="startTimerModal"' in html
|
||||
assert 'id="openStartTimer"' in html
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.routes
|
||||
def test_base_layout_has_sidebar_toggle(authenticated_client):
|
||||
"""Ensure sidebar collapse toggle is present on pages."""
|
||||
response = authenticated_client.get('/dashboard')
|
||||
assert response.status_code == 200
|
||||
html = response.get_data(as_text=True)
|
||||
assert 'id="sidebarCollapseBtn"' in html
|
||||
assert 'id="mobileSidebarBtn"' in html
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.routes
|
||||
@@ -410,14 +430,17 @@ def test_create_task_api(authenticated_client, project, user, app):
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.routes
|
||||
@pytest.mark.api
|
||||
@pytest.mark.xfail(reason="PATCH /api/tasks/{id}/status endpoint may not exist or not allow PATCH method")
|
||||
def test_update_task_status_api(authenticated_client, task, app):
|
||||
"""Test updating task status via API."""
|
||||
def test_update_task_status_api_put(authenticated_client, task, app):
|
||||
"""Test updating task status via API using PUT (current behavior)."""
|
||||
with app.app_context():
|
||||
response = authenticated_client.patch(f'/api/tasks/{task.id}/status', json={
|
||||
response = authenticated_client.put(f'/api/tasks/{task.id}/status', json={
|
||||
'status': 'in_progress'
|
||||
})
|
||||
assert response.status_code in [200, 400, 404]
|
||||
assert response.status_code in [200, 400, 403, 404]
|
||||
if response.status_code == 200:
|
||||
data = response.get_json()
|
||||
assert data.get('success') is True
|
||||
assert data.get('task', {}).get('status') == 'in_progress'
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
71
tests/test_tasks_templates.py
Normal file
71
tests/test_tasks_templates.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
from app.models import User, Project, Task
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.routes
|
||||
def test_create_task_page_has_tips(client, app):
|
||||
with app.app_context():
|
||||
# Minimal data to render page
|
||||
user = User(username='ui_user', role='user')
|
||||
db.session.add(user)
|
||||
db.session.add(Project(name='UI Test Project', client='UI Test Client'))
|
||||
db.session.commit()
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
sess['_fresh'] = True
|
||||
|
||||
resp = client.get('/tasks/create')
|
||||
assert resp.status_code == 200
|
||||
assert b'data-testid="task-create-tips"' in resp.data
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.routes
|
||||
def test_edit_task_page_has_tips(client, app):
|
||||
with app.app_context():
|
||||
# Minimal data to render page
|
||||
user = User(username='ui_editor', role='user')
|
||||
project = Project(name='Edit UI Project', client='Client X')
|
||||
db.session.add_all([user, project])
|
||||
db.session.commit()
|
||||
|
||||
task = Task(project_id=project.id, name='Edit Me', created_by=user.id, assigned_to=user.id)
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
sess['_fresh'] = True
|
||||
|
||||
resp = client.get(f'/tasks/{task.id}/edit')
|
||||
assert resp.status_code == 200
|
||||
assert b'data-testid="task-edit-tips"' in resp.data
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.routes
|
||||
def test_kanban_board_aria_and_dnd(authenticated_client, app):
|
||||
with app.app_context():
|
||||
# Minimal data for rendering board
|
||||
user = User(username='kanban_user', role='admin')
|
||||
project = Project(name='Kanban Project', client='Client K')
|
||||
db.session.add_all([user, project])
|
||||
db.session.commit()
|
||||
|
||||
# login session
|
||||
with authenticated_client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(user.id)
|
||||
sess['_fresh'] = True
|
||||
|
||||
resp = authenticated_client.get('/kanban')
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
# ARIA presence on board wrapper and columns
|
||||
assert 'role="application"' in html or 'aria-label="Kanban board"' in html
|
||||
assert 'aria-live' in html # counts or empty placeholder live regions
|
||||
|
||||
|
||||
36
tests/test_ui_quick_wins.py
Normal file
36
tests/test_ui_quick_wins.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.routes
|
||||
def test_base_layout_has_skip_link(authenticated_client):
|
||||
response = authenticated_client.get('/dashboard')
|
||||
assert response.status_code == 200
|
||||
html = response.get_data(as_text=True)
|
||||
assert 'Skip to content' in html
|
||||
assert 'href="#mainContentAnchor"' in html
|
||||
assert 'id="mainContentAnchor"' in html
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.routes
|
||||
def test_login_has_primary_button_and_user_icon(client):
|
||||
response = client.get('/login')
|
||||
assert response.status_code == 200
|
||||
html = response.get_data(as_text=True)
|
||||
assert 'class="btn btn-primary' in html or 'class="btn btn-primary"' in html
|
||||
assert 'fa-user' in html
|
||||
assert 'id="username"' in html
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.routes
|
||||
def test_tasks_table_has_sticky_and_zebra(authenticated_client):
|
||||
response = authenticated_client.get('/tasks')
|
||||
assert response.status_code == 200
|
||||
html = response.get_data(as_text=True)
|
||||
assert 'class="table table-zebra' in html or 'class="table table-zebra"' in html
|
||||
# numeric alignment utility present on Due/Progress columns
|
||||
assert 'table-number' in html
|
||||
|
||||
|
||||
Reference in New Issue
Block a user