diff --git a/ai/__pycache__/__init__.cpython-311.pyc b/ai/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..b9450fa Binary files /dev/null and b/ai/__pycache__/__init__.cpython-311.pyc differ diff --git a/ai/__pycache__/local_ai_api.cpython-311.pyc b/ai/__pycache__/local_ai_api.cpython-311.pyc new file mode 100644 index 0000000..e906db1 Binary files /dev/null and b/ai/__pycache__/local_ai_api.cpython-311.pyc differ diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index d79d6a7..92a7737 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/settings.py b/config/settings.py index 291d043..c6b38a4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -23,6 +23,7 @@ DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" ALLOWED_HOSTS = [ "127.0.0.1", "localhost", + "testserver", os.getenv("HOST_FQDN", ""), ] @@ -150,9 +151,11 @@ STATIC_URL = 'static/' STATIC_ROOT = BASE_DIR / 'staticfiles' STATICFILES_DIRS = [ - BASE_DIR / 'static', - BASE_DIR / 'assets', - BASE_DIR / 'node_modules', + path for path in [ + BASE_DIR / 'static', + BASE_DIR / 'assets', + BASE_DIR / 'node_modules', + ] if path.exists() ] # Email diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a..6e4b522 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000..7a1b973 Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index a251b5f..4244555 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000..ab6f822 Binary files /dev/null and b/core/__pycache__/tests.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988..7a38e2e 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f0989c..3963773 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 8c38f3f..baa445e 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,48 @@ from django.contrib import admin -# Register your models here. +from .models import ActivityItem, QuoteLine, SalesWorkspace + + +class QuoteLineInline(admin.TabularInline): + model = QuoteLine + extra = 0 + + +class ActivityItemInline(admin.TabularInline): + model = ActivityItem + extra = 0 + + +@admin.register(SalesWorkspace) +class SalesWorkspaceAdmin(admin.ModelAdmin): + list_display = ( + "customer_name", + "project_number", + "opportunity_title", + "stage", + "estimated_value", + "updated_at", + ) + list_filter = ("stage", "layout_template") + search_fields = ( + "customer_name", + "project_name", + "project_number", + "zipcode", + "address", + "opportunity_title", + ) + inlines = [QuoteLineInline, ActivityItemInline] + + +@admin.register(QuoteLine) +class QuoteLineAdmin(admin.ModelAdmin): + list_display = ("product_name", "workspace", "quantity", "unit_price", "created_at") + search_fields = ("product_name", "workspace__customer_name", "workspace__project_number") + + +@admin.register(ActivityItem) +class ActivityItemAdmin(admin.ModelAdmin): + list_display = ("title", "workspace", "activity_type", "due_at", "owner", "is_done") + list_filter = ("activity_type", "is_done") + search_fields = ("title", "workspace__customer_name", "owner") diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..21e02a9 --- /dev/null +++ b/core/forms.py @@ -0,0 +1,126 @@ +from datetime import timedelta + +from django import forms +from django.utils import timezone + +from .models import ActivityItem, QuoteLine, SalesWorkspace + + +class StyledFormMixin: + def _apply_styles(self): + for name, field in self.fields.items(): + widget = field.widget + css_class = "form-select" if isinstance(widget, forms.Select) else "form-control" + existing = widget.attrs.get("class", "") + widget.attrs["class"] = f"{existing} {css_class} workspace-input".strip() + + +class WorkspaceIntakeForm(StyledFormMixin, forms.ModelForm): + next_meeting_at = forms.DateTimeField( + required=False, + widget=forms.DateTimeInput(attrs={"type": "datetime-local"}), + ) + + class Meta: + model = SalesWorkspace + fields = [ + "customer_name", + "project_name", + "project_number", + "zipcode", + "address", + "city", + "contact_name", + "contact_email", + "contact_phone", + "opportunity_title", + "estimated_value", + "layout_template", + "summary", + "next_step", + "next_meeting_at", + ] + widgets = { + "summary": forms.Textarea(attrs={"rows": 3}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._apply_styles() + placeholders = { + "customer_name": "Van der Meer Construction", + "project_name": "Renovation of showroom", + "project_number": "PRJ-24018", + "zipcode": "3011 AA", + "address": "Binnenweg 18", + "city": "Rotterdam", + "contact_name": "Eva Jansen", + "contact_email": "eva@example.com", + "contact_phone": "+31 6 12345678", + "opportunity_title": "Lighting upgrade quotation", + "estimated_value": "18500", + "summary": "Existing customer requesting quick quote with site visit.", + "next_step": "Confirm site meeting and draft first quotation.", + } + for name, placeholder in placeholders.items(): + self.fields[name].widget.attrs.setdefault("placeholder", placeholder) + self.fields["layout_template"].widget.attrs.setdefault("aria-label", "Workspace template") + + def clean_next_meeting_at(self): + meeting = self.cleaned_data.get("next_meeting_at") + if meeting and meeting < timezone.now(): + raise forms.ValidationError("Choose a future time for the next meeting.") + return meeting + + +class StageUpdateForm(StyledFormMixin, forms.Form): + stage = forms.ChoiceField(choices=SalesWorkspace.Stage.choices) + + def __init__(self, *args, **kwargs): + current_stage = kwargs.pop("current_stage", None) + super().__init__(*args, **kwargs) + self._apply_styles() + if current_stage: + self.fields["stage"].initial = current_stage + + +class QuoteLineForm(StyledFormMixin, forms.ModelForm): + class Meta: + model = QuoteLine + fields = ["product_name", "description", "quantity", "unit_price"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._apply_styles() + self.fields["product_name"].widget.attrs.setdefault("placeholder", "Product or service") + self.fields["description"].widget.attrs.setdefault("placeholder", "Optional scope note") + self.fields["quantity"].widget.attrs.setdefault("min", 1) + self.fields["unit_price"].widget.attrs.setdefault("min", 0) + self.fields["unit_price"].widget.attrs.setdefault("step", "0.01") + + +class ActivityForm(StyledFormMixin, forms.ModelForm): + due_at = forms.DateTimeField( + widget=forms.DateTimeInput(attrs={"type": "datetime-local"}) + ) + + class Meta: + model = ActivityItem + fields = ["title", "activity_type", "due_at", "owner", "notes"] + widgets = { + "notes": forms.Textarea(attrs={"rows": 3}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._apply_styles() + self.fields["title"].widget.attrs.setdefault("placeholder", "Schedule follow-up or task") + self.fields["owner"].widget.attrs.setdefault("placeholder", "Rep or team owner") + self.fields["notes"].widget.attrs.setdefault("placeholder", "Talking points, reminders, dependencies") + self.fields["due_at"].initial = (timezone.now() + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M") + + def clean_due_at(self): + due_at = self.cleaned_data["due_at"] + if due_at < timezone.now() - timedelta(minutes=5): + raise forms.ValidationError("Activity time should be now or in the future.") + return due_at diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..f8bce9f --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# Generated by Django 5.2.7 on 2026-04-03 11:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SalesWorkspace', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('customer_name', models.CharField(max_length=160)), + ('project_name', models.CharField(blank=True, max_length=160)), + ('project_number', models.CharField(blank=True, max_length=60)), + ('zipcode', models.CharField(blank=True, max_length=20)), + ('address', models.CharField(blank=True, max_length=255)), + ('city', models.CharField(blank=True, max_length=120)), + ('contact_name', models.CharField(blank=True, max_length=160)), + ('contact_email', models.EmailField(blank=True, max_length=254)), + ('contact_phone', models.CharField(blank=True, max_length=40)), + ('opportunity_title', models.CharField(max_length=180)), + ('stage', models.CharField(choices=[('new', 'New lead'), ('qualified', 'Qualified'), ('proposal', 'Proposal / Quote'), ('negotiation', 'Negotiation'), ('won', 'Won'), ('lost', 'Lost')], default='new', max_length=20)), + ('estimated_value', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('layout_template', models.CharField(choices=[('customer360', 'Customer 360'), ('quotation_focus', 'Quotation focus'), ('planning', 'Planning board')], default='customer360', max_length=24)), + ('summary', models.TextField(blank=True)), + ('next_step', models.CharField(blank=True, max_length=180)), + ('next_meeting_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['-updated_at', '-created_at'], + }, + ), + migrations.CreateModel( + name='QuoteLine', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('product_name', models.CharField(max_length=140)), + ('description', models.CharField(blank=True, max_length=255)), + ('quantity', models.PositiveIntegerField(default=1)), + ('unit_price', models.DecimalField(decimal_places=2, max_digits=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quote_lines', to='core.salesworkspace')), + ], + options={ + 'ordering': ['created_at', 'id'], + }, + ), + migrations.CreateModel( + name='ActivityItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=180)), + ('activity_type', models.CharField(choices=[('call', 'Call'), ('meeting', 'Meeting'), ('email', 'Email'), ('task', 'Task')], max_length=20)), + ('due_at', models.DateTimeField()), + ('owner', models.CharField(blank=True, max_length=120)), + ('notes', models.TextField(blank=True)), + ('is_done', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='core.salesworkspace')), + ], + options={ + 'ordering': ['due_at', 'id'], + }, + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..749880c Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 71a8362..5b23dfb 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,110 @@ -from django.db import models +from decimal import Decimal -# Create your models here. +from django.db import models +from django.utils import timezone + + +class SalesWorkspace(models.Model): + class Stage(models.TextChoices): + NEW = "new", "New lead" + QUALIFIED = "qualified", "Qualified" + PROPOSAL = "proposal", "Proposal / Quote" + NEGOTIATION = "negotiation", "Negotiation" + WON = "won", "Won" + LOST = "lost", "Lost" + + class LayoutTemplate(models.TextChoices): + CUSTOMER_360 = "customer360", "Customer 360" + QUOTATION_FOCUS = "quotation_focus", "Quotation focus" + PLANNING = "planning", "Planning board" + + customer_name = models.CharField(max_length=160) + project_name = models.CharField(max_length=160, blank=True) + project_number = models.CharField(max_length=60, blank=True) + zipcode = models.CharField(max_length=20, blank=True) + address = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=120, blank=True) + contact_name = models.CharField(max_length=160, blank=True) + contact_email = models.EmailField(blank=True) + contact_phone = models.CharField(max_length=40, blank=True) + opportunity_title = models.CharField(max_length=180) + stage = models.CharField(max_length=20, choices=Stage.choices, default=Stage.NEW) + estimated_value = models.DecimalField(max_digits=12, decimal_places=2, default=0) + layout_template = models.CharField( + max_length=24, choices=LayoutTemplate.choices, default=LayoutTemplate.CUSTOMER_360 + ) + summary = models.TextField(blank=True) + next_step = models.CharField(max_length=180, blank=True) + next_meeting_at = models.DateTimeField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at", "-created_at"] + + def __str__(self): + project = f" · {self.project_number}" if self.project_number else "" + return f"{self.customer_name} — {self.opportunity_title}{project}" + + @property + def quote_total(self): + total = sum((line.subtotal for line in self.quote_lines.all()), Decimal("0.00")) + return total.quantize(Decimal("0.01")) if total else Decimal("0.00") + + @property + def open_activities_count(self): + return self.activities.filter(is_done=False).count() + + @property + def pipeline_label(self): + return self.get_stage_display() + + @property + def next_meeting_is_upcoming(self): + return bool(self.next_meeting_at and self.next_meeting_at >= timezone.now()) + + +class QuoteLine(models.Model): + workspace = models.ForeignKey( + SalesWorkspace, related_name="quote_lines", on_delete=models.CASCADE + ) + product_name = models.CharField(max_length=140) + description = models.CharField(max_length=255, blank=True) + quantity = models.PositiveIntegerField(default=1) + unit_price = models.DecimalField(max_digits=10, decimal_places=2) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at", "id"] + + def __str__(self): + return f"{self.product_name} × {self.quantity}" + + @property + def subtotal(self): + return (self.unit_price or Decimal("0.00")) * self.quantity + + +class ActivityItem(models.Model): + class ActivityType(models.TextChoices): + CALL = "call", "Call" + MEETING = "meeting", "Meeting" + EMAIL = "email", "Email" + TASK = "task", "Task" + + workspace = models.ForeignKey( + SalesWorkspace, related_name="activities", on_delete=models.CASCADE + ) + title = models.CharField(max_length=180) + activity_type = models.CharField(max_length=20, choices=ActivityType.choices) + due_at = models.DateTimeField() + owner = models.CharField(max_length=120, blank=True) + notes = models.TextField(blank=True) + is_done = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["due_at", "id"] + + def __str__(self): + return f"{self.title} ({self.get_activity_type_display()})" diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..0ca5cdf 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,25 +1,25 @@ +{% load static %} - - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} - - - - {% endif %} + + {% block title %}{{ meta_title|default:project_name|default:"FlowDesk Sales" }}{% endblock %} + {% if project_image_url %} {% endif %} - {% load static %} + + + + + {% block head %}{% endblock %} - {% block content %}{% endblock %} + - diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..53528cc 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,195 @@ {% extends "base.html" %} +{% load static %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} +{% block title %}{{ meta_title }}{% endblock %} {% block content %} -
-
-

