Files
TimeTracker/app/templates/components/ui.html
T
Dries Peeters 8bb42ddd02 feat(app): recurring invoices, gantt/reporting services, license UI
- Add RecurringInvoiceRepository and RecurringInvoiceService; refactor recurring_invoice model
- Add GanttService and move gantt logic from route to service
- Expand ReportingService and simplify reports route
- Add license_utils and user license template/settings
- Refactor routes to use scope_filter, api_responses, and services (API v1, timer, admin, invoices, etc.)
- Extend invoice_service for recurring; cache and scope_filter utils; base/template updates
2026-03-15 09:37:00 +01:00

885 lines
43 KiB
HTML

{# ============================================
UNIFIED COMPONENT LIBRARY - Tailwind CSS
All UI components in one place for consistency
============================================ #}
{# ============================================
PAGE HEADERS
============================================ #}
{% macro page_header(icon_class, title_text, subtitle_text=None, actions_html=None, breadcrumbs=None) %}
<div class="bg-gradient-to-r from-card-light to-card-light/50 dark:from-card-dark dark:to-card-dark/50 rounded-lg shadow-sm mb-6 overflow-visible relative z-20">
{% if breadcrumbs %}
<div class="px-6 pt-4 pb-2">
{{ breadcrumb_nav(breadcrumbs) }}
</div>
{% endif %}
<div class="p-6 flex flex-col md:flex-row justify-between items-start md:items-center">
<div class="mb-4 md:mb-0">
<div class="flex items-center mb-2">
{% if icon_class %}
<div class="w-14 h-14 bg-primary/10 dark:bg-primary/20 rounded-full flex items-center justify-center mr-4 backdrop-blur-sm shadow-sm">
<i class="{{ icon_class }} text-primary text-xl"></i>
</div>
{% endif %}
<div>
<h1 class="text-3xl font-bold bg-gradient-to-r from-text-light to-primary dark:from-text-dark dark:to-primary bg-clip-text text-transparent">
{{ _(title_text) }}
</h1>
{% if subtitle_text %}
<p class="text-text-muted-light dark:text-text-muted-dark text-sm mt-1">{{ _(subtitle_text) }}</p>
{% endif %}
</div>
</div>
</div>
{% if actions_html %}
<div class="flex flex-wrap gap-2 items-center">
{{ actions_html|safe }}
</div>
{% endif %}
</div>
</div>
{% endmacro %}
{# ============================================
BREADCRUMBS
============================================ #}
{% macro breadcrumb_nav(items) %}
<nav class="flex" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<li class="inline-flex items-center">
<a href="{% if get_current_client is defined and get_current_client() %}{{ url_for('client_portal.dashboard') }}{% else %}{{ url_for('main.dashboard') }}{% endif %}" class="inline-flex items-center text-sm font-medium text-text-muted-light dark:text-text-muted-dark hover:text-primary transition-colors">
<i class="fas fa-home mr-2"></i>
{{ _('Home') }}
</a>
</li>
{% for item in items %}
<li>
<div class="flex items-center">
<i class="fas fa-chevron-right text-text-muted-light dark:text-text-muted-dark text-xs mx-2"></i>
{% if item.url and not loop.last %}
<a href="{{ item.url }}" class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark hover:text-primary transition-colors">
{{ _(item.text) }}
</a>
{% else %}
<span class="text-sm font-medium text-text-light dark:text-text-dark">{{ _(item.text) }}</span>
{% endif %}
</div>
</li>
{% endfor %}
</ol>
</nav>
{% endmacro %}
{# ============================================
PAGINATION (reusable for list pages)
============================================ #}
{% macro pagination_nav(pagination, route_name, url_params=None, aria_label=None) %}
{% if pagination and pagination.pages > 1 %}
{% set _params = url_params or {} %}
<nav class="px-4 py-3 border-t border-border-light dark:border-border-dark flex items-center justify-between flex-wrap gap-2" aria-label="{{ aria_label or _('Pagination') }}">
<div class="text-sm text-text-light dark:text-text-dark">
{{ _('Showing %(start)s to %(end)s of %(total)s entries', start=pagination.page * pagination.per_page - pagination.per_page + 1, end=pagination.page * pagination.per_page if pagination.page * pagination.per_page < pagination.total else pagination.total, total=pagination.total) }}
</div>
<div class="flex gap-2 flex-wrap">
{% if pagination.has_prev %}
<a href="{{ url_for(route_name, page=pagination.prev_num, **_params) }}" class="btn btn-secondary btn-sm">{{ _('Previous') }}</a>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if page_num %}
{% if page_num == pagination.page %}
<span class="px-3 py-1.5 bg-primary text-white rounded-lg text-sm font-medium" aria-current="page">{{ page_num }}</span>
{% else %}
<a href="{{ url_for(route_name, page=page_num, **_params) }}" class="btn btn-ghost btn-sm">{{ page_num }}</a>
{% endif %}
{% else %}
<span class="px-3 py-1.5 text-text-muted-light dark:text-text-muted-dark">...</span>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<a href="{{ url_for(route_name, page=pagination.next_num, **_params) }}" class="btn btn-secondary btn-sm">{{ _('Next') }}</a>
{% endif %}
</div>
</nav>
{% endif %}
{% endmacro %}
{# ============================================
STAT CARDS
============================================ #}
{% macro stat_card(title, value, icon_class, color="primary", trend=None, subtitle=None) %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6 hover:shadow-md transition-shadow duration-200 relative overflow-hidden group">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-{{ color }} to-{{ color }}/50"></div>
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _(title) }}</p>
<h3 class="text-2xl lg:text-3xl font-bold text-text-light dark:text-text-dark mb-2" data-count-up="{{ value }}">{{ value }}</h3>
{% if subtitle %}
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _(subtitle) }}</p>
{% endif %}
{% if trend %}
<div class="mt-2 flex items-center text-sm">
{% if trend > 0 %}
<span class="text-green-600 dark:text-green-400 flex items-center">
<i class="fas fa-arrow-up mr-1"></i>
{{ "%.1f"|format(trend) }}%
</span>
{% elif trend < 0 %}
<span class="text-red-600 dark:text-red-400 flex items-center">
<i class="fas fa-arrow-down mr-1"></i>
{{ "%.1f"|format(trend|abs) }}%
</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark flex items-center">
<i class="fas fa-minus mr-1"></i>
0%
</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="w-12 h-12 bg-{{ color }}/10 dark:bg-{{ color }}/20 rounded-lg flex items-center justify-center backdrop-blur-sm group-hover:scale-110 transition-transform duration-200">
<i class="{{ icon_class }} text-{{ color }} text-xl"></i>
</div>
</div>
</div>
{% endmacro %}
{# ============================================
EMPTY STATES
============================================ #}
{% macro empty_state(icon_class, title, message, actions_html=None, type="default", illustration=None) %}
{% set type_colors = {
'default': 'primary',
'no-data': 'gray-500',
'no-results': 'amber-500',
'error': 'red-500',
'success': 'green-500',
'info': 'blue-500'
} %}
{% set color = type_colors.get(type, 'primary') %}
<div class="text-center py-12 px-4 fade-in-up">
{% if illustration %}
<div class="mb-6">
{{ illustration|safe }}
</div>
{% else %}
<div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-{{ color }}/10 dark:bg-{{ color }}/20 mb-6 animate-float">
<i class="{{ icon_class }} text-{{ color }} text-4xl"></i>
</div>
{% endif %}
<h3 class="text-xl font-semibold text-text-light dark:text-text-dark mb-2">{{ _(title) }}</h3>
<p class="text-text-muted-light dark:text-text-muted-dark max-w-md mx-auto mb-6">{{ _(message) }}</p>
{% if actions_html %}
<div class="flex flex-wrap gap-3 justify-center">
{{ actions_html|safe }}
</div>
{% endif %}
</div>
{% endmacro %}
{# Compact empty state for tables / inline use #}
{% macro empty_state_compact(icon_class, title, message, actions_html=None, type="default") %}
{% set type_colors = {
'default': 'primary',
'no-data': 'gray-500',
'no-results': 'amber-500',
'error': 'red-500'
} %}
{% set color = type_colors.get(type, 'primary') %}
<div class="flex flex-col items-center justify-center py-8 px-4 text-center">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full bg-{{ color }}/10 dark:bg-{{ color }}/20 mb-3">
<i class="{{ icon_class }} text-{{ color }} text-2xl"></i>
</div>
<h3 class="text-base font-semibold text-text-light dark:text-text-dark mb-1">{{ _(title) }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark max-w-sm mb-4">{{ _(message) }}</p>
{% if actions_html %}
<div class="flex flex-wrap gap-2 justify-center">
{{ actions_html|safe }}
</div>
{% endif %}
</div>
{% endmacro %}
{# Full-page loading overlay (Tailwind) #}
{% macro loading_overlay(text="Loading...") %}
<div class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/30 dark:bg-black/50 backdrop-blur-sm" role="status" aria-live="polite">
<div class="inline-block w-12 h-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin"></div>
<p class="mt-3 text-sm font-medium text-text-light dark:text-text-dark">{{ _(text) }}</p>
</div>
{% endmacro %}
{# ============================================
LOADING STATES
============================================ #}
{% macro loading_spinner(size="md", text=None, context=None) %}
{% set size_classes = {'sm': 'w-6 h-6', 'md': 'w-10 h-10', 'lg': 'w-16 h-16'} %}
{% set context_icons = {
'export': 'fas fa-download',
'delete': 'fas fa-trash',
'save': 'fas fa-save',
'load': 'fas fa-spinner',
'bulk': 'fas fa-tasks'
} %}
<div class="text-center">
{% if context and context in context_icons %}
<div class="relative inline-block">
<div class="{{ size_classes.get(size, 'w-10 h-10') }} border-4 border-primary/30 border-t-primary rounded-full animate-spin"></div>
<i class="{{ context_icons[context] }} absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-primary text-sm"></i>
</div>
{% else %}
<div class="inline-block {{ size_classes.get(size, 'w-10 h-10') }} border-4 border-primary/30 border-t-primary rounded-full animate-spin"></div>
{% endif %}
{% if text %}
<p class="mt-3 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _(text) }}</p>
{% endif %}
</div>
{% endmacro %}
{% macro progress_indicator(text, percentage=None, show_percentage=True) %}
<div class="space-y-2">
{% if text %}
<div class="flex items-center justify-between text-sm">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _(text) }}</span>
{% if show_percentage and percentage is not none %}
<span class="font-medium text-primary">{{ percentage }}%</span>
{% endif %}
</div>
{% endif %}
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div class="h-full bg-primary rounded-full transition-all duration-300 {% if percentage is none %}animate-pulse{% endif %}"
style="{% if percentage is not none %}width: {{ percentage }}%{% else %}width: 100%; animation: progress-indeterminate 2s ease-in-out infinite;{% endif %}">
</div>
</div>
</div>
{% endmacro %}
{% macro skeleton_card() %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6 animate-pulse">
<div class="flex items-start justify-between">
<div class="flex-1 space-y-3">
<div class="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
<div class="h-8 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
<div class="h-3 bg-gray-300 dark:bg-gray-700 rounded w-1/3"></div>
</div>
<div class="w-12 h-12 bg-gray-300 dark:bg-gray-700 rounded-lg"></div>
</div>
</div>
{% endmacro %}
{# Generic loading placeholder for lists/tables: use kind="table" (default) or "spinner" #}
{% macro loading_placeholder(kind="table", rows=5, cols=4, text=None, show_checkbox=False) %}
{% if kind == "table" %}
{{ skeleton_table(rows=rows, cols=cols, show_checkbox=show_checkbox) }}
{% else %}
<div class="py-12">
{{ loading_spinner(size="lg", text=text or _('Loading...')) }}
</div>
{% endif %}
{% endmacro %}
{% macro skeleton_table(rows=5, cols=4, show_checkbox=False) %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm overflow-hidden animate-pulse">
<!-- Table Header -->
<div class="border-b border-border-light dark:border-border-dark p-4">
<div class="grid grid-cols-{{ cols + (1 if show_checkbox else 0) }} gap-4">
{% if show_checkbox %}
<div class="h-4 bg-gray-300 dark:bg-gray-700 rounded w-4"></div>
{% endif %}
{% for j in range(cols) %}
<div class="h-4 bg-gray-300 dark:bg-gray-700 rounded"></div>
{% endfor %}
</div>
</div>
<!-- Table Rows -->
<div class="divide-y divide-border-light dark:divide-border-dark">
{% for i in range(rows) %}
<div class="p-4">
<div class="grid grid-cols-{{ cols + (1 if show_checkbox else 0) }} gap-4">
{% if show_checkbox %}
<div class="h-4 bg-gray-300 dark:bg-gray-700 rounded w-4"></div>
{% endif %}
{% for j in range(cols) %}
<div class="h-4 bg-gray-300 dark:bg-gray-700 rounded {% if j == 0 %}w-3/4{% elif j == cols - 1 %}w-1/2{% else %}w-full{% endif %}"></div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endmacro %}
{% macro skeleton_list(items=5) %}
<div class="space-y-3 animate-pulse">
{% for i in range(items) %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-4 flex items-center gap-4">
<div class="w-12 h-12 bg-gray-300 dark:bg-gray-700 rounded-full"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
<div class="h-3 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
</div>
<div class="w-20 h-6 bg-gray-300 dark:bg-gray-700 rounded-full"></div>
</div>
{% endfor %}
</div>
{% endmacro %}
{# ============================================
BADGES & CHIPS
============================================ #}
{% macro badge(text, color="primary", icon=None, size="md") %}
{% set size_classes = {
'sm': 'text-xs px-2 py-0.5',
'md': 'text-sm px-3 py-1',
'lg': 'text-base px-4 py-1.5'
} %}
<span class="inline-flex items-center {{ size_classes.get(size, size_classes['md']) }} rounded-full font-medium bg-{{ color }}/10 dark:bg-{{ color }}/20 text-{{ color }} dark:text-{{ color }}">
{% if icon %}<i class="{{ icon }} mr-1"></i>{% endif %}
{{ _(text) }}
</span>
{% endmacro %}
{# ============================================
BUTTONS
============================================ #}
{% macro button(text, url=None, icon_class=None, variant="primary", size="md", type="button", attributes="", loading=False) %}
{% set size_classes = {
'sm': 'px-3 py-1.5 text-sm',
'md': 'px-4 py-2',
'lg': 'px-6 py-3 text-lg'
} %}
{% set variant_classes = {
'primary': 'bg-primary text-white hover:bg-primary/90',
'secondary': 'bg-gray-500 text-white hover:bg-gray-600',
'success': 'bg-green-600 text-white hover:bg-green-700',
'danger': 'bg-red-600 text-white hover:bg-red-700',
'warning': 'bg-amber-500 text-white hover:bg-amber-600',
'outline': 'border-2 border-primary text-primary hover:bg-primary hover:text-white',
'ghost': 'text-primary hover:bg-primary/10'
} %}
{% set tag = 'a' if url else 'button' %}
<{{ tag }}
{% if url %}href="{{ url }}"{% endif %}
{% if type and tag == 'button' %}type="{{ type }}"{% endif %}
class="inline-flex items-center justify-center {{ size_classes.get(size, size_classes['md']) }} {{ variant_classes.get(variant, variant_classes['primary']) }} rounded-lg font-medium transition-all duration-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
{{ attributes|safe }}
{% if loading %}disabled{% endif %}
>
{% if loading %}
<i class="fas fa-spinner fa-spin mr-2"></i>
{% elif icon_class %}
<i class="{{ icon_class }} mr-2"></i>
{% endif %}
{{ _(text) }}
</{{ tag }}>
{% endmacro %}
{# ============================================
FILTER BADGES
============================================ #}
{% macro filter_badge(label, value, remove_url) %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary/10 dark:bg-primary/20 text-primary border border-primary/20 dark:border-primary/30">
<span class="font-medium">{{ _(label) }}:</span>
<span class="ml-1">{{ value }}</span>
<a href="{{ remove_url }}" class="ml-2 hover:text-red-600 transition-colors">
<i class="fas fa-times"></i>
</a>
</span>
{% endmacro %}
{# ============================================
PROGRESS BARS
============================================ #}
{% macro progress_bar(current, total, color="primary", show_label=True, animate=True) %}
{% set percentage = (current / total * 100) if total > 0 else 0 %}
<div class="space-y-2">
{% if show_label %}
<div class="flex justify-between text-sm">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ current }} / {{ total }}</span>
<span class="font-semibold text-{{ color }}">{{ "%.0f"|format(percentage) }}%</span>
</div>
{% endif %}
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden">
<div class="bg-{{ color }} h-full rounded-full {% if animate %}transition-all duration-500{% endif %} relative overflow-hidden"
style="width: {{ percentage }}%">
{% if animate %}
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer"></div>
{% endif %}
</div>
</div>
</div>
{% endmacro %}
{# ============================================
ALERTS & NOTIFICATIONS
============================================ #}
{% macro alert(message, type="info", icon=None, dismissible=True) %}
{% set type_config = {
'info': {'bg': 'blue-50', 'border': 'blue-200', 'text': 'blue-800', 'icon': 'fa-info-circle'},
'success': {'bg': 'green-50', 'border': 'green-200', 'text': 'green-800', 'icon': 'fa-check-circle'},
'warning': {'bg': 'amber-50', 'border': 'amber-200', 'text': 'amber-800', 'icon': 'fa-exclamation-triangle'},
'error': {'bg': 'red-50', 'border': 'red-200', 'text': 'red-800', 'icon': 'fa-exclamation-circle'}
} %}
{% set config = type_config.get(type, type_config['info']) %}
<div class="bg-{{ config.bg }} dark:bg-{{ config.text }}/10 border border-{{ config.border }} dark:border-{{ config.text }}/20 text-{{ config.text }} dark:text-{{ config.text }} px-4 py-3 rounded-lg flex items-center justify-between fade-in" role="alert">
<div class="flex items-center">
<i class="fas {{ icon or config.icon }} mr-3 text-lg"></i>
<span>{{ _(message) }}</span>
</div>
{% if dismissible %}
<button type="button" class="ml-4 hover:opacity-70 transition-opacity" onclick="this.parentElement.remove()">
<i class="fas fa-times"></i>
</button>
{% endif %}
</div>
{% endmacro %}
{# ============================================
MODAL WRAPPER
============================================ #}
{% macro modal(id, title, content_html, footer_html=None, size="md") %}
{% set size_classes = {
'sm': 'max-w-md',
'md': 'max-w-lg',
'lg': 'max-w-2xl',
'xl': 'max-w-4xl',
'full': 'max-w-full mx-4'
} %}
<div id="{{ id }}" class="fixed inset-0 z-50 hidden overflow-y-auto" aria-labelledby="{{ id }}-title" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-black/50 dark:bg-gray-900 dark:bg-opacity-75" aria-hidden="true" onclick="document.getElementById('{{ id }}').classList.add('hidden')"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-card-light dark:bg-card-dark rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle {{ size_classes.get(size, size_classes['md']) }} w-full">
<div class="bg-card-light dark:bg-card-dark px-6 pt-5 pb-4">
<div class="flex items-start justify-between mb-4">
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark" id="{{ id }}-title">{{ _(title) }}</h3>
<button type="button" class="text-text-muted-light dark:text-text-muted-dark hover:text-text-light dark:hover:text-text-dark" onclick="document.getElementById('{{ id }}').classList.add('hidden')">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div class="mt-2">
{{ content_html|safe }}
</div>
</div>
{% if footer_html %}
<div class="bg-gray-50 dark:bg-gray-800 px-6 py-4 flex justify-end gap-3">
{{ footer_html|safe }}
</div>
{% endif %}
</div>
</div>
</div>
{% endmacro %}
{# ============================================
CONFIRMATION DIALOG
============================================ #}
{% macro confirm_dialog(id, title, message, confirm_text="Confirm", cancel_text="Cancel", confirm_class="danger") %}
<div id="{{ id }}" class="fixed inset-0 z-50 hidden overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="{{ id }}-title" aria-describedby="{{ id }}-desc">
<div class="flex items-center justify-center min-h-screen px-4">
<div class="fixed inset-0 transition-opacity bg-black/50 dark:bg-gray-900 dark:bg-opacity-75" onclick="document.getElementById('{{ id }}').classList.add('hidden')"></div>
<div class="relative bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full p-6 zoom-in">
<div class="flex items-start mb-4">
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 text-xl" aria-hidden="true"></i>
</div>
</div>
<div class="ml-4 flex-1">
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark mb-2" id="{{ id }}-title">{{ _(title) }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark" id="{{ id }}-desc">{{ _(message) }}</p>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button type="button" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" onclick="document.getElementById('{{ id }}').classList.add('hidden')">
{{ _(cancel_text) }}
</button>
<button type="button" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" onclick="document.getElementById('{{ id }}-form').submit()">
{{ _(confirm_text) }}
</button>
</div>
</div>
</div>
</div>
{% endmacro %}
{# ============================================
DATA TABLE WRAPPER
============================================ #}
{% macro data_table(headers, rows, actions=None, empty_message="No data available", sortable=True) %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-800 border-b border-border-light dark:border-border-dark">
<tr>
{% for header in headers %}
<th class="px-4 py-3 text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider {% if sortable %}cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700{% endif %}" {% if sortable %}data-sortable{% endif %}>
<div class="flex items-center">
{{ _(header.label) }}
{% if sortable %}
<i class="fas fa-sort ml-2 text-gray-400"></i>
{% endif %}
</div>
</th>
{% endfor %}
{% if actions %}
<th class="px-4 py-3 text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">
{{ _('Actions') }}
</th>
{% endif %}
</tr>
</thead>
<tbody class="divide-y divide-border-light dark:divide-border-dark">
{% if rows %}
{{ rows|safe }}
{% else %}
<tr>
<td colspan="{{ headers|length + (1 if actions else 0) }}" class="px-4 py-8 text-center text-text-muted-light dark:text-text-muted-dark">
{{ _(empty_message) }}
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endmacro %}
{# ============================================
TABS
============================================ #}
{% macro tabs(items, active_tab) %}
<div class="border-b border-border-light dark:border-border-dark mb-6">
<nav class="flex space-x-8" aria-label="Tabs">
{% for item in items %}
<a href="{{ item.url }}"
class="py-4 px-1 border-b-2 font-medium text-sm transition-colors {% if item.id == active_tab %}border-primary text-primary{% else %}border-transparent text-text-muted-light dark:text-text-muted-dark hover:text-text-light dark:hover:text-text-dark hover:border-gray-300 dark:hover:border-gray-600{% endif %}">
{% if item.icon %}<i class="{{ item.icon }} mr-2"></i>{% endif %}
{{ _(item.label) }}
{% if item.count is defined %}
<span class="ml-2 py-0.5 px-2 rounded-full text-xs bg-gray-100 dark:bg-gray-700">{{ item.count }}</span>
{% endif %}
</a>
{% endfor %}
</nav>
</div>
{% endmacro %}
{# ============================================
TIMELINE ITEM
============================================ #}
{% macro timeline_item(icon, title, description, time, color="primary", is_last=False) %}
<div class="flex">
<div class="flex flex-col items-center mr-4">
<div class="w-10 h-10 rounded-full bg-{{ color }}/10 dark:bg-{{ color }}/20 flex items-center justify-center">
<i class="{{ icon }} text-{{ color }}"></i>
</div>
{% if not is_last %}
<div class="w-0.5 h-full bg-gray-200 dark:bg-gray-700 mt-2"></div>
{% endif %}
</div>
<div class="pb-8 flex-1">
<h4 class="text-sm font-semibold text-text-light dark:text-text-dark mb-1">{{ _(title) }}</h4>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">{{ _(description) }}</p>
<span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ time }}</span>
</div>
</div>
{% endmacro %}
{# ============================================
FORM COMPONENTS
Reusable form macros for consistent, accessible,
mobile-friendly data entry across the app
============================================ #}
{% macro form_group(name, label, type="text", value="", required=False, help_text=None, error=None, placeholder="", icon=None, attrs="", disabled=False, readonly=False, min=None, max=None, step=None, pattern=None, autocomplete=None) %}
<div class="form-group-wrapper">
<label for="{{ name }}" class="form-label">
{% if icon %}<i class="{{ icon }} mr-1.5 text-text-muted-light dark:text-text-muted-dark"></i>{% endif %}
{{ _(label) }}
{% if required %}<span class="text-red-500 ml-0.5" aria-hidden="true">*</span>{% endif %}
</label>
<div class="relative">
<input
type="{{ type }}"
id="{{ name }}"
name="{{ name }}"
value="{{ value }}"
class="form-input {% if error %}border-red-500 focus:border-red-500 focus:ring-red-500/30{% endif %}"
{% if placeholder %}placeholder="{{ _(placeholder) }}"{% endif %}
{% if required %}required aria-required="true"{% endif %}
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly{% endif %}
{% if min is not none %}min="{{ min }}"{% endif %}
{% if max is not none %}max="{{ max }}"{% endif %}
{% if step is not none %}step="{{ step }}"{% endif %}
{% if pattern %}pattern="{{ pattern }}"{% endif %}
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
{% if error %}aria-invalid="true" aria-describedby="{{ name }}-error"{% endif %}
{% if help_text and not error %}aria-describedby="{{ name }}-help"{% endif %}
{{ attrs|safe }}
>
</div>
{% if error %}
<p id="{{ name }}-error" class="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
<i class="fas fa-exclamation-circle text-xs"></i> {{ _(error) }}
</p>
{% endif %}
{% if help_text %}
<p id="{{ name }}-help" class="mt-1.5 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _(help_text) }}</p>
{% endif %}
</div>
{% endmacro %}
{% macro form_select(name, label, options, selected=None, required=False, help_text=None, error=None, placeholder="", icon=None, attrs="", disabled=False, multiple=False) %}
<div class="form-group-wrapper">
<label for="{{ name }}" class="form-label">
{% if icon %}<i class="{{ icon }} mr-1.5 text-text-muted-light dark:text-text-muted-dark"></i>{% endif %}
{{ _(label) }}
{% if required %}<span class="text-red-500 ml-0.5" aria-hidden="true">*</span>{% endif %}
</label>
<select
id="{{ name }}"
name="{{ name }}"
class="form-input {% if error %}border-red-500 focus:border-red-500 focus:ring-red-500/30{% endif %}"
{% if required %}required aria-required="true"{% endif %}
{% if disabled %}disabled{% endif %}
{% if multiple %}multiple{% endif %}
{% if error %}aria-invalid="true" aria-describedby="{{ name }}-error"{% endif %}
{% if help_text and not error %}aria-describedby="{{ name }}-help"{% endif %}
{{ attrs|safe }}
>
{% if placeholder %}
<option value="" {% if not selected %}selected{% endif %} disabled>{{ _(placeholder) }}</option>
{% endif %}
{% for opt in options %}
{% if opt is mapping %}
<option value="{{ opt.value }}" {% if selected is not none and opt.value|string == selected|string %}selected{% endif %} {% if opt.disabled %}disabled{% endif %}>{{ opt.label }}</option>
{% else %}
<option value="{{ opt[0] }}" {% if selected is not none and opt[0]|string == selected|string %}selected{% endif %}>{{ opt[1] if opt is iterable and opt|length > 1 else opt }}</option>
{% endif %}
{% endfor %}
</select>
{% if error %}
<p id="{{ name }}-error" class="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
<i class="fas fa-exclamation-circle text-xs"></i> {{ _(error) }}
</p>
{% endif %}
{% if help_text %}
<p id="{{ name }}-help" class="mt-1.5 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _(help_text) }}</p>
{% endif %}
</div>
{% endmacro %}
{% macro form_textarea(name, label, value="", rows=4, required=False, help_text=None, error=None, placeholder="", icon=None, attrs="", disabled=False, maxlength=None) %}
<div class="form-group-wrapper">
<label for="{{ name }}" class="form-label">
{% if icon %}<i class="{{ icon }} mr-1.5 text-text-muted-light dark:text-text-muted-dark"></i>{% endif %}
{{ _(label) }}
{% if required %}<span class="text-red-500 ml-0.5" aria-hidden="true">*</span>{% endif %}
</label>
<textarea
id="{{ name }}"
name="{{ name }}"
rows="{{ rows }}"
class="form-input resize-y {% if error %}border-red-500 focus:border-red-500 focus:ring-red-500/30{% endif %}"
{% if placeholder %}placeholder="{{ _(placeholder) }}"{% endif %}
{% if required %}required aria-required="true"{% endif %}
{% if disabled %}disabled{% endif %}
{% if maxlength %}maxlength="{{ maxlength }}"{% endif %}
{% if error %}aria-invalid="true" aria-describedby="{{ name }}-error"{% endif %}
{% if help_text and not error %}aria-describedby="{{ name }}-help"{% endif %}
{{ attrs|safe }}
>{{ value }}</textarea>
{% if maxlength %}
<div class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark text-right">
<span class="char-counter" data-for="{{ name }}">{{ value|length }}</span> / {{ maxlength }}
</div>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
<i class="fas fa-exclamation-circle text-xs"></i> {{ _(error) }}
</p>
{% endif %}
{% if help_text %}
<p id="{{ name }}-help" class="mt-1.5 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _(help_text) }}</p>
{% endif %}
</div>
{% endmacro %}
{% macro form_checkbox(name, label, checked=False, help_text=None, error=None, attrs="", disabled=False, value="1") %}
<div class="form-group-wrapper">
<label for="{{ name }}" class="relative flex items-start gap-3 cursor-pointer group min-h-[44px] py-2">
<div class="flex items-center h-5 mt-0.5">
<input
type="checkbox"
id="{{ name }}"
name="{{ name }}"
value="{{ value }}"
class="h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-primary focus:ring-primary focus:ring-2 dark:bg-gray-700 transition-colors cursor-pointer"
{% if checked %}checked{% endif %}
{% if disabled %}disabled{% endif %}
{% if error %}aria-invalid="true" aria-describedby="{{ name }}-error"{% endif %}
{{ attrs|safe }}
>
</div>
<div class="flex-1 min-w-0">
<span class="text-sm font-medium text-text-light dark:text-text-dark group-hover:text-primary transition-colors">{{ _(label) }}</span>
{% if help_text %}
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-0.5">{{ _(help_text) }}</p>
{% endif %}
</div>
</label>
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
<i class="fas fa-exclamation-circle text-xs"></i> {{ _(error) }}
</p>
{% endif %}
</div>
{% endmacro %}
{% macro form_file(name, label, accept="", help_text=None, error=None, required=False, multiple=False, attrs="") %}
<div class="form-group-wrapper">
<label for="{{ name }}" class="form-label">
{{ _(label) }}
{% if required %}<span class="text-red-500 ml-0.5" aria-hidden="true">*</span>{% endif %}
</label>
<div class="mt-1">
<label for="{{ name }}" class="flex flex-col items-center justify-center w-full min-h-[120px] px-4 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors border-gray-300 dark:border-gray-600 hover:border-primary dark:hover:border-primary bg-background-light/50 dark:bg-gray-800/50 hover:bg-primary/5 dark:hover:bg-primary/10 {% if error %}border-red-500{% endif %}">
<div class="flex flex-col items-center text-center">
<i class="fas fa-cloud-upload-alt text-2xl text-text-muted-light dark:text-text-muted-dark mb-2"></i>
<p class="text-sm text-text-light dark:text-text-dark font-medium">{{ _('Click to upload or drag and drop') }}</p>
{% if accept %}
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ accept }}</p>
{% endif %}
</div>
<input
type="file"
id="{{ name }}"
name="{{ name }}"
class="hidden"
{% if accept %}accept="{{ accept }}"{% endif %}
{% if required %}required{% endif %}
{% if multiple %}multiple{% endif %}
{% if error %}aria-invalid="true" aria-describedby="{{ name }}-error"{% endif %}
{{ attrs|safe }}
>
</label>
<div id="{{ name }}-preview" class="mt-2 hidden">
<div class="flex items-center gap-2 p-2 bg-background-light dark:bg-gray-800 rounded-lg">
<i class="fas fa-file text-primary"></i>
<span class="text-sm text-text-light dark:text-text-dark truncate" id="{{ name }}-filename"></span>
<button type="button" class="ml-auto text-text-muted-light hover:text-red-500 transition-colors" onclick="document.getElementById('{{ name }}').value=''; document.getElementById('{{ name }}-preview').classList.add('hidden');">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
{% if error %}
<p id="{{ name }}-error" class="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
<i class="fas fa-exclamation-circle text-xs"></i> {{ _(error) }}
</p>
{% endif %}
{% if help_text %}
<p id="{{ name }}-help" class="mt-1.5 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _(help_text) }}</p>
{% endif %}
</div>
{% endmacro %}
{% macro form_date(name, label, value="", required=False, help_text=None, error=None, placeholder="", icon="fas fa-calendar", attrs="", disabled=False, min_date=None, max_date=None, enable_time=False) %}
<div class="form-group-wrapper">
<label for="{{ name }}" class="form-label">
{% if icon %}<i class="{{ icon }} mr-1.5 text-text-muted-light dark:text-text-muted-dark"></i>{% endif %}
{{ _(label) }}
{% if required %}<span class="text-red-500 ml-0.5" aria-hidden="true">*</span>{% endif %}
</label>
<input
type="text"
id="{{ name }}"
name="{{ name }}"
value="{{ value }}"
class="form-input flatpickr-input {% if error %}border-red-500 focus:border-red-500 focus:ring-red-500/30{% endif %}"
{% if placeholder %}placeholder="{{ _(placeholder) }}"{% endif %}
{% if required %}required aria-required="true"{% endif %}
{% if disabled %}disabled{% endif %}
{% if error %}aria-invalid="true" aria-describedby="{{ name }}-error"{% endif %}
data-flatpickr
{% if enable_time %}data-enable-time="true"{% endif %}
{% if min_date %}data-min-date="{{ min_date }}"{% endif %}
{% if max_date %}data-max-date="{{ max_date }}"{% endif %}
{{ attrs|safe }}
>
{% if error %}
<p id="{{ name }}-error" class="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
<i class="fas fa-exclamation-circle text-xs"></i> {{ _(error) }}
</p>
{% endif %}
{% if help_text %}
<p id="{{ name }}-help" class="mt-1.5 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _(help_text) }}</p>
{% endif %}
</div>
{% endmacro %}
{% macro form_section(title, icon=None, description=None) %}
<div class="form-section-divider pt-6 pb-2 mb-4 border-b border-border-light dark:border-border-dark">
<h3 class="form-section-title">
{% if icon %}<i class="{{ icon }} text-primary"></i>{% endif %}
{{ _(title) }}
</h3>
{% if description %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark -mt-2 mb-2">{{ _(description) }}</p>
{% endif %}
</div>
{% endmacro %}
{% macro form_actions(submit_text="Save", cancel_url=None, cancel_text="Cancel", show_reset=False, submit_icon="fas fa-check", loading_text=None) %}
<div class="form-actions-bar sticky bottom-0 z-10 bg-card-light dark:bg-card-dark border-t border-border-light dark:border-border-dark -mx-6 -mb-6 px-6 py-4 mt-6 flex flex-col-reverse sm:flex-row sm:justify-end gap-3 rounded-b-lg">
{% if show_reset %}
<button type="reset" class="btn btn-ghost w-full sm:w-auto">
<i class="fas fa-undo mr-1.5"></i>{{ _('Reset') }}
</button>
{% endif %}
{% if cancel_url %}
<a href="{{ cancel_url }}" class="btn btn-secondary w-full sm:w-auto text-center">
{{ _(cancel_text) }}
</a>
{% endif %}
<button type="submit" class="btn btn-primary w-full sm:w-auto" {% if loading_text %}data-loading-text="{{ _(loading_text) }}"{% endif %}>
{% if submit_icon %}<i class="{{ submit_icon }} mr-1.5"></i>{% endif %}
{{ _(submit_text) }}
</button>
</div>
{% endmacro %}
{# ============================================
LOADING SKELETONS
============================================ #}
{% macro skeleton_row() %}
<div class="skeleton-row flex items-center gap-3 p-4 border-b border-border-light dark:border-border-dark">
<div class="skeleton skeleton-avatar flex-shrink-0"></div>
<div class="flex-1 min-w-0 space-y-2">
<div class="skeleton skeleton-text medium"></div>
<div class="skeleton skeleton-text short"></div>
</div>
<div class="skeleton skeleton-text short w-16 flex-shrink-0"></div>
</div>
{% endmacro %}
{% macro skeleton_table_rows(count=5, cols=5) %}
{% for _ in range(count) %}
<tr class="border-b border-border-light dark:border-border-dark">
{% for _ in range(cols) %}
<td class="p-4"><div class="skeleton skeleton-text medium"></div></td>
{% endfor %}
</tr>
{% endfor %}
{% endmacro %}
{% macro skeleton_card() %}
<div class="skeleton-card space-y-3">
<div class="skeleton skeleton-text long"></div>
<div class="skeleton skeleton-text medium"></div>
<div class="skeleton skeleton-text short"></div>
</div>
{% endmacro %}