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" }}
-
-
-
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
-
-{% 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.
+
+
+
+
+
+
+
+ {% 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
+
+
+
+
+
+
+
+
+
+ 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 %}
+
+
+
+
+ {% if messages %}
+
+ {% for message in messages %}
+ {{ message }}
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
+
+
+
+ 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" }}
+
+
+ {{ workspace.summary|default:"Add qualification notes, buying signals, and constraints here." }}
+
+
+
+
+
+ Projects
+
Project context
+
+
Collapse
+
+
+
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
+
+
+
+ {% if quote_lines %}
+
+
+
+
+ Item
+ Description
+ Qty
+ Unit
+ Subtotal
+
+
+
+ {% for line in quote_lines %}
+
+ {{ line.product_name }}
+ {{ line.description|default:"—" }}
+ {{ line.quantity }}
+ €{{ line.unit_price|floatformat:2 }}
+ €{{ line.subtotal|floatformat:2 }}
+
+ {% endfor %}
+
+
+
+ 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
+
+
+
+
+ {% 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;
+ }
}