Analyzing your requirements and generating your app…

-
- Loading… -
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
-
- -{% endblock %} \ No newline at end of file +
+ + +
+
+
+ Web-first sales cockpit +

Compact customer workspace built for lead → quote → won.

+

Default concept for your Odoo-connected UI: searchable customer/project context, persistent icon-only navigation, and dockable panels for Opportunities, Quotations, Projects, Agenda, and Tasks.

+ +
+ +
+ +
+ + Admin +
+ + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + +
+
+ Tracked workspaces + {{ workspace_count }} +

Customer or project contexts accessible from one search bar.

+
+
+ Active deals + {{ active_count }} +

Fast switching without leaving the main canvas.

+
+
+ Won + {{ won_count }} +

Deals ready to hand over into project delivery later.

+
+
+ Pipeline value + €{{ pipeline_total|floatformat:0 }} +

Compact overview for managers and sales reps.

+
+
+ +
+
+
+
+ Saved templates +

Pick the right focus instantly

+
+
+
+ {% for value, label in layout_choices %} +
+
+
+

{{ label }}

+

{% if value == 'customer360' %}See opportunity, agenda, projects, and quotation side by side.{% elif value == 'quotation_focus' %}Give most space to the quote editor when speed matters.{% else %}Keep projects and follow-ups visible during planning calls.{% endif %}

+
+
+ {% endfor %} +
+
+ +
+
+
+ First workflow widget +

Create a sales workspace

+
+ Lead intake → detail workspace +
+
+ {% csrf_token %} +
+ {% for field in create_form %} +
+ + {{ field }} + {% if field.errors %}
{{ field.errors|join:', ' }}
{% endif %} +
+ {% endfor %} +
+
+ +

This creates the customer/project context and opens the single-screen detail view.

+
+
+
+
+ +
+
+
+
+ Pipeline list +

Recent workspaces

+
+
+ {% if workspaces %} + + {% else %} +
+ +

No workspaces yet

+

Create the first customer/project context above to preview the one-screen layout.

