feat(client-portal): enhance error handling and UI improvements

- Add custom error handlers (403, 404, 500) with user-friendly error pages
- Create new error.html template with consistent styling and navigation
- Enhance context processor to inject pending approvals and notifications counts
- Add error handling to client approval service with proper rollback
- Update all client portal templates with improved UI/UX
- Improve base template with better navigation and data injection
- Add graceful error handling to prevent cascading failures

This update significantly improves the user experience in the client portal
by providing clear error messages and better visual feedback throughout
all pages.
This commit is contained in:
Dries Peeters
2026-01-16 09:14:46 +01:00
parent be88d867c7
commit 2ca4fd3f1a
16 changed files with 1537 additions and 513 deletions
+109 -4
View File
@@ -20,6 +20,75 @@ from functools import wraps
client_portal_bp = Blueprint("client_portal", __name__)
# Custom error handlers for client portal
@client_portal_bp.errorhandler(403)
def handle_forbidden(error):
"""Handle 403 Forbidden errors in client portal with nice error page"""
# Check if user is logged in as regular user (not client portal)
from flask_login import current_user
if current_user.is_authenticated:
# User is logged in but accessing client portal - redirect to login
# This clears their session and lets them log in as client portal user
flash(_("Please log in to access the client portal."), "error")
return redirect(url_for("client_portal.login", next=request.url))
current_client = get_current_client()
# If not authenticated, redirect to login instead of showing error
if not current_client:
flash(_("Please log in to access the client portal."), "error")
return redirect(url_for("client_portal.login", next=request.url))
# User is authenticated but doesn't have access - show error page
return render_template(
"client_portal/error.html",
error_info={
"title": _("Access Denied"),
"subtitle": _("403 Forbidden"),
"message": _("You don't have permission to access this resource. Client portal access may not be enabled for your account."),
"details": [
_("Your account may not have client portal access enabled"),
_("Your account may be inactive"),
_("You may not be assigned to a client")
],
"show_back": True
}
), 403
@client_portal_bp.errorhandler(404)
def handle_not_found(error):
"""Handle 404 Not Found errors in client portal with nice error page"""
current_client = get_current_client()
return render_template(
"client_portal/error.html",
error_info={
"title": _("Page Not Found"),
"subtitle": _("404 Not Found"),
"message": _("The page you're looking for doesn't exist or has been moved."),
"show_back": True
}
), 404
@client_portal_bp.errorhandler(500)
def handle_internal_error(error):
"""Handle 500 Internal Server errors in client portal with nice error page"""
current_app.logger.exception("Internal server error in client portal")
current_client = get_current_client()
return render_template(
"client_portal/error.html",
error_info={
"title": _("Server Error"),
"subtitle": _("500 Internal Server Error"),
"message": _("An unexpected error occurred. Please try again later or contact support if the problem persists."),
"show_back": True
}
), 500
def get_current_client():
"""Get the currently logged-in client from session (either Client or User portal access)"""
# Check for Client portal authentication
@@ -40,8 +109,34 @@ def get_current_client():
# Make get_current_client available to templates
@client_portal_bp.app_context_processor
def inject_get_current_client():
"""Make get_current_client available in templates"""
return dict(get_current_client=get_current_client)
"""Make get_current_client available in templates and inject portal data"""
client = get_current_client()
pending_approvals_count = 0
unread_notifications_count = 0
if client:
try:
# Get pending approvals count with error handling
approval_service = ClientApprovalService()
pending_approvals = approval_service.get_pending_approvals_for_client(client.id)
pending_approvals_count = len(pending_approvals) if pending_approvals else 0
except Exception as e:
current_app.logger.error(f"Error getting pending approvals count: {e}", exc_info=True)
pending_approvals_count = 0
try:
# Get unread notifications count with error handling
notification_service = ClientNotificationService()
unread_notifications_count = notification_service.get_unread_count(client.id)
except Exception as e:
current_app.logger.error(f"Error getting unread notifications count: {e}", exc_info=True)
unread_notifications_count = 0
return dict(
get_current_client=get_current_client,
pending_approvals_count=pending_approvals_count,
unread_notifications_count=unread_notifications_count
)
def check_client_portal_access():
@@ -242,6 +337,15 @@ def set_password():
return render_template("client_portal/set_password.html", client=client, token=token)
@client_portal_bp.route("/client-portal/")
def client_portal_base():
"""Handle base client portal URL with trailing slash"""
result = check_client_portal_access()
if not isinstance(result, Client): # It's a redirect response
return result
return redirect(url_for("client_portal.dashboard"))
@client_portal_bp.route("/client-portal")
@client_portal_bp.route("/client-portal/dashboard")
def dashboard():
@@ -1011,7 +1115,7 @@ def documents():
# Get client attachments
attachments = ClientAttachment.query.filter_by(
client_id=client.id,
visible_to_client=True
is_visible_to_client=True
).order_by(ClientAttachment.uploaded_at.desc()).all()
# Get project attachments
@@ -1154,7 +1258,8 @@ def activity_feed():
activities = []
if project_ids:
activities = Activity.query.filter(
Activity.project_id.in_(project_ids)
Activity.entity_type == 'project',
Activity.entity_id.in_(project_ids)
).order_by(Activity.created_at.desc()).limit(50).all()
return render_template("client_portal/activity_feed.html", client=client, activities=activities)
+16 -6
View File
@@ -92,12 +92,22 @@ class ClientApprovalService:
return {"success": True, "message": "Time entry rejected", "approval": approval.to_dict()}
def get_pending_approvals_for_client(self, client_id: int) -> List[ClientTimeApproval]:
"""Get pending approvals for a client"""
return (
ClientTimeApproval.query.filter_by(client_id=client_id, status=ClientApprovalStatus.PENDING)
.order_by(ClientTimeApproval.requested_at.desc())
.all()
)
"""Get pending approvals for a client with error handling"""
try:
return (
ClientTimeApproval.query.filter_by(client_id=client_id, status=ClientApprovalStatus.PENDING)
.order_by(ClientTimeApproval.requested_at.desc())
.all()
)
except Exception as e:
logger.error(f"Error getting pending approvals for client {client_id}: {e}", exc_info=True)
# Rollback any failed transaction
try:
db.session.rollback()
except Exception as rollback_error:
logger.error(f"Error during rollback: {rollback_error}", exc_info=True)
# Return empty list on error to prevent cascading failures
return []
def _notify_client_contacts(self, client: Client, approval: ClientTimeApproval):
"""Send notifications to client contacts"""
+56 -25
View File
@@ -19,38 +19,69 @@
{% if activities %}
<div class="space-y-4">
{% for activity in activities %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center flex-shrink-0">
<i class="fas fa-{{ activity.action_type|lower|replace('_', '-') }} text-primary-600 dark:text-primary-400"></i>
</div>
<div class="flex-1">
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">{{ activity.description or activity.action_type|replace('_', ' ')|title }}</h3>
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ activity.created_at.strftime('%Y-%m-%d %H:%M') if activity.created_at else _('N/A') }}
</span>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden">
<div class="p-6">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-primary to-primary/80 flex items-center justify-center flex-shrink-0 shadow-md">
<i class="fas
{% if 'time' in activity.action_type|lower %}fa-clock
{% elif 'invoice' in activity.action_type|lower %}fa-file-invoice
{% elif 'project' in activity.action_type|lower %}fa-folder-open
{% elif 'comment' in activity.action_type|lower %}fa-comment
{% elif 'update' in activity.action_type|lower %}fa-edit
{% elif 'create' in activity.action_type|lower %}fa-plus-circle
{% elif 'delete' in activity.action_type|lower %}fa-trash
{% else %}fa-circle{% endif %}
text-white"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between mb-2">
<div class="flex-1">
<h3 class="font-bold text-text-light dark:text-text-dark mb-1">
{{ activity.description or activity.action_type|replace('_', ' ')|title }}
</h3>
<div class="flex flex-wrap items-center gap-3 text-sm">
{% if activity.project %}
<div class="flex items-center space-x-1 text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-folder text-primary"></i>
<span>{{ activity.project.name }}</span>
</div>
{% endif %}
{% if activity.user %}
<div class="flex items-center space-x-1 text-text-muted-light dark:text-text-muted-dark">
<div class="w-5 h-5 bg-primary/10 rounded-full flex items-center justify-center">
<i class="fas fa-user text-primary text-xs"></i>
</div>
<span>{{ activity.user.display_name if activity.user.display_name else activity.user.username }}</span>
</div>
{% endif %}
<div class="flex items-center space-x-1 text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-clock"></i>
<span>{{ activity.created_at.strftime('%b %d, %Y at %H:%M') if activity.created_at else _('N/A') }}</span>
</div>
</div>
</div>
<span class="ml-4 px-3 py-1 text-xs font-semibold rounded-full bg-primary/10 text-primary">
{{ activity.action_type|replace('_', ' ')|title }}
</span>
</div>
</div>
{% if activity.project %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-folder-open mr-1"></i>{{ activity.project.name }}
</p>
{% endif %}
{% if activity.user %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">
<i class="fas fa-user mr-1"></i>{{ activity.user.display_name if activity.user.display_name else activity.user.username }}
</p>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark p-12 rounded-lg shadow text-center">
<i class="fas fa-history text-6xl text-text-muted-light dark:text-text-muted-dark mb-4"></i>
<h3 class="text-xl font-semibold mb-2">{{ _('No Activity') }}</h3>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No recent activity to display.') }}</p>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg p-12">
<div class="text-center">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-8 w-32 h-32 mx-auto mb-6 flex items-center justify-center">
<i class="fas fa-history text-5xl text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-text-light dark:text-text-dark mb-2">{{ _('No Activity') }}</h3>
<p class="text-text-muted-light dark:text-text-muted-dark max-w-md mx-auto">
{{ _('No recent activity to display. Activity will appear here as projects are updated and time entries are logged.') }}
</p>
</div>
</div>
{% endif %}
{% endblock %}
@@ -139,6 +139,7 @@
<div class="space-y-4">
<!-- Approve Form -->
<form method="POST" action="{{ url_for('client_portal.approve_time_entry', approval_id=approval.id) }}" class="border-b border-border-light dark:border-border-dark pb-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="approval_comment" class="block text-sm font-medium mb-2">{{ _('Comment (optional)') }}</label>
<textarea id="approval_comment" name="comment" rows="3"
@@ -154,6 +155,7 @@
<!-- Reject Form -->
<form method="POST" action="{{ url_for('client_portal.reject_time_entry', approval_id=approval.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="rejection_reason" class="block text-sm font-medium mb-2">{{ _('Rejection Reason') }} <span class="text-red-600">*</span></label>
<textarea id="rejection_reason" name="reason" rows="3" required
+148 -72
View File
@@ -16,24 +16,51 @@
breadcrumbs=breadcrumbs
) }}
<!-- Status Filter -->
<div class="mb-6">
<div class="flex gap-2">
<!-- Enhanced Status Filter -->
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg p-4 mb-6">
<div class="flex flex-wrap gap-2">
<a href="{{ url_for('client_portal.time_entry_approvals', status='pending') }}"
class="px-4 py-2 rounded-lg {% if status_filter == 'pending' %}bg-primary-600 text-white{% else %}bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark hover:bg-secondary-200 dark:hover:bg-secondary-700{% endif %} transition-colors">
{{ _('Pending') }} {% if status_filter == 'pending' %}({{ pending_count }}){% endif %}
class="px-5 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 flex items-center space-x-2
{% if status_filter == 'pending' %}
bg-yellow-600 text-white shadow-md
{% else %}
bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700
{% endif %}">
<i class="fas fa-clock"></i>
<span>{{ _('Pending') }}</span>
{% if status_filter == 'pending' and pending_count > 0 %}
<span class="ml-1 px-2 py-0.5 bg-white/20 rounded-full text-xs font-bold">{{ pending_count }}</span>
{% endif %}
</a>
<a href="{{ url_for('client_portal.time_entry_approvals', status='approved') }}"
class="px-4 py-2 rounded-lg {% if status_filter == 'approved' %}bg-primary-600 text-white{% else %}bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark hover:bg-secondary-200 dark:hover:bg-secondary-700{% endif %} transition-colors">
{{ _('Approved') }}
class="px-5 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 flex items-center space-x-2
{% if status_filter == 'approved' %}
bg-green-600 text-white shadow-md
{% else %}
bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700
{% endif %}">
<i class="fas fa-check-circle"></i>
<span>{{ _('Approved') }}</span>
</a>
<a href="{{ url_for('client_portal.time_entry_approvals', status='rejected') }}"
class="px-4 py-2 rounded-lg {% if status_filter == 'rejected' %}bg-primary-600 text-white{% else %}bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark hover:bg-secondary-200 dark:hover:bg-secondary-700{% endif %} transition-colors">
{{ _('Rejected') }}
class="px-5 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 flex items-center space-x-2
{% if status_filter == 'rejected' %}
bg-red-600 text-white shadow-md
{% else %}
bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700
{% endif %}">
<i class="fas fa-times-circle"></i>
<span>{{ _('Rejected') }}</span>
</a>
<a href="{{ url_for('client_portal.time_entry_approvals', status='all') }}"
class="px-4 py-2 rounded-lg {% if status_filter == 'all' %}bg-primary-600 text-white{% else %}bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark hover:bg-secondary-200 dark:hover:bg-secondary-700{% endif %} transition-colors">
{{ _('All') }}
class="px-5 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 flex items-center space-x-2
{% if status_filter == 'all' %}
bg-primary text-white shadow-md
{% else %}
bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700
{% endif %}">
<i class="fas fa-list"></i>
<span>{{ _('All') }}</span>
</a>
</div>
</div>
@@ -41,64 +68,109 @@
{% if approvals %}
<div class="space-y-4">
{% for approval in approvals %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-4 mb-2">
<h3 class="text-lg font-semibold">
<a href="{{ url_for('client_portal.view_approval', approval_id=approval.id) }}" class="text-primary hover:underline">
{{ _('Time Entry') }} #{{ approval.time_entry_id }}
</a>
</h3>
<span class="px-3 py-1 text-xs rounded-full
{% if approval.status.value == 'pending' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
{% elif approval.status.value == 'approved' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif approval.status.value == 'rejected' %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
{{ approval.status.value|title }}
</span>
</div>
{% if approval.time_entry %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}:</span>
<span class="font-medium">{{ approval.time_entry.project.name if approval.time_entry.project else _('N/A') }}</span>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden
{% if approval.status.value == 'pending' %}border-l-4 border-l-yellow-500
{% elif approval.status.value == 'approved' %}border-l-4 border-l-green-500
{% elif approval.status.value == 'rejected' %}border-l-4 border-l-red-500{% endif %}">
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-3">
<div class="bg-primary/10 p-2 rounded-lg">
<i class="fas fa-file-invoice text-primary text-xl"></i>
</div>
<div>
<h3 class="text-xl font-bold text-text-light dark:text-text-dark">
<a href="{{ url_for('client_portal.view_approval', approval_id=approval.id) }}"
class="text-primary hover:text-primary/80 hover:underline">
{{ _('Time Entry') }} #{{ approval.time_entry_id }}
</a>
</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Requested on') }} {{ approval.requested_at.strftime('%B %d, %Y at %H:%M') if approval.requested_at else _('N/A') }}
</p>
</div>
<span class="ml-auto px-3 py-1.5 text-xs font-semibold rounded-full
{% if approval.status.value == 'pending' %}
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
{% elif approval.status.value == 'approved' %}
bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400
{% elif approval.status.value == 'rejected' %}
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
{% else %}
bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
{% endif %}">
<i class="fas
{% if approval.status.value == 'pending' %}fa-clock
{% elif approval.status.value == 'approved' %}fa-check-circle
{% elif approval.status.value == 'rejected' %}fa-times-circle
{% else %}fa-question{% endif %} mr-1"></i>
{{ approval.status.value|title }}
</span>
</div>
<div>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Duration') }}:</span>
<span class="font-medium">{{ "%.2f"|format(approval.time_entry.duration_hours) }} {{ _('hours') }}</span>
{% if approval.time_entry %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div class="flex items-center space-x-3 p-3 bg-background-light dark:bg-background-dark rounded-lg">
<div class="bg-blue-100 dark:bg-blue-900/30 p-2 rounded-lg">
<i class="fas fa-folder text-blue-600 dark:text-blue-400"></i>
</div>
<div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}</p>
<p class="font-semibold text-text-light dark:text-text-dark">
{{ approval.time_entry.project.name if approval.time_entry.project else _('N/A') }}
</p>
</div>
</div>
<div class="flex items-center space-x-3 p-3 bg-background-light dark:bg-background-dark rounded-lg">
<div class="bg-green-100 dark:bg-green-900/30 p-2 rounded-lg">
<i class="fas fa-clock text-green-600 dark:text-green-400"></i>
</div>
<div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Duration') }}</p>
<p class="font-semibold text-text-light dark:text-text-dark">
{{ "%.2f"|format(approval.time_entry.duration_hours) }} {{ _('hours') }}
</p>
</div>
</div>
<div class="flex items-center space-x-3 p-3 bg-background-light dark:bg-background-dark rounded-lg">
<div class="bg-purple-100 dark:bg-purple-900/30 p-2 rounded-lg">
<i class="fas fa-calendar text-purple-600 dark:text-purple-400"></i>
</div>
<div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Date') }}</p>
<p class="font-semibold text-text-light dark:text-text-dark">
{{ approval.time_entry.start_time.strftime('%b %d, %Y') if approval.time_entry.start_time else _('N/A') }}
</p>
</div>
</div>
</div>
<div>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Date') }}:</span>
<span class="font-medium">{{ approval.time_entry.start_time.strftime('%Y-%m-%d') if approval.time_entry.start_time else _('N/A') }}</span>
{% if approval.time_entry.description %}
<div class="mb-4 p-4 bg-background-light dark:bg-background-dark rounded-lg border border-border-light dark:border-border-dark">
<p class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Description') }}</p>
<p class="text-sm text-text-light dark:text-text-dark">{{ approval.time_entry.description }}</p>
</div>
</div>
{% if approval.time_entry.notes %}
<div class="mt-3">
<span class="text-text-muted-light dark:text-text-muted-dark text-sm">{{ _('Notes') }}:</span>
<p class="text-sm mt-1">{{ approval.time_entry.notes[:100] }}{% if approval.time_entry.notes|length > 100 %}...{% endif %}</p>
</div>
{% endif %}
{% endif %}
{% if approval.request_comment %}
<div class="mt-3 p-3 bg-background-light dark:bg-background-dark rounded">
<span class="text-text-muted-light dark:text-text-muted-dark text-sm font-medium">{{ _('Request Comment') }}:</span>
<p class="text-sm mt-1">{{ approval.request_comment }}</p>
</div>
{% endif %}
<div class="mt-3 text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Requested on') }} {{ approval.requested_at.strftime('%Y-%m-%d %H:%M') if approval.requested_at else _('N/A') }}
{% endif %}
{% endif %}
{% if approval.request_comment %}
<div class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<p class="text-sm font-semibold text-blue-700 dark:text-blue-300 mb-1">
<i class="fas fa-comment mr-1"></i>{{ _('Request Comment') }}
</p>
<p class="text-sm text-blue-900 dark:text-blue-100">{{ approval.request_comment }}</p>
</div>
{% endif %}
</div>
</div>
<div class="ml-4">
<div class="flex items-center justify-end space-x-3 pt-4 border-t border-border-light dark:border-border-dark">
<a href="{{ url_for('client_portal.view_approval', approval_id=approval.id) }}"
class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors">
<i class="fas fa-eye mr-2"></i>{{ _('View Details') }}
class="inline-flex items-center px-5 py-2.5 bg-primary hover:bg-primary/90 text-white font-semibold rounded-lg transition-colors shadow-md hover:shadow-lg group">
<i class="fas fa-eye mr-2"></i>
<span>{{ _('View Details') }}</span>
<i class="fas fa-arrow-right ml-2 group-hover:translate-x-1 transition-transform"></i>
</a>
</div>
</div>
@@ -106,16 +178,20 @@
{% endfor %}
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark p-12 rounded-lg shadow text-center">
<i class="fas fa-check-circle text-6xl text-text-muted-light dark:text-text-muted-dark mb-4"></i>
<h3 class="text-xl font-semibold mb-2">{{ _('No Approvals') }}</h3>
<p class="text-text-muted-light dark:text-text-muted-dark">
{% if status_filter == 'pending' %}
{{ _('You have no pending time entry approvals.') }}
{% else %}
{{ _('No approvals found for this status.') }}
{% endif %}
</p>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg p-12">
<div class="text-center">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-8 w-32 h-32 mx-auto mb-6 flex items-center justify-center">
<i class="fas fa-check-circle text-5xl text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-text-light dark:text-text-dark mb-2">{{ _('No Approvals') }}</h3>
<p class="text-text-muted-light dark:text-text-muted-dark max-w-md mx-auto">
{% if status_filter == 'pending' %}
{{ _('You have no pending time entry approvals. All caught up!') }}
{% else %}
{{ _('No approvals found for this status.') }}
{% endif %}
</p>
</div>
</div>
{% endif %}
{% endblock %}
+315 -61
View File
@@ -32,6 +32,165 @@
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='toast-notifications.css') }}">
<style>
/* Prevent main app sidebar from appearing */
#sidebar,
#appShell,
aside#sidebar,
.sidebar-collapsed,
#mainContent {
display: none !important;
}
/* Client portal specific styles */
.client-portal-nav {
transition: all 0.3s ease;
}
.client-portal-nav-item {
position: relative;
padding: 0.625rem 1.25rem;
border-radius: 0.5rem;
transition: all 0.2s ease;
margin: 0 0.125rem;
font-size: 0.9375rem;
display: inline-flex;
align-items: center;
}
.client-portal-nav-item:hover {
background-color: rgba(74, 144, 226, 0.12);
transform: translateY(-1px);
}
.client-portal-nav-item.active {
background-color: rgba(74, 144, 226, 0.2);
color: #4A90E2;
font-weight: 600;
box-shadow: 0 2px 4px rgba(74, 144, 226, 0.2);
}
.client-portal-nav-item.active::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 2px;
background-color: #4A90E2;
border-radius: 2px 2px 0 0;
}
.mobile-menu-overlay {
display: none;
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 40;
}
.mobile-menu-overlay.active {
display: block;
}
.mobile-menu {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: 20rem;
background-color: var(--card-bg, #ffffff);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.15);
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 50;
overflow-y: auto;
}
.dark .mobile-menu {
background-color: var(--card-bg-dark, #1f2937);
}
.mobile-menu.active {
transform: translateX(0);
}
.badge-count {
position: absolute;
top: -0.375rem;
right: -0.375rem;
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
color: white;
font-size: 0.6875rem;
font-weight: 700;
border-radius: 9999px;
min-width: 1.375rem;
height: 1.375rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0 0.5rem;
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.4);
border: 2px solid rgba(255, 255, 255, 0.9);
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .8;
}
}
/* Smooth transitions for all interactive elements */
* {
transition-property: color, background-color, border-color, transform, opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
/* Enhanced card hover effects */
.hover-lift {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
/* Line clamp utility */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Loading skeleton animation */
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.skeleton {
animation: shimmer 2s infinite linear;
background: linear-gradient(to right, #f0f0f0 8%, #e0e0e0 18%, #f0f0f0 33%);
background-size: 1000px 100%;
}
.dark .skeleton {
background: linear-gradient(to right, #374151 8%, #4b5563 18%, #374151 33%);
background-size: 1000px 100%;
}
</style>
<script>
// Theme init
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
@@ -39,91 +198,187 @@
} else {
document.documentElement.classList.remove('dark');
}
// Mobile menu toggle
function toggleMobileMenu() {
const overlay = document.getElementById('mobile-menu-overlay');
const menu = document.getElementById('mobile-menu');
overlay.classList.toggle('active');
menu.classList.toggle('active');
}
function closeMobileMenu() {
const overlay = document.getElementById('mobile-menu-overlay');
const menu = document.getElementById('mobile-menu');
overlay.classList.remove('active');
menu.classList.remove('active');
}
// Close mobile menu when clicking overlay
document.addEventListener('DOMContentLoaded', function() {
const overlay = document.getElementById('mobile-menu-overlay');
if (overlay) {
overlay.addEventListener('click', closeMobileMenu);
}
});
</script>
</head>
<body class="bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark">
<!-- Simple header for client portal -->
<header class="bg-card-light dark:bg-card-dark border-b border-border-light dark:border-border-dark shadow-sm">
{% set current_client = get_current_client() %}
{% set current_endpoint = request.endpoint or '' %}
<!-- Enhanced header for client portal -->
<header class="bg-card-light/95 dark:bg-card-dark/95 backdrop-blur-md border-b border-border-light dark:border-border-dark shadow-lg sticky top-0 z-30">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<h1 class="text-xl font-bold text-primary">{{ _('Client Portal') }}</h1>
{% set current_client = get_current_client() %}
<!-- Logo and Client Name -->
<div class="flex items-center space-x-4">
<a href="{{ url_for('client_portal.dashboard') }}" class="flex items-center space-x-2 hover:opacity-80 transition-opacity">
<img src="{{ url_for('static', filename='images/timetracker-logo.svg') }}" alt="Logo" class="h-8 w-8">
<h1 class="text-xl font-bold text-primary hidden sm:block">{{ _('Client Portal') }}</h1>
</a>
{% if current_client %}
<span class="ml-4 text-sm text-text-muted-light dark:text-text-muted-dark">{{ current_client.name }}</span>
<div class="hidden md:flex items-center space-x-2 pl-4 border-l border-border-light dark:border-border-dark">
<i class="fas fa-building text-text-muted-light dark:text-text-muted-dark"></i>
<span class="text-sm font-medium text-text-light dark:text-text-dark">{{ current_client.name }}</span>
</div>
{% endif %}
</div>
{% set current_client = get_current_client() %}
<!-- Desktop Navigation -->
{% if current_client %}
<nav class="flex items-center space-x-4">
<a href="{{ url_for('client_portal.dashboard') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
<i class="fas fa-home mr-1"></i>{{ _('Dashboard') }}
<nav class="hidden lg:flex items-center space-x-0.5">
<a href="{{ url_for('client_portal.dashboard') }}" class="client-portal-nav-item {% if current_endpoint == 'client_portal.dashboard' %}active{% endif %}">
<i class="fas fa-home mr-2"></i>{{ _('Dashboard') }}
</a>
<a href="{{ url_for('client_portal.projects') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
<i class="fas fa-folder-open mr-1"></i>{{ _('Projects') }}
<a href="{{ url_for('client_portal.projects') }}" class="client-portal-nav-item {% if current_endpoint == 'client_portal.projects' %}active{% endif %}">
<i class="fas fa-folder-open mr-2"></i>{{ _('Projects') }}
</a>
<a href="{{ url_for('client_portal.invoices') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
<i class="fas fa-file-invoice mr-1"></i>{{ _('Invoices') }}
<a href="{{ url_for('client_portal.invoices') }}" class="client-portal-nav-item {% if current_endpoint == 'client_portal.invoices' or current_endpoint == 'client_portal.view_invoice' %}active{% endif %}">
<i class="fas fa-file-invoice mr-2"></i>{{ _('Invoices') }}
</a>
<a href="{{ url_for('client_portal.time_entries') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
<i class="fas fa-clock mr-1"></i>{{ _('Time Entries') }}
<a href="{{ url_for('client_portal.time_entries') }}" class="client-portal-nav-item {% if current_endpoint == 'client_portal.time_entries' %}active{% endif %}">
<i class="fas fa-clock mr-2"></i>{{ _('Time Entries') }}
</a>
{% set current_client = get_current_client() %}
{% if current_client %}
<a href="{{ url_for('client_portal.time_entry_approvals') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors relative">
<i class="fas fa-check-circle mr-1"></i>{{ _('Approvals') }}
{% set approval_service = namespace(service=None) %}
{% set pending_approvals = [] %}
{% if current_client %}
{% from app.services.client_approval_service import ClientApprovalService %}
{% set service = ClientApprovalService() %}
{% set pending_approvals = service.get_pending_approvals_for_client(current_client.id) %}
{% endif %}
{% if pending_approvals|length > 0 %}
<span class="absolute -top-1 -right-1 bg-red-600 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">{{ pending_approvals|length }}</span>
<a href="{{ url_for('client_portal.time_entry_approvals') }}" class="client-portal-nav-item relative {% if current_endpoint == 'client_portal.time_entry_approvals' or current_endpoint == 'client_portal.view_approval' %}active{% endif %}">
<i class="fas fa-check-circle mr-2"></i>{{ _('Approvals') }}
{% if pending_approvals_count > 0 %}
<span class="badge-count">{{ pending_approvals_count }}</span>
{% endif %}
</a>
{% if current_client.portal_issues_enabled %}
<a href="{{ url_for('client_portal.issues') }}" class="client-portal-nav-item {% if current_endpoint.startswith('client_portal.issues') %}active{% endif %}">
<i class="fas fa-bug mr-2"></i>{{ _('Issues') }}
</a>
{% endif %}
{% set current_client = get_current_client() %}
{% if current_client and current_client.portal_issues_enabled %}
<a href="{{ url_for('client_portal.issues') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
<i class="fas fa-bug mr-1"></i>{{ _('Issues') }}
</a>
{% endif %}
{% set current_client = get_current_client() %}
{% if current_client %}
<a href="{{ url_for('client_portal.notifications') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors relative">
<i class="fas fa-bell mr-1"></i>{{ _('Notifications') }}
{% set notification_service = namespace(service=None) %}
{% set unread_count = 0 %}
{% if current_client %}
{% from app.services.client_notification_service import ClientNotificationService %}
{% set service = ClientNotificationService() %}
{% set unread_count = service.get_unread_count(current_client.id) %}
{% endif %}
{% if unread_count > 0 %}
<span class="absolute -top-1 -right-1 bg-red-600 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">{{ unread_count }}</span>
<a href="{{ url_for('client_portal.notifications') }}" class="client-portal-nav-item relative {% if current_endpoint == 'client_portal.notifications' %}active{% endif %}">
<i class="fas fa-bell mr-2"></i>{{ _('Notifications') }}
{% if unread_notifications_count > 0 %}
<span class="badge-count">{{ unread_notifications_count }}</span>
{% endif %}
</a>
<a href="{{ url_for('client_portal.documents') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
<i class="fas fa-file-alt mr-1"></i>{{ _('Documents') }}
</a>
<a href="{{ url_for('client_portal.reports') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
<i class="fas fa-chart-bar mr-1"></i>{{ _('Reports') }}
</a>
<a href="{{ url_for('client_portal.activity_feed') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
<i class="fas fa-history mr-1"></i>{{ _('Activity') }}
</a>
{% endif %}
<a href="{{ url_for('client_portal.logout') }}" class="text-sm text-text-light dark:text-text-dark hover:text-red-600 transition-colors">
<i class="fas fa-sign-out-alt mr-1"></i>{{ _('Logout') }}
<div class="relative group">
<button class="client-portal-nav-item">
<i class="fas fa-ellipsis-h mr-2"></i>{{ _('More') }}
<i class="fas fa-chevron-down ml-1 text-xs"></i>
</button>
<div class="absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark rounded-lg shadow-lg border border-border-light dark:border-border-dark opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
<div class="py-1">
<a href="{{ url_for('client_portal.documents') }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark {% if current_endpoint == 'client_portal.documents' %}text-primary font-semibold{% endif %}">
<i class="fas fa-file-alt mr-2"></i>{{ _('Documents') }}
</a>
<a href="{{ url_for('client_portal.reports') }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark {% if current_endpoint == 'client_portal.reports' %}text-primary font-semibold{% endif %}">
<i class="fas fa-chart-bar mr-2"></i>{{ _('Reports') }}
</a>
<a href="{{ url_for('client_portal.activity_feed') }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark {% if current_endpoint == 'client_portal.activity_feed' %}text-primary font-semibold{% endif %}">
<i class="fas fa-history mr-2"></i>{{ _('Activity') }}
</a>
</div>
</div>
</div>
<a href="{{ url_for('client_portal.logout') }}" class="client-portal-nav-item text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-700 dark:hover:text-red-300 ml-3">
<i class="fas fa-sign-out-alt mr-2"></i>{{ _('Logout') }}
</a>
</nav>
<!-- Mobile Menu Button -->
<button onclick="toggleMobileMenu()" class="lg:hidden p-2.5 rounded-lg hover:bg-background-light dark:hover:bg-background-dark transition-all duration-200 hover:scale-105 active:scale-95" aria-label="{{ _('Toggle menu') }}">
<i class="fas fa-bars text-xl text-text-light dark:text-text-dark"></i>
</button>
{% endif %}
</div>
</div>
</header>
<!-- Mobile Menu Overlay -->
<div id="mobile-menu-overlay" class="mobile-menu-overlay"></div>
<!-- Mobile Menu -->
<div id="mobile-menu" class="mobile-menu">
<div class="p-5 border-b border-border-light dark:border-border-dark bg-gradient-to-r from-primary/5 to-transparent">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-3">
<img src="{{ url_for('static', filename='images/timetracker-logo.svg') }}" alt="Logo" class="h-7 w-7">
<span class="font-bold text-primary text-lg">{{ _('Client Portal') }}</span>
</div>
<button onclick="closeMobileMenu()" class="p-2.5 rounded-lg hover:bg-background-light dark:hover:bg-background-dark transition-colors">
<i class="fas fa-times text-lg"></i>
</button>
</div>
{% if current_client %}
<div class="flex items-center space-x-2 text-sm">
<i class="fas fa-building text-text-muted-light dark:text-text-muted-dark"></i>
<span class="font-medium text-text-light dark:text-text-dark">{{ current_client.name }}</span>
</div>
{% endif %}
</div>
<nav class="p-4 space-y-1">
<a href="{{ url_for('client_portal.dashboard') }}" onclick="closeMobileMenu()" class="flex items-center px-5 py-3.5 rounded-lg transition-all duration-200 {% if current_endpoint == 'client_portal.dashboard' %}bg-primary/15 text-primary font-semibold shadow-sm{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark hover:translate-x-1{% endif %}">
<i class="fas fa-home w-5 mr-3"></i>{{ _('Dashboard') }}
</a>
<a href="{{ url_for('client_portal.projects') }}" onclick="closeMobileMenu()" class="flex items-center px-5 py-3.5 rounded-lg transition-all duration-200 {% if current_endpoint == 'client_portal.projects' %}bg-primary/15 text-primary font-semibold shadow-sm{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark hover:translate-x-1{% endif %}">
<i class="fas fa-folder-open w-5 mr-3"></i>{{ _('Projects') }}
</a>
<a href="{{ url_for('client_portal.invoices') }}" onclick="closeMobileMenu()" class="flex items-center px-5 py-3.5 rounded-lg transition-all duration-200 {% if current_endpoint == 'client_portal.invoices' or current_endpoint == 'client_portal.view_invoice' %}bg-primary/15 text-primary font-semibold shadow-sm{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark hover:translate-x-1{% endif %}">
<i class="fas fa-file-invoice w-5 mr-3"></i>{{ _('Invoices') }}
</a>
<a href="{{ url_for('client_portal.time_entries') }}" onclick="closeMobileMenu()" class="flex items-center px-5 py-3.5 rounded-lg transition-all duration-200 {% if current_endpoint == 'client_portal.time_entries' %}bg-primary/15 text-primary font-semibold shadow-sm{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark hover:translate-x-1{% endif %}">
<i class="fas fa-clock w-5 mr-3"></i>{{ _('Time Entries') }}
</a>
<a href="{{ url_for('client_portal.time_entry_approvals') }}" onclick="closeMobileMenu()" class="flex items-center px-5 py-3.5 rounded-lg relative transition-all duration-200 {% if current_endpoint == 'client_portal.time_entry_approvals' or current_endpoint == 'client_portal.view_approval' %}bg-primary/15 text-primary font-semibold shadow-sm{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark hover:translate-x-1{% endif %}">
<i class="fas fa-check-circle w-5 mr-3"></i>{{ _('Approvals') }}
{% if pending_approvals_count > 0 %}
<span class="badge-count ml-auto">{{ pending_approvals_count }}</span>
{% endif %}
</a>
{% if current_client and current_client.portal_issues_enabled %}
<a href="{{ url_for('client_portal.issues') }}" onclick="closeMobileMenu()" class="flex items-center px-5 py-3.5 rounded-lg transition-all duration-200 {% if current_endpoint.startswith('client_portal.issues') %}bg-primary/15 text-primary font-semibold shadow-sm{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark hover:translate-x-1{% endif %}">
<i class="fas fa-bug w-5 mr-3"></i>{{ _('Issues') }}
</a>
{% endif %}
<a href="{{ url_for('client_portal.notifications') }}" onclick="closeMobileMenu()" class="flex items-center px-5 py-3.5 rounded-lg relative transition-all duration-200 {% if current_endpoint == 'client_portal.notifications' %}bg-primary/15 text-primary font-semibold shadow-sm{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark hover:translate-x-1{% endif %}">
<i class="fas fa-bell w-5 mr-3"></i>{{ _('Notifications') }}
{% if unread_notifications_count > 0 %}
<span class="badge-count ml-auto">{{ unread_notifications_count }}</span>
{% endif %}
</a>
<div class="border-t border-border-light dark:border-border-dark my-3"></div>
<a href="{{ url_for('client_portal.documents') }}" onclick="closeMobileMenu()" class="flex items-center px-5 py-3.5 rounded-lg text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark transition-all duration-200 hover:translate-x-1">
<i class="fas fa-file-alt w-5 mr-3"></i>{{ _('Documents') }}
</a>
<a href="{{ url_for('client_portal.reports') }}" onclick="closeMobileMenu()" class="flex items-center px-5 py-3.5 rounded-lg text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark transition-all duration-200 hover:translate-x-1">
<i class="fas fa-chart-bar w-5 mr-3"></i>{{ _('Reports') }}
</a>
<a href="{{ url_for('client_portal.activity_feed') }}" onclick="closeMobileMenu()" class="flex items-center px-5 py-3.5 rounded-lg text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark transition-all duration-200 hover:translate-x-1">
<i class="fas fa-history w-5 mr-3"></i>{{ _('Activity') }}
</a>
<div class="border-t border-border-light dark:border-border-dark my-3"></div>
<a href="{{ url_for('client_portal.logout') }}" onclick="closeMobileMenu()" class="flex items-center px-5 py-3.5 rounded-lg text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all duration-200 hover:translate-x-1">
<i class="fas fa-sign-out-alt w-5 mr-3"></i>{{ _('Logout') }}
</a>
</nav>
</div>
<!-- Main content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% from "components/ui.html" import breadcrumb_nav %}
@@ -145,4 +400,3 @@
<script src="{{ url_for('static', filename='toast-notifications.js') }}"></script>
</body>
</html>
+181 -81
View File
@@ -15,57 +15,78 @@
breadcrumbs=breadcrumbs
) }}
<!-- Statistics Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<!-- Statistics Cards with Enhanced Design -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Active Projects Card -->
<div class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 border border-blue-200 dark:border-blue-800 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Active Projects') }}</p>
<p class="text-2xl font-bold text-text-light dark:text-text-dark">{{ total_projects }}</p>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 mb-1">{{ _('Active Projects') }}</p>
<p class="text-3xl font-bold text-blue-900 dark:text-blue-100">{{ total_projects }}</p>
<p class="text-xs text-blue-600/70 dark:text-blue-400/70 mt-1">{{ _('Total active') }}</p>
</div>
<div class="bg-blue-500/10 dark:bg-blue-400/10 p-4 rounded-full">
<i class="fas fa-folder-open text-blue-600 dark:text-blue-400 text-3xl"></i>
</div>
<i class="fas fa-folder-open text-primary text-3xl"></i>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<!-- Total Hours Card -->
<div class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border border-green-200 dark:border-green-800 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total Hours') }}</p>
<p class="text-2xl font-bold text-text-light dark:text-text-dark">{{ "%.2f"|format(total_hours) }}</p>
<p class="text-sm font-medium text-green-600 dark:text-green-400 mb-1">{{ _('Total Hours') }}</p>
<p class="text-3xl font-bold text-green-900 dark:text-green-100">{{ "%.1f"|format(total_hours) }}</p>
<p class="text-xs text-green-600/70 dark:text-green-400/70 mt-1">{{ _('Hours tracked') }}</p>
</div>
<div class="bg-green-500/10 dark:bg-green-400/10 p-4 rounded-full">
<i class="fas fa-clock text-green-600 dark:text-green-400 text-3xl"></i>
</div>
<i class="fas fa-clock text-green-600 text-3xl"></i>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<!-- Total Invoices Card -->
<div class="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 border border-purple-200 dark:border-purple-800 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total Invoices') }}</p>
<p class="text-2xl font-bold text-text-light dark:text-text-dark">{{ total_invoices }}</p>
<p class="text-sm font-medium text-purple-600 dark:text-purple-400 mb-1">{{ _('Total Invoices') }}</p>
<p class="text-3xl font-bold text-purple-900 dark:text-purple-100">{{ total_invoices }}</p>
<p class="text-xs text-purple-600/70 dark:text-purple-400/70 mt-1">{{ _('All invoices') }}</p>
</div>
<div class="bg-purple-500/10 dark:bg-purple-400/10 p-4 rounded-full">
<i class="fas fa-file-invoice text-purple-600 dark:text-purple-400 text-3xl"></i>
</div>
<i class="fas fa-file-invoice text-blue-600 text-3xl"></i>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<!-- Outstanding Amount Card -->
<div class="bg-gradient-to-br from-amber-50 to-amber-100 dark:from-amber-900/20 dark:to-amber-800/20 border border-amber-200 dark:border-amber-800 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Outstanding') }}</p>
<p class="text-2xl font-bold text-text-light dark:text-text-dark">{{ "%.2f"|format(unpaid_invoice_amount) }} {{ invoices[0].currency_code if invoices else 'EUR' }}</p>
<p class="text-sm font-medium text-amber-600 dark:text-amber-400 mb-1">{{ _('Outstanding') }}</p>
<p class="text-2xl font-bold text-amber-900 dark:text-amber-100">{{ "%.2f"|format(unpaid_invoice_amount) }}</p>
<p class="text-xs text-amber-600/70 dark:text-amber-400/70 mt-1">{{ invoices[0].currency_code if invoices else 'EUR' }}</p>
</div>
<div class="bg-amber-500/10 dark:bg-amber-400/10 p-4 rounded-full">
<i class="fas fa-exclamation-triangle text-amber-600 dark:text-amber-400 text-3xl"></i>
</div>
<i class="fas fa-exclamation-triangle text-yellow-600 text-3xl"></i>
</div>
</div>
</div>
<!-- Action Cards for Pending Items -->
{% if pending_approvals_count > 0 or unread_notifications_count > 0 %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{% if pending_approvals_count > 0 %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border-l-4 border-blue-600">
<div class="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 p-6 rounded-xl shadow-lg text-white transform hover:scale-105 transition-all duration-300">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-text-light dark:text-text-dark">{{ _('Pending Approvals') }}</p>
<p class="text-lg font-bold text-blue-600">{{ pending_approvals_count }} {{ _('time entries') }}</p>
<p class="text-sm font-medium text-blue-100 mb-2">{{ _('Action Required') }}</p>
<p class="text-2xl font-bold mb-1">{{ pending_approvals_count }} {{ _('Pending Approvals') }}</p>
<p class="text-sm text-blue-100/80">{{ _('Time entries awaiting your review') }}</p>
</div>
<a href="{{ url_for('client_portal.time_entry_approvals') }}"
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
class="inline-flex items-center px-5 py-3 bg-white text-blue-600 font-semibold rounded-lg hover:bg-blue-50 transition-colors shadow-md">
<i class="fas fa-check-circle mr-2"></i>{{ _('Review Now') }}
</a>
</div>
@@ -73,113 +94,192 @@
{% endif %}
{% if unread_notifications_count > 0 %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow border-l-4 border-green-600">
<div class="bg-gradient-to-r from-green-500 to-green-600 dark:from-green-600 dark:to-green-700 p-6 rounded-xl shadow-lg text-white transform hover:scale-105 transition-all duration-300">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-text-light dark:text-text-dark">{{ _('New Notifications') }}</p>
<p class="text-lg font-bold text-green-600">{{ unread_notifications_count }} {{ _('unread') }}</p>
<p class="text-sm font-medium text-green-100 mb-2">{{ _('New Updates') }}</p>
<p class="text-2xl font-bold mb-1">{{ unread_notifications_count }} {{ _('Unread') }}</p>
<p class="text-sm text-green-100/80">{{ _('Notifications waiting for you') }}</p>
</div>
<a href="{{ url_for('client_portal.notifications') }}"
class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
<i class="fas fa-bell mr-2"></i>{{ _('View Notifications') }}
class="inline-flex items-center px-5 py-3 bg-white text-green-600 font-semibold rounded-lg hover:bg-green-50 transition-colors shadow-md">
<i class="fas fa-bell mr-2"></i>{{ _('View All') }}
</a>
</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Projects and Invoices Overview -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Projects Overview -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">{{ _('Projects') }}</h2>
<a href="{{ url_for('client_portal.projects') }}" class="text-primary hover:underline text-sm">{{ _('View All') }}</a>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-xl shadow-lg">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center space-x-3">
<div class="bg-blue-100 dark:bg-blue-900/30 p-2 rounded-lg">
<i class="fas fa-folder-open text-blue-600 dark:text-blue-400"></i>
</div>
<h2 class="text-xl font-bold text-text-light dark:text-text-dark">{{ _('Projects') }}</h2>
</div>
<a href="{{ url_for('client_portal.projects') }}" class="text-primary hover:text-primary/80 font-medium text-sm flex items-center space-x-1 transition-colors">
<span>{{ _('View All') }}</span>
<i class="fas fa-arrow-right text-xs"></i>
</a>
</div>
{% if projects %}
<div class="space-y-3">
{% for project in projects[:5] %}
<div class="flex items-center justify-between p-3 bg-background-light dark:bg-background-dark rounded-lg">
<div>
<p class="font-medium">{{ project.name }}</p>
{% if project.description %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ project.description[:50] }}{% if project.description|length > 50 %}...{% endif %}</p>
{% endif %}
<a href="{{ url_for('client_portal.projects') }}" class="block p-4 bg-background-light dark:bg-background-dark rounded-lg border border-border-light dark:border-border-dark hover:border-primary hover:shadow-md transition-all duration-200 group">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-1">
<p class="font-semibold text-text-light dark:text-text-dark group-hover:text-primary transition-colors">{{ project.name }}</p>
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">{{ project.status|capitalize }}</span>
</div>
{% if project.description %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark line-clamp-2">{{ project.description }}</p>
{% else %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark italic">{{ _('No description') }}</p>
{% endif %}
</div>
<i class="fas fa-chevron-right text-text-muted-light dark:text-text-muted-dark group-hover:text-primary group-hover:translate-x-1 transition-all"></i>
</div>
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ project.status|capitalize }}</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No projects found.') }}</p>
<div class="text-center py-12">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
<i class="fas fa-folder-open text-3xl text-gray-400"></i>
</div>
<p class="text-text-muted-light dark:text-text-muted-dark font-medium">{{ _('No projects found') }}</p>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Projects will appear here once they are created') }}</p>
</div>
{% endif %}
</div>
<!-- Recent Invoices -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">{{ _('Recent Invoices') }}</h2>
<a href="{{ url_for('client_portal.invoices') }}" class="text-primary hover:underline text-sm">{{ _('View All') }}</a>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-xl shadow-lg">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center space-x-3">
<div class="bg-purple-100 dark:bg-purple-900/30 p-2 rounded-lg">
<i class="fas fa-file-invoice text-purple-600 dark:text-purple-400"></i>
</div>
<h2 class="text-xl font-bold text-text-light dark:text-text-dark">{{ _('Recent Invoices') }}</h2>
</div>
<a href="{{ url_for('client_portal.invoices') }}" class="text-primary hover:text-primary/80 font-medium text-sm flex items-center space-x-1 transition-colors">
<span>{{ _('View All') }}</span>
<i class="fas fa-arrow-right text-xs"></i>
</a>
</div>
{% if invoices %}
<div class="space-y-3">
{% for invoice in invoices[:5] %}
<div class="flex items-center justify-between p-3 bg-background-light dark:bg-background-dark rounded-lg">
<div>
<a href="{{ url_for('client_portal.view_invoice', invoice_id=invoice.id) }}" class="font-medium text-primary hover:underline">{{ invoice.invoice_number }}</a>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ invoice.issue_date.strftime('%Y-%m-%d') }}</p>
<a href="{{ url_for('client_portal.view_invoice', invoice_id=invoice.id) }}" class="block p-4 bg-background-light dark:bg-background-dark rounded-lg border border-border-light dark:border-border-dark hover:border-primary hover:shadow-md transition-all duration-200 group">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-1">
<p class="font-semibold text-primary group-hover:underline">{{ invoice.invoice_number }}</p>
<span class="px-2 py-0.5 text-xs font-medium rounded-full
{% if invoice.payment_status == 'fully_paid' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400
{% elif invoice.is_overdue %}bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
{% else %}bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400{% endif %}">
{{ invoice.payment_status|replace('_', ' ')|title }}
</span>
</div>
<div class="flex items-center space-x-4 text-sm text-text-muted-light dark:text-text-muted-dark">
<span><i class="fas fa-calendar mr-1"></i>{{ invoice.issue_date.strftime('%b %d, %Y') }}</span>
<span class="font-semibold text-text-light dark:text-text-dark">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</span>
</div>
</div>
<i class="fas fa-chevron-right text-text-muted-light dark:text-text-muted-dark group-hover:text-primary group-hover:translate-x-1 transition-all"></i>
</div>
<div class="text-right">
<p class="font-medium">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</p>
<span class="px-2 py-1 text-xs rounded-full
{% if invoice.payment_status == 'fully_paid' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif invoice.is_overdue %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
{% else %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200{% endif %}">
{{ invoice.payment_status|replace('_', ' ')|title }}
</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No invoices found.') }}</p>
<div class="text-center py-12">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
<i class="fas fa-file-invoice text-3xl text-gray-400"></i>
</div>
<p class="text-text-muted-light dark:text-text-muted-dark font-medium">{{ _('No invoices found') }}</p>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Invoices will appear here once they are created') }}</p>
</div>
{% endif %}
</div>
</div>
<!-- Recent Time Entries -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">{{ _('Recent Time Entries') }}</h2>
<a href="{{ url_for('client_portal.time_entries') }}" class="text-primary hover:underline text-sm">{{ _('View All') }}</a>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-xl shadow-lg">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center space-x-3">
<div class="bg-green-100 dark:bg-green-900/30 p-2 rounded-lg">
<i class="fas fa-clock text-green-600 dark:text-green-400"></i>
</div>
<h2 class="text-xl font-bold text-text-light dark:text-text-dark">{{ _('Recent Time Entries') }}</h2>
</div>
<a href="{{ url_for('client_portal.time_entries') }}" class="text-primary hover:text-primary/80 font-medium text-sm flex items-center space-x-1 transition-colors">
<span>{{ _('View All') }}</span>
<i class="fas fa-arrow-right text-xs"></i>
</a>
</div>
{% if recent_time_entries %}
<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-3">{{ _('Date') }}</th>
<th class="p-3">{{ _('Project') }}</th>
<th class="p-3">{{ _('User') }}</th>
<th class="p-3">{{ _('Duration') }}</th>
<th class="p-3">{{ _('Description') }}</th>
<table class="w-full">
<thead>
<tr class="border-b-2 border-border-light dark:border-border-dark">
<th class="text-left py-3 px-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Date') }}</th>
<th class="text-left py-3 px-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}</th>
<th class="text-left py-3 px-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('User') }}</th>
<th class="text-left py-3 px-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Duration') }}</th>
<th class="text-left py-3 px-4 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Description') }}</th>
</tr>
</thead>
<tbody>
{% for entry in recent_time_entries[:10] %}
<tr class="border-b border-border-light dark:border-border-dark">
<td class="p-3">{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td class="p-3">{{ entry.project.name if entry.project else _('N/A') }}</td>
<td class="p-3">{{ entry.user.display_name if entry.user else _('N/A') }}</td>
<td class="p-3">{{ "%.2f"|format(entry.duration_hours) }}h</td>
<td class="p-3">{{ entry.description[:50] if entry.description else '-' }}{% if entry.description and entry.description|length > 50 %}...{% endif %}</td>
<tr class="border-b border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark transition-colors">
<td class="py-3 px-4 text-sm">
<div class="font-medium text-text-light dark:text-text-dark">{{ entry.start_time.strftime('%b %d') }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ entry.start_time.strftime('%Y') }}</div>
</td>
<td class="py-3 px-4">
<div class="flex items-center space-x-2">
<i class="fas fa-folder text-primary text-xs"></i>
<span class="text-sm text-text-light dark:text-text-dark">{{ entry.project.name if entry.project else _('N/A') }}</span>
</div>
</td>
<td class="py-3 px-4">
<div class="flex items-center space-x-2">
<div class="w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center">
<i class="fas fa-user text-primary text-xs"></i>
</div>
<span class="text-sm text-text-light dark:text-text-dark">{{ entry.user.display_name if entry.user else _('N/A') }}</span>
</div>
</td>
<td class="py-3 px-4">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<i class="fas fa-clock mr-1"></i>{{ "%.2f"|format(entry.duration_hours) }}h
</span>
</td>
<td class="py-3 px-4">
<p class="text-sm text-text-light dark:text-text-dark max-w-xs truncate" title="{{ entry.description or '-' }}">
{{ entry.description[:50] if entry.description else '-' }}{% if entry.description and entry.description|length > 50 %}...{% endif %}
</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No time entries found.') }}</p>
<div class="text-center py-12">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
<i class="fas fa-clock text-3xl text-gray-400"></i>
</div>
<p class="text-text-muted-light dark:text-text-muted-dark font-medium">{{ _('No time entries found') }}</p>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Time entries will appear here once they are logged') }}</p>
</div>
{% endif %}
</div>
{% endblock %}
+53 -31
View File
@@ -19,47 +19,69 @@
{% if attachments %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for attachment in attachments %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow hover:shadow-lg transition-shadow">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-lg bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
<i class="fas fa-file text-primary-600 dark:text-primary-400 text-xl"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold truncate" title="{{ attachment.original_filename if attachment.original_filename else attachment.filename }}">{{ attachment.original_filename if attachment.original_filename else attachment.filename }}</h3>
{% if hasattr(attachment, 'project') and attachment.project %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ attachment.project.name }}</p>
{% elif hasattr(attachment, 'client') and attachment.client %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Client Document') }}</p>
{% endif %}
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 overflow-hidden group">
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="w-14 h-14 rounded-xl bg-gradient-to-br from-primary to-primary/80 flex items-center justify-center flex-shrink-0 shadow-md">
<i class="fas fa-file text-white text-xl"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-bold text-text-light dark:text-text-dark truncate" title="{{ attachment.original_filename if attachment.original_filename else attachment.filename }}">
{{ attachment.original_filename if attachment.original_filename else attachment.filename }}
</h3>
{% if hasattr(attachment, 'project') and attachment.project %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1 flex items-center">
<i class="fas fa-folder text-primary mr-1 text-xs"></i>
{{ attachment.project.name }}
</p>
{% elif hasattr(attachment, 'client') and attachment.client %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1 flex items-center">
<i class="fas fa-building text-primary mr-1 text-xs"></i>
{{ _('Client Document') }}
</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="space-y-2 text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{% if attachment.uploaded_at %}
<p><i class="fas fa-calendar mr-2"></i>{{ attachment.uploaded_at.strftime('%Y-%m-%d') }}</p>
{% endif %}
{% if attachment.file_size %}
<p><i class="fas fa-hdd mr-2"></i>{{ attachment.file_size_display if hasattr(attachment, 'file_size_display') else ("%.2f"|format(attachment.file_size / 1024) + " KB") }}</p>
{% endif %}
</div>
<div class="flex gap-2">
<div class="space-y-2 mb-4">
{% if attachment.uploaded_at %}
<div class="flex items-center text-sm text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-calendar text-primary mr-2 w-4"></i>
<span>{{ attachment.uploaded_at.strftime('%B %d, %Y') }}</span>
</div>
{% endif %}
{% if attachment.file_size %}
<div class="flex items-center text-sm text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-hdd text-primary mr-2 w-4"></i>
<span>{{ attachment.file_size_display if hasattr(attachment, 'file_size_display') else ("%.2f"|format(attachment.file_size / 1024) + " KB") }}</span>
</div>
{% endif %}
</div>
<a href="{{ url_for('client_portal.download_attachment', attachment_id=attachment.id) }}"
target="_blank"
class="flex-1 inline-flex items-center justify-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors">
<i class="fas fa-download mr-2"></i>{{ _('Download') }}
class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-primary hover:bg-primary/90 text-white font-semibold rounded-lg transition-colors shadow-md hover:shadow-lg group">
<i class="fas fa-download mr-2"></i>
<span>{{ _('Download') }}</span>
<i class="fas fa-external-link-alt ml-2 text-xs group-hover:translate-x-0.5 transition-transform"></i>
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark p-12 rounded-lg shadow text-center">
<i class="fas fa-folder-open text-6xl text-text-muted-light dark:text-text-muted-dark mb-4"></i>
<h3 class="text-xl font-semibold mb-2">{{ _('No Documents') }}</h3>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No documents have been shared with you yet.') }}</p>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg p-12">
<div class="text-center">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-8 w-32 h-32 mx-auto mb-6 flex items-center justify-center">
<i class="fas fa-folder-open text-5xl text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-text-light dark:text-text-dark mb-2">{{ _('No Documents') }}</h3>
<p class="text-text-muted-light dark:text-text-muted-dark max-w-md mx-auto">
{{ _('No documents have been shared with you yet. Documents will appear here once they are uploaded and made visible to clients.') }}
</p>
</div>
</div>
{% endif %}
{% endblock %}
+76
View File
@@ -0,0 +1,76 @@
{% extends "client_portal/base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ error_info.title }} - {{ _('Client Portal') }}{% endblock %}
{% block content %}
{% set current_client = get_current_client() %}
<div class="max-w-2xl mx-auto">
<div class="bg-card-light dark:bg-card-dark rounded-xl shadow-lg border border-border-light dark:border-border-dark overflow-hidden">
<!-- Error Header -->
<div class="bg-gradient-to-r from-red-500 to-red-600 dark:from-red-600 dark:to-red-700 px-8 py-6 text-white">
<div class="flex items-center space-x-4">
<div class="bg-white/20 rounded-full p-4">
<i class="fas fa-exclamation-triangle text-3xl"></i>
</div>
<div>
<h1 class="text-3xl font-bold">{{ error_info.title }}</h1>
<p class="text-red-100 mt-1">{{ error_info.subtitle or _('An error occurred') }}</p>
</div>
</div>
</div>
<!-- Error Content -->
<div class="p-8">
<div class="text-center mb-6">
<p class="text-lg text-text-light dark:text-text-dark mb-4">
{{ error_info.message }}
</p>
{% if error_info.details %}
<div class="bg-background-light dark:bg-background-dark rounded-lg p-4 mb-6 text-left">
<h3 class="font-semibold mb-2 text-text-light dark:text-text-dark">{{ _('Details') }}:</h3>
<ul class="list-disc list-inside space-y-1 text-text-muted-light dark:text-text-muted-dark">
{% for detail in error_info.details %}
<li>{{ detail }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center">
{% if current_client %}
<a href="{{ url_for('client_portal.dashboard') }}"
class="inline-flex items-center justify-center px-6 py-3 bg-primary hover:bg-primary/90 text-white font-medium rounded-lg transition-colors shadow-md hover:shadow-lg">
<i class="fas fa-home mr-2"></i>{{ _('Go to Dashboard') }}
</a>
{% else %}
<a href="{{ url_for('client_portal.login') }}"
class="inline-flex items-center justify-center px-6 py-3 bg-primary hover:bg-primary/90 text-white font-medium rounded-lg transition-colors shadow-md hover:shadow-lg">
<i class="fas fa-sign-in-alt mr-2"></i>{{ _('Login to Client Portal') }}
</a>
{% endif %}
{% if error_info.show_back %}
<button onclick="window.history.back()"
class="inline-flex items-center justify-center px-6 py-3 bg-secondary-200 hover:bg-secondary-300 dark:bg-secondary-700 dark:hover:bg-secondary-600 text-secondary-900 dark:text-secondary-100 font-medium rounded-lg transition-colors">
<i class="fas fa-arrow-left mr-2"></i>{{ _('Go Back') }}
</button>
{% endif %}
</div>
<!-- Help Text -->
{% if not current_client %}
<div class="mt-8 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('If you believe you should have access to the client portal, please contact your administrator or use the login link above.') }}
</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+130 -46
View File
@@ -16,70 +16,124 @@
breadcrumbs=breadcrumbs
) }}
<!-- Filter Tabs -->
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg shadow mb-6">
<div class="flex gap-2">
<!-- Filter Tabs with Enhanced Design -->
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg p-4 mb-6">
<div class="flex flex-wrap gap-2">
<a href="{{ url_for('client_portal.invoices', status='all') }}"
class="px-4 py-2 rounded-lg {% if status_filter == 'all' %}bg-primary text-white{% else %}bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700{% endif %}">
{{ _('All') }}
class="px-5 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 flex items-center space-x-2
{% if status_filter == 'all' %}
bg-primary text-white shadow-md
{% else %}
bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700
{% endif %}">
<i class="fas fa-list"></i>
<span>{{ _('All') }}</span>
</a>
<a href="{{ url_for('client_portal.invoices', status='paid') }}"
class="px-4 py-2 rounded-lg {% if status_filter == 'paid' %}bg-primary text-white{% else %}bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700{% endif %}">
{{ _('Paid') }}
class="px-5 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 flex items-center space-x-2
{% if status_filter == 'paid' %}
bg-green-600 text-white shadow-md
{% else %}
bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700
{% endif %}">
<i class="fas fa-check-circle"></i>
<span>{{ _('Paid') }}</span>
</a>
<a href="{{ url_for('client_portal.invoices', status='unpaid') }}"
class="px-4 py-2 rounded-lg {% if status_filter == 'unpaid' %}bg-primary text-white{% else %}bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700{% endif %}">
{{ _('Unpaid') }}
class="px-5 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 flex items-center space-x-2
{% if status_filter == 'unpaid' %}
bg-yellow-600 text-white shadow-md
{% else %}
bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700
{% endif %}">
<i class="fas fa-clock"></i>
<span>{{ _('Unpaid') }}</span>
</a>
<a href="{{ url_for('client_portal.invoices', status='overdue') }}"
class="px-4 py-2 rounded-lg {% if status_filter == 'overdue' %}bg-primary text-white{% else %}bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700{% endif %}">
{{ _('Overdue') }}
class="px-5 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 flex items-center space-x-2
{% if status_filter == 'overdue' %}
bg-red-600 text-white shadow-md
{% else %}
bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700
{% endif %}">
<i class="fas fa-exclamation-triangle"></i>
<span>{{ _('Overdue') }}</span>
</a>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
{% if invoices %}
{% if invoices %}
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<table class="w-full">
<thead class="bg-background-light dark:bg-background-dark border-b-2 border-border-light dark:border-border-dark">
<tr>
<th class="p-3">{{ _('Invoice Number') }}</th>
<th class="p-3">{{ _('Issue Date') }}</th>
<th class="p-3">{{ _('Due Date') }}</th>
<th class="p-3">{{ _('Amount') }}</th>
<th class="p-3">{{ _('Status') }}</th>
<th class="p-3">{{ _('Actions') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Invoice Number') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Issue Date') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Due Date') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Amount') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Status') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-border-light dark:divide-border-dark">
{% for invoice in invoices %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<td class="p-3">
<a href="{{ url_for('client_portal.view_invoice', invoice_id=invoice.id) }}" class="text-primary hover:underline font-medium">
{{ invoice.invoice_number }}
<tr class="hover:bg-background-light dark:hover:bg-background-dark transition-colors group">
<td class="py-4 px-6">
<a href="{{ url_for('client_portal.view_invoice', invoice_id=invoice.id) }}"
class="font-semibold text-primary hover:text-primary/80 hover:underline flex items-center space-x-2 group-hover:text-primary transition-colors">
<i class="fas fa-file-invoice text-sm"></i>
<span>{{ invoice.invoice_number }}</span>
</a>
</td>
<td class="p-3">{{ invoice.issue_date.strftime('%Y-%m-%d') }}</td>
<td class="p-3">
{{ invoice.due_date.strftime('%Y-%m-%d') }}
{% if invoice.is_overdue %}
<span class="text-red-600 text-xs ml-1">({{ invoice.days_overdue }} {{ _('days overdue') }})</span>
{% endif %}
<td class="py-4 px-6">
<div class="flex items-center space-x-2 text-sm text-text-light dark:text-text-dark">
<i class="fas fa-calendar text-text-muted-light dark:text-text-muted-dark text-xs"></i>
<span>{{ invoice.issue_date.strftime('%b %d, %Y') }}</span>
</div>
</td>
<td class="p-3 font-medium">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</td>
<td class="p-3">
<span class="px-2 py-1 text-xs rounded-full
{% if invoice.payment_status == 'fully_paid' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif invoice.is_overdue %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
{% elif invoice.payment_status == 'partially_paid' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
<td class="py-4 px-6">
<div class="flex items-center space-x-2">
<div class="flex items-center space-x-2 text-sm text-text-light dark:text-text-dark">
<i class="fas fa-calendar-check text-text-muted-light dark:text-text-muted-dark text-xs"></i>
<span>{{ invoice.due_date.strftime('%b %d, %Y') }}</span>
</div>
{% if invoice.is_overdue %}
<span class="px-2 py-1 text-xs font-medium rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
{{ invoice.days_overdue }} {{ _('days overdue') }}
</span>
{% endif %}
</div>
</td>
<td class="py-4 px-6">
<div class="font-semibold text-text-light dark:text-text-dark">
{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}
</div>
</td>
<td class="py-4 px-6">
<span class="inline-flex items-center px-3 py-1.5 text-xs font-semibold rounded-full
{% if invoice.payment_status == 'fully_paid' %}
bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400
{% elif invoice.is_overdue %}
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
{% elif invoice.payment_status == 'partially_paid' %}
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
{% else %}
bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
{% endif %}">
<i class="fas
{% if invoice.payment_status == 'fully_paid' %}fa-check-circle
{% elif invoice.is_overdue %}fa-exclamation-triangle
{% elif invoice.payment_status == 'partially_paid' %}fa-clock
{% else %}fa-file-invoice{% endif %} mr-1.5"></i>
{{ invoice.payment_status|replace('_', ' ')|title }}
</span>
</td>
<td class="p-3">
<a href="{{ url_for('client_portal.view_invoice', invoice_id=invoice.id) }}" class="text-primary hover:underline text-sm">
{{ _('View') }}
<td class="py-4 px-6">
<a href="{{ url_for('client_portal.view_invoice', invoice_id=invoice.id) }}"
class="inline-flex items-center px-4 py-2 bg-primary hover:bg-primary/90 text-white text-sm font-medium rounded-lg transition-colors group">
<span>{{ _('View') }}</span>
<i class="fas fa-arrow-right ml-2 text-xs group-hover:translate-x-1 transition-transform"></i>
</a>
</td>
</tr>
@@ -87,9 +141,39 @@
</tbody>
</table>
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">{{ _('No invoices found.') }}</p>
{% endif %}
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg p-12">
<div class="text-center">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-8 w-32 h-32 mx-auto mb-6 flex items-center justify-center">
<i class="fas fa-file-invoice text-5xl text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-text-light dark:text-text-dark mb-2">
{% if status_filter == 'all' %}
{{ _('No invoices found') }}
{% elif status_filter == 'paid' %}
{{ _('No paid invoices') }}
{% elif status_filter == 'unpaid' %}
{{ _('No unpaid invoices') }}
{% elif status_filter == 'overdue' %}
{{ _('No overdue invoices') }}
{% endif %}
</h3>
<p class="text-text-muted-light dark:text-text-muted-dark max-w-md mx-auto">
{% if status_filter == 'all' %}
{{ _('There are no invoices available at this time. Invoices will appear here once they are created.') }}
{% else %}
{{ _('There are no invoices matching this filter. Try selecting a different status.') }}
{% endif %}
</p>
{% if status_filter != 'all' %}
<a href="{{ url_for('client_portal.invoices', status='all') }}"
class="inline-flex items-center mt-4 px-5 py-2.5 bg-primary hover:bg-primary/90 text-white font-medium rounded-lg transition-colors">
<i class="fas fa-list mr-2"></i>
{{ _('View All Invoices') }}
</a>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}
+115 -69
View File
@@ -16,90 +16,136 @@
breadcrumbs=breadcrumbs
) }}
<!-- Filter Tabs -->
<div class="mb-6">
<div class="flex gap-2 border-b border-border-light dark:border-border-dark">
<a href="{{ url_for('client_portal.notifications', filter='all') }}"
class="px-4 py-2 {% if filter_type == 'all' %}border-b-2 border-primary-600 text-primary-600 font-medium{% else %}text-text-muted-light dark:text-text-muted-dark hover:text-primary{% endif %} transition-colors">
{{ _('All') }}
</a>
<a href="{{ url_for('client_portal.notifications', filter='unread') }}"
class="px-4 py-2 {% if filter_type == 'unread' %}border-b-2 border-primary-600 text-primary-600 font-medium{% else %}text-text-muted-light dark:text-text-muted-dark hover:text-primary{% endif %} transition-colors relative">
{{ _('Unread') }}
{% if unread_count > 0 %}
<span class="ml-2 px-2 py-0.5 bg-red-600 text-white text-xs rounded-full">{{ unread_count }}</span>
{% endif %}
</a>
<!-- Enhanced Filter Tabs -->
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg p-4 mb-6">
<div class="flex items-center justify-between">
<div class="flex gap-2">
<a href="{{ url_for('client_portal.notifications', filter='all') }}"
class="px-5 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 flex items-center space-x-2
{% if filter_type == 'all' %}
bg-primary text-white shadow-md
{% else %}
bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700
{% endif %}">
<i class="fas fa-list"></i>
<span>{{ _('All') }}</span>
</a>
<a href="{{ url_for('client_portal.notifications', filter='unread') }}"
class="px-5 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 flex items-center space-x-2 relative
{% if filter_type == 'unread' %}
bg-red-600 text-white shadow-md
{% else %}
bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700
{% endif %}">
<i class="fas fa-envelope"></i>
<span>{{ _('Unread') }}</span>
{% if unread_count > 0 %}
<span class="ml-1 px-2 py-0.5 {% if filter_type == 'unread' %}bg-white/20{% else %}bg-red-600 text-white{% endif %} rounded-full text-xs font-bold">
{{ unread_count }}
</span>
{% endif %}
</a>
</div>
<!-- Mark All Read Button -->
{% if unread_count > 0 and filter_type == 'all' %}
<form method="POST" action="{{ url_for('client_portal.mark_all_notifications_read') }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors shadow-md hover:shadow-lg">
<i class="fas fa-check-double mr-2"></i>{{ _('Mark All as Read') }}
</button>
</form>
{% endif %}
</div>
</div>
<!-- Mark All Read Button -->
{% if unread_count > 0 %}
<div class="mb-4">
<form method="POST" action="{{ url_for('client_portal.mark_all_notifications_read') }}" class="inline">
<button type="submit"
class="inline-flex items-center px-4 py-2 bg-secondary-200 hover:bg-secondary-300 dark:bg-secondary-700 dark:hover:bg-secondary-600 text-secondary-900 dark:text-secondary-100 font-medium rounded-lg transition-colors">
<i class="fas fa-check-double mr-2"></i>{{ _('Mark All as Read') }}
</button>
</form>
</div>
{% endif %}
<!-- Notifications List -->
{% if notifications %}
<div class="space-y-3">
<div class="space-y-4">
{% for notification in notifications %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow {% if not notification.is_read %}border-l-4 border-primary-600{% endif %}">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
{% if not notification.is_read %}
<span class="w-2 h-2 bg-primary-600 rounded-full"></span>
{% endif %}
<h3 class="text-lg font-semibold {% if not notification.is_read %}font-bold{% endif %}">
{{ notification.title }}
</h3>
<span class="px-2 py-1 text-xs rounded-full bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
{{ notification.type|replace('_', ' ')|title }}
</span>
</div>
<p class="text-text-light dark:text-text-dark mb-3">{{ notification.message }}</p>
<div class="flex items-center gap-4 text-sm text-text-muted-light dark:text-text-muted-dark">
<span>
<i class="fas fa-clock mr-1"></i>{{ notification.created_at.strftime('%Y-%m-%d %H:%M') if notification.created_at else _('N/A') }}
</span>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden
{% if not notification.is_read %}border-l-4 border-l-primary bg-primary/5{% endif %}">
<div class="p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-3">
{% if not notification.is_read %}
<div class="w-3 h-3 bg-primary rounded-full animate-pulse"></div>
{% else %}
<div class="w-3 h-3"></div>
{% endif %}
<div class="bg-primary/10 p-2 rounded-lg">
<i class="fas
{% if 'approval' in notification.type %}fa-check-circle
{% elif 'invoice' in notification.type %}fa-file-invoice
{% elif 'project' in notification.type %}fa-folder-open
{% elif 'time' in notification.type %}fa-clock
{% else %}fa-bell{% endif %}
text-primary"></i>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold {% if not notification.is_read %}font-bold text-text-light dark:text-text-dark{% else %}text-text-light dark:text-text-dark{% endif %}">
{{ notification.title }}
</h3>
<div class="flex items-center gap-2 mt-1">
<span class="px-2.5 py-1 text-xs font-medium rounded-full bg-primary/10 text-primary">
{{ notification.type|replace('_', ' ')|title }}
</span>
<span class="text-xs text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-clock mr-1"></i>{{ notification.created_at.strftime('%b %d, %Y at %H:%M') if notification.created_at else _('N/A') }}
</span>
</div>
</div>
</div>
<p class="text-text-light dark:text-text-dark mb-4 ml-11">{{ notification.message }}</p>
{% if notification.link_url %}
<a href="{{ notification.link_url }}"
class="text-primary hover:underline">
<i class="fas fa-arrow-right mr-1"></i>{{ notification.link_text or _('View Details') }}
</a>
<div class="ml-11">
<a href="{{ notification.link_url }}"
class="inline-flex items-center px-4 py-2 bg-primary hover:bg-primary/90 text-white text-sm font-medium rounded-lg transition-colors group">
<span>{{ notification.link_text or _('View Details') }}</span>
<i class="fas fa-arrow-right ml-2 group-hover:translate-x-1 transition-transform"></i>
</a>
</div>
{% endif %}
</div>
{% if not notification.is_read %}
<form method="POST" action="{{ url_for('client_portal.mark_notification_read', notification_id=notification.id) }}" class="ml-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="p-3 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
title="{{ _('Mark as read') }}">
<i class="fas fa-check"></i>
</button>
</form>
{% else %}
<div class="ml-4 p-3 text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-check-circle"></i>
</div>
{% endif %}
</div>
{% if not notification.is_read %}
<form method="POST" action="{{ url_for('client_portal.mark_notification_read', notification_id=notification.id) }}" class="ml-4">
<button type="submit"
class="px-3 py-1 text-sm bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 rounded-lg hover:bg-primary-200 dark:hover:bg-primary-800 transition-colors"
title="{{ _('Mark as read') }}">
<i class="fas fa-check"></i>
</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark p-12 rounded-lg shadow text-center">
<i class="fas fa-bell-slash text-6xl text-text-muted-light dark:text-text-muted-dark mb-4"></i>
<h3 class="text-xl font-semibold mb-2">{{ _('No Notifications') }}</h3>
<p class="text-text-muted-light dark:text-text-muted-dark">
{% if filter_type == 'unread' %}
{{ _('You have no unread notifications.') }}
{% else %}
{{ _('You have no notifications yet.') }}
{% endif %}
</p>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg p-12">
<div class="text-center">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-8 w-32 h-32 mx-auto mb-6 flex items-center justify-center">
<i class="fas fa-bell-slash text-5xl text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-text-light dark:text-text-dark mb-2">{{ _('No Notifications') }}</h3>
<p class="text-text-muted-light dark:text-text-muted-dark max-w-md mx-auto">
{% if filter_type == 'unread' %}
{{ _('You have no unread notifications. All caught up!') }}
{% else %}
{{ _('You have no notifications yet. Notifications will appear here when there are updates to your projects or invoices.') }}
{% endif %}
</p>
</div>
</div>
{% endif %}
{% endblock %}
@@ -21,6 +21,7 @@
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h3 class="text-lg font-semibold mb-4">{{ _('Add Comment') }}</h3>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="comment" class="block text-sm font-medium mb-2">{{ _('Your Comment') }}</label>
<textarea id="comment" name="comment" rows="4" required
+80 -27
View File
@@ -16,38 +16,91 @@
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
{% if project_stats %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for stat in project_stats %}
<div class="border border-border-light dark:border-border-dark rounded-lg p-4 hover:shadow-md transition-shadow">
{% if project_stats %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for stat in project_stats %}
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 overflow-hidden group">
<!-- Project Header -->
<div class="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 p-6 text-white">
<div class="flex items-start justify-between mb-2">
<h3 class="text-lg font-semibold">{{ stat.project.name }}</h3>
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ stat.project.status|capitalize }}</span>
<h3 class="text-xl font-bold line-clamp-2 flex-1">{{ stat.project.name }}</h3>
<span class="ml-2 px-2.5 py-1 text-xs font-semibold rounded-full bg-white/20 backdrop-blur-sm">
{{ stat.project.status|capitalize }}
</span>
</div>
{% if stat.project.description %}
<div class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3 prose prose-sm dark:prose-invert max-w-none">{{ stat.project.description | markdown | safe }}</div>
{% endif %}
<div class="flex items-center justify-between text-sm">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Total Hours') }}:</span>
<span class="font-medium">{{ "%.2f"|format(stat.total_hours) }}h</span>
</div>
<div class="flex items-center justify-between text-sm mt-1">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Time Entries') }}:</span>
<span class="font-medium">{{ stat.entry_count }}</span>
</div>
{% if stat.project.hourly_rate %}
<div class="flex items-center justify-between text-sm mt-1">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Hourly Rate') }}:</span>
<span class="font-medium">{{ "%.2f"|format(stat.project.hourly_rate) }} {{ stat.project.client_obj.default_hourly_rate and 'EUR' or '' }}</span>
</div>
<p class="text-sm text-blue-100 line-clamp-2 mt-2">{{ stat.project.description }}</p>
{% endif %}
</div>
{% endfor %}
<!-- Project Stats -->
<div class="p-6">
<div class="space-y-4">
<!-- Total Hours -->
<div class="flex items-center justify-between p-3 bg-background-light dark:bg-background-dark rounded-lg border border-border-light dark:border-border-dark">
<div class="flex items-center space-x-3">
<div class="bg-green-100 dark:bg-green-900/30 p-2 rounded-lg">
<i class="fas fa-clock text-green-600 dark:text-green-400"></i>
</div>
<div>
<p class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">{{ _('Total Hours') }}</p>
<p class="text-lg font-bold text-text-light dark:text-text-dark">{{ "%.2f"|format(stat.total_hours) }}h</p>
</div>
</div>
</div>
<!-- Time Entries Count -->
<div class="flex items-center justify-between p-3 bg-background-light dark:bg-background-dark rounded-lg border border-border-light dark:border-border-dark">
<div class="flex items-center space-x-3">
<div class="bg-blue-100 dark:bg-blue-900/30 p-2 rounded-lg">
<i class="fas fa-list text-blue-600 dark:text-blue-400"></i>
</div>
<div>
<p class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">{{ _('Time Entries') }}</p>
<p class="text-lg font-bold text-text-light dark:text-text-dark">{{ stat.entry_count }}</p>
</div>
</div>
</div>
<!-- Hourly Rate (if available) -->
{% if stat.project.hourly_rate %}
<div class="flex items-center justify-between p-3 bg-background-light dark:bg-background-dark rounded-lg border border-border-light dark:border-border-dark">
<div class="flex items-center space-x-3">
<div class="bg-purple-100 dark:bg-purple-900/30 p-2 rounded-lg">
<i class="fas fa-euro-sign text-purple-600 dark:text-purple-400"></i>
</div>
<div>
<p class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">{{ _('Hourly Rate') }}</p>
<p class="text-lg font-bold text-text-light dark:text-text-dark">{{ "%.2f"|format(stat.project.hourly_rate) }} {{ stat.project.client_obj.default_hourly_rate and 'EUR' or '' }}</p>
</div>
</div>
</div>
{% endif %}
</div>
<!-- View Details Link -->
<div class="mt-6 pt-4 border-t border-border-light dark:border-border-dark">
<a href="{{ url_for('client_portal.time_entries', project_id=stat.project.id) }}"
class="w-full inline-flex items-center justify-center px-4 py-2 bg-primary hover:bg-primary/90 text-white font-medium rounded-lg transition-colors group">
<span>{{ _('View Time Entries') }}</span>
<i class="fas fa-arrow-right ml-2 group-hover:translate-x-1 transition-transform"></i>
</a>
</div>
</div>
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">{{ _('No projects found.') }}</p>
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg p-12">
<div class="text-center">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-8 w-32 h-32 mx-auto mb-6 flex items-center justify-center">
<i class="fas fa-folder-open text-5xl text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-text-light dark:text-text-dark mb-2">{{ _('No projects found') }}</h3>
<p class="text-text-muted-light dark:text-text-muted-dark max-w-md mx-auto">
{{ _('There are no projects available at this time. Projects will appear here once they are created and assigned to your account.') }}
</p>
</div>
</div>
{% endif %}
{% endblock %}
@@ -120,6 +120,7 @@
<h3 class="text-lg font-semibold mb-4">{{ _('Actions') }}</h3>
<div class="flex flex-col sm:flex-row gap-4">
<form method="POST" action="{{ url_for('client_portal.accept_quote', quote_id=quote.id) }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="inline-flex items-center justify-center px-6 py-3 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors w-full sm:w-auto"
onclick="return confirm('{{ _('Are you sure you want to accept this quote?') }}');">
@@ -144,6 +145,7 @@
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
<h3 class="text-lg font-semibold mb-4">{{ _('Reject Quote') }}</h3>
<form method="POST" action="{{ url_for('client_portal.reject_quote', quote_id=quote.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="reason" class="block text-sm font-medium mb-2">{{ _('Reason (optional)') }}</label>
<textarea id="reason" name="reason" rows="4"
+118 -46
View File
@@ -16,106 +16,178 @@
breadcrumbs=breadcrumbs
) }}
<!-- Summary Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<!-- Enhanced Summary Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border border-green-200 dark:border-green-800 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total Hours') }}</p>
<p class="text-2xl font-bold text-text-light dark:text-text-dark">{{ total_hours }}</p>
<p class="text-sm font-medium text-green-600 dark:text-green-400 mb-1">{{ _('Total Hours') }}</p>
<p class="text-3xl font-bold text-green-900 dark:text-green-100">{{ "%.1f"|format(total_hours) }}</p>
<p class="text-xs text-green-600/70 dark:text-green-400/70 mt-1">{{ _('Hours tracked') }}</p>
</div>
<div class="bg-green-500/10 dark:bg-green-400/10 p-4 rounded-full">
<i class="fas fa-clock text-green-600 dark:text-green-400 text-3xl"></i>
</div>
<i class="fas fa-clock text-green-600 text-3xl"></i>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 border border-blue-200 dark:border-blue-800 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total Invoiced') }}</p>
<p class="text-2xl font-bold text-text-light dark:text-text-dark">{{ "%.2f"|format(invoice_summary.total) }} EUR</p>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 mb-1">{{ _('Total Invoiced') }}</p>
<p class="text-3xl font-bold text-blue-900 dark:text-blue-100">{{ "%.2f"|format(invoice_summary.total) }}</p>
<p class="text-xs text-blue-600/70 dark:text-blue-400/70 mt-1">EUR</p>
</div>
<div class="bg-blue-500/10 dark:bg-blue-400/10 p-4 rounded-full">
<i class="fas fa-file-invoice text-blue-600 dark:text-blue-400 text-3xl"></i>
</div>
<i class="fas fa-file-invoice text-blue-600 text-3xl"></i>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="bg-gradient-to-br from-emerald-50 to-emerald-100 dark:from-emerald-900/20 dark:to-emerald-800/20 border border-emerald-200 dark:border-emerald-800 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Paid') }}</p>
<p class="text-2xl font-bold text-green-600">{{ "%.2f"|format(invoice_summary.paid) }} EUR</p>
<p class="text-sm font-medium text-emerald-600 dark:text-emerald-400 mb-1">{{ _('Paid') }}</p>
<p class="text-3xl font-bold text-emerald-900 dark:text-emerald-100">{{ "%.2f"|format(invoice_summary.paid) }}</p>
<p class="text-xs text-emerald-600/70 dark:text-emerald-400/70 mt-1">EUR</p>
</div>
<div class="bg-emerald-500/10 dark:bg-emerald-400/10 p-4 rounded-full">
<i class="fas fa-check-circle text-emerald-600 dark:text-emerald-400 text-3xl"></i>
</div>
<i class="fas fa-check-circle text-green-600 text-3xl"></i>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="bg-gradient-to-br from-amber-50 to-amber-100 dark:from-amber-900/20 dark:to-amber-800/20 border border-amber-200 dark:border-amber-800 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Outstanding') }}</p>
<p class="text-2xl font-bold text-yellow-600">{{ "%.2f"|format(invoice_summary.unpaid) }} EUR</p>
<p class="text-sm font-medium text-amber-600 dark:text-amber-400 mb-1">{{ _('Outstanding') }}</p>
<p class="text-3xl font-bold text-amber-900 dark:text-amber-100">{{ "%.2f"|format(invoice_summary.unpaid) }}</p>
<p class="text-xs text-amber-600/70 dark:text-amber-400/70 mt-1">EUR</p>
</div>
<div class="bg-amber-500/10 dark:bg-amber-400/10 p-4 rounded-full">
<i class="fas fa-exclamation-triangle text-amber-600 dark:text-amber-400 text-3xl"></i>
</div>
<i class="fas fa-exclamation-triangle text-yellow-600 text-3xl"></i>
</div>
</div>
</div>
<!-- Project Hours Breakdown -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">{{ _('Hours by Project') }}</h2>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg mb-6 overflow-hidden">
<div class="p-6 border-b border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark">
<div class="flex items-center space-x-3">
<div class="bg-blue-100 dark:bg-blue-900/30 p-2 rounded-lg">
<i class="fas fa-chart-pie text-blue-600 dark:text-blue-400"></i>
</div>
<h2 class="text-xl font-bold text-text-light dark:text-text-dark">{{ _('Hours by Project') }}</h2>
</div>
</div>
{% if project_hours %}
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<table class="w-full">
<thead class="bg-background-light dark:bg-background-dark border-b-2 border-border-light dark:border-border-dark">
<tr>
<th class="p-3">{{ _('Project') }}</th>
<th class="p-3">{{ _('Total Hours') }}</th>
<th class="p-3">{{ _('Billable Hours') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Total Hours') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Billable Hours') }}</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-border-light dark:divide-border-dark">
{% for ph in project_hours %}
<tr class="border-b border-border-light dark:border-border-dark">
<td class="p-3 font-medium">{{ ph.project.name if ph.project else _('N/A') }}</td>
<td class="p-3">{{ "%.2f"|format(ph.hours) }}</td>
<td class="p-3">{{ "%.2f"|format(ph.billable_hours) }}</td>
<tr class="hover:bg-background-light dark:hover:bg-background-dark transition-colors">
<td class="py-4 px-6">
<div class="flex items-center space-x-2">
<i class="fas fa-folder text-primary"></i>
<span class="font-medium text-text-light dark:text-text-dark">{{ ph.project.name if ph.project else _('N/A') }}</span>
</div>
</td>
<td class="py-4 px-6">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<i class="fas fa-clock mr-1.5 text-xs"></i>
{{ "%.2f"|format(ph.hours) }}h
</span>
</td>
<td class="py-4 px-6">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
<i class="fas fa-euro-sign mr-1.5 text-xs"></i>
{{ "%.2f"|format(ph.billable_hours) }}h
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No project hours data available.') }}</p>
<div class="p-12 text-center">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
<i class="fas fa-chart-pie text-3xl text-gray-400"></i>
</div>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No project hours data available.') }}</p>
</div>
{% endif %}
</div>
<!-- Recent Activity -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Recent Time Entries (Last 30 Days)') }}</h2>
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg overflow-hidden">
<div class="p-6 border-b border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark">
<div class="flex items-center space-x-3">
<div class="bg-green-100 dark:bg-green-900/30 p-2 rounded-lg">
<i class="fas fa-history text-green-600 dark:text-green-400"></i>
</div>
<h2 class="text-xl font-bold text-text-light dark:text-text-dark">{{ _('Recent Time Entries (Last 30 Days)') }}</h2>
</div>
</div>
{% if recent_entries %}
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<table class="w-full">
<thead class="bg-background-light dark:bg-background-dark border-b-2 border-border-light dark:border-border-dark">
<tr>
<th class="p-3">{{ _('Date') }}</th>
<th class="p-3">{{ _('Project') }}</th>
<th class="p-3">{{ _('Duration') }}</th>
<th class="p-3">{{ _('User') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Date') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Duration') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('User') }}</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-border-light dark:divide-border-dark">
{% for entry in recent_entries[:20] %}
<tr class="border-b border-border-light dark:border-border-dark">
<td class="p-3">{{ entry.start_time.strftime('%Y-%m-%d') if entry.start_time else _('N/A') }}</td>
<td class="p-3">{{ entry.project.name if entry.project else _('N/A') }}</td>
<td class="p-3">{{ "%.2f"|format(entry.duration_hours) }}h</td>
<td class="p-3">{{ entry.user.display_name if entry.user else _('N/A') }}</td>
<tr class="hover:bg-background-light dark:hover:bg-background-dark transition-colors">
<td class="py-4 px-6">
<div class="text-sm font-medium text-text-light dark:text-text-dark">
{{ entry.start_time.strftime('%b %d, %Y') if entry.start_time else _('N/A') }}
</div>
</td>
<td class="py-4 px-6">
<div class="flex items-center space-x-2">
<i class="fas fa-folder text-primary text-xs"></i>
<span class="text-sm text-text-light dark:text-text-dark">{{ entry.project.name if entry.project else _('N/A') }}</span>
</div>
</td>
<td class="py-4 px-6">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<i class="fas fa-clock mr-1.5 text-xs"></i>
{{ "%.2f"|format(entry.duration_hours) }}h
</span>
</td>
<td class="py-4 px-6">
<div class="flex items-center space-x-2">
<div class="w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center">
<i class="fas fa-user text-primary text-xs"></i>
</div>
<span class="text-sm text-text-light dark:text-text-dark">{{ entry.user.display_name if entry.user else _('N/A') }}</span>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No recent time entries.') }}</p>
<div class="p-12 text-center">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
<i class="fas fa-history text-3xl text-gray-400"></i>
</div>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No recent time entries.') }}</p>
</div>
{% endif %}
</div>
{% endblock %}
+135 -45
View File
@@ -16,12 +16,14 @@
breadcrumbs=breadcrumbs
) }}
<!-- Filters -->
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg shadow mb-6">
<!-- Enhanced Filters -->
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-xl shadow-lg 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 mb-1">{{ _('Project') }}</label>
<select name="project_id" id="project_id" class="form-input">
<label for="project_id" class="block text-sm font-semibold text-text-light dark:text-text-dark mb-2">
<i class="fas fa-folder-open mr-1 text-primary"></i>{{ _('Project') }}
</label>
<select name="project_id" id="project_id" class="w-full px-4 py-2.5 bg-background-light dark:bg-background-dark border border-border-light dark:border-border-dark rounded-lg text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-primary transition-all">
<option value="">{{ _('All Projects') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if selected_project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
@@ -29,67 +31,155 @@
</select>
</div>
<div>
<label for="date_from" class="block text-sm font-medium mb-1">{{ _('From Date') }}</label>
<input type="date" name="date_from" id="date_from" value="{{ date_from or '' }}" class="form-input">
<label for="date_from" class="block text-sm font-semibold text-text-light dark:text-text-dark mb-2">
<i class="fas fa-calendar-alt mr-1 text-primary"></i>{{ _('From Date') }}
</label>
<input type="date" name="date_from" id="date_from" value="{{ date_from or '' }}"
class="w-full px-4 py-2.5 bg-background-light dark:bg-background-dark border border-border-light dark:border-border-dark rounded-lg text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-primary transition-all">
</div>
<div>
<label for="date_to" class="block text-sm font-medium mb-1">{{ _('To Date') }}</label>
<input type="date" name="date_to" id="date_to" value="{{ date_to or '' }}" class="form-input">
<label for="date_to" class="block text-sm font-semibold text-text-light dark:text-text-dark mb-2">
<i class="fas fa-calendar-check mr-1 text-primary"></i>{{ _('To Date') }}
</label>
<input type="date" name="date_to" id="date_to" value="{{ date_to or '' }}"
class="w-full px-4 py-2.5 bg-background-light dark:bg-background-dark border border-border-light dark:border-border-dark rounded-lg text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-primary transition-all">
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 w-full">{{ _('Filter') }}</button>
<button type="submit" class="w-full px-5 py-2.5 bg-primary hover:bg-primary/90 text-white font-semibold rounded-lg transition-colors flex items-center justify-center space-x-2 shadow-md hover:shadow-lg">
<i class="fas fa-filter"></i>
<span>{{ _('Filter') }}</span>
</button>
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
{% if time_entries %}
{% if time_entries %}
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<table class="w-full">
<thead class="bg-background-light dark:bg-background-dark border-b-2 border-border-light dark:border-border-dark">
<tr>
<th class="p-3">{{ _('Date') }}</th>
<th class="p-3">{{ _('Project') }}</th>
<th class="p-3">{{ _('User') }}</th>
<th class="p-3">{{ _('Start Time') }}</th>
<th class="p-3">{{ _('End Time') }}</th>
<th class="p-3">{{ _('Duration') }}</th>
<th class="p-3">{{ _('Description') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Date') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('User') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Time') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Duration') }}</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Description') }}</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-border-light dark:divide-border-dark">
{% for entry in time_entries %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<td class="p-3">{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td class="p-3">
{% if entry.project %}
{{ entry.project.name }}
{% elif entry.client %}
{{ entry.client.name }} <span class="text-xs text-gray-500">({{ _('Direct') }})</span>
{% else %}
{{ _('N/A') }}
{% endif %}
<tr class="hover:bg-background-light dark:hover:bg-background-dark transition-colors">
<td class="py-4 px-6">
<div class="text-sm font-medium text-text-light dark:text-text-dark">
{{ entry.start_time.strftime('%b %d, %Y') }}
</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">
{{ entry.start_time.strftime('%A') }}
</div>
</td>
<td class="py-4 px-6">
<div class="flex items-center space-x-2">
<i class="fas fa-folder text-primary text-sm"></i>
<span class="text-sm text-text-light dark:text-text-dark">
{% if entry.project %}
{{ entry.project.name }}
{% elif entry.client %}
{{ entry.client.name }} <span class="text-xs text-text-muted-light dark:text-text-muted-dark">({{ _('Direct') }})</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('N/A') }}</span>
{% endif %}
</span>
</div>
</td>
<td class="py-4 px-6">
<div class="flex items-center space-x-2">
<div class="w-7 h-7 bg-primary/10 rounded-full flex items-center justify-center">
<i class="fas fa-user text-primary text-xs"></i>
</div>
<span class="text-sm text-text-light dark:text-text-dark">
{{ entry.user.display_name if entry.user else _('N/A') }}
</span>
</div>
</td>
<td class="py-4 px-6">
<div class="flex items-center space-x-3 text-sm">
<div class="flex items-center space-x-1">
<i class="fas fa-play-circle text-green-500 text-xs"></i>
<span class="text-text-light dark:text-text-dark font-medium">{{ entry.start_time.strftime('%H:%M') }}</span>
</div>
{% if entry.end_time %}
<span class="text-text-muted-light dark:text-text-muted-dark"></span>
<div class="flex items-center space-x-1">
<i class="fas fa-stop-circle text-red-500 text-xs"></i>
<span class="text-text-light dark:text-text-dark font-medium">{{ entry.end_time.strftime('%H:%M') }}</span>
</div>
{% endif %}
</div>
</td>
<td class="py-4 px-6">
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-semibold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<i class="fas fa-clock mr-1.5 text-xs"></i>
{{ "%.2f"|format(entry.duration_hours) }}h
</span>
</td>
<td class="py-4 px-6">
<p class="text-sm text-text-light dark:text-text-dark max-w-md line-clamp-2" title="{{ entry.description or '-' }}">
{% if entry.description %}
{{ entry.description }}
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark italic">-</span>
{% endif %}
</p>
</td>
<td class="p-3">{{ entry.user.display_name if entry.user else _('N/A') }}</td>
<td class="p-3">{{ entry.start_time.strftime('%H:%M') }}</td>
<td class="p-3">{{ entry.end_time.strftime('%H:%M') if entry.end_time else '-' }}</td>
<td class="p-3 font-medium">{{ "%.2f"|format(entry.duration_hours) }}h</td>
<td class="p-3">{{ entry.description[:100] if entry.description else '-' }}{% if entry.description and entry.description|length > 100 %}...{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-4 pt-4 border-t border-border-light dark:border-border-dark">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Total entries') }}: {{ time_entries|length }} |
{{ _('Total hours') }}: {{ "%.2f"|format(time_entries|sum(attribute='duration_hours')) }}h
</p>
<!-- Summary Footer -->
<div class="bg-background-light dark:bg-background-dark border-t border-border-light dark:border-border-dark px-6 py-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center space-x-6">
<div class="flex items-center space-x-2">
<i class="fas fa-list text-primary"></i>
<span class="text-sm font-medium text-text-light dark:text-text-dark">
{{ _('Total entries') }}: <span class="font-bold">{{ time_entries|length }}</span>
</span>
</div>
<div class="flex items-center space-x-2">
<i class="fas fa-clock text-green-500"></i>
<span class="text-sm font-medium text-text-light dark:text-text-dark">
{{ _('Total hours') }}: <span class="font-bold text-green-600 dark:text-green-400">{{ "%.2f"|format(time_entries|sum(attribute='duration_hours')) }}h</span>
</span>
</div>
</div>
</div>
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">{{ _('No time entries found.') }}</p>
{% endif %}
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg p-12">
<div class="text-center">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-8 w-32 h-32 mx-auto mb-6 flex items-center justify-center">
<i class="fas fa-clock text-5xl text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-text-light dark:text-text-dark mb-2">{{ _('No time entries found') }}</h3>
<p class="text-text-muted-light dark:text-text-muted-dark max-w-md mx-auto mb-4">
{% if selected_project_id or date_from or date_to %}
{{ _('No time entries match your current filters. Try adjusting your filter criteria.') }}
{% else %}
{{ _('There are no time entries available at this time. Time entries will appear here once they are logged for your projects.') }}
{% endif %}
</p>
{% if selected_project_id or date_from or date_to %}
<a href="{{ url_for('client_portal.time_entries') }}"
class="inline-flex items-center px-5 py-2.5 bg-primary hover:bg-primary/90 text-white font-medium rounded-lg transition-colors">
<i class="fas fa-times mr-2"></i>
{{ _('Clear Filters') }}
</a>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}