Merge pull request #89 from DRYTRIX/Feat-UIRedesign

feat: Implement Tailwind CSS UI redesign across application
This commit is contained in:
Dries Peeters
2025-10-17 12:41:14 +02:00
committed by GitHub
80 changed files with 5137 additions and 18967 deletions

9
.gitignore vendored
View File

@@ -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/

View File

@@ -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"]

View File

@@ -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

View File

@@ -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'"
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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():

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}

View File

@@ -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; }

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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>
`;

View File

@@ -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
View 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); }

View 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

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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
View 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;
}

View File

@@ -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.
*/

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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;} }

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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()">&times;</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) {

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View 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>

View 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">Nonbillable</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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -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
View 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: [],
}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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):

View File

@@ -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'
# ============================================================================

View 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

View 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