+
+ {% endif %} +
+ +
+
+
+ Agenda snapshot +

Upcoming tasks

+
+
+ {% if upcoming_items %} +
+ {% for item in upcoming_items %} +
+
+
+ {{ item.title }} +

{{ item.workspace.customer_name }} · {{ item.get_activity_type_display }} · {{ item.due_at|date:"d M H:i" }}

+
+
+ {% endfor %} +
+ {% else %} +
+ +

Upcoming meetings and actions appear here after you create a workspace.

+
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/core/templates/core/workspace_detail.html b/core/templates/core/workspace_detail.html new file mode 100644 index 0000000..713fe9f --- /dev/null +++ b/core/templates/core/workspace_detail.html @@ -0,0 +1,236 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}{{ meta_title }}{% endblock %} + +{% block content %} +
+ + +
+
+
+ Back to pipeline +

{{ workspace.customer_name }}

+

{{ workspace.opportunity_title }}{% if workspace.project_number %} · {{ workspace.project_number }}{% elif workspace.project_name %} · {{ workspace.project_name }}{% endif %}

+
+
+ {{ workspace.get_stage_display }} +
€{{ workspace.estimated_value|floatformat:0 }}
+
+
+ + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + +
+
+
+ + +
+
+ Admin +
+ +
+
+
+
+ Opportunity +

Lead status

+
+ Single-screen control +
+
+
Customer{{ workspace.customer_name }}
+
Contact{{ workspace.contact_name|default:"Not added yet" }}
+
Email{{ workspace.contact_email|default:"Not added yet" }}
+
Phone{{ workspace.contact_phone|default:"Not added yet" }}
+
Next step{{ workspace.next_step|default:"Define the next best action" }}
+
+
+ {% csrf_token %} +
+ + {{ stage_form.stage }} +
+ +
+
{{ workspace.summary|default:"Add qualification notes, buying signals, and constraints here." }}
+
+ +
+
+
+ Projects +

Project context

+
+ +
+
+
Project{{ workspace.project_name|default:"Current customer record" }}
+
Project #{{ workspace.project_number|default:"To be assigned" }}
+
Zip code{{ workspace.zipcode|default:"—" }}
+
Address{{ workspace.address|default:"No address yet" }}
+
City{{ workspace.city|default:"—" }}
+
+
+ +
+
+
+ Quotations +

Quotation focus

+
+ Takes the most space when needed +
+
+ {% csrf_token %} +
+ {% for field in quote_form %} +
+ + {{ field }} + {% if field.errors %}
{{ field.errors|join:', ' }}
{% endif %} +
+ {% endfor %} +
+
+ +

Adding a quote line moves early leads into Proposal automatically.

+
+
+ + {% if quote_lines %} +
+ + + + + + + + + + + + {% for line in quote_lines %} + + + + + + + + {% endfor %} + +
ItemDescriptionQtyUnitSubtotal
{{ line.product_name }}{{ line.description|default:"—" }}{{ line.quantity }}€{{ line.unit_price|floatformat:2 }}€{{ line.subtotal|floatformat:2 }}
+
+
Quote total €{{ workspace.quote_total|floatformat:2 }}
+ {% else %} +
+ +

No quotation lines yet. Start with one service or product to preview the dense editor.

+
+ {% endif %} +
+ +
+
+
+ Agenda +

Meeting planning

+
+
+
+ {% if workspace.next_meeting_at %} +
+
+
+ Next meeting +

{{ workspace.next_meeting_at|date:"D d M · H:i" }}{% if workspace.next_meeting_is_upcoming %} · upcoming{% endif %}

+
+
+ {% endif %} + {% if upcoming_activities %} + {% for item in upcoming_activities|slice:":4" %} +
+
+
+ {{ item.title }} +

{{ item.get_activity_type_display }} · {{ item.due_at|date:"d M H:i" }}

+
+
+ {% endfor %} + {% else %} +
+ +

Add a call or meeting to keep the deal moving.

+
+ {% endif %} +
+
+ +
+
+
+ Activities / Tasks +

Schedule the next action

+
+
+
+ {% csrf_token %} +
+ {% for field in activity_form %} +
+ + {{ field }} + {% if field.errors %}
{{ field.errors|join:', ' }}
{% endif %} +
+ {% endfor %} +
+
+ +
+
+ + {% if activities %} +
+ {% for item in activities %} +
+
+ {{ item.title }} +

{{ item.get_activity_type_display }} · {{ item.due_at|date:"d M H:i" }}{% if item.owner %} · {{ item.owner }}{% endif %}

+
+ {% if item.is_done %}Done{% else %}Open{% endif %} +
+ {% endfor %} +
+ {% else %} +
+ +

No activities yet. Add one to make the workspace actionable.

