mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 12:50:11 -05:00
8bb42ddd02
- 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
885 lines
43 KiB
HTML
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">​</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 %}
|
|
|