diff --git a/src/servala/frontend/templates/frontend/organization_selection.html b/src/servala/frontend/templates/frontend/organization_selection.html new file mode 100644 index 0000000..04db6ec --- /dev/null +++ b/src/servala/frontend/templates/frontend/organization_selection.html @@ -0,0 +1,111 @@ +{% extends "frontend/base.html" %} +{% load i18n static %} +{% block html_title %} + {% block page_title %} + {% translate "Welcome to Servala" %} + {% endblock page_title %} +{% endblock html_title %} +{% block content %} +
+ {% if not user_organizations %} +
+
+
+
+

{% translate "Welcome to Servala!" %}

+
+
+
+
+ +
+
{% translate "Get Started with Your First Organization" %}
+

+ {% blocktranslate trimmed %} + Organizations help you manage your services and resources. + Create your first organization to start using Servala's service catalog. + {% endblocktranslate %} +

+ +
+
{% translate "Next Steps" %}
+
+
+
+ +
{% translate "Create Organization" %}
+ {% translate "Set up your workspace" %} +
+
+
+
+ +
{% translate "Discover Services" %}
+ {% translate "Browse available services" %} +
+
+
+
+ +
{% translate "Create Instances" %}
+ {% translate "Deploy your first service" %} +
+
+
+
+
+
+
+
+
+ {% else %} +
+ {% for organization in user_organizations %} +
+
+
+
+ +
+
{{ organization.name }}
+
+
+ + + {{ organization.instance_count }} + {% blocktranslate trimmed count count=organization.instance_count %} + instance + {% plural %} + instances + {% endblocktranslate %} + + + + {{ organization.members.count }} + {% blocktranslate trimmed count count=organization.members.count %} + member + {% plural %} + members + {% endblocktranslate %} + +
+
+ +
+
+
+ {% endfor %} +
+
+ {% endif %} +
+{% endblock content %} diff --git a/src/servala/frontend/templates/frontend/organizations/dashboard.html b/src/servala/frontend/templates/frontend/organizations/dashboard.html index 88cb596..b0dd273 100644 --- a/src/servala/frontend/templates/frontend/organizations/dashboard.html +++ b/src/servala/frontend/templates/frontend/organizations/dashboard.html @@ -1 +1,258 @@ {% extends "frontend/base.html" %} +{% load i18n static %} +{% block html_title %} + {{ object.name }} {% translate "Dashboard" %} +{% endblock html_title %} +{% block page_title %}{% endblock %} +{% block content %} +
+
+
+
+
+
+
+

{% translate "Welcome to" %} {{ object.name }}

+

+ {% if has_instances %} + {% translate "Here's an overview of your organization's services and resources." %} + {% else %} + {% translate "Ready to get started? Discover services and create your first instance." %} + {% endif %} +

+
+ {% if user_role %} +
+ + {% if user_role == "owner" %} + {% translate "Owner" %} + {% elif user_role == "admin" %} + {% translate "Administrator" %} + {% else %} + {% translate "Member" %} + {% endif %} + +
+ {% endif %} +
+
+
+
+
+ {% if has_instances %} +
+
+
+
+
+
+ +
+
+
{% translate "Service Instances" %}
+

{{ service_instances_count }}

+
+
+
+
+
+
+
+
+
+
+ +
+
+
{% translate "Team Members" %}
+

{{ members_count }}

+
+
+
+
+
+
+ {% endif %} +
+ {% if has_instances %} +
+
+
+

{% translate "Recent Service Instances" %}