+
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/core/tests.py b/core/tests.py index 7ce503c..eefa89e 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,3 +1,46 @@ from django.test import TestCase +from django.urls import reverse +from django.utils import timezone -# Create your tests here. +from .models import SalesWorkspace + + +class SalesWorkspaceFlowTests(TestCase): + def test_home_page_loads(self): + response = self.client.get(reverse("home")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Compact sales workspace") + + def test_create_workspace_redirects_to_detail(self): + payload = { + "workspace-customer_name": "Northwind BV", + "workspace-project_name": "Warehouse fit-out", + "workspace-project_number": "PRJ-001", + "workspace-zipcode": "1000 AA", + "workspace-address": "Harbor Street 10", + "workspace-city": "Amsterdam", + "workspace-contact_name": "Lotte", + "workspace-contact_email": "lotte@example.com", + "workspace-contact_phone": "+31000000", + "workspace-opportunity_title": "Prepare first quotation", + "workspace-estimated_value": "9800", + "workspace-layout_template": "customer360", + "workspace-summary": "Test summary", + "workspace-next_step": "Schedule visit", + "workspace-next_meeting_at": (timezone.now() + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M"), + } + response = self.client.post(reverse("home"), payload) + workspace = SalesWorkspace.objects.get(customer_name="Northwind BV") + self.assertRedirects(response, reverse("workspace_detail", args=[workspace.pk])) + self.assertEqual(workspace.stage, SalesWorkspace.Stage.NEW) + + def test_workspace_detail_shows_panels(self): + workspace = SalesWorkspace.objects.create( + customer_name="Northwind BV", + opportunity_title="Prepare first quotation", + estimated_value=10000, + ) + response = self.client.get(reverse("workspace_detail", args=[workspace.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Quotations") + self.assertContains(response, workspace.customer_name) diff --git a/core/urls.py b/core/urls.py index 6299e3d..2a3cfc5 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,11 @@ from django.urls import path -from .views import home +from .views import add_activity, add_quote_line, home, update_stage, workspace_detail urlpatterns = [ path("", home, name="home"), + path("workspaces//", workspace_detail, name="workspace_detail"), + path("workspaces//stage/", update_stage, name="update_stage"), + path("workspaces//quote/", add_quote_line, name="add_quote_line"), + path("workspaces//activity/", add_activity, name="add_activity"), ] diff --git a/core/views.py b/core/views.py index c9aed12..b291827 100644 --- a/core/views.py +++ b/core/views.py @@ -1,25 +1,148 @@ -import os -import platform - -from django import get_version as django_version -from django.shortcuts import render +from django.contrib import messages +from django.db.models import Q +from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone +from .forms import ActivityForm, QuoteLineForm, StageUpdateForm, WorkspaceIntakeForm +from .models import SalesWorkspace + + +def _base_shell_context(): + return { + "project_name": "FlowDesk Sales", + "project_tagline": "Compact sales workspace for fast lead-to-quote execution", + "meta_description": ( + "Single-screen sales workspace with compact navigation, opportunity tracking, " + "quotation drafting, projects, and agenda panels." + ), + "nav_items": [ + {"icon": "bi-grid-1x2-fill", "label": "Workspace", "url": "/"}, + {"icon": "bi-people-fill", "label": "Contacts", "url": "/"}, + {"icon": "bi-graph-up-arrow", "label": "Opportunities", "url": "/"}, + {"icon": "bi-receipt-cutoff", "label": "Quotations", "url": "/"}, + {"icon": "bi-kanban-fill", "label": "Projects", "url": "/"}, + {"icon": "bi-calendar3", "label": "Agenda", "url": "/"}, + {"icon": "bi-shield-lock-fill", "label": "Admin", "url": "/admin/"}, + ], + } + + +def _workspace_queryset(query=None): + queryset = SalesWorkspace.objects.prefetch_related("quote_lines", "activities") + if query: + queryset = queryset.filter( + Q(customer_name__icontains=query) + | Q(project_name__icontains=query) + | Q(project_number__icontains=query) + | Q(zipcode__icontains=query) + | Q(address__icontains=query) + | Q(opportunity_title__icontains=query) + ) + return queryset + def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() + query = request.GET.get("q", "").strip() + if request.method == "POST": + create_form = WorkspaceIntakeForm(request.POST, prefix="workspace") + if create_form.is_valid(): + workspace = create_form.save(commit=False) + workspace.stage = SalesWorkspace.Stage.NEW + workspace.save() + if workspace.next_meeting_at: + workspace.activities.create( + title="Initial meeting", + activity_type="meeting", + due_at=workspace.next_meeting_at, + owner=workspace.contact_name or "Sales", + notes="Auto-created from workspace intake.", + ) + messages.success(request, f"{workspace.customer_name} workspace created.") + return redirect("workspace_detail", pk=workspace.pk) + messages.error(request, "Please review the highlighted fields and try again.") + else: + create_form = WorkspaceIntakeForm(prefix="workspace") + + workspaces = list(_workspace_queryset(query)[:8]) + upcoming_items = [] + for workspace in workspaces: + upcoming_items.extend([item for item in workspace.activities.all() if not item.is_done]) + upcoming_items.sort(key=lambda item: item.due_at) + + all_workspaces = _workspace_queryset() + active_workspaces = [item for item in all_workspaces if item.stage not in {SalesWorkspace.Stage.WON, SalesWorkspace.Stage.LOST}] context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + **_base_shell_context(), + "meta_title": "FlowDesk Sales Workspace", + "search_query": query, + "create_form": create_form, + "workspaces": workspaces, + "workspace_count": all_workspaces.count(), + "active_count": len(active_workspaces), + "won_count": sum(1 for item in all_workspaces if item.stage == SalesWorkspace.Stage.WON), + "pipeline_total": sum((item.estimated_value for item in active_workspaces), 0), + "upcoming_items": upcoming_items[:5], + "stage_choices": SalesWorkspace.Stage.choices, + "layout_choices": SalesWorkspace.LayoutTemplate.choices, } return render(request, "core/index.html", context) + + +def workspace_detail(request, pk): + workspace = get_object_or_404(_workspace_queryset(), pk=pk) + context = { + **_base_shell_context(), + "meta_title": f"{workspace.customer_name} · Workspace", + "meta_description": f"Compact sales workspace for {workspace.customer_name} and project {workspace.project_name or workspace.project_number or workspace.opportunity_title}.", + "workspace": workspace, + "stage_form": StageUpdateForm(prefix="stage", current_stage=workspace.stage), + "quote_form": QuoteLineForm(prefix="quote"), + "activity_form": ActivityForm(prefix="activity"), + "quote_lines": workspace.quote_lines.all(), + "activities": workspace.activities.all(), + "upcoming_activities": [item for item in workspace.activities.all() if not item.is_done], + "completed_activities": [item for item in workspace.activities.all() if item.is_done], + } + return render(request, "core/workspace_detail.html", context) + + +def update_stage(request, pk): + workspace = get_object_or_404(SalesWorkspace, pk=pk) + form = StageUpdateForm(request.POST, prefix="stage", current_stage=workspace.stage) + if request.method == "POST" and form.is_valid(): + workspace.stage = form.cleaned_data["stage"] + workspace.save(update_fields=["stage", "updated_at"]) + messages.success(request, f"Stage updated to {workspace.get_stage_display()}.") + else: + messages.error(request, "Could not update the stage. Please choose a valid value.") + return redirect("workspace_detail", pk=workspace.pk) + + +def add_quote_line(request, pk): + workspace = get_object_or_404(SalesWorkspace, pk=pk) + form = QuoteLineForm(request.POST, prefix="quote") + if request.method == "POST" and form.is_valid(): + quote_line = form.save(commit=False) + quote_line.workspace = workspace + quote_line.save() + if workspace.stage in {SalesWorkspace.Stage.NEW, SalesWorkspace.Stage.QUALIFIED}: + workspace.stage = SalesWorkspace.Stage.PROPOSAL + workspace.save(update_fields=["stage", "updated_at"]) + messages.success(request, f"Added quote line: {quote_line.product_name}.") + else: + messages.error(request, "Please correct the quote line fields and try again.") + return redirect("workspace_detail", pk=workspace.pk) + + +def add_activity(request, pk): + workspace = get_object_or_404(SalesWorkspace, pk=pk) + form = ActivityForm(request.POST, prefix="activity") + if request.method == "POST" and form.is_valid(): + activity = form.save(commit=False) + activity.workspace = workspace + activity.save() + messages.success(request, f"Activity scheduled: {activity.title}.") + else: + messages.error(request, "Please correct the activity fields and try again.") + return redirect("workspace_detail", pk=workspace.pk) diff --git a/static/css/custom.css b/static/css/custom.css index 925f6ed..fc38fd5 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1,4 +1,932 @@ -/* Custom styles for the application */ -body { - font-family: system-ui, -apple-system, sans-serif; +/* FlowDesk Sales custom UI */ +:root { + --fd-bg: #edf4f6; + --fd-bg-deep: #0f172a; + --fd-surface: rgba(255, 255, 255, 0.84); + --fd-surface-strong: #ffffff; + --fd-text: #102133; + --fd-muted: #5f7187; + --fd-border: rgba(15, 23, 42, 0.08); + --fd-primary: #0f766e; + --fd-secondary: #14b8a6; + --fd-accent: #f97316; + --fd-accent-soft: rgba(249, 115, 22, 0.16); + --fd-warning: #f59e0b; + --fd-success: #10b981; + --fd-danger: #ef4444; + --fd-shadow: 0 30px 60px rgba(15, 23, 42, 0.12); + --fd-radius-xl: 28px; + --fd-radius-lg: 22px; + --fd-radius-md: 16px; + --fd-radius-sm: 12px; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: var(--fd-text); + background: + radial-gradient(circle at top left, rgba(20, 184, 166, 0.22), transparent 24%), + radial-gradient(circle at top right, rgba(249, 115, 22, 0.12), transparent 20%), + linear-gradient(160deg, #f8fbfc 0%, #edf4f6 45%, #e8eef4 100%); + overflow: hidden; +} + +a { + color: inherit; + text-decoration: none; +} + +h1, +h2, +h3, +h4, +.brand-mark__core { + font-family: 'Manrope', 'Inter', sans-serif; + letter-spacing: -0.03em; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 88px minmax(0, 1fr); +} + +.app-sidebar { + position: relative; + padding: 24px 18px; + background: rgba(10, 15, 25, 0.95); + display: flex; + flex-direction: column; + align-items: center; + gap: 28px; + border-right: 1px solid rgba(255, 255, 255, 0.06); +} + +.brand-mark { + width: 52px; + height: 52px; + border-radius: 18px; + display: grid; + place-items: center; + color: white; + background: linear-gradient(135deg, var(--fd-secondary), var(--fd-primary)); + box-shadow: 0 16px 30px rgba(20, 184, 166, 0.3); +} + +.brand-mark__core { + font-size: 1.35rem; + font-weight: 800; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + align-items: center; +} + +.sidebar-link { + width: 52px; + height: 52px; + border-radius: 16px; + display: grid; + place-items: center; + color: rgba(255, 255, 255, 0.76); + font-size: 1.1rem; + position: relative; + transition: transform 0.2s ease, background 0.2s ease, color 0.2s ease; +} + +.sidebar-link:hover, +.sidebar-link:focus-visible, +.sidebar-link.is-active { + color: #fff; + background: rgba(255, 255, 255, 0.1); + transform: translateY(-1px); + outline: none; +} + +.sidebar-link::after { + content: attr(data-label); + position: absolute; + left: 62px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(10, 15, 25, 0.94); + color: #fff; + font-size: 0.83rem; + line-height: 1; + opacity: 0; + transform: translateX(-6px); + pointer-events: none; + transition: 0.2s ease; + white-space: nowrap; +} + +.sidebar-link:hover::after, +.sidebar-link:focus-visible::after { + opacity: 1; + transform: translateX(0); +} + +.app-main { + padding: 24px 28px 28px; + overflow: auto; + display: grid; + gap: 20px; + align-content: start; +} + +.hero-panel, +.surface-card, +.search-card, +.stat-card, +.detail-topbar { + border: 1px solid var(--fd-border); + box-shadow: var(--fd-shadow); +} + +.hero-panel { + position: relative; + overflow: hidden; + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.9fr); + gap: 16px; + padding: 32px; + border-radius: var(--fd-radius-xl); + background: linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(10, 15, 25, 0.98)); + color: white; +} + +.hero-panel::before { + content: ''; + position: absolute; + inset: auto auto -60px -60px; + width: 220px; + height: 220px; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.22), transparent 64%); +} + +.hero-copy { + position: relative; + z-index: 1; +} + +.eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.78rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fd-secondary); + font-weight: 700; +} + +.hero-panel .eyebrow { + color: rgba(255, 255, 255, 0.72); +} + +.hero-copy h1, +.detail-topbar h1 { + margin: 10px 0 12px; + font-size: clamp(2rem, 3vw, 3.5rem); + line-height: 1.02; +} + +.hero-copy h1 span { + color: #a7f3d0; +} + +.hero-copy p, +.detail-topbar p, +.stat-card p, +.template-tile p, +.empty-state p, +.form-actions p, +.workspace-row__meta, +.agenda-item p, +.activity-row p { + color: rgba(255, 255, 255, 0.8); +} + +.hero-copy p { + max-width: 720px; + font-size: 1rem; + margin-bottom: 0; +} + +.hero-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 24px; +} + +.hero-visual { + position: relative; + min-height: 220px; +} + +.shape { + position: absolute; + border-radius: 28px; + backdrop-filter: blur(18px); +} + +.shape-sphere { + width: 142px; + height: 142px; + top: 6px; + right: 40px; + border-radius: 50%; + background: radial-gradient(circle at 32% 32%, rgba(255, 255, 255, 0.95), rgba(20, 184, 166, 0.18) 45%, rgba(255, 255, 255, 0.08) 72%); + box-shadow: inset -20px -20px 42px rgba(255, 255, 255, 0.06); +} + +.shape-card { + width: 240px; + height: 170px; + right: 20px; + bottom: 14px; + padding: 18px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.18); +} + +.mini-bar { + height: 14px; + width: 110px; + border-radius: 999px; + margin-bottom: 20px; + background: linear-gradient(90deg, rgba(255,255,255,0.9), rgba(255,255,255,0.35)); +} + +.mini-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.mini-grid span { + display: block; + height: 46px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.14); +} + +.shape-cylinder { + width: 84px; + height: 150px; + left: 20px; + bottom: 4px; + border-radius: 999px; + background: linear-gradient(180deg, rgba(249, 115, 22, 0.95), rgba(249, 115, 22, 0.16)); +} + +.top-toolbar, +.detail-toolbar { + display: flex; + gap: 14px; + align-items: center; + justify-content: space-between; +} + +.search-card { + display: flex; + gap: 14px; + align-items: center; + border-radius: var(--fd-radius-lg); + background: rgba(255, 255, 255, 0.72); + padding: 12px; + flex: 1; +} + +.search-card--static { + padding: 10px 12px; +} + +.search-field-wrap { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + padding: 0 10px; + border-radius: 14px; + background: rgba(15, 23, 42, 0.04); +} + +.search-field-wrap i { + color: var(--fd-muted); +} + +.search-field { + width: 100%; + border: 0; + padding: 12px 0; + background: transparent; + color: var(--fd-text); + font-size: 0.98rem; +} + +.search-field:focus { + outline: none; +} + +.admin-pill, +.section-chip, +.stage-pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 999px; + padding: 10px 14px; + font-size: 0.82rem; + font-weight: 700; +} + +.admin-pill { + background: rgba(15, 23, 42, 0.94); + color: #fff; +} + +.stats-grid, +.content-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 18px; +} + +.stat-card { + grid-column: span 3; + padding: 22px; + border-radius: 24px; + background: rgba(255, 255, 255, 0.72); +} + +.stat-card strong { + display: block; + margin: 10px 0 8px; + font-size: 2rem; + font-family: 'Manrope', sans-serif; +} + +.stat-label { + color: var(--fd-muted); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.stat-card p, +.template-tile p, +.empty-state p, +.form-actions p, +.workspace-row__meta, +.agenda-item p, +.activity-row p, +.detail-topbar p, +.info-row span, +.summary-box { + color: var(--fd-muted); +} + +.stat-card--accent { + background: linear-gradient(135deg, rgba(249, 115, 22, 0.96), rgba(255, 149, 60, 0.94)); + color: white; +} + +.stat-card--accent .stat-label, +.stat-card--accent p { + color: rgba(255, 255, 255, 0.85); +} + +.surface-card { + padding: 24px; + border-radius: var(--fd-radius-xl); + background: var(--fd-surface); + backdrop-filter: blur(22px); +} + +.template-card-list { + grid-column: span 4; +} + +.create-card { + grid-column: span 8; +} + +.list-card { + grid-column: span 7; +} + +.agenda-card { + grid-column: span 5; +} + +.section-heading, +.panel-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 18px; +} + +.section-heading h2, +.panel-head h2 { + margin: 6px 0 0; + font-size: 1.5rem; +} + +.section-chip { + background: rgba(20, 184, 166, 0.12); + color: var(--fd-primary); +} + +.template-list, +.agenda-list, +.activity-feed { + display: grid; + gap: 12px; +} + +.template-tile, +.agenda-item, +.activity-row, +.workspace-row { + display: flex; + gap: 14px; + align-items: center; + justify-content: space-between; + border-radius: 18px; + padding: 16px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.template-icon, +.agenda-icon { + width: 44px; + height: 44px; + border-radius: 14px; + display: grid; + place-items: center; + background: rgba(20, 184, 166, 0.12); + color: var(--fd-primary); + flex-shrink: 0; +} + +.workspace-form, +.compact-form { + display: grid; + gap: 18px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 14px; +} + +.form-grid--quote .form-field { + grid-column: span 3; +} + +.form-field { + grid-column: span 4; + display: grid; + gap: 8px; +} + +.form-field--wide { + grid-column: span 6; +} + +.form-field--full { + grid-column: 1 / -1; +} + +.form-field label, +.compact-form__field label { + font-size: 0.84rem; + font-weight: 700; + color: var(--fd-text); +} + +.workspace-input { + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 14px; + padding: 0.76rem 0.95rem; + background: rgba(255, 255, 255, 0.96); + color: var(--fd-text); +} + +.workspace-input:focus { + border-color: rgba(20, 184, 166, 0.8); + box-shadow: 0 0 0 0.24rem rgba(20, 184, 166, 0.14); +} + +.field-error { + color: var(--fd-danger); + font-size: 0.82rem; +} + +.form-actions { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + flex-wrap: wrap; +} + +.form-actions--inline { + justify-content: flex-start; +} + +.btn { + border-radius: 14px; + border: none; + padding: 0.85rem 1.2rem; + font-weight: 700; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.btn:hover, +.btn:focus-visible { + transform: translateY(-1px); +} + +.btn-accent { + color: white; + background: linear-gradient(135deg, var(--fd-accent), #fb923c); + box-shadow: 0 16px 24px rgba(249, 115, 22, 0.24); +} + +.btn-ghost { + color: white; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); +} + +.btn-dark-shell { + color: white; + background: linear-gradient(135deg, #172033, #0f172a); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.18); +} + +.workspace-list { + display: grid; + gap: 12px; +} + +.workspace-row__title { + font-weight: 800; + font-size: 1.02rem; +} + +.workspace-row__side { + display: grid; + justify-items: end; + gap: 8px; + flex-shrink: 0; +} + +.empty-state { + min-height: 220px; + display: grid; + place-items: center; + text-align: center; + gap: 8px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.58); + border: 1px dashed rgba(15, 23, 42, 0.14); + padding: 20px; +} + +.empty-state i { + font-size: 1.7rem; + color: var(--fd-primary); +} + +.empty-state--small { + min-height: 120px; +} + +.message-stack { + display: grid; + gap: 10px; +} + +.custom-alert { + margin: 0; + border-radius: 16px; + border: 0; + padding: 14px 16px; +} + +.alert-success { + background: rgba(16, 185, 129, 0.14); + color: #0d7a55; +} + +.alert-error { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; +} + +.detail-topbar { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: end; + padding: 24px 28px; + border-radius: var(--fd-radius-xl); + background: rgba(255, 255, 255, 0.72); +} + +.back-link { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--fd-primary); + font-weight: 700; +} + +.detail-meta { + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-end; +} + +.detail-value { + font-family: 'Manrope', sans-serif; + font-size: 2.1rem; + font-weight: 800; +} + +.workspace-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 18px; +} + +.panel { + min-height: 260px; + overflow: hidden; +} + +.panel-opportunity { + grid-column: span 4; +} + +.panel-projects { + grid-column: span 3; +} + +.panel-quote { + grid-column: span 5; +} + +.panel-agenda { + grid-column: span 4; +} + +.panel-activities { + grid-column: span 8; +} + +.template-quotation_focus .panel-quote { + grid-column: span 8; +} + +.template-quotation_focus .panel-projects { + grid-column: span 4; +} + +.template-planning .panel-agenda { + grid-column: span 5; +} + +.template-planning .panel-activities { + grid-column: span 7; +} + +.info-stack { + display: grid; + gap: 10px; +} + +.info-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.72); +} + +.info-row strong { + text-align: right; +} + +.summary-box { + border-radius: 18px; + background: rgba(15, 118, 110, 0.08); + padding: 16px; +} + +.compact-form--inline { + grid-template-columns: 1fr auto; + align-items: end; +} + +.compact-form__field { + display: grid; + gap: 8px; +} + +.panel-action { + border: 0; + background: rgba(15, 23, 42, 0.04); + border-radius: 999px; + padding: 8px 12px; + color: var(--fd-muted); + font-weight: 700; +} + +.quote-table-wrap { + overflow: auto; + border-radius: 18px; + border: 1px solid rgba(15, 23, 42, 0.08); +} + +.quote-table { + margin: 0; +} + +.quote-table thead th { + background: rgba(15, 23, 42, 0.03); + color: var(--fd-muted); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + border-bottom: 0; +} + +.quote-table tbody td { + vertical-align: middle; + color: var(--fd-text); + border-color: rgba(15, 23, 42, 0.06); +} + +.quote-total { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 4px 0; + font-weight: 700; +} + +.activity-row { + align-items: flex-start; +} + +.stage-pill { + padding: 8px 12px; +} + +.stage-new { + background: rgba(20, 184, 166, 0.12); + color: var(--fd-primary); +} + +.stage-qualified { + background: rgba(14, 165, 233, 0.14); + color: #0369a1; +} + +.stage-proposal { + background: rgba(245, 158, 11, 0.18); + color: #b45309; +} + +.stage-negotiation { + background: rgba(249, 115, 22, 0.18); + color: #c2410c; +} + +.stage-won { + background: rgba(16, 185, 129, 0.14); + color: #047857; +} + +.stage-lost { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; +} + +@media (max-width: 1199px) { + body { + overflow: auto; + } + + .stats-grid .stat-card, + .template-card-list, + .create-card, + .list-card, + .agenda-card, + .panel-opportunity, + .panel-projects, + .panel-quote, + .panel-agenda, + .panel-activities { + grid-column: span 12; + } +} + +@media (max-width: 991px) { + .app-shell { + grid-template-columns: 1fr; + } + + .app-sidebar { + position: sticky; + top: 0; + z-index: 20; + flex-direction: row; + justify-content: space-between; + padding: 16px 20px; + } + + .sidebar-nav { + flex-direction: row; + justify-content: center; + overflow: auto; + } + + .sidebar-link::after { + display: none; + } + + .hero-panel, + .detail-topbar { + grid-template-columns: 1fr; + } + + .top-toolbar, + .detail-toolbar, + .search-card, + .form-actions, + .compact-form--inline, + .detail-topbar { + flex-direction: column; + align-items: stretch; + } + + .form-field, + .form-field--wide, + .form-field--full, + .form-grid--quote .form-field { + grid-column: span 12; + } + + .app-main { + padding: 18px; + } +} + +@media (max-width: 575px) { + .hero-panel, + .surface-card, + .detail-topbar { + padding: 20px; + border-radius: 22px; + } + + .hero-copy h1, + .detail-topbar h1 { + font-size: 1.85rem; + } + + .sidebar-link { + width: 46px; + height: 46px; + } } diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css index 108056f..fc38fd5 100644 --- a/staticfiles/css/custom.css +++ b/staticfiles/css/custom.css @@ -1,21 +1,932 @@ - +/* FlowDesk Sales custom UI */ :root { - --bg-color-start: #6a11cb; - --bg-color-end: #2575fc; - --text-color: #ffffff; - --card-bg-color: rgba(255, 255, 255, 0.01); - --card-border-color: rgba(255, 255, 255, 0.1); + --fd-bg: #edf4f6; + --fd-bg-deep: #0f172a; + --fd-surface: rgba(255, 255, 255, 0.84); + --fd-surface-strong: #ffffff; + --fd-text: #102133; + --fd-muted: #5f7187; + --fd-border: rgba(15, 23, 42, 0.08); + --fd-primary: #0f766e; + --fd-secondary: #14b8a6; + --fd-accent: #f97316; + --fd-accent-soft: rgba(249, 115, 22, 0.16); + --fd-warning: #f59e0b; + --fd-success: #10b981; + --fd-danger: #ef4444; + --fd-shadow: 0 30px 60px rgba(15, 23, 42, 0.12); + --fd-radius-xl: 28px; + --fd-radius-lg: 22px; + --fd-radius-md: 16px; + --fd-radius-sm: 12px; } + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + body { margin: 0; - font-family: 'Inter', sans-serif; - background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); - color: var(--text-color); - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - text-align: center; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: var(--fd-text); + background: + radial-gradient(circle at top left, rgba(20, 184, 166, 0.22), transparent 24%), + radial-gradient(circle at top right, rgba(249, 115, 22, 0.12), transparent 20%), + linear-gradient(160deg, #f8fbfc 0%, #edf4f6 45%, #e8eef4 100%); overflow: hidden; - position: relative; +} + +a { + color: inherit; + text-decoration: none; +} + +h1, +h2, +h3, +h4, +.brand-mark__core { + font-family: 'Manrope', 'Inter', sans-serif; + letter-spacing: -0.03em; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 88px minmax(0, 1fr); +} + +.app-sidebar { + position: relative; + padding: 24px 18px; + background: rgba(10, 15, 25, 0.95); + display: flex; + flex-direction: column; + align-items: center; + gap: 28px; + border-right: 1px solid rgba(255, 255, 255, 0.06); +} + +.brand-mark { + width: 52px; + height: 52px; + border-radius: 18px; + display: grid; + place-items: center; + color: white; + background: linear-gradient(135deg, var(--fd-secondary), var(--fd-primary)); + box-shadow: 0 16px 30px rgba(20, 184, 166, 0.3); +} + +.brand-mark__core { + font-size: 1.35rem; + font-weight: 800; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + align-items: center; +} + +.sidebar-link { + width: 52px; + height: 52px; + border-radius: 16px; + display: grid; + place-items: center; + color: rgba(255, 255, 255, 0.76); + font-size: 1.1rem; + position: relative; + transition: transform 0.2s ease, background 0.2s ease, color 0.2s ease; +} + +.sidebar-link:hover, +.sidebar-link:focus-visible, +.sidebar-link.is-active { + color: #fff; + background: rgba(255, 255, 255, 0.1); + transform: translateY(-1px); + outline: none; +} + +.sidebar-link::after { + content: attr(data-label); + position: absolute; + left: 62px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(10, 15, 25, 0.94); + color: #fff; + font-size: 0.83rem; + line-height: 1; + opacity: 0; + transform: translateX(-6px); + pointer-events: none; + transition: 0.2s ease; + white-space: nowrap; +} + +.sidebar-link:hover::after, +.sidebar-link:focus-visible::after { + opacity: 1; + transform: translateX(0); +} + +.app-main { + padding: 24px 28px 28px; + overflow: auto; + display: grid; + gap: 20px; + align-content: start; +} + +.hero-panel, +.surface-card, +.search-card, +.stat-card, +.detail-topbar { + border: 1px solid var(--fd-border); + box-shadow: var(--fd-shadow); +} + +.hero-panel { + position: relative; + overflow: hidden; + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.9fr); + gap: 16px; + padding: 32px; + border-radius: var(--fd-radius-xl); + background: linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(10, 15, 25, 0.98)); + color: white; +} + +.hero-panel::before { + content: ''; + position: absolute; + inset: auto auto -60px -60px; + width: 220px; + height: 220px; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.22), transparent 64%); +} + +.hero-copy { + position: relative; + z-index: 1; +} + +.eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.78rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--fd-secondary); + font-weight: 700; +} + +.hero-panel .eyebrow { + color: rgba(255, 255, 255, 0.72); +} + +.hero-copy h1, +.detail-topbar h1 { + margin: 10px 0 12px; + font-size: clamp(2rem, 3vw, 3.5rem); + line-height: 1.02; +} + +.hero-copy h1 span { + color: #a7f3d0; +} + +.hero-copy p, +.detail-topbar p, +.stat-card p, +.template-tile p, +.empty-state p, +.form-actions p, +.workspace-row__meta, +.agenda-item p, +.activity-row p { + color: rgba(255, 255, 255, 0.8); +} + +.hero-copy p { + max-width: 720px; + font-size: 1rem; + margin-bottom: 0; +} + +.hero-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 24px; +} + +.hero-visual { + position: relative; + min-height: 220px; +} + +.shape { + position: absolute; + border-radius: 28px; + backdrop-filter: blur(18px); +} + +.shape-sphere { + width: 142px; + height: 142px; + top: 6px; + right: 40px; + border-radius: 50%; + background: radial-gradient(circle at 32% 32%, rgba(255, 255, 255, 0.95), rgba(20, 184, 166, 0.18) 45%, rgba(255, 255, 255, 0.08) 72%); + box-shadow: inset -20px -20px 42px rgba(255, 255, 255, 0.06); +} + +.shape-card { + width: 240px; + height: 170px; + right: 20px; + bottom: 14px; + padding: 18px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.18); +} + +.mini-bar { + height: 14px; + width: 110px; + border-radius: 999px; + margin-bottom: 20px; + background: linear-gradient(90deg, rgba(255,255,255,0.9), rgba(255,255,255,0.35)); +} + +.mini-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.mini-grid span { + display: block; + height: 46px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.14); +} + +.shape-cylinder { + width: 84px; + height: 150px; + left: 20px; + bottom: 4px; + border-radius: 999px; + background: linear-gradient(180deg, rgba(249, 115, 22, 0.95), rgba(249, 115, 22, 0.16)); +} + +.top-toolbar, +.detail-toolbar { + display: flex; + gap: 14px; + align-items: center; + justify-content: space-between; +} + +.search-card { + display: flex; + gap: 14px; + align-items: center; + border-radius: var(--fd-radius-lg); + background: rgba(255, 255, 255, 0.72); + padding: 12px; + flex: 1; +} + +.search-card--static { + padding: 10px 12px; +} + +.search-field-wrap { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + padding: 0 10px; + border-radius: 14px; + background: rgba(15, 23, 42, 0.04); +} + +.search-field-wrap i { + color: var(--fd-muted); +} + +.search-field { + width: 100%; + border: 0; + padding: 12px 0; + background: transparent; + color: var(--fd-text); + font-size: 0.98rem; +} + +.search-field:focus { + outline: none; +} + +.admin-pill, +.section-chip, +.stage-pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 999px; + padding: 10px 14px; + font-size: 0.82rem; + font-weight: 700; +} + +.admin-pill { + background: rgba(15, 23, 42, 0.94); + color: #fff; +} + +.stats-grid, +.content-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 18px; +} + +.stat-card { + grid-column: span 3; + padding: 22px; + border-radius: 24px; + background: rgba(255, 255, 255, 0.72); +} + +.stat-card strong { + display: block; + margin: 10px 0 8px; + font-size: 2rem; + font-family: 'Manrope', sans-serif; +} + +.stat-label { + color: var(--fd-muted); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.stat-card p, +.template-tile p, +.empty-state p, +.form-actions p, +.workspace-row__meta, +.agenda-item p, +.activity-row p, +.detail-topbar p, +.info-row span, +.summary-box { + color: var(--fd-muted); +} + +.stat-card--accent { + background: linear-gradient(135deg, rgba(249, 115, 22, 0.96), rgba(255, 149, 60, 0.94)); + color: white; +} + +.stat-card--accent .stat-label, +.stat-card--accent p { + color: rgba(255, 255, 255, 0.85); +} + +.surface-card { + padding: 24px; + border-radius: var(--fd-radius-xl); + background: var(--fd-surface); + backdrop-filter: blur(22px); +} + +.template-card-list { + grid-column: span 4; +} + +.create-card { + grid-column: span 8; +} + +.list-card { + grid-column: span 7; +} + +.agenda-card { + grid-column: span 5; +} + +.section-heading, +.panel-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 18px; +} + +.section-heading h2, +.panel-head h2 { + margin: 6px 0 0; + font-size: 1.5rem; +} + +.section-chip { + background: rgba(20, 184, 166, 0.12); + color: var(--fd-primary); +} + +.template-list, +.agenda-list, +.activity-feed { + display: grid; + gap: 12px; +} + +.template-tile, +.agenda-item, +.activity-row, +.workspace-row { + display: flex; + gap: 14px; + align-items: center; + justify-content: space-between; + border-radius: 18px; + padding: 16px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.template-icon, +.agenda-icon { + width: 44px; + height: 44px; + border-radius: 14px; + display: grid; + place-items: center; + background: rgba(20, 184, 166, 0.12); + color: var(--fd-primary); + flex-shrink: 0; +} + +.workspace-form, +.compact-form { + display: grid; + gap: 18px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 14px; +} + +.form-grid--quote .form-field { + grid-column: span 3; +} + +.form-field { + grid-column: span 4; + display: grid; + gap: 8px; +} + +.form-field--wide { + grid-column: span 6; +} + +.form-field--full { + grid-column: 1 / -1; +} + +.form-field label, +.compact-form__field label { + font-size: 0.84rem; + font-weight: 700; + color: var(--fd-text); +} + +.workspace-input { + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 14px; + padding: 0.76rem 0.95rem; + background: rgba(255, 255, 255, 0.96); + color: var(--fd-text); +} + +.workspace-input:focus { + border-color: rgba(20, 184, 166, 0.8); + box-shadow: 0 0 0 0.24rem rgba(20, 184, 166, 0.14); +} + +.field-error { + color: var(--fd-danger); + font-size: 0.82rem; +} + +.form-actions { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + flex-wrap: wrap; +} + +.form-actions--inline { + justify-content: flex-start; +} + +.btn { + border-radius: 14px; + border: none; + padding: 0.85rem 1.2rem; + font-weight: 700; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.btn:hover, +.btn:focus-visible { + transform: translateY(-1px); +} + +.btn-accent { + color: white; + background: linear-gradient(135deg, var(--fd-accent), #fb923c); + box-shadow: 0 16px 24px rgba(249, 115, 22, 0.24); +} + +.btn-ghost { + color: white; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); +} + +.btn-dark-shell { + color: white; + background: linear-gradient(135deg, #172033, #0f172a); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.18); +} + +.workspace-list { + display: grid; + gap: 12px; +} + +.workspace-row__title { + font-weight: 800; + font-size: 1.02rem; +} + +.workspace-row__side { + display: grid; + justify-items: end; + gap: 8px; + flex-shrink: 0; +} + +.empty-state { + min-height: 220px; + display: grid; + place-items: center; + text-align: center; + gap: 8px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.58); + border: 1px dashed rgba(15, 23, 42, 0.14); + padding: 20px; +} + +.empty-state i { + font-size: 1.7rem; + color: var(--fd-primary); +} + +.empty-state--small { + min-height: 120px; +} + +.message-stack { + display: grid; + gap: 10px; +} + +.custom-alert { + margin: 0; + border-radius: 16px; + border: 0; + padding: 14px 16px; +} + +.alert-success { + background: rgba(16, 185, 129, 0.14); + color: #0d7a55; +} + +.alert-error { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; +} + +.detail-topbar { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: end; + padding: 24px 28px; + border-radius: var(--fd-radius-xl); + background: rgba(255, 255, 255, 0.72); +} + +.back-link { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--fd-primary); + font-weight: 700; +} + +.detail-meta { + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-end; +} + +.detail-value { + font-family: 'Manrope', sans-serif; + font-size: 2.1rem; + font-weight: 800; +} + +.workspace-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 18px; +} + +.panel { + min-height: 260px; + overflow: hidden; +} + +.panel-opportunity { + grid-column: span 4; +} + +.panel-projects { + grid-column: span 3; +} + +.panel-quote { + grid-column: span 5; +} + +.panel-agenda { + grid-column: span 4; +} + +.panel-activities { + grid-column: span 8; +} + +.template-quotation_focus .panel-quote { + grid-column: span 8; +} + +.template-quotation_focus .panel-projects { + grid-column: span 4; +} + +.template-planning .panel-agenda { + grid-column: span 5; +} + +.template-planning .panel-activities { + grid-column: span 7; +} + +.info-stack { + display: grid; + gap: 10px; +} + +.info-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.72); +} + +.info-row strong { + text-align: right; +} + +.summary-box { + border-radius: 18px; + background: rgba(15, 118, 110, 0.08); + padding: 16px; +} + +.compact-form--inline { + grid-template-columns: 1fr auto; + align-items: end; +} + +.compact-form__field { + display: grid; + gap: 8px; +} + +.panel-action { + border: 0; + background: rgba(15, 23, 42, 0.04); + border-radius: 999px; + padding: 8px 12px; + color: var(--fd-muted); + font-weight: 700; +} + +.quote-table-wrap { + overflow: auto; + border-radius: 18px; + border: 1px solid rgba(15, 23, 42, 0.08); +} + +.quote-table { + margin: 0; +} + +.quote-table thead th { + background: rgba(15, 23, 42, 0.03); + color: var(--fd-muted); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + border-bottom: 0; +} + +.quote-table tbody td { + vertical-align: middle; + color: var(--fd-text); + border-color: rgba(15, 23, 42, 0.06); +} + +.quote-total { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 4px 0; + font-weight: 700; +} + +.activity-row { + align-items: flex-start; +} + +.stage-pill { + padding: 8px 12px; +} + +.stage-new { + background: rgba(20, 184, 166, 0.12); + color: var(--fd-primary); +} + +.stage-qualified { + background: rgba(14, 165, 233, 0.14); + color: #0369a1; +} + +.stage-proposal { + background: rgba(245, 158, 11, 0.18); + color: #b45309; +} + +.stage-negotiation { + background: rgba(249, 115, 22, 0.18); + color: #c2410c; +} + +.stage-won { + background: rgba(16, 185, 129, 0.14); + color: #047857; +} + +.stage-lost { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; +} + +@media (max-width: 1199px) { + body { + overflow: auto; + } + + .stats-grid .stat-card, + .template-card-list, + .create-card, + .list-card, + .agenda-card, + .panel-opportunity, + .panel-projects, + .panel-quote, + .panel-agenda, + .panel-activities { + grid-column: span 12; + } +} + +@media (max-width: 991px) { + .app-shell { + grid-template-columns: 1fr; + } + + .app-sidebar { + position: sticky; + top: 0; + z-index: 20; + flex-direction: row; + justify-content: space-between; + padding: 16px 20px; + } + + .sidebar-nav { + flex-direction: row; + justify-content: center; + overflow: auto; + } + + .sidebar-link::after { + display: none; + } + + .hero-panel, + .detail-topbar { + grid-template-columns: 1fr; + } + + .top-toolbar, + .detail-toolbar, + .search-card, + .form-actions, + .compact-form--inline, + .detail-topbar { + flex-direction: column; + align-items: stretch; + } + + .form-field, + .form-field--wide, + .form-field--full, + .form-grid--quote .form-field { + grid-column: span 12; + } + + .app-main { + padding: 18px; + } +} + +@media (max-width: 575px) { + .hero-panel, + .surface-card, + .detail-topbar { + padding: 20px; + border-radius: 22px; + } + + .hero-copy h1, + .detail-topbar h1 { + font-size: 1.85rem; + } + + .sidebar-link { + width: 46px; + height: 46px; + } }