+
+
+
+ + + + + + + + + + + + {% for instance in service_instances %} + + + + + + + + {% endfor %} + +
{% translate "Name" %}{% translate "Service" %}{% translate "Status" %}{% translate "Created" %}{% translate "Actions" %}
+ {{ instance.name }} + +
+ {% if instance.context.service_offering.service.logo %} + {{ instance.context.service_offering.service.name }} + {% endif %} + {{ instance.context.service_offering.service.name }} +
+
+ {% if instance.is_deleted %} + {% translate "Deleted" %} + {% else %} + {% translate "Active" %} + {% endif %} + + {{ instance.created_at|date:"M d, Y" }} + +
+ + + + {% if instance.has_change_permission %} + + + + {% endif %} +
+
+
+ +
+
+
+ + {% else %} + {# if has_instances #} +
+
+
+

{% translate "Get Started" %}

+
+
+
+
+ +
+
{% translate "Ready to deploy your first service?" %}
+

+ {% translate "Browse our service catalog and deploy databases, storage, and other services with just a few clicks." %} +

+ + + {% translate "Discover Services" %} + +
+
+
+
+
+
+
+

{% translate "Next Steps" %}

+
+
+
+
+
+
+
{% translate "Organization Created" %}
+ {% translate "You're all set up!" %} +
+
+
+
+
+
{% translate "Discover Services" %}
+ {% translate "Browse what's available" %} +
+
+
+
+
+
{% translate "Deploy Your First Service" %}
+ {% translate "Create an instance" %} +
+
+
+
+
+
+ {% endif %} +
+
+{% endblock content %} diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 52a048e..7790b22 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -6,6 +6,11 @@ from servala.frontend import views urlpatterns = [ path("accounts/profile/", views.ProfileView.as_view(), name="profile"), path("accounts/logout/", views.LogoutView.as_view(), name="logout"), + path( + "organizations/", + views.OrganizationSelectionView.as_view(), + name="organization.selection", + ), path( "organizations/create", views.OrganizationCreateView.as_view(), @@ -68,5 +73,9 @@ urlpatterns = [ ] ), ), - path("", RedirectView.as_view(pattern_name="frontend:profile"), name="index"), + path( + "", + RedirectView.as_view(pattern_name="frontend:organization.selection"), + name="index", + ), ] diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index 5ae01ec..5f11a75 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -1,5 +1,12 @@ from .auth import LogoutView -from .generic import IndexView, ProfileView, custom_403, custom_404, custom_500 +from .generic import ( + IndexView, + OrganizationSelectionView, + ProfileView, + custom_403, + custom_404, + custom_500, +) from .organization import ( OrganizationCreateView, OrganizationDashboardView, @@ -21,6 +28,7 @@ __all__ = [ "LogoutView", "OrganizationCreateView", "OrganizationDashboardView", + "OrganizationSelectionView", "OrganizationUpdateView", "ServiceDetailView", "ServiceInstanceDeleteView", diff --git a/src/servala/frontend/views/generic.py b/src/servala/frontend/views/generic.py index 7d1a3a0..16b1ec2 100644 --- a/src/servala/frontend/views/generic.py +++ b/src/servala/frontend/views/generic.py @@ -1,5 +1,7 @@ from django.conf import settings -from django.shortcuts import render +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Count, Q +from django.shortcuts import redirect, render from django.urls import reverse_lazy from django.utils.functional import cached_property from django.views.generic import TemplateView @@ -13,6 +15,30 @@ class IndexView(TemplateView): template_name = "frontend/index.html" +class OrganizationSelectionView(LoginRequiredMixin, TemplateView): + template_name = "frontend/organization_selection.html" + + @cached_property + def user_organizations(self): + return self.request.user.organizations.all() + + def dispatch(self, request, *args, **kwargs): + # Users with a single organization get redirected to the organization’s dashboard + if self.user_organizations.count() == 1: + organization = self.user_organizations.first() + return redirect(organization.urls.base) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["user_organizations"] = self.user_organizations.annotate( + instance_count=Count( + "service_instances", filter=Q(service_instances__is_deleted=False) + ) + ) + return context + + class ProfileView(HtmxUpdateView): template_name = "frontend/profile.html" form_class = UserProfileForm diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 29eb5f5..6545d39 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -3,7 +3,12 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView, DetailView from rules.contrib.views import AutoPermissionRequiredMixin -from servala.core.models import BillingEntity, Organization +from servala.core.models import ( + BillingEntity, + Organization, + OrganizationMembership, + ServiceInstance, +) from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin @@ -61,6 +66,37 @@ class OrganizationDashboardView( ): template_name = "frontend/organizations/dashboard.html" + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + organization = self.get_object() + + service_instances = ServiceInstance.objects.filter( + organization=organization, is_deleted=False + ) + recent_instances = service_instances.order_by("-created_at")[:5] + + for instance in recent_instances: + instance.has_change_permission = self.request.user.has_perm( + "core.change_serviceinstance", instance + ) + + user_membership = OrganizationMembership.objects.filter( + user=self.request.user, organization=organization + ).first() + user_role = user_membership.role if user_membership else None + + context.update( + { + "service_instances_count": service_instances.count(), + "service_instances": recent_instances, + "members_count": organization.members.count(), + "has_instances": service_instances.exists(), + "user_role": user_role, + } + ) + + return context + class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView): template_name = "frontend/organizations/update.html" diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css index b7a5b8b..db9052a 100644 --- a/src/servala/static/css/servala.css +++ b/src/servala/static/css/servala.css @@ -99,3 +99,78 @@ a.btn-keycloak { .hide-form-errors .alert.form-errors { display: none; } +.organization-card { + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + cursor: pointer; +} + +.organization-card:hover { + transform: translateY(-5px); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} +.stats-icon { + width: 60px; + height: 60px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: white; + i::before { + display: block; + margin-top: -4px; + margin-left: -5px; + } +} + +.stats-icon.purple { + background-color: var(--bs-primary); +} + +.stats-icon.blue { + background-color: var(--bs-info); +} + +.stats-icon.green { + background-color: var(--brand-success); +} + +.timeline { + position: relative; + padding-left: 20px; +} + +.timeline::before { + content: ''; + position: absolute; + left: 8px; + top: 0; + bottom: 0; + width: 2px; + background-color: #e5e7eb; +} + +.timeline-item { + position: relative; + margin-bottom: 1rem; +} + +.timeline-marker { + position: absolute; + left: -16px; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: #d1d5db; + border: 3px solid white; + box-shadow: 0 0 0 3px #f3f4f6; +} + +.timeline-content { + margin-left: 10px; +} +.quick-actions i.bi { + margin-top: -16px; + padding-right: 28px; +}