From b1fdffc1cc578f32b50c78ec93ee03477458f80e Mon Sep 17 00:00:00 2001 From: Ashwin Date: Tue, 9 Dec 2025 16:26:17 +0530 Subject: [PATCH] feat: Implement enterprise constraints, update models, serializers, views, and enhance UI components across the application. --- backend/accounts/migrations/0001_initial.py | 1 - backend/accounts/migrations/0002_initial.py | 1 - .../migrations/0003_add_tags_related_name.py | 1 - .../0004_remove_phonenumberfield.py | 1 - .../migrations/0005_add_currency_fields.py | 1 - .../0006_add_enterprise_constraints.py | 27 +- backend/accounts/serializer.py | 10 +- backend/accounts/tasks.py | 2 - backend/accounts/views.py | 47 +- backend/cases/kanban_views.py | 4 +- backend/cases/migrations/0001_initial.py | 1 - backend/cases/migrations/0002_initial.py | 1 - .../0003_fix_contacts_related_name.py | 1 - .../cases/migrations/0004_add_tags_field.py | 1 - .../migrations/0005_add_tags_related_name.py | 1 - .../migrations/0006_fix_case_type_default.py | 1 - ...eated_by_alter_case_updated_by_and_more.py | 1 - .../0008_add_enterprise_constraints.py | 1 - backend/cases/migrations/0009_case_kanban.py | 286 +- backend/cases/models.py | 4 +- backend/cases/views.py | 227 +- backend/common/duplicate_detection.py | 7 +- .../common/management/commands/manage_rls.py | 14 +- .../common/management/commands/seed_data.py | 60 +- backend/common/migrations/0001_initial.py | 9 +- .../0003_add_org_to_commentfiles.py | 1 - .../0004_populate_commentfiles_org_and_rls.py | 1 - .../migrations/0005_increase_apikey_length.py | 1 - .../0006_remove_phonenumberfield.py | 1 - .../migrations/0007_add_currency_fields.py | 1 - .../0009_add_org_company_profile.py | 1 - ...0012_add_tag_color_description_isactive.py | 1 - backend/common/models.py | 1 - backend/common/permissions.py | 2 - backend/common/serializer.py | 15 +- backend/common/utils.py | 603 ++- backend/common/validators.py | 5 +- backend/common/views/auth_views.py | 49 +- backend/common/views/dashboard_views.py | 96 +- backend/common/views/document_views.py | 97 +- backend/common/views/organization_views.py | 29 +- backend/common/views/settings_views.py | 87 +- backend/common/views/team_views.py | 83 +- backend/common/views/user_views.py | 122 +- backend/contacts/migrations/0001_initial.py | 1 - .../0002_change_phone_to_charfield.py | 1 - .../migrations/0003_add_account_fk.py | 1 - .../0004_change_phone_to_phonenumberfield.py | 1 - ...05_rename_linked_in_url_to_linkedin_url.py | 1 - .../migrations/0006_add_tags_related_name.py | 1 - .../0007_remove_organization_index.py | 1 - .../0008_change_phone_to_charfield.py | 1 - ...act_created_by_alter_contact_updated_by.py | 1 - .../0010_add_enterprise_constraints.py | 27 +- backend/contacts/views.py | 36 +- backend/crm/server_settings.py | 4 +- backend/invoices/admin.py | 9 +- backend/invoices/api_views.py | 292 +- backend/invoices/migrations/0001_initial.py | 9 +- .../0002_remove_phonenumberfield.py | 1 - .../migrations/0003_add_currency_fields.py | 1 - ...neitem_invoicetemplate_payment_and_more.py | 2918 ++++++++++--- ...ling_period_po_number_and_lineitem_name.py | 1 - .../0006_remove_legacy_invoice_fields.py | 1 - .../migrations/0007_add_cancelled_at_field.py | 11 +- backend/invoices/models.py | 46 +- backend/invoices/pdf.py | 80 +- backend/invoices/public_views.py | 18 +- backend/invoices/seed.py | 236 +- backend/invoices/serializer.py | 31 +- backend/invoices/tasks.py | 77 +- backend/invoices/tests.py | 10 - backend/invoices/tests_celery_tasks.py | 4 +- backend/leads/migrations/0001_initial.py | 5 +- .../0002_add_converted_account_fk.py | 1 - .../0003_add_full_conversion_fields.py | 1 - .../migrations/0004_remove_company_model.py | 5 +- .../0005_rename_title_to_salutation.py | 1 - .../migrations/0006_add_tags_related_name.py | 1 - .../leads/migrations/0007_add_title_field.py | 1 - .../migrations/0008_remove_unused_fields.py | 1 - .../0009_change_phone_to_charfield.py | 1 - .../migrations/0010_add_currency_fields.py | 1 - .../0011_add_enterprise_constraints.py | 49 +- .../migrations/0012_lead_kanban_pipeline.py | 289 +- backend/leads/models.py | 26 +- backend/leads/serializer.py | 4 +- backend/leads/services.py | 10 +- backend/leads/urls.py | 8 +- backend/leads/views/kanban_views.py | 4 +- backend/leads/views/lead_interactions.py | 8 +- backend/leads/views/lead_views.py | 72 +- .../opportunity/migrations/0001_initial.py | 1 - .../migrations/0002_add_tags_related_name.py | 1 - .../0003_alter_opportunity_assigned_to.py | 1 - ...4_alter_opportunity_created_by_and_more.py | 1 - .../0005_add_enterprise_constraints.py | 5 +- .../0006_add_opportunity_line_item.py | 11 +- ...opportunity_opportu_456e8f_idx_and_more.py | 40 +- backend/opportunity/urls.py | 5 +- backend/opportunity/views/line_item_views.py | 48 +- .../opportunity/views/opportunity_views.py | 34 +- backend/ruff.toml | 10 + backend/tasks/celery_tasks.py | 4 +- backend/tasks/migrations/0001_initial.py | 1 - .../0002_add_opportunity_case_lead_fks.py | 1 - .../0003_fix_contacts_related_name.py | 1 - .../tasks/migrations/0004_add_tags_field.py | 1 - .../migrations/0005_add_tags_related_name.py | 1 - .../migrations/0006_alter_task_assigned_to.py | 1 - ...ated_by_alter_board_updated_by_and_more.py | 1 - .../0008_add_enterprise_constraints.py | 1 - backend/tasks/migrations/0009_task_kanban.py | 287 +- ...ban_idx_task_status_e6f973_idx_and_more.py | 1 - backend/tasks/models.py | 6 +- backend/tasks/serializer.py | 16 +- backend/tasks/urls.py | 20 +- backend/tasks/views/kanban_views.py | 8 +- backend/tasks/views/task_views.py | 32 +- frontend/.prettierrc | 27 +- frontend/components.json | 28 +- frontend/eslint.config.js | 28 +- frontend/jsconfig.json | 40 +- frontend/package.json | 110 +- frontend/src/app.css | 407 +- frontend/src/app.d.ts | 40 +- frontend/src/app.html | 52 +- frontend/src/hooks.server.js | 342 +- frontend/src/lib/api-helpers.js | 282 +- frontend/src/lib/api.js | 600 +-- .../components/dashboard/ActivityFeed.svelte | 313 +- .../lib/components/dashboard/FocusBar.svelte | 139 +- .../components/dashboard/HotLeadsPanel.svelte | 260 +- .../lib/components/dashboard/KPICard.svelte | 154 +- .../components/dashboard/MiniPipeline.svelte | 96 +- .../dashboard/OpportunitiesTable.svelte | 274 +- .../components/dashboard/PipelineChart.svelte | 118 +- .../lib/components/dashboard/TaskList.svelte | 369 +- .../invoices/InvoiceViewPage.svelte | 1132 ++--- .../src/lib/components/layout/AppShell.svelte | 52 +- .../lib/components/layout/AppSidebar.svelte | 854 ++-- .../components/layout/FilterPopover.svelte | 68 +- .../lib/components/layout/PageHeader.svelte | 44 +- .../alert-dialog/alert-dialog-action.svelte | 30 +- .../alert-dialog/alert-dialog-cancel.svelte | 30 +- .../alert-dialog/alert-dialog-content.svelte | 62 +- .../alert-dialog-description.svelte | 28 +- .../alert-dialog/alert-dialog-footer.svelte | 30 +- .../alert-dialog/alert-dialog-header.svelte | 30 +- .../alert-dialog/alert-dialog-overlay.svelte | 34 +- .../ui/alert-dialog/alert-dialog-title.svelte | 28 +- .../alert-dialog/alert-dialog-trigger.svelte | 4 +- .../lib/components/ui/alert-dialog/index.js | 46 +- .../ui/avatar/avatar-fallback.svelte | 14 +- .../components/ui/avatar/avatar-image.svelte | 14 +- .../lib/components/ui/avatar/avatar.svelte | 50 +- .../src/lib/components/ui/avatar/index.js | 14 +- .../src/lib/components/ui/badge/badge.svelte | 86 +- .../lib/components/ui/button/button.svelte | 150 +- .../src/lib/components/ui/button/index.js | 8 +- .../components/ui/calendar/Calendar.svelte | 240 +- .../components/ui/card/card-content.svelte | 22 +- .../ui/card/card-description.svelte | 14 +- .../lib/components/ui/card/card-header.svelte | 20 +- .../lib/components/ui/card/card-title.svelte | 14 +- .../src/lib/components/ui/card/card.svelte | 36 +- frontend/src/lib/components/ui/card/index.js | 22 +- .../components/ui/case-kanban/CaseCard.svelte | 280 +- .../ui/case-kanban/CaseKanban.svelte | 104 +- .../ui/collapsible/collapsible-content.svelte | 4 +- .../ui/collapsible/collapsible-trigger.svelte | 4 +- .../ui/collapsible/collapsible.svelte | 4 +- .../lib/components/ui/collapsible/index.js | 14 +- .../components/ui/crm-drawer/CrmDrawer.svelte | 616 +-- .../ui/crm-drawer/CrmPropertyRow.svelte | 612 +-- .../components/ui/crm-table/CrmTable.svelte | 795 ++-- .../components/ui/dialog/dialog-close.svelte | 6 +- .../ui/dialog/dialog-content.svelte | 68 +- .../ui/dialog/dialog-description.svelte | 20 +- .../components/ui/dialog/dialog-footer.svelte | 21 +- .../components/ui/dialog/dialog-header.svelte | 21 +- .../ui/dialog/dialog-overlay.svelte | 26 +- .../components/ui/dialog/dialog-portal.svelte | 6 +- .../components/ui/dialog/dialog-title.svelte | 20 +- .../ui/dialog/dialog-trigger.svelte | 6 +- .../lib/components/ui/dialog/dialog.svelte | 6 +- .../src/lib/components/ui/dialog/index.js | 64 +- .../dropdown-menu-checkbox-group.svelte | 12 +- .../dropdown-menu-checkbox-item.svelte | 62 +- .../dropdown-menu-content.svelte | 62 +- .../dropdown-menu/dropdown-menu-group.svelte | 4 +- .../dropdown-menu/dropdown-menu-item.svelte | 56 +- .../dropdown-menu/dropdown-menu-label.svelte | 34 +- .../dropdown-menu-separator.svelte | 28 +- .../dropdown-menu-trigger.svelte | 4 +- .../lib/components/ui/dropdown-menu/index.js | 40 +- .../editable-field/EditableMultiSelect.svelte | 258 +- .../ui/filter/DateRangeFilter.svelte | 436 +- .../lib/components/ui/filter/FilterBar.svelte | 160 +- .../components/ui/filter/SearchInput.svelte | 158 +- .../components/ui/filter/SelectFilter.svelte | 352 +- .../lib/components/ui/filter/TagFilter.svelte | 276 +- frontend/src/lib/components/ui/input/index.js | 6 +- .../src/lib/components/ui/input/input.svelte | 96 +- .../components/ui/kanban/KanbanBoard.svelte | 508 ++- .../components/ui/kanban/KanbanColumn.svelte | 290 +- frontend/src/lib/components/ui/label/index.js | 6 +- .../src/lib/components/ui/label/label.svelte | 20 +- .../components/ui/lead-kanban/LeadCard.svelte | 315 +- .../ui/lead-kanban/LeadKanban.svelte | 104 +- .../ui/pagination/Pagination.svelte | 245 +- .../src/lib/components/ui/popover/index.js | 26 +- .../ui/popover/popover-content.svelte | 64 +- .../ui/popover/popover-trigger.svelte | 20 +- .../src/lib/components/ui/progress/index.js | 10 +- .../components/ui/progress/progress.svelte | 36 +- .../src/lib/components/ui/select/index.js | 70 +- .../ui/select/select-content.svelte | 72 +- .../ui/select/select-group-heading.svelte | 23 +- .../components/ui/select/select-group.svelte | 6 +- .../components/ui/select/select-item.svelte | 64 +- .../components/ui/select/select-label.svelte | 21 +- .../components/ui/select/select-portal.svelte | 6 +- .../select/select-scroll-down-button.svelte | 24 +- .../ui/select/select-scroll-up-button.svelte | 24 +- .../ui/select/select-separator.svelte | 20 +- .../ui/select/select-trigger.svelte | 42 +- .../lib/components/ui/select/select.svelte | 16 +- .../src/lib/components/ui/separator/index.js | 10 +- .../components/ui/separator/separator.svelte | 32 +- frontend/src/lib/components/ui/sheet/index.js | 30 +- .../components/ui/sheet/sheet-content.svelte | 94 +- .../ui/sheet/sheet-description.svelte | 30 +- .../components/ui/sheet/sheet-header.svelte | 30 +- .../components/ui/sheet/sheet-overlay.svelte | 34 +- .../components/ui/sheet/sheet-title.svelte | 30 +- .../components/ui/sidebar/context.svelte.js | 62 +- .../src/lib/components/ui/sidebar/index.js | 68 +- .../ui/sidebar/sidebar-content.svelte | 38 +- .../ui/sidebar/sidebar-footer.svelte | 32 +- .../ui/sidebar/sidebar-group-content.svelte | 32 +- .../ui/sidebar/sidebar-group-label.svelte | 50 +- .../ui/sidebar/sidebar-group.svelte | 32 +- .../ui/sidebar/sidebar-header.svelte | 32 +- .../ui/sidebar/sidebar-inset.svelte | 22 +- .../ui/sidebar/sidebar-menu-button.svelte | 174 +- .../ui/sidebar/sidebar-menu-item.svelte | 32 +- .../ui/sidebar/sidebar-menu-sub-button.svelte | 80 +- .../ui/sidebar/sidebar-menu-sub-item.svelte | 32 +- .../ui/sidebar/sidebar-menu-sub.svelte | 40 +- .../components/ui/sidebar/sidebar-menu.svelte | 32 +- .../ui/sidebar/sidebar-provider.svelte | 78 +- .../components/ui/sidebar/sidebar-rail.svelte | 60 +- .../lib/components/ui/sidebar/sidebar.svelte | 196 +- .../src/lib/components/ui/skeleton/index.js | 6 +- .../components/ui/skeleton/skeleton.svelte | 12 +- .../lib/components/ui/sonner/sonner.svelte | 14 +- frontend/src/lib/components/ui/table/index.js | 26 +- .../lib/components/ui/table/table-body.svelte | 30 +- .../lib/components/ui/table/table-cell.svelte | 36 +- .../lib/components/ui/table/table-head.svelte | 36 +- .../components/ui/table/table-header.svelte | 30 +- .../lib/components/ui/table/table-row.svelte | 36 +- .../src/lib/components/ui/table/table.svelte | 36 +- frontend/src/lib/components/ui/tabs/index.js | 28 +- .../components/ui/tabs/tabs-content.svelte | 23 +- .../lib/components/ui/tabs/tabs-list.svelte | 26 +- .../components/ui/tabs/tabs-trigger.svelte | 29 +- .../src/lib/components/ui/tabs/tabs.svelte | 23 +- .../components/ui/task-kanban/TaskCard.svelte | 278 +- .../ui/task-kanban/TaskKanban.svelte | 104 +- .../src/lib/components/ui/textarea/index.js | 10 +- .../components/ui/textarea/textarea.svelte | 34 +- .../src/lib/components/ui/tooltip/index.js | 22 +- .../ui/tooltip/tooltip-content.svelte | 104 +- .../ui/tooltip/tooltip-trigger.svelte | 4 +- .../ui/view-toggle/ViewToggle.svelte | 88 +- .../src/lib/components/users/TeamCard.svelte | 244 +- .../components/users/TeamFormDialog.svelte | 271 +- frontend/src/lib/constants/colors.js | 184 +- frontend/src/lib/constants/countries.js | 498 +-- frontend/src/lib/constants/filters.js | 174 +- frontend/src/lib/constants/lead-choices.js | 238 +- frontend/src/lib/hooks/is-mobile.svelte.js | 6 +- .../src/lib/hooks/use-drawer-state.svelte.js | 316 +- .../src/lib/hooks/use-list-filters.svelte.js | 326 +- frontend/src/lib/stores/org.js | 16 +- frontend/src/lib/utils.js | 2 +- frontend/src/lib/utils/drag-drop.js | 68 +- frontend/src/lib/utils/formatting.js | 112 +- frontend/src/lib/utils/phone.js | 228 +- frontend/src/lib/utils/pkce.js | 28 +- frontend/src/lib/utils/table-helpers.js | 314 +- frontend/src/lib/utils/ui-helpers.js | 133 +- frontend/src/routes/(app)/+layout.server.js | 20 +- frontend/src/routes/(app)/+layout.svelte | 28 +- frontend/src/routes/(app)/+page.server.js | 252 +- frontend/src/routes/(app)/+page.svelte | 252 +- .../src/routes/(app)/accounts/+page.server.js | 744 ++-- .../src/routes/(app)/accounts/+page.svelte | 2246 +++++----- .../src/routes/(app)/cases/+page.server.js | 897 ++-- frontend/src/routes/(app)/cases/+page.svelte | 2244 +++++----- .../src/routes/(app)/contacts/+page.server.js | 602 +-- .../src/routes/(app)/contacts/+page.svelte | 1878 ++++----- .../src/routes/(app)/invoices/+page.server.js | 186 +- .../src/routes/(app)/invoices/+page.svelte | 838 ++-- .../(app)/invoices/[id]/+page.server.js | 630 +-- .../routes/(app)/invoices/[id]/+page.svelte | 1432 ++++--- .../(app)/invoices/estimates/+page.server.js | 850 ++-- .../(app)/invoices/estimates/+page.svelte | 1888 +++++---- .../routes/(app)/invoices/new/+page.server.js | 325 +- .../routes/(app)/invoices/new/+page.svelte | 1116 +++-- .../(app)/invoices/products/+page.server.js | 294 +- .../(app)/invoices/products/+page.svelte | 983 +++-- .../(app)/invoices/recurring/+page.server.js | 566 +-- .../(app)/invoices/recurring/+page.svelte | 1504 +++---- .../(app)/invoices/reports/+page.server.js | 72 +- .../(app)/invoices/reports/+page.svelte | 732 ++-- .../(app)/invoices/templates/+page.server.js | 274 +- .../(app)/invoices/templates/+page.svelte | 860 ++-- .../src/routes/(app)/leads/+page.server.js | 1317 +++--- frontend/src/routes/(app)/leads/+page.svelte | 3709 +++++++++-------- .../(app)/opportunities/+page.server.js | 1018 ++--- .../routes/(app)/opportunities/+page.svelte | 3053 +++++++------- .../src/routes/(app)/profile/+page.server.js | 180 +- .../src/routes/(app)/profile/+page.svelte | 618 +-- .../settings/organization/+page.server.js | 60 +- .../(app)/settings/organization/+page.svelte | 402 +- .../(app)/settings/tags/+page.server.js | 152 +- .../routes/(app)/settings/tags/+page.svelte | 516 +-- .../src/routes/(app)/support/+page.svelte | 466 +-- .../src/routes/(app)/tasks/+page.server.js | 836 ++-- frontend/src/routes/(app)/tasks/+page.svelte | 3168 +++++++------- .../tasks/board/[boardId]/+page.server.js | 404 +- .../(app)/tasks/board/[boardId]/+page.svelte | 960 ++--- .../(app)/tasks/calendar/+page.server.js | 4 +- .../routes/(app)/tasks/calendar/+page.svelte | 4 +- .../src/routes/(app)/users/+page.server.js | 668 +-- frontend/src/routes/(app)/users/+page.svelte | 1007 ++--- .../routes/(no-layout)/bounce/+page.svelte | 12 +- .../routes/(no-layout)/login/+page.server.js | 228 +- .../src/routes/(no-layout)/login/+page.svelte | 447 +- .../routes/(no-layout)/logout/+page.server.js | 32 +- .../routes/(no-layout)/org/+page.server.js | 180 +- .../src/routes/(no-layout)/org/+page.svelte | 248 +- .../(no-layout)/org/new/+page.server.js | 160 +- .../routes/(no-layout)/org/new/+page.svelte | 242 +- .../portal/estimate/[token]/+page.server.js | 182 +- .../portal/estimate/[token]/+page.svelte | 648 ++- .../portal/invoice/[token]/+page.server.js | 112 +- .../portal/invoice/[token]/+page.svelte | 481 +-- frontend/static/funding.json | 230 +- frontend/tailwind.config.ts | 2 +- frontend/vite.config.js | 2 +- 354 files changed, 36654 insertions(+), 32764 deletions(-) create mode 100644 backend/ruff.toml diff --git a/backend/accounts/migrations/0001_initial.py b/backend/accounts/migrations/0001_initial.py index 7e053909..d3ad3b6c 100644 --- a/backend/accounts/migrations/0001_initial.py +++ b/backend/accounts/migrations/0001_initial.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [] diff --git a/backend/accounts/migrations/0002_initial.py b/backend/accounts/migrations/0002_initial.py index cd492c60..ab3d278b 100644 --- a/backend/accounts/migrations/0002_initial.py +++ b/backend/accounts/migrations/0002_initial.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/backend/accounts/migrations/0003_add_tags_related_name.py b/backend/accounts/migrations/0003_add_tags_related_name.py index 2db99fd3..23c1d3ba 100644 --- a/backend/accounts/migrations/0003_add_tags_related_name.py +++ b/backend/accounts/migrations/0003_add_tags_related_name.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("accounts", "0002_initial"), ("common", "0002_enable_rls"), diff --git a/backend/accounts/migrations/0004_remove_phonenumberfield.py b/backend/accounts/migrations/0004_remove_phonenumberfield.py index d08accc5..2703bbed 100644 --- a/backend/accounts/migrations/0004_remove_phonenumberfield.py +++ b/backend/accounts/migrations/0004_remove_phonenumberfield.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("accounts", "0003_add_tags_related_name"), ] diff --git a/backend/accounts/migrations/0005_add_currency_fields.py b/backend/accounts/migrations/0005_add_currency_fields.py index 03a5e448..adec8b74 100644 --- a/backend/accounts/migrations/0005_add_currency_fields.py +++ b/backend/accounts/migrations/0005_add_currency_fields.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("accounts", "0004_remove_phonenumberfield"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/backend/accounts/migrations/0006_add_enterprise_constraints.py b/backend/accounts/migrations/0006_add_enterprise_constraints.py index dc40dfac..8d4d9bbe 100644 --- a/backend/accounts/migrations/0006_add_enterprise_constraints.py +++ b/backend/accounts/migrations/0006_add_enterprise_constraints.py @@ -13,25 +13,23 @@ def cleanup_duplicate_account_names(apps, schema_editor): Find duplicate account names (case-insensitive) within each org and append a numeric suffix to make them unique. """ - Account = apps.get_model('accounts', 'Account') + Account = apps.get_model("accounts", "Account") # Find duplicates: group by lower(name) + org, having count > 1 duplicates = ( - Account.objects - .annotate(name_lower=Lower('name')) - .values('name_lower', 'org_id') - .annotate(count=Count('id')) + Account.objects.annotate(name_lower=Lower("name")) + .values("name_lower", "org_id") + .annotate(count=Count("id")) .filter(count__gt=1) ) for dup in duplicates: # Get all accounts with this name in this org, ordered by created_at accounts = list( - Account.objects - .filter(org_id=dup['org_id']) - .annotate(name_lower=Lower('name')) - .filter(name_lower=dup['name_lower']) - .order_by('created_at') + Account.objects.filter(org_id=dup["org_id"]) + .annotate(name_lower=Lower("name")) + .filter(name_lower=dup["name_lower"]) + .order_by("created_at") ) # Keep the first one (oldest), rename the rest @@ -40,17 +38,19 @@ def cleanup_duplicate_account_names(apps, schema_editor): new_name = f"{account.name} ({i})" # Ensure the new name is also unique counter = i - while Account.objects.filter(org_id=account.org_id, name__iexact=new_name).exists(): + while Account.objects.filter( + org_id=account.org_id, name__iexact=new_name + ).exists(): counter += 1 new_name = f"{original_name} ({counter})" account.name = new_name - account.save(update_fields=['name']) + account.save(update_fields=["name"]) print(f" Renamed duplicate account: '{original_name}' -> '{new_name}'") def cleanup_negative_revenue(apps, schema_editor): """Set negative annual_revenue values to NULL.""" - Account = apps.get_model('accounts', 'Account') + Account = apps.get_model("accounts", "Account") updated = Account.objects.filter(annual_revenue__lt=0).update(annual_revenue=None) if updated: print(f" Set {updated} negative annual_revenue values to NULL") @@ -62,7 +62,6 @@ def reverse_noop(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("accounts", "0005_add_currency_fields"), ("common", "0008_enable_rls_product_invoice_line_item"), diff --git a/backend/accounts/serializer.py b/backend/accounts/serializer.py index 69cd0090..4b2d46dc 100644 --- a/backend/accounts/serializer.py +++ b/backend/accounts/serializer.py @@ -50,7 +50,15 @@ class AccountSerializer(serializers.ModelSerializer): @extend_schema_field(list) def get_opportunities(self, obj): """Return opportunities linked to this account""" - return [{"id": str(o.id), "name": o.name, "stage": o.stage, "amount": str(o.amount) if o.amount else "0"} for o in obj.opportunities.all()] + return [ + { + "id": str(o.id), + "name": o.name, + "stage": o.stage, + "amount": str(o.amount) if o.amount else "0", + } + for o in obj.opportunities.all() + ] class Meta: model = Account diff --git a/backend/accounts/tasks.py b/backend/accounts/tasks.py index 9e97f194..2acb9f6f 100644 --- a/backend/accounts/tasks.py +++ b/backend/accounts/tasks.py @@ -85,5 +85,3 @@ def send_email_to_assigned_user(recipients, account_id, org_id): msg = EmailMessage(subject, html_content, to=recipients_list) msg.content_subtype = "html" msg.send() - - diff --git a/backend/accounts/views.py b/backend/accounts/views.py index 627e7f74..68ec4a82 100644 --- a/backend/accounts/views.py +++ b/backend/accounts/views.py @@ -85,9 +85,13 @@ class AccountsListView(APIView, LimitOffsetPagination): if params.get("search"): queryset = queryset.filter(name__icontains=params.get("search")) if params.get("created_at__gte"): - queryset = queryset.filter(created_at__gte=params.get("created_at__gte")) + queryset = queryset.filter( + created_at__gte=params.get("created_at__gte") + ) if params.get("created_at__lte"): - queryset = queryset.filter(created_at__lte=params.get("created_at__lte")) + queryset = queryset.filter( + created_at__lte=params.get("created_at__lte") + ) context = {} @@ -187,19 +191,25 @@ class AccountsListView(APIView, LimitOffsetPagination): # Handle M2M relationships using utilities handle_m2m_assignment( - account_object, 'contacts', data.get('contacts'), - Contact, request.profile.org + account_object, + "contacts", + data.get("contacts"), + Contact, + request.profile.org, ) - tags = get_or_create_tags(data.get('tags'), request.profile.org) + tags = get_or_create_tags(data.get("tags"), request.profile.org) if tags: account_object.tags.add(*tags) handle_m2m_assignment( - account_object, 'teams', data.get('teams'), - Teams, request.profile.org + account_object, "teams", data.get("teams"), Teams, request.profile.org ) handle_m2m_assignment( - account_object, 'assigned_to', data.get('assigned_to'), - Profile, request.profile.org, extra_filters={'is_active': True} + account_object, + "assigned_to", + data.get("assigned_to"), + Profile, + request.profile.org, + extra_filters={"is_active": True}, ) # Handle attachment @@ -207,7 +217,7 @@ class AccountsListView(APIView, LimitOffsetPagination): create_attachment( request.FILES.get("account_attachment"), account_object, - request.profile + request.profile, ) recipients = list( @@ -286,7 +296,9 @@ class AccountDetailView(APIView): account_object.tags.clear() if data.get("tags"): tags = json.loads(data.get("tags")) - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) account_object.tags.add(*tag_objs) account_object.teams.clear() @@ -546,7 +558,10 @@ class AccountDetailView(APIView): account_object = self.get_object(pk=pk) if account_object.org != request.profile.org: return Response( - {"error": True, "errors": "User company does not match with header...."}, + { + "error": True, + "errors": "User company does not match with header....", + }, status=status.HTTP_403_FORBIDDEN, ) if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin: @@ -591,7 +606,9 @@ class AccountDetailView(APIView): if tags: if isinstance(tags, str): tags = json.loads(tags) - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) account_object.tags.add(*tag_objs) if "teams" in data: @@ -600,7 +617,9 @@ class AccountDetailView(APIView): if teams_list: if isinstance(teams_list, str): teams_list = json.loads(teams_list) - teams = Teams.objects.filter(id__in=teams_list, org=request.profile.org) + teams = Teams.objects.filter( + id__in=teams_list, org=request.profile.org + ) account_object.teams.add(*teams) if "assigned_to" in data: diff --git a/backend/cases/kanban_views.py b/backend/cases/kanban_views.py index 612ac4e4..84a76803 100644 --- a/backend/cases/kanban_views.py +++ b/backend/cases/kanban_views.py @@ -534,9 +534,7 @@ class CaseStageCreateView(APIView): status=status.HTTP_400_BAD_REQUEST, ) - stage = serializer.save( - pipeline=pipeline, org=org, created_by=request.user - ) + stage = serializer.save(pipeline=pipeline, org=org, created_by=request.user) return Response(CaseStageSerializer(stage).data, status=status.HTTP_201_CREATED) diff --git a/backend/cases/migrations/0001_initial.py b/backend/cases/migrations/0001_initial.py index 10a0a713..7b63ce1a 100644 --- a/backend/cases/migrations/0001_initial.py +++ b/backend/cases/migrations/0001_initial.py @@ -7,7 +7,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/backend/cases/migrations/0002_initial.py b/backend/cases/migrations/0002_initial.py index cdb26a7f..a360734b 100644 --- a/backend/cases/migrations/0002_initial.py +++ b/backend/cases/migrations/0002_initial.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/backend/cases/migrations/0003_fix_contacts_related_name.py b/backend/cases/migrations/0003_fix_contacts_related_name.py index b8f2ad4d..ae2adeee 100644 --- a/backend/cases/migrations/0003_fix_contacts_related_name.py +++ b/backend/cases/migrations/0003_fix_contacts_related_name.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("cases", "0002_initial"), ("contacts", "0004_change_phone_to_phonenumberfield"), diff --git a/backend/cases/migrations/0004_add_tags_field.py b/backend/cases/migrations/0004_add_tags_field.py index b8634760..19522479 100644 --- a/backend/cases/migrations/0004_add_tags_field.py +++ b/backend/cases/migrations/0004_add_tags_field.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("cases", "0003_fix_contacts_related_name"), ("common", "0002_enable_rls"), diff --git a/backend/cases/migrations/0005_add_tags_related_name.py b/backend/cases/migrations/0005_add_tags_related_name.py index 1421e264..c49a1890 100644 --- a/backend/cases/migrations/0005_add_tags_related_name.py +++ b/backend/cases/migrations/0005_add_tags_related_name.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("cases", "0004_add_tags_field"), ("common", "0002_enable_rls"), diff --git a/backend/cases/migrations/0006_fix_case_type_default.py b/backend/cases/migrations/0006_fix_case_type_default.py index f07c0c7b..17f151d0 100644 --- a/backend/cases/migrations/0006_fix_case_type_default.py +++ b/backend/cases/migrations/0006_fix_case_type_default.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("cases", "0005_add_tags_related_name"), ] diff --git a/backend/cases/migrations/0007_alter_case_created_by_alter_case_updated_by_and_more.py b/backend/cases/migrations/0007_alter_case_created_by_alter_case_updated_by_and_more.py index 94fce94e..770e28a2 100644 --- a/backend/cases/migrations/0007_alter_case_created_by_alter_case_updated_by_and_more.py +++ b/backend/cases/migrations/0007_alter_case_created_by_alter_case_updated_by_and_more.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("cases", "0006_fix_case_type_default"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/backend/cases/migrations/0008_add_enterprise_constraints.py b/backend/cases/migrations/0008_add_enterprise_constraints.py index 8d08254b..d890a74e 100644 --- a/backend/cases/migrations/0008_add_enterprise_constraints.py +++ b/backend/cases/migrations/0008_add_enterprise_constraints.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("cases", "0007_alter_case_created_by_alter_case_updated_by_and_more"), ] diff --git a/backend/cases/migrations/0009_case_kanban.py b/backend/cases/migrations/0009_case_kanban.py index 6f3df617..7fc3cb71 100644 --- a/backend/cases/migrations/0009_case_kanban.py +++ b/backend/cases/migrations/0009_case_kanban.py @@ -11,95 +11,267 @@ class Migration(migrations.Migration): atomic = False dependencies = [ - ('accounts', '0006_add_enterprise_constraints'), - ('cases', '0008_add_enterprise_constraints'), - ('common', '0012_add_tag_color_description_isactive'), - ('contacts', '0010_add_enterprise_constraints'), + ("accounts", "0006_add_enterprise_constraints"), + ("cases", "0008_add_enterprise_constraints"), + ("common", "0012_add_tag_color_description_isactive"), + ("contacts", "0010_add_enterprise_constraints"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='case', - name='kanban_order', - field=models.DecimalField(decimal_places=6, default=0, help_text='Order within the kanban column for drag-drop positioning', max_digits=15, verbose_name='Kanban Order'), + model_name="case", + name="kanban_order", + field=models.DecimalField( + decimal_places=6, + default=0, + help_text="Order within the kanban column for drag-drop positioning", + max_digits=15, + verbose_name="Kanban Order", + ), ), migrations.CreateModel( - name='CasePipeline', + name="CasePipeline", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='Pipeline Name')), - ('description', models.TextField(blank=True, null=True, verbose_name='Description')), - ('is_default', models.BooleanField(default=False, help_text='If true, new cases without explicit pipeline go here')), - ('is_active', models.BooleanField(default=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='case_pipelines', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="Pipeline Name"), + ), + ( + "description", + models.TextField(blank=True, null=True, verbose_name="Description"), + ), + ( + "is_default", + models.BooleanField( + default=False, + help_text="If true, new cases without explicit pipeline go here", + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="case_pipelines", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Case Pipeline', - 'verbose_name_plural': 'Case Pipelines', - 'db_table': 'case_pipeline', - 'ordering': ('-is_default', 'name'), + "verbose_name": "Case Pipeline", + "verbose_name_plural": "Case Pipelines", + "db_table": "case_pipeline", + "ordering": ("-is_default", "name"), }, ), migrations.CreateModel( - name='CaseStage', + name="CaseStage", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=100, verbose_name='Stage Name')), - ('order', models.PositiveIntegerField(default=0)), - ('color', models.CharField(default='#6B7280', max_length=7)), - ('stage_type', models.CharField(choices=[('open', 'Open'), ('closed', 'Closed'), ('rejected', 'Rejected')], default='open', max_length=20)), - ('maps_to_status', models.CharField(blank=True, choices=[('New', 'New'), ('Assigned', 'Assigned'), ('Pending', 'Pending'), ('Closed', 'Closed'), ('Rejected', 'Rejected'), ('Duplicate', 'Duplicate')], help_text='When case enters this stage, also update Case.status', max_length=64, null=True, verbose_name='Maps to Status')), - ('wip_limit', models.PositiveIntegerField(blank=True, help_text='Maximum cases allowed in this stage (null = unlimited)', null=True, verbose_name='WIP Limit')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='case_stages', to='common.org')), - ('pipeline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='cases.casepipeline')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=100, verbose_name="Stage Name")), + ("order", models.PositiveIntegerField(default=0)), + ("color", models.CharField(default="#6B7280", max_length=7)), + ( + "stage_type", + models.CharField( + choices=[ + ("open", "Open"), + ("closed", "Closed"), + ("rejected", "Rejected"), + ], + default="open", + max_length=20, + ), + ), + ( + "maps_to_status", + models.CharField( + blank=True, + choices=[ + ("New", "New"), + ("Assigned", "Assigned"), + ("Pending", "Pending"), + ("Closed", "Closed"), + ("Rejected", "Rejected"), + ("Duplicate", "Duplicate"), + ], + help_text="When case enters this stage, also update Case.status", + max_length=64, + null=True, + verbose_name="Maps to Status", + ), + ), + ( + "wip_limit", + models.PositiveIntegerField( + blank=True, + help_text="Maximum cases allowed in this stage (null = unlimited)", + null=True, + verbose_name="WIP Limit", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="case_stages", + to="common.org", + ), + ), + ( + "pipeline", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="stages", + to="cases.casepipeline", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Case Stage', - 'verbose_name_plural': 'Case Stages', - 'db_table': 'case_stage', - 'ordering': ('order',), + "verbose_name": "Case Stage", + "verbose_name_plural": "Case Stages", + "db_table": "case_stage", + "ordering": ("order",), }, ), migrations.AddField( - model_name='case', - name='stage', - field=models.ForeignKey(blank=True, help_text='Current pipeline stage (null = use status-based kanban)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cases', to='cases.casestage'), + model_name="case", + name="stage", + field=models.ForeignKey( + blank=True, + help_text="Current pipeline stage (null = use status-based kanban)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cases", + to="cases.casestage", + ), ), migrations.AddIndex( - model_name='case', - index=models.Index(fields=['stage', 'kanban_order'], name='case_stage_i_f04bec_idx'), + model_name="case", + index=models.Index( + fields=["stage", "kanban_order"], name="case_stage_i_f04bec_idx" + ), ), migrations.AddIndex( - model_name='case', - index=models.Index(fields=['status', 'kanban_order'], name='case_status_a33019_idx'), + model_name="case", + index=models.Index( + fields=["status", "kanban_order"], name="case_status_a33019_idx" + ), ), migrations.AddIndex( - model_name='casepipeline', - index=models.Index(fields=['org', '-created_at'], name='case_pipeli_org_id_f4590e_idx'), + model_name="casepipeline", + index=models.Index( + fields=["org", "-created_at"], name="case_pipeli_org_id_f4590e_idx" + ), ), migrations.AddConstraint( - model_name='casepipeline', - constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('org',), name='unique_default_case_pipeline_per_org'), + model_name="casepipeline", + constraint=models.UniqueConstraint( + condition=models.Q(("is_default", True)), + fields=("org",), + name="unique_default_case_pipeline_per_org", + ), ), migrations.AddIndex( - model_name='casestage', - index=models.Index(fields=['org', 'order'], name='case_stage_org_id_b9d581_idx'), + model_name="casestage", + index=models.Index( + fields=["org", "order"], name="case_stage_org_id_b9d581_idx" + ), ), migrations.AddIndex( - model_name='casestage', - index=models.Index(fields=['pipeline', 'order'], name='case_stage_pipelin_3fbf31_idx'), + model_name="casestage", + index=models.Index( + fields=["pipeline", "order"], name="case_stage_pipelin_3fbf31_idx" + ), ), migrations.AlterUniqueTogether( - name='casestage', - unique_together={('pipeline', 'name')}, + name="casestage", + unique_together={("pipeline", "name")}, ), ] diff --git a/backend/cases/models.py b/backend/cases/models.py index af9ab818..d8d641ad 100644 --- a/backend/cases/models.py +++ b/backend/cases/models.py @@ -281,9 +281,7 @@ class CaseStage(BaseModel): help_text="Maximum cases allowed in this stage (null = unlimited)", ) - org = models.ForeignKey( - Org, on_delete=models.CASCADE, related_name="case_stages" - ) + org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="case_stages") class Meta: verbose_name = "Case Stage" diff --git a/backend/cases/views.py b/backend/cases/views.py index cd73cac8..1169a9e8 100644 --- a/backend/cases/views.py +++ b/backend/cases/views.py @@ -2,7 +2,12 @@ import json from django.contrib.contenttypes.models import ContentType from django.db.models import Q -from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema, inline_serializer +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiParameter, + extend_schema, + inline_serializer, +) from rest_framework import serializers, status from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import IsAuthenticated @@ -73,13 +78,19 @@ class CaseListView(APIView, LimitOffsetPagination): assigned_to__id__in=params.getlist("assigned_to") ).distinct() if params.get("tags"): - queryset = queryset.filter(tags__id__in=params.getlist("tags")).distinct() + queryset = queryset.filter( + tags__id__in=params.getlist("tags") + ).distinct() if params.get("search"): queryset = queryset.filter(name__icontains=params.get("search")) if params.get("created_at__gte"): - queryset = queryset.filter(created_at__gte=params.get("created_at__gte")) + queryset = queryset.filter( + created_at__gte=params.get("created_at__gte") + ) if params.get("created_at__lte"): - queryset = queryset.filter(created_at__lte=params.get("created_at__lte")) + queryset = queryset.filter( + created_at__lte=params.get("created_at__lte") + ) context = {} @@ -110,19 +121,21 @@ class CaseListView(APIView, LimitOffsetPagination): operation_id="cases_list", tags=["Cases"], parameters=swagger_params.cases_list_get_params, - responses={200: inline_serializer( - name="CaseListResponse", - fields={ - "cases_count": serializers.IntegerField(), - "offset": serializers.IntegerField(allow_null=True), - "cases": CaseSerializer(many=True), - "status": serializers.ListField(), - "priority": serializers.ListField(), - "type_of_case": serializers.ListField(), - "accounts_list": AccountSerializer(many=True), - "contacts_list": ContactSerializer(many=True), - } - )}, + responses={ + 200: inline_serializer( + name="CaseListResponse", + fields={ + "cases_count": serializers.IntegerField(), + "offset": serializers.IntegerField(allow_null=True), + "cases": CaseSerializer(many=True), + "status": serializers.ListField(), + "priority": serializers.ListField(), + "type_of_case": serializers.ListField(), + "accounts_list": AccountSerializer(many=True), + "contacts_list": ContactSerializer(many=True), + }, + ) + }, ) def get(self, request, *args, **kwargs): context = self.get_context_data(**kwargs) @@ -133,15 +146,17 @@ class CaseListView(APIView, LimitOffsetPagination): tags=["Cases"], parameters=swagger_params.organization_params, request=CaseCreateSwaggerSerializer, - responses={200: inline_serializer( - name="CaseCreateResponse", - fields={ - "error": serializers.BooleanField(), - "message": serializers.CharField(), - "id": serializers.CharField(), - "cases_obj": CaseSerializer(), - } - )}, + responses={ + 200: inline_serializer( + name="CaseCreateResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + "id": serializers.CharField(), + "cases_obj": CaseSerializer(), + }, + ) + }, ) def post(self, request, *args, **kwargs): params = request.data @@ -180,7 +195,9 @@ class CaseListView(APIView, LimitOffsetPagination): tags = params.get("tags") if isinstance(tags, str): tags = json.loads(tags) - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) cases_obj.tags.add(*tag_objs) if self.request.FILES.get("case_attachment"): @@ -226,10 +243,15 @@ class CaseDetailView(APIView): tags=["Cases"], parameters=swagger_params.organization_params, request=CaseCreateSwaggerSerializer, - responses={200: inline_serializer( - name="CaseUpdateResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="CaseUpdateResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def put(self, request, pk, format=None): params = request.data @@ -295,7 +317,9 @@ class CaseDetailView(APIView): tags = params.get("tags") if isinstance(tags, str): tags = json.loads(tags) - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) cases_object.tags.add(*tag_objs) if self.request.FILES.get("case_attachment"): @@ -329,10 +353,15 @@ class CaseDetailView(APIView): operation_id="cases_destroy", tags=["Cases"], parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="CaseDeleteResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="CaseDeleteResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def delete(self, request, pk, format=None): self.object = self.get_object(pk) @@ -360,16 +389,18 @@ class CaseDetailView(APIView): operation_id="cases_retrieve", tags=["Cases"], parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="CaseDetailResponse", - fields={ - "cases_obj": CaseSerializer(), - "attachments": AttachmentsSerializer(many=True), - "comments": CommentSerializer(many=True), - "comment_permission": serializers.BooleanField(), - "users_mention": serializers.ListField(), - } - )}, + responses={ + 200: inline_serializer( + name="CaseDetailResponse", + fields={ + "cases_obj": CaseSerializer(), + "attachments": AttachmentsSerializer(many=True), + "comments": CommentSerializer(many=True), + "comment_permission": serializers.BooleanField(), + "users_mention": serializers.ListField(), + }, + ) + }, ) def get(self, request, pk, format=None): self.cases = self.get_object(pk=pk) @@ -454,14 +485,16 @@ class CaseDetailView(APIView): tags=["Cases"], parameters=swagger_params.organization_params, request=CaseDetailEditSwaggerSerializer, - responses={200: inline_serializer( - name="CaseCommentAttachmentResponse", - fields={ - "cases_obj": CaseSerializer(), - "attachments": AttachmentsSerializer(many=True), - "comments": CommentSerializer(many=True), - } - )}, + responses={ + 200: inline_serializer( + name="CaseCommentAttachmentResponse", + fields={ + "cases_obj": CaseSerializer(), + "attachments": AttachmentsSerializer(many=True), + "comments": CommentSerializer(many=True), + }, + ) + }, ) def post(self, request, pk, **kwargs): params = request.data @@ -527,10 +560,15 @@ class CaseDetailView(APIView): parameters=swagger_params.organization_params, request=CaseCreateSwaggerSerializer, description="Partial Case Update", - responses={200: inline_serializer( - name="CasePatchResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="CasePatchResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def patch(self, request, pk, format=None): """Handle partial updates to a case.""" @@ -538,7 +576,10 @@ class CaseDetailView(APIView): cases_object = self.get_object(pk=pk) if cases_object.org != request.profile.org: return Response( - {"error": True, "errors": "User company does not match with header...."}, + { + "error": True, + "errors": "User company does not match with header....", + }, status=status.HTTP_403_FORBIDDEN, ) if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin: @@ -563,8 +604,12 @@ class CaseDetailView(APIView): if serializer.is_valid(): cases_object = serializer.save( - closed_on=params.get("closed_on") if "closed_on" in params else cases_object.closed_on, - case_type=params.get("case_type") if "case_type" in params else cases_object.case_type, + closed_on=params.get("closed_on") + if "closed_on" in params + else cases_object.closed_on, + case_type=params.get("case_type") + if "case_type" in params + else cases_object.case_type, ) # Handle M2M fields if present in request @@ -581,7 +626,9 @@ class CaseDetailView(APIView): cases_object.teams.clear() teams_list = params.get("teams") if teams_list: - teams = Teams.objects.filter(id__in=teams_list, org=request.profile.org) + teams = Teams.objects.filter( + id__in=teams_list, org=request.profile.org + ) cases_object.teams.add(*teams) if "assigned_to" in params: @@ -599,7 +646,9 @@ class CaseDetailView(APIView): if tags_list: if isinstance(tags_list, str): tags_list = json.loads(tags_list) - tag_objs = Tags.objects.filter(id__in=tags_list, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags_list, org=request.profile.org, is_active=True + ) cases_object.tags.add(*tag_objs) return Response( @@ -623,10 +672,15 @@ class CaseCommentView(APIView): tags=["Cases"], parameters=swagger_params.organization_params, request=CaseCommentEditSwaggerSerializer, - responses={200: inline_serializer( - name="CaseCommentUpdateResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="CaseCommentUpdateResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def put(self, request, pk, format=None): params = request.data @@ -661,10 +715,15 @@ class CaseCommentView(APIView): parameters=swagger_params.organization_params, request=CaseCommentEditSwaggerSerializer, description="Partial Comment Update", - responses={200: inline_serializer( - name="CaseCommentPatchResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="CaseCommentPatchResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def patch(self, request, pk, format=None): """Handle partial updates to a comment.""" @@ -697,10 +756,15 @@ class CaseCommentView(APIView): @extend_schema( tags=["Cases"], parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="CaseCommentDeleteResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="CaseCommentDeleteResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def delete(self, request, pk, format=None): self.object = self.get_object(pk) @@ -730,10 +794,15 @@ class CaseAttachmentView(APIView): @extend_schema( tags=["Cases"], parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="CaseAttachmentDeleteResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="CaseAttachmentDeleteResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def delete(self, request, pk, format=None): self.object = self.model.objects.get(pk=pk) diff --git a/backend/common/duplicate_detection.py b/backend/common/duplicate_detection.py index 87f1379f..ff1416eb 100644 --- a/backend/common/duplicate_detection.py +++ b/backend/common/duplicate_detection.py @@ -4,6 +4,7 @@ Duplicate detection service for CRM entities. Provides methods to find potential duplicate records based on email, phone, name, and other identifying fields. """ + import re from typing import TYPE_CHECKING @@ -206,9 +207,9 @@ class DuplicateDetector: # Also check for partial matches on first word (for company variations) first_word = name.split()[0] if name else "" if len(first_word) >= 3: - partial_matches = base_qs.filter( - name__istartswith=first_word - ).exclude(name__iexact=name) + partial_matches = base_qs.filter(name__istartswith=first_word).exclude( + name__iexact=name + ) for account in partial_matches: if account not in duplicates: duplicates.append(account) diff --git a/backend/common/management/commands/manage_rls.py b/backend/common/management/commands/manage_rls.py index 0ae7e661..f98af9c6 100644 --- a/backend/common/management/commands/manage_rls.py +++ b/backend/common/management/commands/manage_rls.py @@ -109,7 +109,9 @@ class Command(BaseCommand): with connection.cursor() as cursor: # Find orgs that have leads (need to check each org since RLS is active) - cursor.execute("SELECT id FROM organization ORDER BY created_at DESC LIMIT 50") + cursor.execute( + "SELECT id FROM organization ORDER BY created_at DESC LIMIT 50" + ) all_orgs = cursor.fetchall() orgs_with_leads = [] @@ -127,14 +129,20 @@ class Command(BaseCommand): orgs = cursor.fetchall() if len(orgs) < 2: self.stdout.write( - self.style.WARNING("Need at least 2 orgs to test RLS. Skipping.") + self.style.WARNING( + "Need at least 2 orgs to test RLS. Skipping." + ) ) return org_a = str(orgs[0][0]) org_b = str(orgs[1][0]) else: org_a = orgs_with_leads[0] - org_b = orgs_with_leads[1] if len(orgs_with_leads) > 1 else str(all_orgs[0][0]) + org_b = ( + orgs_with_leads[1] + if len(orgs_with_leads) > 1 + else str(all_orgs[0][0]) + ) # Test with org_a context cursor.execute(set_context_sql, [org_a]) diff --git a/backend/common/management/commands/seed_data.py b/backend/common/management/commands/seed_data.py index 6002686e..b59a4054 100644 --- a/backend/common/management/commands/seed_data.py +++ b/backend/common/management/commands/seed_data.py @@ -314,7 +314,9 @@ class Command(BaseCommand): Teams.objects.all().delete() Tags.objects.all().delete() - self.stdout.write(" Cleared: Tasks, Cases, Opportunities, Leads, Accounts, Contacts, Teams, Tags") + self.stdout.write( + " Cleared: Tasks, Cases, Opportunities, Leads, Accounts, Contacts, Teams, Tags" + ) def get_or_create_admin(self, email, password): """Get existing user or create new admin user.""" @@ -366,16 +368,28 @@ class Command(BaseCommand): # Invoice entities invoices = self.invoice_seeder.create_invoices( - org, profiles, teams, products, templates, - accounts, contacts, opportunities, options["invoices"] + org, + profiles, + teams, + products, + templates, + accounts, + contacts, + opportunities, + options["invoices"], ) self.invoice_seeder.create_payments(org, invoices) self.invoice_seeder.create_estimates( org, profiles, teams, products, accounts, contacts, options["estimates"] ) self.invoice_seeder.create_recurring_invoices( - org, profiles, teams, products, accounts, contacts, - options["recurring_invoices"] + org, + profiles, + teams, + products, + accounts, + contacts, + options["recurring_invoices"], ) cases = self.create_cases( @@ -454,7 +468,9 @@ class Command(BaseCommand): profiles.append(profile) self.stats["profiles"] += 1 - self.stdout.write(f" Created {len(profiles)} profiles (1 admin, {user_count} users)") + self.stdout.write( + f" Created {len(profiles)} profiles (1 admin, {user_count} users)" + ) return profiles def create_teams(self, org, profiles, count): @@ -542,9 +558,7 @@ class Command(BaseCommand): number_of_employees=random.choice( [10, 50, 100, 250, 500, 1000, 5000, 10000] ), - annual_revenue=Decimal( - str(random.randint(100000, 10000000)) - ), + annual_revenue=Decimal(str(random.randint(100000, 10000000))), currency=org.default_currency, address_line=self.fake.street_address(), city=self.fake.city(), @@ -617,9 +631,9 @@ class Command(BaseCommand): opportunity_amount=Decimal(str(random.randint(5000, 500000))), currency=org.default_currency, probability=random.randint(10, 90), - close_date=self.fake.date_between( - start_date="today", end_date="+90d" - ) if random.random() > 0.3 else None, + close_date=self.fake.date_between(start_date="today", end_date="+90d") + if random.random() > 0.3 + else None, address_line=self.fake.street_address(), city=self.fake.city(), state=self.fake.state_abbr(), @@ -664,11 +678,9 @@ class Command(BaseCommand): amount=Decimal(str(random.randint(10000, 1000000))), currency=org.default_currency, probability=self._stage_to_probability(stage), - closed_on=self.fake.date_between( - start_date="today", end_date="+120d" - ) if stage not in ["CLOSED_WON", "CLOSED_LOST"] else self.fake.date_between( - start_date="-30d", end_date="today" - ), + closed_on=self.fake.date_between(start_date="today", end_date="+120d") + if stage not in ["CLOSED_WON", "CLOSED_LOST"] + else self.fake.date_between(start_date="-30d", end_date="today"), lead_source=random.choice(sources), description=self.fake.paragraph() if random.random() > 0.5 else None, org=org, @@ -710,9 +722,9 @@ class Command(BaseCommand): priority=random.choice(priorities), case_type=random.choice(case_types), account=account, - closed_on=self.fake.date_between( - start_date="-30d", end_date="today" - ) if status == "Closed" else None, + closed_on=self.fake.date_between(start_date="-30d", end_date="today") + if status == "Closed" + else None, description=self.fake.paragraph(), org=org, ) @@ -763,11 +775,9 @@ class Command(BaseCommand): "title": self.fake.sentence(nb_words=5)[:200], "status": status, "priority": random.choice(priorities), - "due_date": self.fake.date_between( - start_date="today", end_date="+30d" - ) if status != "Completed" else self.fake.date_between( - start_date="-14d", end_date="today" - ), + "due_date": self.fake.date_between(start_date="today", end_date="+30d") + if status != "Completed" + else self.fake.date_between(start_date="-14d", end_date="today"), "description": self.fake.paragraph() if random.random() > 0.5 else None, "org": org, } diff --git a/backend/common/migrations/0001_initial.py b/backend/common/migrations/0001_initial.py index 72a0c6f5..7ad9acdc 100644 --- a/backend/common/migrations/0001_initial.py +++ b/backend/common/migrations/0001_initial.py @@ -10,7 +10,6 @@ import common.models class Migration(migrations.Migration): - initial = True dependencies = [ @@ -677,15 +676,11 @@ class Migration(migrations.Migration): ), ( "phone", - models.CharField( - blank=True, max_length=20, null=True - ), + models.CharField(blank=True, max_length=20, null=True), ), ( "alternate_phone", - models.CharField( - blank=True, max_length=20, null=True - ), + models.CharField(blank=True, max_length=20, null=True), ), ( "role", diff --git a/backend/common/migrations/0003_add_org_to_commentfiles.py b/backend/common/migrations/0003_add_org_to_commentfiles.py index a8936236..4f435d01 100644 --- a/backend/common/migrations/0003_add_org_to_commentfiles.py +++ b/backend/common/migrations/0003_add_org_to_commentfiles.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0002_enable_rls"), ] diff --git a/backend/common/migrations/0004_populate_commentfiles_org_and_rls.py b/backend/common/migrations/0004_populate_commentfiles_org_and_rls.py index 71deba72..a8f21f4f 100644 --- a/backend/common/migrations/0004_populate_commentfiles_org_and_rls.py +++ b/backend/common/migrations/0004_populate_commentfiles_org_and_rls.py @@ -49,7 +49,6 @@ def disable_rls_on_commentfiles(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("common", "0003_add_org_to_commentfiles"), ] diff --git a/backend/common/migrations/0005_increase_apikey_length.py b/backend/common/migrations/0005_increase_apikey_length.py index ef957d36..14c3a6f7 100644 --- a/backend/common/migrations/0005_increase_apikey_length.py +++ b/backend/common/migrations/0005_increase_apikey_length.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0004_populate_commentfiles_org_and_rls"), ] diff --git a/backend/common/migrations/0006_remove_phonenumberfield.py b/backend/common/migrations/0006_remove_phonenumberfield.py index d4231066..639512e9 100644 --- a/backend/common/migrations/0006_remove_phonenumberfield.py +++ b/backend/common/migrations/0006_remove_phonenumberfield.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0005_increase_apikey_length"), ] diff --git a/backend/common/migrations/0007_add_currency_fields.py b/backend/common/migrations/0007_add_currency_fields.py index 6cf52f25..3baf3f58 100644 --- a/backend/common/migrations/0007_add_currency_fields.py +++ b/backend/common/migrations/0007_add_currency_fields.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0006_remove_phonenumberfield"), ] diff --git a/backend/common/migrations/0009_add_org_company_profile.py b/backend/common/migrations/0009_add_org_company_profile.py index 7e4e5e1c..8bb67b0e 100644 --- a/backend/common/migrations/0009_add_org_company_profile.py +++ b/backend/common/migrations/0009_add_org_company_profile.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0008_enable_rls_product_invoice_line_item"), ] diff --git a/backend/common/migrations/0012_add_tag_color_description_isactive.py b/backend/common/migrations/0012_add_tag_color_description_isactive.py index c2b76da1..f9fcb762 100644 --- a/backend/common/migrations/0012_add_tag_color_description_isactive.py +++ b/backend/common/migrations/0012_add_tag_color_description_isactive.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0011_enable_rls_opportunity_line_item"), ] diff --git a/backend/common/models.py b/backend/common/models.py index 119eedf1..0e80e75b 100644 --- a/backend/common/models.py +++ b/backend/common/models.py @@ -402,7 +402,6 @@ def document_path(self, filename): class Document(BaseModel): - DOCUMENT_STATUS_CHOICE = (("active", "active"), ("inactive", "inactive")) title = models.TextField(blank=True, null=True) diff --git a/backend/common/permissions.py b/backend/common/permissions.py index d650dafa..f70765cd 100644 --- a/backend/common/permissions.py +++ b/backend/common/permissions.py @@ -75,5 +75,3 @@ class IsSuperAdmin(permissions.BasePermission): # Check for super admin email domain return request.user.email.endswith("@micropyramid.com") - - diff --git a/backend/common/serializer.py b/backend/common/serializer.py index f3278585..c2baba95 100644 --- a/backend/common/serializer.py +++ b/backend/common/serializer.py @@ -132,7 +132,15 @@ class OrgSettingsSerializer(serializers.ModelSerializer): class TagsSerializer(serializers.ModelSerializer): class Meta: model = Tags - fields = ("id", "name", "slug", "color", "description", "is_active", "created_at") + fields = ( + "id", + "name", + "slug", + "color", + "description", + "is_active", + "created_at", + ) read_only_fields = ("id", "slug", "created_at") @@ -261,7 +269,6 @@ class BillingAddressSerializer(serializers.ModelSerializer): class CreateUserSerializer(serializers.ModelSerializer): - class Meta: model = User fields = ( @@ -306,7 +313,6 @@ class CreateProfileSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer): - class Meta: model = User fields = ["id", "email", "profile_pic"] @@ -549,7 +555,6 @@ class UserCreateSwaggerSerializer(serializers.Serializer): class UserUpdateStatusSwaggerSerializer(serializers.Serializer): - STATUS_CHOICES = ["Active", "Inactive"] status = serializers.ChoiceField(choices=STATUS_CHOICES, required=True) @@ -714,7 +719,6 @@ class TeamsSerializer(serializers.ModelSerializer): class TeamCreateSerializer(serializers.ModelSerializer): - def __init__(self, *args, **kwargs): request_obj = kwargs.pop("request_obj", None) super().__init__(*args, **kwargs) @@ -748,7 +752,6 @@ class TeamCreateSerializer(serializers.ModelSerializer): class TeamswaggerCreateSerializer(serializers.ModelSerializer): - class Meta: model = Teams fields = ( diff --git a/backend/common/utils.py b/backend/common/utils.py index 3230c1c6..cd6ab30f 100644 --- a/backend/common/utils.py +++ b/backend/common/utils.py @@ -421,6 +421,7 @@ def convert_to_custom_timezone(custom_date, custom_timezone, to_utc=False): # CRM Entity Utilities # ============================================================================= + def get_or_create_tags(tag_data, org): """ Handle tag creation/lookup for any CRM model. @@ -450,15 +451,15 @@ def get_or_create_tags(tag_data, org): if not tag_name: continue tag_obj, _ = Tags.objects.get_or_create( - slug=tag_name.lower(), - org=org, - defaults={'name': tag_name} + slug=tag_name.lower(), org=org, defaults={"name": tag_name} ) tags.append(tag_obj) return tags -def handle_m2m_assignment(instance, field_name, ids, model_class, org, extra_filters=None): +def handle_m2m_assignment( + instance, field_name, ids, model_class, org, extra_filters=None +): """ Handle ManyToMany field assignment with org filtering. @@ -487,7 +488,7 @@ def handle_m2m_assignment(instance, field_name, ids, model_class, org, extra_fil except json.JSONDecodeError: ids = [ids] - filters = {'id__in': ids, 'org': org} + filters = {"id__in": ids, "org": org} if extra_filters: filters.update(extra_filters) @@ -525,10 +526,22 @@ def create_attachment(file, content_object, profile): # Document File Type Utilities # ============================================================================= + def is_document_file_image(ext): image_ext_list = [ - "bmp", "dds", "gif", "jpg", "jpeg", "png", "psd", - "pspimage", "tga", "thm", "tif", "tiff", "yuv", + "bmp", + "dds", + "gif", + "jpg", + "jpeg", + "png", + "psd", + "pspimage", + "tga", + "thm", + "tif", + "tiff", + "yuv", ] return ext.lower() in image_ext_list @@ -540,8 +553,20 @@ def is_document_file_audio(ext): def is_document_file_video(ext): video_ext_list = [ - "3g2", "3gp", "asf", "avi", "flv", "m4v", "mov", - "mp4", "mpg", "rm", "srt", "swf", "vob", "wmv", + "3g2", + "3gp", + "asf", + "avi", + "flv", + "m4v", + "mov", + "mp4", + "mpg", + "rm", + "srt", + "swf", + "vob", + "wmv", ] return ext.lower() in video_ext_list @@ -553,64 +578,520 @@ def is_document_file_pdf(ext): def is_document_file_code(ext): code_ext_list = [ - "aspx", "json", "jsp", "do", "htm", "html", "ser", "php", "jad", "cfm", - "xml", "js", "pod", "asp", "atomsvc", "rdf", "pou", "jsf", "abs", "pl", - "asm", "srz", "luac", "cod", "lib", "arxml", "bas", "ejs", "fs", "hbs", - "s", "ss", "cms", "pyc", "vcxproj", "jse", "smali", "xla", "lxk", "pdb", - "src", "cs", "ipb", "ave", "mst", "vls", "rcc", "sax", "scr", "dtd", - "axd", "mrl", "xsl", "ino", "spr", "xsd", "cgi", "isa", "ws", "rss", - "dvb", "nupkg", "xlm", "v4e", "prg", "form", "bat", "mrc", "asi", "jdp", - "fmb", "graphml", "gcode", "aia", "py", "atp", "mzp", "o", "scs", "mm", - "cpp", "java", "gypi", "idb", "txml", "c", "vip", "tra", "rc", "action", - "vlx", "asta", "pyo", "lua", "gml", "prl", "rfs", "cpb", "sh", "rbf", - "gp", "phtml", "bp", "scb", "sln", "vbp", "wbf", "bdt", "mac", "rpy", - "eaf", "mc", "mwp", "gnt", "h", "swift", "e", "styl", "cxx", "as", - "liquid", "dep", "fas", "vbs", "aps", "vbe", "lss", "cmake", "resx", - "csb", "dpk", "pdml", "txx", "dbg", "jsa", "sxs", "sasf", "pm", "csx", - "r", "wml", "au3", "stm", "cls", "cc", "ins", "jsc", "dwp", "rpg", - "arb", "bml", "inc", "eld", "sct", "sm", "wbt", "csproj", "tcz", "html5", - "gbl", "cmd", "dlg", "tpl", "rbt", "xcp", "tpm", "qry", "mfa", "ptx", - "lsp", "pag", "ebc", "php3", "cob", "csc", "pyt", "dwt", "rb", "wsdl", - "lap", "textile", "sfx", "x", "a5r", "dbp", "pmp", "ipr", "fwx", "pbl", - "vbw", "phl", "cbl", "pas", "mom", "dbmdl", "lol", "wdl", "ppam", "plx", - "vb", "cgx", "lst", "lmp", "vd", "bcp", "thtml", "scpt", "isu", "mrd", - "perl", "dtx", "f", "wpk", "ipf", "ptl", "luca", "hx", "uvproj", "qvs", - "vba", "xjb", "appxupload", "ti", "svn-base", "bsc", "mak", "vcproj", - "dsd", "ksh", "pyw", "bxml", "mo", "irc", "gcl", "dbml", "mlv", "wsf", - "tcl", "dqy", "ssi", "pbxproj", "bal", "trt", "sal", "hkp", "vbi", "dob", - "htc", "p", "ats", "seam", "loc", "pli", "rptproj", "pxml", "pkb", "dpr", - "scss", "dsb", "bb", "vbproj", "ash", "rml", "nbk", "nvi", "lmv", "mw", - "jl", "dso", "cba", "jks", "ary", "run", "vps", "clm", "brml", "msha", - "mdp", "tmh", "jsx", "sdl", "ptxml", "fxl", "wmw", "dcr", "bcc", "cbp", - "bmo", "bsv", "less", "gss", "ctl", "rpyc", "ascx", "odc", "wiki", "obr", - "l", "axs", "bpr", "ppa", "rpo", "sqlproj", "smm", "dsr", "arq", "din", - "jml", "jsonp", "ml", "rc2", "myapp", "cla", "xme", "obj", "jsdtscope", - "gyp", "datasource", "cp", "rh", "lpx", "a2w", "ctp", "ulp", "nt", - "script", "bxl", "gs", "xslt", "mg", "pch", "mhl", "zpd", "psm1", "asz", - "m", "jacl", "pym", "rws", "acu", "ssq", "wxs", "coffee", "ncb", "akt", - "pyx", "zero", "hs", "mkb", "tru", "xul", "mfl", "sca", "sbr", "master", - "opv", "matlab", "sami", "agc", "slim", "tea", "m51", "mec", "asc", - "gch", "enml", "kst", "jade", "dfb", "ips", "rgs", "vbx", "cspkg", "ncx", - "brs", "wfs", "ifp", "nse", "xtx", "j", "cx", "ps1", "nas", "mk", "ccs", - "vrp", "lnp", "cml", "c#", "idl", "exp", "apb", "nsi", "asmx", "tdo", - "pjt", "fdt", "s5d", "mvba", "mf", "odl", "bzs", "jardesc", "tgml", - "moc", "wxi", "cpz", "fsx", "jav", "ocb", "agi", "tec", "txl", "amw", - "mscr", "dfd", "dpd", "pun", "f95", "vdproj", "xsc", "diff", "wxl", - "dgml", "airi", "kmt", "ksc", "io", "rbw", "sas", "vcp", "resources", - "param", "cg", "hlsl", "vssscc", "bgm", "xn", "targets", "sl", "gsc", - "qs", "owl", "devpak", "phps", "hdf", "pri", "nbin", "xaml", "s4e", - "scm", "tk", "poc", "uix", "clw", "factorypath", "s43", "awd", "htr", - "php2", "classpath", "pickle", "rob", "msil", "ebx", "tsq", "lml", "f90", - "lds", "vup", "pbi", "swt", "vap", "ig", "pdo", "frt", "fcg", "c++", - "xcl", "dfn", "aar", "for", "re", "twig", "ebm", "dhtml", "hc", "pro", - "ahk", "rule", "bsh", "jcs", "zrx", "wsdd", "csp", "drc", "appxsym", + "aspx", + "json", + "jsp", + "do", + "htm", + "html", + "ser", + "php", + "jad", + "cfm", + "xml", + "js", + "pod", + "asp", + "atomsvc", + "rdf", + "pou", + "jsf", + "abs", + "pl", + "asm", + "srz", + "luac", + "cod", + "lib", + "arxml", + "bas", + "ejs", + "fs", + "hbs", + "s", + "ss", + "cms", + "pyc", + "vcxproj", + "jse", + "smali", + "xla", + "lxk", + "pdb", + "src", + "cs", + "ipb", + "ave", + "mst", + "vls", + "rcc", + "sax", + "scr", + "dtd", + "axd", + "mrl", + "xsl", + "ino", + "spr", + "xsd", + "cgi", + "isa", + "ws", + "rss", + "dvb", + "nupkg", + "xlm", + "v4e", + "prg", + "form", + "bat", + "mrc", + "asi", + "jdp", + "fmb", + "graphml", + "gcode", + "aia", + "py", + "atp", + "mzp", + "o", + "scs", + "mm", + "cpp", + "java", + "gypi", + "idb", + "txml", + "c", + "vip", + "tra", + "rc", + "action", + "vlx", + "asta", + "pyo", + "lua", + "gml", + "prl", + "rfs", + "cpb", + "sh", + "rbf", + "gp", + "phtml", + "bp", + "scb", + "sln", + "vbp", + "wbf", + "bdt", + "mac", + "rpy", + "eaf", + "mc", + "mwp", + "gnt", + "h", + "swift", + "e", + "styl", + "cxx", + "as", + "liquid", + "dep", + "fas", + "vbs", + "aps", + "vbe", + "lss", + "cmake", + "resx", + "csb", + "dpk", + "pdml", + "txx", + "dbg", + "jsa", + "sxs", + "sasf", + "pm", + "csx", + "r", + "wml", + "au3", + "stm", + "cls", + "cc", + "ins", + "jsc", + "dwp", + "rpg", + "arb", + "bml", + "inc", + "eld", + "sct", + "sm", + "wbt", + "csproj", + "tcz", + "html5", + "gbl", + "cmd", + "dlg", + "tpl", + "rbt", + "xcp", + "tpm", + "qry", + "mfa", + "ptx", + "lsp", + "pag", + "ebc", + "php3", + "cob", + "csc", + "pyt", + "dwt", + "rb", + "wsdl", + "lap", + "textile", + "sfx", + "x", + "a5r", + "dbp", + "pmp", + "ipr", + "fwx", + "pbl", + "vbw", + "phl", + "cbl", + "pas", + "mom", + "dbmdl", + "lol", + "wdl", + "ppam", + "plx", + "vb", + "cgx", + "lst", + "lmp", + "vd", + "bcp", + "thtml", + "scpt", + "isu", + "mrd", + "perl", + "dtx", + "f", + "wpk", + "ipf", + "ptl", + "luca", + "hx", + "uvproj", + "qvs", + "vba", + "xjb", + "appxupload", + "ti", + "svn-base", + "bsc", + "mak", + "vcproj", + "dsd", + "ksh", + "pyw", + "bxml", + "mo", + "irc", + "gcl", + "dbml", + "mlv", + "wsf", + "tcl", + "dqy", + "ssi", + "pbxproj", + "bal", + "trt", + "sal", + "hkp", + "vbi", + "dob", + "htc", + "p", + "ats", + "seam", + "loc", + "pli", + "rptproj", + "pxml", + "pkb", + "dpr", + "scss", + "dsb", + "bb", + "vbproj", + "ash", + "rml", + "nbk", + "nvi", + "lmv", + "mw", + "jl", + "dso", + "cba", + "jks", + "ary", + "run", + "vps", + "clm", + "brml", + "msha", + "mdp", + "tmh", + "jsx", + "sdl", + "ptxml", + "fxl", + "wmw", + "dcr", + "bcc", + "cbp", + "bmo", + "bsv", + "less", + "gss", + "ctl", + "rpyc", + "ascx", + "odc", + "wiki", + "obr", + "l", + "axs", + "bpr", + "ppa", + "rpo", + "sqlproj", + "smm", + "dsr", + "arq", + "din", + "jml", + "jsonp", + "ml", + "rc2", + "myapp", + "cla", + "xme", + "obj", + "jsdtscope", + "gyp", + "datasource", + "cp", + "rh", + "lpx", + "a2w", + "ctp", + "ulp", + "nt", + "script", + "bxl", + "gs", + "xslt", + "mg", + "pch", + "mhl", + "zpd", + "psm1", + "asz", + "m", + "jacl", + "pym", + "rws", + "acu", + "ssq", + "wxs", + "coffee", + "ncb", + "akt", + "pyx", + "zero", + "hs", + "mkb", + "tru", + "xul", + "mfl", + "sca", + "sbr", + "master", + "opv", + "matlab", + "sami", + "agc", + "slim", + "tea", + "m51", + "mec", + "asc", + "gch", + "enml", + "kst", + "jade", + "dfb", + "ips", + "rgs", + "vbx", + "cspkg", + "ncx", + "brs", + "wfs", + "ifp", + "nse", + "xtx", + "j", + "cx", + "ps1", + "nas", + "mk", + "ccs", + "vrp", + "lnp", + "cml", + "c#", + "idl", + "exp", + "apb", + "nsi", + "asmx", + "tdo", + "pjt", + "fdt", + "s5d", + "mvba", + "mf", + "odl", + "bzs", + "jardesc", + "tgml", + "moc", + "wxi", + "cpz", + "fsx", + "jav", + "ocb", + "agi", + "tec", + "txl", + "amw", + "mscr", + "dfd", + "dpd", + "pun", + "f95", + "vdproj", + "xsc", + "diff", + "wxl", + "dgml", + "airi", + "kmt", + "ksc", + "io", + "rbw", + "sas", + "vcp", + "resources", + "param", + "cg", + "hlsl", + "vssscc", + "bgm", + "xn", + "targets", + "sl", + "gsc", + "qs", + "owl", + "devpak", + "phps", + "hdf", + "pri", + "nbin", + "xaml", + "s4e", + "scm", + "tk", + "poc", + "uix", + "clw", + "factorypath", + "s43", + "awd", + "htr", + "php2", + "classpath", + "pickle", + "rob", + "msil", + "ebx", + "tsq", + "lml", + "f90", + "lds", + "vup", + "pbi", + "swt", + "vap", + "ig", + "pdo", + "frt", + "fcg", + "c++", + "xcl", + "dfn", + "aar", + "for", + "re", + "twig", + "ebm", + "dhtml", + "hc", + "pro", + "ahk", + "rule", + "bsh", + "jcs", + "zrx", + "wsdd", + "csp", + "drc", + "appxsym", ] return ext.lower() in code_ext_list def is_document_file_text(ext): text_ext_list = [ - "doc", "docx", "log", "msg", "odt", "pages", "rtf", "tex", "txt", "wpd", "wps", + "doc", + "docx", + "log", + "msg", + "odt", + "pages", + "rtf", + "tex", + "txt", + "wpd", + "wps", ] return ext.lower() in text_ext_list diff --git a/backend/common/validators.py b/backend/common/validators.py index c3467ff6..dd8f32d8 100644 --- a/backend/common/validators.py +++ b/backend/common/validators.py @@ -1,6 +1,7 @@ """ Common validators for CRM models. """ + import re from django.core.validators import RegexValidator @@ -19,7 +20,9 @@ e164_phone_validator = RegexValidator( # Examples: +1 (202) 555-1234, 202-555-1234, +44 20 7123 4567 flexible_phone_validator = RegexValidator( regex=r"^[\d\s\-\(\)\+\.]{7,25}$", - message=_("Enter a valid phone number (7-25 characters, digits and separators only)"), + message=_( + "Enter a valid phone number (7-25 characters, digits and separators only)" + ), ) diff --git a/backend/common/views/auth_views.py b/backend/common/views/auth_views.py index 5feefc59..e94ff356 100644 --- a/backend/common/views/auth_views.py +++ b/backend/common/views/auth_views.py @@ -35,16 +35,18 @@ class GoogleOAuthCallbackView(APIView): "code": serializers.CharField(), "code_verifier": serializers.CharField(), "redirect_uri": serializers.CharField(), - } + }, ), - responses={200: inline_serializer( - name="GoogleOAuthResponse", - fields={ - "access_token": serializers.CharField(), - "refresh_token": serializers.CharField(), - "user": serializers.DictField(), - } - )}, + responses={ + 200: inline_serializer( + name="GoogleOAuthResponse", + fields={ + "access_token": serializers.CharField(), + "refresh_token": serializers.CharField(), + "user": serializers.DictField(), + }, + ) + }, ) def post(self, request): import base64 @@ -133,11 +135,13 @@ class GoogleOAuthCallbackView(APIView): # Generate JWT tokens token = RefreshToken.for_user(user) - return Response({ - "access_token": str(token.access_token), - "refresh_token": str(token), - "user": {"id": str(user.id), "email": user.email}, - }) + return Response( + { + "access_token": str(token.access_token), + "refresh_token": str(token), + "user": {"id": str(user.id), "email": user.email}, + } + ) class LoginView(APIView): @@ -211,7 +215,9 @@ class LoginView(APIView): # Generate JWT tokens with org context (include profile for role) if default_org: - token = OrgAwareRefreshToken.for_user_and_org(user, default_org, profile) + token = OrgAwareRefreshToken.for_user_and_org( + user, default_org, profile + ) else: # User has no orgs - generate token without org context token = RefreshToken.for_user(user) @@ -317,9 +323,7 @@ class OrgAwareTokenRefreshView(APIView): description="Refresh access token with org membership validation", request=inline_serializer( name="OrgAwareTokenRefreshRequest", - fields={ - "refresh": serializers.CharField(help_text="Refresh token") - }, + fields={"refresh": serializers.CharField(help_text="Refresh token")}, ), responses={ 200: inline_serializer( @@ -393,7 +397,10 @@ class OrgAwareTokenRefreshView(APIView): ) except TokenError: - return Response({"error": "Invalid or expired token"}, status=status.HTTP_401_UNAUTHORIZED) + return Response( + {"error": "Invalid or expired token"}, + status=status.HTTP_401_UNAUTHORIZED, + ) except User.DoesNotExist: return Response( {"error": "User not found"}, status=status.HTTP_401_UNAUTHORIZED @@ -459,7 +466,9 @@ class OrgSwitchView(APIView): ) # Generate new tokens with the target org (include profile for role) - token = OrgAwareRefreshToken.for_user_and_org(request.user, profile.org, profile) + token = OrgAwareRefreshToken.for_user_and_org( + request.user, profile.org, profile + ) # Audit log the org switch audit_log.org_switch(request.user, from_org, profile.org, request) diff --git a/backend/common/views/dashboard_views.py b/backend/common/views/dashboard_views.py index f6f51e9d..a91cddcf 100644 --- a/backend/common/views/dashboard_views.py +++ b/backend/common/views/dashboard_views.py @@ -26,25 +26,26 @@ from tasks.serializer import TaskSerializer class ApiHomeView(APIView): - permission_classes = (IsAuthenticated,) @extend_schema( tags=["home"], parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="ApiHomeResponse", - fields={ - "accounts_count": serializers.IntegerField(), - "contacts_count": serializers.IntegerField(), - "leads_count": serializers.IntegerField(), - "opportunities_count": serializers.IntegerField(), - "accounts": AccountSerializer(many=True), - "contacts": ContactSerializer(many=True), - "leads": LeadSerializer(many=True), - "opportunities": OpportunitySerializer(many=True), - } - )}, + responses={ + 200: inline_serializer( + name="ApiHomeResponse", + fields={ + "accounts_count": serializers.IntegerField(), + "contacts_count": serializers.IntegerField(), + "leads_count": serializers.IntegerField(), + "opportunities_count": serializers.IntegerField(), + "accounts": AccountSerializer(many=True), + "contacts": ContactSerializer(many=True), + "leads": LeadSerializer(many=True), + "opportunities": OpportunitySerializer(many=True), + }, + ) + }, ) def get(self, request, format=None): org = request.profile.org @@ -63,24 +64,19 @@ class ApiHomeView(APIView): if not is_admin: accounts = accounts.filter( - Q(assigned_to=profile) - | Q(created_by=profile.user) + Q(assigned_to=profile) | Q(created_by=profile.user) ) contacts = contacts.filter( - Q(assigned_to__id__in=[profile.id]) - | Q(created_by=profile.user) + Q(assigned_to__id__in=[profile.id]) | Q(created_by=profile.user) ) leads = leads.filter( - Q(assigned_to__id__in=[profile.id]) - | Q(created_by=profile.user) + Q(assigned_to__id__in=[profile.id]) | Q(created_by=profile.user) ).exclude(status="closed") opportunities = opportunities.filter( - Q(assigned_to__id__in=[profile.id]) - | Q(created_by=profile.user) + Q(assigned_to__id__in=[profile.id]) | Q(created_by=profile.user) ) tasks = tasks.filter( - Q(assigned_to__id__in=[profile.id]) - | Q(created_by=profile.user) + Q(assigned_to__id__in=[profile.id]) | Q(created_by=profile.user) ) # Build base context (existing) @@ -96,22 +92,17 @@ class ApiHomeView(APIView): # NEW: Urgent counts for Focus Bar overdue_tasks = tasks.filter( - status__in=["New", "In Progress"], - due_date__lt=today + status__in=["New", "In Progress"], due_date__lt=today ).count() tasks_due_today = tasks.filter( - status__in=["New", "In Progress"], - due_date=today + status__in=["New", "In Progress"], due_date=today ).count() - followups_today = leads.filter( - next_follow_up=today - ).count() + followups_today = leads.filter(next_follow_up=today).count() hot_leads = leads.filter( - rating="HOT", - status__in=["assigned", "in process"] + rating="HOT", status__in=["assigned", "in process"] ).count() context["urgent_counts"] = { @@ -160,16 +151,17 @@ class ApiHomeView(APIView): total=Coalesce( Sum(F("amount") * F("probability") / 100), 0, - output_field=DecimalField() + output_field=DecimalField(), ) )["total"] # Won this month (use timezone-aware datetime for updated_at comparison) now = timezone.now() - first_day_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + first_day_of_month = now.replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) won_opps = opportunities.filter( - stage="CLOSED_WON", - updated_at__gte=first_day_of_month + stage="CLOSED_WON", updated_at__gte=first_day_of_month ) won_opps_with_currency = won_opps.filter( Q(currency=org_currency) | Q(currency__isnull=True) | Q(currency="") @@ -181,7 +173,9 @@ class ApiHomeView(APIView): # Conversion rate: leads converted / total leads total_leads_all = Lead.objects.filter(org=org).count() converted_leads = Lead.objects.filter(org=org, status="converted").count() - conversion_rate = (converted_leads / total_leads_all * 100) if total_leads_all > 0 else 0 + conversion_rate = ( + (converted_leads / total_leads_all * 100) if total_leads_all > 0 else 0 + ) # Count opportunities in other currencies (for info) other_currency_count = opportunities.exclude( @@ -199,8 +193,7 @@ class ApiHomeView(APIView): # NEW: Hot leads list for dedicated panel hot_leads_qs = leads.filter( - rating="HOT", - status__in=["assigned", "in process"] + rating="HOT", status__in=["assigned", "in process"] ).order_by("-created_at")[:10] context["hot_leads"] = [ @@ -210,24 +203,31 @@ class ApiHomeView(APIView): "last_name": lead.last_name, "company": lead.company_name, "rating": lead.rating, - "next_follow_up": lead.next_follow_up.isoformat() if lead.next_follow_up else None, - "last_contacted": lead.last_contacted.isoformat() if lead.last_contacted else None, + "next_follow_up": lead.next_follow_up.isoformat() + if lead.next_follow_up + else None, + "last_contacted": lead.last_contacted.isoformat() + if lead.last_contacted + else None, } for lead in hot_leads_qs ] # Include tasks in dashboard response (avoid separate API call) upcoming_tasks = tasks.filter( - status__in=["New", "In Progress"], - due_date__isnull=False + status__in=["New", "In Progress"], due_date__isnull=False ).order_by("due_date")[:10] context["tasks"] = TaskSerializer(upcoming_tasks, many=True).data # Include recent activities (avoid separate API call) - activities = Activity.objects.filter( - org=org - ).select_related("user", "user__user").order_by("-created_at")[:10] - context["activities"] = serializer.ActivitySerializer(activities, many=True).data + activities = ( + Activity.objects.filter(org=org) + .select_related("user", "user__user") + .order_by("-created_at")[:10] + ) + context["activities"] = serializer.ActivitySerializer( + activities, many=True + ).data return Response(context, status=status.HTTP_200_OK) diff --git a/backend/common/views/document_views.py b/backend/common/views/document_views.py index af3461f7..050afb3b 100644 --- a/backend/common/views/document_views.py +++ b/backend/common/views/document_views.py @@ -121,16 +121,18 @@ class DocumentListView(APIView, LimitOffsetPagination): tags=["documents"], operation_id="documents_list", parameters=swagger_params.document_get_params, - responses={200: inline_serializer( - name="DocumentListResponse", - fields={ - "search": serializers.BooleanField(), - "documents_active": serializers.DictField(), - "documents_inactive": serializers.DictField(), - "users": ProfileSerializer(many=True), - "status_choices": serializers.ListField(), - } - )}, + responses={ + 200: inline_serializer( + name="DocumentListResponse", + fields={ + "search": serializers.BooleanField(), + "documents_active": serializers.DictField(), + "documents_inactive": serializers.DictField(), + "users": ProfileSerializer(many=True), + "status_choices": serializers.ListField(), + }, + ) + }, ) def get(self, request, *args, **kwargs): context = self.get_context_data(**kwargs) @@ -141,10 +143,15 @@ class DocumentListView(APIView, LimitOffsetPagination): operation_id="documents_create", parameters=swagger_params.organization_params, request=DocumentCreateSwaggerSerializer, - responses={201: inline_serializer( - name="DocumentCreateResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 201: inline_serializer( + name="DocumentCreateResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def post(self, request, *args, **kwargs): params = request.data @@ -189,14 +196,16 @@ class DocumentDetailView(APIView): tags=["documents"], operation_id="documents_retrieve", parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="DocumentDetailResponse", - fields={ - "doc_obj": DocumentSerializer(), - "file_type_code": serializers.CharField(), - "users": ProfileSerializer(many=True), - } - )}, + responses={ + 200: inline_serializer( + name="DocumentDetailResponse", + fields={ + "doc_obj": DocumentSerializer(), + "file_type_code": serializers.CharField(), + "users": ProfileSerializer(many=True), + }, + ) + }, ) def get(self, request, pk, format=None): # get_object_or_404 now handles org filtering - returns 404 if not found/wrong org @@ -232,10 +241,15 @@ class DocumentDetailView(APIView): tags=["documents"], operation_id="documents_destroy", parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="DocumentDeleteResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="DocumentDeleteResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def delete(self, request, pk, format=None): document = self.get_object(pk) @@ -272,10 +286,15 @@ class DocumentDetailView(APIView): operation_id="documents_update", parameters=swagger_params.organization_params, request=DocumentEditSwaggerSerializer, - responses={200: inline_serializer( - name="DocumentUpdateResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="DocumentUpdateResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def put(self, request, pk, format=None): self.object = self.get_object(pk) @@ -340,10 +359,15 @@ class DocumentDetailView(APIView): parameters=swagger_params.organization_params, request=DocumentEditSwaggerSerializer, description="Partial Document Update", - responses={200: inline_serializer( - name="DocumentPatchResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="DocumentPatchResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def patch(self, request, pk, format=None): """Handle partial updates to a document.""" @@ -356,7 +380,10 @@ class DocumentDetailView(APIView): ) if self.object.org != self.request.profile.org: return Response( - {"error": True, "errors": "User company does not match with header...."}, + { + "error": True, + "errors": "User company does not match with header....", + }, status=status.HTTP_403_FORBIDDEN, ) if self.request.profile.role != "ADMIN" and not self.request.user.is_superuser: diff --git a/backend/common/views/organization_views.py b/backend/common/views/organization_views.py index 5cf0d9a0..fbd48a7c 100644 --- a/backend/common/views/organization_views.py +++ b/backend/common/views/organization_views.py @@ -66,7 +66,7 @@ class OrgProfileCreateView(APIView): @extend_schema( operation_id="org_list", - description="Just Pass the token, will return ORG list, associated with user" + description="Just Pass the token, will return ORG list, associated with user", ) def get(self, request, format=None): """ @@ -210,7 +210,8 @@ class OrgUpdateView(APIView): @extend_schema( operation_id="org_retrieve", - description="Get organization details", responses={200: OrganizationSerializer} + description="Get organization details", + responses={200: OrganizationSerializer}, ) def get(self, request, pk, format=None): # Check if user has access to this organization @@ -247,10 +248,11 @@ class ProfileView(APIView): @extend_schema( tags=["profile"], parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="ProfileViewResponse", - fields={"user_obj": ProfileSerializer()} - )}, + responses={ + 200: inline_serializer( + name="ProfileViewResponse", fields={"user_obj": ProfileSerializer()} + ) + }, ) def get(self, request, format=None): # profile=Profile.objects.get(user=request.user) @@ -263,12 +265,17 @@ class ProfileView(APIView): parameters=swagger_params.organization_params, request=inline_serializer( name="ProfilePatchRequest", - fields={"phone": serializers.CharField(required=False)} + fields={"phone": serializers.CharField(required=False)}, ), - responses={200: inline_serializer( - name="ProfilePatchUpdateResponse", - fields={"message": serializers.CharField(), "user_obj": ProfileSerializer()} - )}, + responses={ + 200: inline_serializer( + name="ProfilePatchUpdateResponse", + fields={ + "message": serializers.CharField(), + "user_obj": ProfileSerializer(), + }, + ) + }, ) def patch(self, request, format=None): profile = request.profile diff --git a/backend/common/views/settings_views.py b/backend/common/views/settings_views.py index 9219e734..4319210f 100644 --- a/backend/common/views/settings_views.py +++ b/backend/common/views/settings_views.py @@ -24,14 +24,16 @@ class DomainList(APIView): tags=["Settings"], operation_id="api_settings_list", parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="DomainListResponse", - fields={ - "error": serializers.BooleanField(), - "api_settings": APISettingsListSerializer(many=True), - "users": ProfileSerializer(many=True), - } - )}, + responses={ + 200: inline_serializer( + name="DomainListResponse", + fields={ + "error": serializers.BooleanField(), + "api_settings": APISettingsListSerializer(many=True), + "users": ProfileSerializer(many=True), + }, + ) + }, ) def get(self, request, *args, **kwargs): api_settings = APISettings.objects.filter(org=request.profile.org) @@ -52,10 +54,15 @@ class DomainList(APIView): operation_id="api_settings_create", parameters=swagger_params.organization_params, request=APISettingsSwaggerSerializer, - responses={201: inline_serializer( - name="DomainCreateResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 201: inline_serializer( + name="DomainCreateResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def post(self, request, *args, **kwargs): params = request.data @@ -97,10 +104,15 @@ class DomainDetailView(APIView): tags=["Settings"], operation_id="api_settings_retrieve", parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="DomainDetailResponse", - fields={"error": serializers.BooleanField(), "domain": APISettingsListSerializer()} - )}, + responses={ + 200: inline_serializer( + name="DomainDetailResponse", + fields={ + "error": serializers.BooleanField(), + "domain": APISettingsListSerializer(), + }, + ) + }, ) def get(self, request, pk, format=None): api_setting = self.get_object(pk) @@ -114,10 +126,15 @@ class DomainDetailView(APIView): operation_id="api_settings_update", parameters=swagger_params.organization_params, request=APISettingsSwaggerSerializer, - responses={200: inline_serializer( - name="DomainUpdateResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="DomainUpdateResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def put(self, request, pk, **kwargs): api_setting = self.get_object(pk) @@ -153,16 +170,23 @@ class DomainDetailView(APIView): parameters=swagger_params.organization_params, request=APISettingsSwaggerSerializer, description="Partial API Settings Update", - responses={200: inline_serializer( - name="DomainPatchResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="DomainPatchResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def patch(self, request, pk, **kwargs): """Handle partial updates to API settings.""" api_setting = self.get_object(pk) params = request.data - serializer = APISettingsSerializer(data=params, instance=api_setting, partial=True) + serializer = APISettingsSerializer( + data=params, instance=api_setting, partial=True + ) if serializer.is_valid(): api_setting = serializer.save() return Response( @@ -178,10 +202,15 @@ class DomainDetailView(APIView): tags=["Settings"], operation_id="api_settings_destroy", parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="DomainDeleteResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="DomainDeleteResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def delete(self, request, pk, **kwargs): api_setting = self.get_object(pk) diff --git a/backend/common/views/team_views.py b/backend/common/views/team_views.py index 8f4c8b8a..057086ef 100644 --- a/backend/common/views/team_views.py +++ b/backend/common/views/team_views.py @@ -55,16 +55,18 @@ class TeamsListView(APIView, LimitOffsetPagination): tags=["Teams"], operation_id="teams_list", parameters=swagger_params.teams_list_get_params, - responses={200: inline_serializer( - name="TeamsListResponse", - fields={ - "per_page": serializers.IntegerField(), - "page_number": serializers.ListField(), - "teams_count": serializers.IntegerField(), - "offset": serializers.IntegerField(allow_null=True), - "teams": TeamsSerializer(many=True), - } - )}, + responses={ + 200: inline_serializer( + name="TeamsListResponse", + fields={ + "per_page": serializers.IntegerField(), + "page_number": serializers.ListField(), + "teams_count": serializers.IntegerField(), + "offset": serializers.IntegerField(allow_null=True), + "teams": TeamsSerializer(many=True), + }, + ) + }, ) def get(self, *args, **kwargs): if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin: @@ -83,10 +85,15 @@ class TeamsListView(APIView, LimitOffsetPagination): operation_id="teams_create", request=TeamswaggerCreateSerializer, parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="TeamCreateResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="TeamCreateResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def post(self, request, *args, **kwargs): if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin: @@ -130,10 +137,11 @@ class TeamsDetailView(APIView): tags=["Teams"], operation_id="teams_retrieve", parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="TeamDetailResponse", - fields={"team": TeamsSerializer()} - )}, + responses={ + 200: inline_serializer( + name="TeamDetailResponse", fields={"team": TeamsSerializer()} + ) + }, ) def get(self, request, pk, **kwargs): if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin: @@ -154,10 +162,15 @@ class TeamsDetailView(APIView): operation_id="teams_update", request=TeamswaggerCreateSerializer, parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="TeamUpdateResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="TeamUpdateResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def put(self, request, pk, *args, **kwargs): if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin: @@ -206,10 +219,15 @@ class TeamsDetailView(APIView): request=TeamswaggerCreateSerializer, parameters=swagger_params.organization_params, description="Partial Team Update", - responses={200: inline_serializer( - name="TeamPatchResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="TeamPatchResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def patch(self, request, pk, *args, **kwargs): """Handle partial updates to a team.""" @@ -241,10 +259,15 @@ class TeamsDetailView(APIView): tags=["Teams"], operation_id="teams_destroy", parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="TeamDeleteResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="TeamDeleteResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def delete(self, request, pk, **kwargs): if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin: diff --git a/backend/common/views/user_views.py b/backend/common/views/user_views.py index 0b1d427e..b8bf2eef 100644 --- a/backend/common/views/user_views.py +++ b/backend/common/views/user_views.py @@ -32,19 +32,20 @@ from opportunity.serializer import OpportunitySerializer class GetTeamsAndUsersView(APIView): - permission_classes = (IsAuthenticated,) @extend_schema( tags=["users"], parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="TeamsAndUsersResponse", - fields={ - "teams": TeamsSerializer(many=True), - "profiles": ProfileSerializer(many=True), - } - )}, + responses={ + 200: inline_serializer( + name="TeamsAndUsersResponse", + fields={ + "teams": TeamsSerializer(many=True), + "profiles": ProfileSerializer(many=True), + }, + ) + }, ) def get(self, request, *args, **kwargs): data = {} @@ -60,17 +61,21 @@ class GetTeamsAndUsersView(APIView): class UsersListView(APIView, LimitOffsetPagination): - permission_classes = (IsAuthenticated,) @extend_schema( tags=["users"], parameters=swagger_params.organization_params, request=UserCreateSwaggerSerializer, - responses={201: inline_serializer( - name="UserCreateResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 201: inline_serializer( + name="UserCreateResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def post(self, request, format=None): if self.request.profile.role != "ADMIN" and not self.request.user.is_superuser: @@ -120,16 +125,18 @@ class UsersListView(APIView, LimitOffsetPagination): @extend_schema( tags=["users"], parameters=swagger_params.user_list_params, - responses={200: inline_serializer( - name="UsersListResponse", - fields={ - "active_users": serializers.DictField(), - "inactive_users": serializers.DictField(), - "admin_email": serializers.CharField(), - "roles": serializers.ListField(), - "status": serializers.ListField(), - } - )}, + responses={ + 200: inline_serializer( + name="UsersListResponse", + fields={ + "active_users": serializers.DictField(), + "inactive_users": serializers.DictField(), + "admin_email": serializers.CharField(), + "roles": serializers.ListField(), + "status": serializers.ListField(), + }, + ) + }, ) def get(self, request, format=None): # Check if profile exists and user has permission @@ -210,10 +217,15 @@ class UserDetailView(APIView): @extend_schema( tags=["users"], parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="UserDetailResponse", - fields={"error": serializers.BooleanField(), "data": serializers.DictField()} - )}, + responses={ + 200: inline_serializer( + name="UserDetailResponse", + fields={ + "error": serializers.BooleanField(), + "data": serializers.DictField(), + }, + ) + }, ) def get(self, request, pk, format=None): profile_obj = self.get_object(pk) @@ -258,10 +270,15 @@ class UserDetailView(APIView): tags=["users"], parameters=swagger_params.organization_params, request=UserCreateSwaggerSerializer, - responses={200: inline_serializer( - name="UserUpdateResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="UserUpdateResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def put(self, request, pk, format=None): params = request.data @@ -321,10 +338,15 @@ class UserDetailView(APIView): parameters=swagger_params.organization_params, request=UserCreateSwaggerSerializer, description="Partial User Update", - responses={200: inline_serializer( - name="UserPatchResponse", - fields={"error": serializers.BooleanField(), "message": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="UserPatchResponse", + fields={ + "error": serializers.BooleanField(), + "message": serializers.CharField(), + }, + ) + }, ) def patch(self, request, pk, format=None): """Handle partial updates to a user.""" @@ -342,7 +364,10 @@ class UserDetailView(APIView): if profile.org != request.profile.org: return Response( - {"error": True, "errors": "User company does not match with header...."}, + { + "error": True, + "errors": "User company does not match with header....", + }, status=status.HTTP_403_FORBIDDEN, ) @@ -377,10 +402,11 @@ class UserDetailView(APIView): @extend_schema( tags=["users"], parameters=swagger_params.organization_params, - responses={200: inline_serializer( - name="UserDeleteResponse", - fields={"status": serializers.CharField()} - )}, + responses={ + 200: inline_serializer( + name="UserDeleteResponse", fields={"status": serializers.CharField()} + ) + }, ) def delete(self, request, pk, format=None): if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin: @@ -411,13 +437,15 @@ class UserStatusView(APIView): description="User Status View", parameters=swagger_params.organization_params, request=UserUpdateStatusSwaggerSerializer, - responses={200: inline_serializer( - name="UserStatusResponse", - fields={ - "active_profiles": ProfileSerializer(many=True), - "inactive_profiles": ProfileSerializer(many=True), - } - )}, + responses={ + 200: inline_serializer( + name="UserStatusResponse", + fields={ + "active_profiles": ProfileSerializer(many=True), + "inactive_profiles": ProfileSerializer(many=True), + }, + ) + }, ) def post(self, request, pk, format=None): if self.request.profile.role != "ADMIN" and not self.request.user.is_superuser: diff --git a/backend/contacts/migrations/0001_initial.py b/backend/contacts/migrations/0001_initial.py index 1f043a86..4cb1ebe5 100644 --- a/backend/contacts/migrations/0001_initial.py +++ b/backend/contacts/migrations/0001_initial.py @@ -8,7 +8,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/backend/contacts/migrations/0002_change_phone_to_charfield.py b/backend/contacts/migrations/0002_change_phone_to_charfield.py index 3a69e2d5..3eb8b2dc 100644 --- a/backend/contacts/migrations/0002_change_phone_to_charfield.py +++ b/backend/contacts/migrations/0002_change_phone_to_charfield.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("contacts", "0001_initial"), ] diff --git a/backend/contacts/migrations/0003_add_account_fk.py b/backend/contacts/migrations/0003_add_account_fk.py index 4acdfdaf..d4d3ba8e 100644 --- a/backend/contacts/migrations/0003_add_account_fk.py +++ b/backend/contacts/migrations/0003_add_account_fk.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("accounts", "0001_initial"), ("contacts", "0002_change_phone_to_charfield"), diff --git a/backend/contacts/migrations/0004_change_phone_to_phonenumberfield.py b/backend/contacts/migrations/0004_change_phone_to_phonenumberfield.py index fc6845b5..316d1c96 100644 --- a/backend/contacts/migrations/0004_change_phone_to_phonenumberfield.py +++ b/backend/contacts/migrations/0004_change_phone_to_phonenumberfield.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("contacts", "0003_add_account_fk"), ] diff --git a/backend/contacts/migrations/0005_rename_linked_in_url_to_linkedin_url.py b/backend/contacts/migrations/0005_rename_linked_in_url_to_linkedin_url.py index 6db06a51..9c2f6211 100644 --- a/backend/contacts/migrations/0005_rename_linked_in_url_to_linkedin_url.py +++ b/backend/contacts/migrations/0005_rename_linked_in_url_to_linkedin_url.py @@ -4,7 +4,6 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ ("contacts", "0004_change_phone_to_phonenumberfield"), ] diff --git a/backend/contacts/migrations/0006_add_tags_related_name.py b/backend/contacts/migrations/0006_add_tags_related_name.py index cce15ed4..c94665c6 100644 --- a/backend/contacts/migrations/0006_add_tags_related_name.py +++ b/backend/contacts/migrations/0006_add_tags_related_name.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0002_enable_rls"), ("contacts", "0005_rename_linked_in_url_to_linkedin_url"), diff --git a/backend/contacts/migrations/0007_remove_organization_index.py b/backend/contacts/migrations/0007_remove_organization_index.py index 1ff0d67c..f3b2dedd 100644 --- a/backend/contacts/migrations/0007_remove_organization_index.py +++ b/backend/contacts/migrations/0007_remove_organization_index.py @@ -4,7 +4,6 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ ("contacts", "0006_add_tags_related_name"), ] diff --git a/backend/contacts/migrations/0008_change_phone_to_charfield.py b/backend/contacts/migrations/0008_change_phone_to_charfield.py index f427d0cb..ee8ab817 100644 --- a/backend/contacts/migrations/0008_change_phone_to_charfield.py +++ b/backend/contacts/migrations/0008_change_phone_to_charfield.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("contacts", "0007_remove_organization_index"), ] diff --git a/backend/contacts/migrations/0009_alter_contact_created_by_alter_contact_updated_by.py b/backend/contacts/migrations/0009_alter_contact_created_by_alter_contact_updated_by.py index a2193cff..ca0442fa 100644 --- a/backend/contacts/migrations/0009_alter_contact_created_by_alter_contact_updated_by.py +++ b/backend/contacts/migrations/0009_alter_contact_created_by_alter_contact_updated_by.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("contacts", "0008_change_phone_to_charfield"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/backend/contacts/migrations/0010_add_enterprise_constraints.py b/backend/contacts/migrations/0010_add_enterprise_constraints.py index fc14d40e..a3ac9055 100644 --- a/backend/contacts/migrations/0010_add_enterprise_constraints.py +++ b/backend/contacts/migrations/0010_add_enterprise_constraints.py @@ -13,36 +13,34 @@ def cleanup_duplicate_contact_emails(apps, schema_editor): Find duplicate contact emails (case-insensitive) within each org and clear email on duplicates (keep oldest). """ - Contact = apps.get_model('contacts', 'Contact') + Contact = apps.get_model("contacts", "Contact") # Find duplicates: group by lower(email) + org, having count > 1 duplicates = ( - Contact.objects - .exclude(email__isnull=True) - .exclude(email='') - .annotate(email_lower=Lower('email')) - .values('email_lower', 'org_id') - .annotate(count=Count('id')) + Contact.objects.exclude(email__isnull=True) + .exclude(email="") + .annotate(email_lower=Lower("email")) + .values("email_lower", "org_id") + .annotate(count=Count("id")) .filter(count__gt=1) ) for dup in duplicates: # Get all contacts with this email in this org, ordered by created_at contacts = list( - Contact.objects - .filter(org_id=dup['org_id']) + Contact.objects.filter(org_id=dup["org_id"]) .exclude(email__isnull=True) - .exclude(email='') - .annotate(email_lower=Lower('email')) - .filter(email_lower=dup['email_lower']) - .order_by('created_at') + .exclude(email="") + .annotate(email_lower=Lower("email")) + .filter(email_lower=dup["email_lower"]) + .order_by("created_at") ) # Keep the first one (oldest), clear email on the rest for contact in contacts[1:]: old_email = contact.email contact.email = None - contact.save(update_fields=['email']) + contact.save(update_fields=["email"]) print(f" Cleared duplicate email on contact {contact.id}: '{old_email}'") @@ -52,7 +50,6 @@ def reverse_noop(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("accounts", "0006_add_enterprise_constraints"), ("common", "0008_enable_rls_product_invoice_line_item"), diff --git a/backend/contacts/views.py b/backend/contacts/views.py index 5feacc94..b9a1f054 100644 --- a/backend/contacts/views.py +++ b/backend/contacts/views.py @@ -3,7 +3,12 @@ import json from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.shortcuts import get_object_or_404 -from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema, inline_serializer +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiParameter, + extend_schema, + inline_serializer, +) from rest_framework import serializers, status from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import IsAuthenticated @@ -51,7 +56,9 @@ class ContactsListView(APIView, LimitOffsetPagination): assigned_to__id__in=params.get("assigned_to") ).distinct() if params.get("tags"): - queryset = queryset.filter(tags__id__in=params.getlist("tags")).distinct() + queryset = queryset.filter( + tags__id__in=params.getlist("tags") + ).distinct() if params.get("search"): search = params.get("search") queryset = queryset.filter( @@ -61,9 +68,13 @@ class ContactsListView(APIView, LimitOffsetPagination): | Q(phone__icontains=search) ) if params.get("created_at__gte"): - queryset = queryset.filter(created_at__gte=params.get("created_at__gte")) + queryset = queryset.filter( + created_at__gte=params.get("created_at__gte") + ) if params.get("created_at__lte"): - queryset = queryset.filter(created_at__lte=params.get("created_at__lte")) + queryset = queryset.filter( + created_at__lte=params.get("created_at__lte") + ) context = {} results_contact = self.paginate_queryset( @@ -160,7 +171,9 @@ class ContactsListView(APIView, LimitOffsetPagination): tags = params.get("tags") if isinstance(tags, str): tags = json.loads(tags) - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) contact_obj.tags.add(*tag_objs) recipients = list(contact_obj.assigned_to.all().values_list("id", flat=True)) @@ -256,7 +269,9 @@ class ContactDetailView(APIView): tags = data.get("tags") if isinstance(tags, str): tags = json.loads(tags) - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) contact_obj.tags.add(*tag_objs) previous_assigned_to_users = list( @@ -514,7 +529,10 @@ class ContactDetailView(APIView): contact_obj = self.get_object(pk=pk) if contact_obj.org != request.profile.org: return Response( - {"error": True, "errors": "User company does not match with header...."}, + { + "error": True, + "errors": "User company does not match with header....", + }, status=status.HTTP_403_FORBIDDEN, ) if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin: @@ -568,7 +586,9 @@ class ContactDetailView(APIView): if tags_list: if isinstance(tags_list, str): tags_list = json.loads(tags_list) - tag_objs = Tags.objects.filter(id__in=tags_list, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags_list, org=request.profile.org, is_active=True + ) contact_obj.tags.add(*tag_objs) return Response( diff --git a/backend/crm/server_settings.py b/backend/crm/server_settings.py index 0d75515b..0699ec2d 100644 --- a/backend/crm/server_settings.py +++ b/backend/crm/server_settings.py @@ -33,8 +33,8 @@ AWS_S3_SECURE_URLS = True EMAIL_BACKEND = "django_ses.SESBackend" SESSION_COOKIE_DOMAIN = ".bottlecrm.io" -SESSION_COOKIE_SECURE = True # Only send session cookie over HTTPS -CSRF_COOKIE_SECURE = True # Only send CSRF cookie over HTTPS +SESSION_COOKIE_SECURE = True # Only send session cookie over HTTPS +CSRF_COOKIE_SECURE = True # Only send CSRF cookie over HTTPS sentry_sdk.init( dsn=os.environ["SENTRY_DSN"], diff --git a/backend/invoices/admin.py b/backend/invoices/admin.py index bc1865ae..dc1e4a7f 100644 --- a/backend/invoices/admin.py +++ b/backend/invoices/admin.py @@ -58,7 +58,14 @@ class InvoiceAdmin(admin.ModelAdmin): "client_email", "account__name", ) - raw_id_fields = ("created_by", "org", "account", "contact", "opportunity", "template") + raw_id_fields = ( + "created_by", + "org", + "account", + "contact", + "opportunity", + "template", + ) filter_horizontal = ("assigned_to", "teams") inlines = [InvoiceLineItemInline, PaymentInline] date_hierarchy = "created_at" diff --git a/backend/invoices/api_views.py b/backend/invoices/api_views.py index 42ab6665..0b81167a 100644 --- a/backend/invoices/api_views.py +++ b/backend/invoices/api_views.py @@ -77,14 +77,17 @@ class InvoiceListView(APIView, LimitOffsetPagination): org = self.request.profile.org role = self.request.profile.role - queryset = self.model.objects.filter(org=org).select_related( - "account", "contact", "opportunity", "created_by" - ).prefetch_related("line_items", "payments", "assigned_to") + queryset = ( + self.model.objects.filter(org=org) + .select_related("account", "contact", "opportunity", "created_by") + .prefetch_related("line_items", "payments", "assigned_to") + ) # Non-admin users can only see their own or assigned invoices if role != "ADMIN" and not self.request.user.is_superuser: queryset = queryset.filter( - Q(created_by=self.request.profile.user) | Q(assigned_to=self.request.profile) + Q(created_by=self.request.profile.user) + | Q(assigned_to=self.request.profile) ).distinct() return queryset @@ -140,7 +143,13 @@ class InvoiceListView(APIView, LimitOffsetPagination): # Sorting sort = params.get("sort", "-created_at") - if sort.lstrip("-") in ["created_at", "due_date", "issue_date", "total_amount", "status"]: + if sort.lstrip("-") in [ + "created_at", + "due_date", + "issue_date", + "total_amount", + "status", + ]: queryset = queryset.order_by(sort) return queryset @@ -339,9 +348,7 @@ class InvoiceSendView(APIView): protocol=request.scheme, ) - return Response( - {"error": False, "message": "Invoice sent successfully"} - ) + return Response({"error": False, "message": "Invoice sent successfully"}) class InvoiceMarkPaidView(APIView): @@ -701,9 +708,7 @@ class PaymentListView(APIView, LimitOffsetPagination): @extend_schema(tags=["Payments"], operation_id="payments_list") def get(self, request, invoice_id): - invoice = Invoice.objects.filter( - id=invoice_id, org=request.profile.org - ).first() + invoice = Invoice.objects.filter(id=invoice_id, org=request.profile.org).first() if not invoice: return Response( {"error": True, "message": "Invoice not found"}, @@ -715,9 +720,7 @@ class PaymentListView(APIView, LimitOffsetPagination): @extend_schema(tags=["Payments"], operation_id="payments_create") def post(self, request, invoice_id): - invoice = Invoice.objects.filter( - id=invoice_id, org=request.profile.org - ).first() + invoice = Invoice.objects.filter(id=invoice_id, org=request.profile.org).first() if not invoice: return Response( {"error": True, "message": "Invoice not found"}, @@ -750,9 +753,13 @@ class PaymentDetailView(APIView): @extend_schema(tags=["Payments"], operation_id="payments_destroy") def delete(self, request, invoice_id, pk): - payment = Payment.objects.filter( - id=pk, invoice_id=invoice_id, org=request.profile.org - ).select_related("invoice").first() + payment = ( + Payment.objects.filter( + id=pk, invoice_id=invoice_id, org=request.profile.org + ) + .select_related("invoice") + .first() + ) if not payment: return Response( {"error": True, "message": "Payment not found"}, @@ -1214,9 +1221,11 @@ class RecurringInvoiceListView(APIView, LimitOffsetPagination): @extend_schema(tags=["Recurring Invoices"], operation_id="recurring_list") def get(self, request): - queryset = RecurringInvoice.objects.filter( - org=request.profile.org - ).select_related("account", "contact").order_by("-created_at") + queryset = ( + RecurringInvoice.objects.filter(org=request.profile.org) + .select_related("account", "contact") + .order_by("-created_at") + ) # Apply filters if request.query_params.get("is_active"): @@ -1363,9 +1372,9 @@ class InvoiceTemplateListView(APIView, LimitOffsetPagination): @extend_schema(tags=["Invoice Templates"], operation_id="templates_list") def get(self, request): - queryset = InvoiceTemplate.objects.filter( - org=request.profile.org - ).order_by("-is_default", "name") + queryset = InvoiceTemplate.objects.filter(org=request.profile.org).order_by( + "-is_default", "name" + ) results = self.paginate_queryset(queryset, request, view=self) return Response( @@ -1474,9 +1483,7 @@ class InvoiceCommentView(APIView): @extend_schema(tags=["Invoice Comments"], operation_id="comments_create") def post(self, request, invoice_id): - invoice = Invoice.objects.filter( - id=invoice_id, org=request.profile.org - ).first() + invoice = Invoice.objects.filter(id=invoice_id, org=request.profile.org).first() if not invoice: return Response( {"error": True, "message": "Invoice not found"}, @@ -1575,9 +1582,7 @@ class InvoiceAttachmentView(APIView): @extend_schema(tags=["Invoice Attachments"], operation_id="attachments_create") def post(self, request, invoice_id): - invoice = Invoice.objects.filter( - id=invoice_id, org=request.profile.org - ).first() + invoice = Invoice.objects.filter(id=invoice_id, org=request.profile.org).first() if not invoice: return Response( {"error": True, "message": "Invoice not found"}, @@ -1617,9 +1622,7 @@ class InvoiceAttachmentDetailView(APIView): @extend_schema(tags=["Invoice Attachments"], operation_id="attachments_destroy") def delete(self, request, pk): - attachment = Attachments.objects.filter( - id=pk, org=request.profile.org - ).first() + attachment = Attachments.objects.filter(id=pk, org=request.profile.org).first() if not attachment: return Response( {"error": True, "message": "Attachment not found"}, @@ -1661,9 +1664,7 @@ class InvoiceDashboardView(APIView): # Status counts status_counts = ( - all_invoices.values("status") - .annotate(count=Count("id")) - .order_by("status") + all_invoices.values("status").annotate(count=Count("id")).order_by("status") ) # Financial summary @@ -1679,14 +1680,18 @@ class InvoiceDashboardView(APIView): due_date__lt=today, ) overdue_count = overdue_invoices.count() - overdue_amount = overdue_invoices.aggregate(total=Sum("amount_due"))["total"] or 0 + overdue_amount = ( + overdue_invoices.aggregate(total=Sum("amount_due"))["total"] or 0 + ) # Recent activity (last 30 days) recent_invoices = all_invoices.filter(created_at__gte=thirty_days_ago) recent_paid = all_invoices.filter(paid_at__gte=thirty_days_ago) recent_revenue = recent_paid.aggregate(total=Sum("amount_paid"))["total"] or 0 - recent_invoiced = recent_invoices.aggregate(total=Sum("total_amount"))["total"] or 0 + recent_invoiced = ( + recent_invoices.aggregate(total=Sum("total_amount"))["total"] or 0 + ) # Estimates summary all_estimates = Estimate.objects.filter(org=org) @@ -1694,31 +1699,33 @@ class InvoiceDashboardView(APIView): estimates_accepted = all_estimates.filter(status="Accepted").count() estimates_declined = all_estimates.filter(status="Declined").count() - return Response({ - "summary": { - "total_invoiced": str(totals["total_invoiced"] or 0), - "total_paid": str(totals["total_paid"] or 0), - "total_due": str(totals["total_due"] or 0), - }, - "status_counts": { - item["status"]: item["count"] for item in status_counts - }, - "overdue": { - "count": overdue_count, - "amount": str(overdue_amount), - }, - "recent_activity": { - "revenue_30d": str(recent_revenue), - "invoiced_30d": str(recent_invoiced), - "invoices_created_30d": recent_invoices.count(), - "invoices_paid_30d": recent_paid.count(), - }, - "estimates": { - "pending": estimates_pending, - "accepted": estimates_accepted, - "declined": estimates_declined, - }, - }) + return Response( + { + "summary": { + "total_invoiced": str(totals["total_invoiced"] or 0), + "total_paid": str(totals["total_paid"] or 0), + "total_due": str(totals["total_due"] or 0), + }, + "status_counts": { + item["status"]: item["count"] for item in status_counts + }, + "overdue": { + "count": overdue_count, + "amount": str(overdue_amount), + }, + "recent_activity": { + "revenue_30d": str(recent_revenue), + "invoiced_30d": str(recent_invoiced), + "invoices_created_30d": recent_invoices.count(), + "invoices_paid_30d": recent_paid.count(), + }, + "estimates": { + "pending": estimates_pending, + "accepted": estimates_accepted, + "declined": estimates_declined, + }, + } + ) class RevenueReportView(APIView): @@ -1755,41 +1762,59 @@ class RevenueReportView(APIView): # Group revenue if group_by == "day": from django.db.models.functions import TruncDay - grouped = paid_invoices.annotate( - period=TruncDay("paid_at") - ).values("period").annotate( - revenue=Sum("amount_paid"), - count=Count("id"), - ).order_by("period") + + grouped = ( + paid_invoices.annotate(period=TruncDay("paid_at")) + .values("period") + .annotate( + revenue=Sum("amount_paid"), + count=Count("id"), + ) + .order_by("period") + ) elif group_by == "week": from django.db.models.functions import TruncWeek - grouped = paid_invoices.annotate( - period=TruncWeek("paid_at") - ).values("period").annotate( - revenue=Sum("amount_paid"), - count=Count("id"), - ).order_by("period") + + grouped = ( + paid_invoices.annotate(period=TruncWeek("paid_at")) + .values("period") + .annotate( + revenue=Sum("amount_paid"), + count=Count("id"), + ) + .order_by("period") + ) elif group_by == "year": from django.db.models.functions import TruncYear - grouped = paid_invoices.annotate( - period=TruncYear("paid_at") - ).values("period").annotate( - revenue=Sum("amount_paid"), - count=Count("id"), - ).order_by("period") + + grouped = ( + paid_invoices.annotate(period=TruncYear("paid_at")) + .values("period") + .annotate( + revenue=Sum("amount_paid"), + count=Count("id"), + ) + .order_by("period") + ) else: # month (default) from django.db.models.functions import TruncMonth - grouped = paid_invoices.annotate( - period=TruncMonth("paid_at") - ).values("period").annotate( - revenue=Sum("amount_paid"), - count=Count("id"), - ).order_by("period") + + grouped = ( + paid_invoices.annotate(period=TruncMonth("paid_at")) + .values("period") + .annotate( + revenue=Sum("amount_paid"), + count=Count("id"), + ) + .order_by("period") + ) # Format results data = [ { - "period": item["period"].strftime("%Y-%m-%d") if item["period"] else None, + "period": item["period"].strftime("%Y-%m-%d") + if item["period"] + else None, "revenue": str(item["revenue"] or 0), "count": item["count"], } @@ -1802,16 +1827,18 @@ class RevenueReportView(APIView): count=Count("id"), ) - return Response({ - "start_date": str(start_date), - "end_date": str(end_date), - "group_by": group_by, - "data": data, - "total": { - "revenue": str(total["revenue"] or 0), - "count": total["count"], - }, - }) + return Response( + { + "start_date": str(start_date), + "end_date": str(end_date), + "group_by": group_by, + "data": data, + "total": { + "revenue": str(total["revenue"] or 0), + "count": total["count"], + }, + } + ) class AgingReportView(APIView): @@ -1868,23 +1895,29 @@ class AgingReportView(APIView): "client_name": inv.client_name, "due_date": str(inv.due_date) if inv.due_date else None, "amount_due": str(inv.amount_due), - "days_overdue": (today - inv.due_date).days if inv.due_date else 0, + "days_overdue": (today - inv.due_date).days + if inv.due_date + else 0, } for inv in invoices[:10] # Limit to 10 per category ], } - return Response({ - "current": summarize(current), - "1_30_days": summarize(days_1_30), - "31_60_days": summarize(days_31_60), - "61_90_days": summarize(days_61_90), - "over_90_days": summarize(over_90), - "total": { - "count": unpaid_invoices.count(), - "amount": str(sum((inv.amount_due or Decimal("0")) for inv in unpaid_invoices)), - }, - }) + return Response( + { + "current": summarize(current), + "1_30_days": summarize(days_1_30), + "31_60_days": summarize(days_31_60), + "61_90_days": summarize(days_61_90), + "over_90_days": summarize(over_90), + "total": { + "count": unpaid_invoices.count(), + "amount": str( + sum((inv.amount_due or Decimal("0")) for inv in unpaid_invoices) + ), + }, + } + ) # ============================================================================= @@ -1917,9 +1950,11 @@ class InvoiceFromOpportunityView(APIView): org = request.profile.org # Get the opportunity - opportunity = Opportunity.objects.filter( - id=opportunity_id, org=org - ).prefetch_related("line_items", "contacts").first() + opportunity = ( + Opportunity.objects.filter(id=opportunity_id, org=org) + .prefetch_related("line_items", "contacts") + .first() + ) if not opportunity: return Response( @@ -1934,14 +1969,20 @@ class InvoiceFromOpportunityView(APIView): or (request.profile in opportunity.assigned_to.all()) ): return Response( - {"error": True, "message": "You do not have permission to create invoice from this opportunity"}, + { + "error": True, + "message": "You do not have permission to create invoice from this opportunity", + }, status=status.HTTP_403_FORBIDDEN, ) # Verify opportunity is CLOSED_WON if opportunity.stage != "CLOSED_WON": return Response( - {"error": True, "message": "Invoice can only be created from CLOSED_WON opportunities"}, + { + "error": True, + "message": "Invoice can only be created from CLOSED_WON opportunities", + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -1949,14 +1990,20 @@ class InvoiceFromOpportunityView(APIView): line_items = opportunity.line_items.all() if not line_items.exists(): return Response( - {"error": True, "message": "Opportunity has no products/line items to invoice"}, + { + "error": True, + "message": "Opportunity has no products/line items to invoice", + }, status=status.HTTP_400_BAD_REQUEST, ) # Check if opportunity has an account if not opportunity.account: return Response( - {"error": True, "message": "Opportunity must have an account to create an invoice"}, + { + "error": True, + "message": "Opportunity must have an account to create an invoice", + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -1966,19 +2013,26 @@ class InvoiceFromOpportunityView(APIView): if not primary_contact: return Response( - {"error": True, "message": "Opportunity must have at least one contact to create an invoice"}, + { + "error": True, + "message": "Opportunity must have at least one contact to create an invoice", + }, status=status.HTTP_400_BAD_REQUEST, ) with transaction.atomic(): # Generate invoice number - last_invoice = Invoice.objects.filter(org=org).order_by("-created_at").first() + last_invoice = ( + Invoice.objects.filter(org=org).order_by("-created_at").first() + ) if last_invoice and last_invoice.invoice_number: try: last_num = int(last_invoice.invoice_number.replace("INV-", "")) new_number = f"INV-{last_num + 1:06d}" except ValueError: - new_number = f"INV-{Invoice.objects.filter(org=org).count() + 1:06d}" + new_number = ( + f"INV-{Invoice.objects.filter(org=org).count() + 1:06d}" + ) else: new_number = "INV-000001" diff --git a/backend/invoices/migrations/0001_initial.py b/backend/invoices/migrations/0001_initial.py index 37154dc1..986b0d65 100644 --- a/backend/invoices/migrations/0001_initial.py +++ b/backend/invoices/migrations/0001_initial.py @@ -8,7 +8,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ @@ -238,9 +237,7 @@ class Migration(migrations.Migration): ), ( "phone", - models.CharField( - blank=True, max_length=20, null=True - ), + models.CharField(blank=True, max_length=20, null=True), ), ( "amount_due", @@ -630,9 +627,7 @@ class Migration(migrations.Migration): ), ( "phone", - models.CharField( - blank=True, max_length=20, null=True - ), + models.CharField(blank=True, max_length=20, null=True), ), ( "amount_due", diff --git a/backend/invoices/migrations/0002_remove_phonenumberfield.py b/backend/invoices/migrations/0002_remove_phonenumberfield.py index d7bf463e..3b1ed9b8 100644 --- a/backend/invoices/migrations/0002_remove_phonenumberfield.py +++ b/backend/invoices/migrations/0002_remove_phonenumberfield.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("invoices", "0001_initial"), ] diff --git a/backend/invoices/migrations/0003_add_currency_fields.py b/backend/invoices/migrations/0003_add_currency_fields.py index bbfa2351..c051a95b 100644 --- a/backend/invoices/migrations/0003_add_currency_fields.py +++ b/backend/invoices/migrations/0003_add_currency_fields.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("invoices", "0002_remove_phonenumberfield"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/backend/invoices/migrations/0004_estimate_estimatelineitem_invoicetemplate_payment_and_more.py b/backend/invoices/migrations/0004_estimate_estimatelineitem_invoicetemplate_payment_and_more.py index 282e0015..bfddc78c 100644 --- a/backend/invoices/migrations/0004_estimate_estimatelineitem_invoicetemplate_payment_and_more.py +++ b/backend/invoices/migrations/0004_estimate_estimatelineitem_invoicetemplate_payment_and_more.py @@ -8,884 +8,2716 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('accounts', '0006_add_enterprise_constraints'), - ('common', '0008_enable_rls_product_invoice_line_item'), - ('contacts', '0010_add_enterprise_constraints'), - ('invoices', '0003_add_currency_fields'), - ('opportunity', '0005_add_enterprise_constraints'), + ("accounts", "0006_add_enterprise_constraints"), + ("common", "0008_enable_rls_product_invoice_line_item"), + ("contacts", "0010_add_enterprise_constraints"), + ("invoices", "0003_add_currency_fields"), + ("opportunity", "0005_add_enterprise_constraints"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Estimate', + name="Estimate", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('estimate_number', models.CharField(max_length=50, unique=True, verbose_name='Estimate Number')), - ('title', models.CharField(max_length=100, verbose_name='Title')), - ('status', models.CharField(choices=[('Draft', 'Draft'), ('Sent', 'Sent'), ('Viewed', 'Viewed'), ('Accepted', 'Accepted'), ('Declined', 'Declined'), ('Expired', 'Expired')], default='Draft', max_length=20, verbose_name='Status')), - ('client_name', models.CharField(default='', max_length=255, verbose_name='Client Name')), - ('client_email', models.EmailField(default='', max_length=254, verbose_name='Client Email')), - ('client_phone', models.CharField(blank=True, max_length=25, verbose_name='Client Phone')), - ('client_address_line', models.CharField(blank=True, max_length=255, verbose_name='Client Address')), - ('client_city', models.CharField(blank=True, max_length=100, verbose_name='Client City')), - ('client_state', models.CharField(blank=True, max_length=100, verbose_name='Client State')), - ('client_postcode', models.CharField(blank=True, max_length=20, verbose_name='Client Postal Code')), - ('client_country', models.CharField(blank=True, choices=[('GB', 'United Kingdom'), ('AF', 'Afghanistan'), ('AX', 'Aland Islands'), ('AL', 'Albania'), ('DZ', 'Algeria'), ('AS', 'American Samoa'), ('AD', 'Andorra'), ('AO', 'Angola'), ('AI', 'Anguilla'), ('AQ', 'Antarctica'), ('AG', 'Antigua and Barbuda'), ('AR', 'Argentina'), ('AM', 'Armenia'), ('AW', 'Aruba'), ('AU', 'Australia'), ('AT', 'Austria'), ('AZ', 'Azerbaijan'), ('BS', 'Bahamas'), ('BH', 'Bahrain'), ('BD', 'Bangladesh'), ('BB', 'Barbados'), ('BY', 'Belarus'), ('BE', 'Belgium'), ('BZ', 'Belize'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BT', 'Bhutan'), ('BO', 'Bolivia'), ('BA', 'Bosnia and Herzegovina'), ('BW', 'Botswana'), ('BV', 'Bouvet Island'), ('BR', 'Brazil'), ('IO', 'British Indian Ocean Territory'), ('BN', 'Brunei Darussalam'), ('BG', 'Bulgaria'), ('BF', 'Burkina Faso'), ('BI', 'Burundi'), ('KH', 'Cambodia'), ('CM', 'Cameroon'), ('CA', 'Canada'), ('CV', 'Cape Verde'), ('KY', 'Cayman Islands'), ('CF', 'Central African Republic'), ('TD', 'Chad'), ('CL', 'Chile'), ('CN', 'China'), ('CX', 'Christmas Island'), ('CC', 'Cocos (Keeling) Islands'), ('CO', 'Colombia'), ('KM', 'Comoros'), ('CG', 'Congo'), ('CD', 'Congo, The Democratic Republic of the'), ('CK', 'Cook Islands'), ('CR', 'Costa Rica'), ('CI', "Cote d'Ivoire"), ('HR', 'Croatia'), ('CU', 'Cuba'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DK', 'Denmark'), ('DJ', 'Djibouti'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('EC', 'Ecuador'), ('EG', 'Egypt'), ('SV', 'El Salvador'), ('GQ', 'Equatorial Guinea'), ('ER', 'Eritrea'), ('EE', 'Estonia'), ('ET', 'Ethiopia'), ('FK', 'Falkland Islands (Malvinas)'), ('FO', 'Faroe Islands'), ('FJ', 'Fiji'), ('FI', 'Finland'), ('FR', 'France'), ('GF', 'French Guiana'), ('PF', 'French Polynesia'), ('TF', 'French Southern Territories'), ('GA', 'Gabon'), ('GM', 'Gambia'), ('GE', 'Georgia'), ('DE', 'Germany'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GR', 'Greece'), ('GL', 'Greenland'), ('GD', 'Grenada'), ('GP', 'Guadeloupe'), ('GU', 'Guam'), ('GT', 'Guatemala'), ('GG', 'Guernsey'), ('GN', 'Guinea'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HT', 'Haiti'), ('HM', 'Heard Island and McDonald Islands'), ('VA', 'Holy See (Vatican City State)'), ('HN', 'Honduras'), ('HK', 'Hong Kong'), ('HU', 'Hungary'), ('IS', 'Iceland'), ('IN', 'India'), ('ID', 'Indonesia'), ('IR', 'Iran, Islamic Republic of'), ('IQ', 'Iraq'), ('IE', 'Ireland'), ('IM', 'Isle of Man'), ('IL', 'Israel'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JP', 'Japan'), ('JE', 'Jersey'), ('JO', 'Jordan'), ('KZ', 'Kazakhstan'), ('KE', 'Kenya'), ('KI', 'Kiribati'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KG', 'Kyrgyzstan'), ('LA', "Lao People's Democratic Republic"), ('LV', 'Latvia'), ('LB', 'Lebanon'), ('LS', 'Lesotho'), ('LR', 'Liberia'), ('LY', 'Libyan Arab Jamahiriya'), ('LI', 'Liechtenstein'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('MO', 'Macao'), ('MK', 'Macedonia, The Former Yugoslav Republic of'), ('MG', 'Madagascar'), ('MW', 'Malawi'), ('MY', 'Malaysia'), ('MV', 'Maldives'), ('ML', 'Mali'), ('MT', 'Malta'), ('MH', 'Marshall Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MU', 'Mauritius'), ('YT', 'Mayotte'), ('MX', 'Mexico'), ('FM', 'Micronesia, Federated States of'), ('MD', 'Moldova'), ('MC', 'Monaco'), ('MN', 'Mongolia'), ('ME', 'Montenegro'), ('MS', 'Montserrat'), ('MA', 'Morocco'), ('MZ', 'Mozambique'), ('MM', 'Myanmar'), ('NA', 'Namibia'), ('NR', 'Nauru'), ('NP', 'Nepal'), ('NL', 'Netherlands'), ('AN', 'Netherlands Antilles'), ('NC', 'New Caledonia'), ('NZ', 'New Zealand'), ('NI', 'Nicaragua'), ('NE', 'Niger'), ('NG', 'Nigeria'), ('NU', 'Niue'), ('NF', 'Norfolk Island'), ('MP', 'Northern Mariana Islands'), ('NO', 'Norway'), ('OM', 'Oman'), ('PK', 'Pakistan'), ('PW', 'Palau'), ('PS', 'Palestinian Territory, Occupied'), ('PA', 'Panama'), ('PG', 'Papua New Guinea'), ('PY', 'Paraguay'), ('PE', 'Peru'), ('PH', 'Philippines'), ('PN', 'Pitcairn'), ('PL', 'Poland'), ('PT', 'Portugal'), ('PR', 'Puerto Rico'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('BL', 'Saint Barthelemy'), ('SH', 'Saint Helena'), ('KN', 'Saint Kitts and Nevis'), ('LC', 'Saint Lucia'), ('MF', 'Saint Martin'), ('PM', 'Saint Pierre and Miquelon'), ('VC', 'Saint Vincent and the Grenadines'), ('WS', 'Samoa'), ('SM', 'San Marino'), ('ST', 'Sao Tome and Principe'), ('SA', 'Saudi Arabia'), ('SN', 'Senegal'), ('RS', 'Serbia'), ('SC', 'Seychelles'), ('SL', 'Sierra Leone'), ('SG', 'Singapore'), ('SK', 'Slovakia'), ('SI', 'Slovenia'), ('SB', 'Solomon Islands'), ('SO', 'Somalia'), ('ZA', 'South Africa'), ('GS', 'South Georgia and the South Sandwich Islands'), ('ES', 'Spain'), ('LK', 'Sri Lanka'), ('SD', 'Sudan'), ('SR', 'Suriname'), ('SJ', 'Svalbard and Jan Mayen'), ('SZ', 'Swaziland'), ('SE', 'Sweden'), ('CH', 'Switzerland'), ('SY', 'Syrian Arab Republic'), ('TW', 'Taiwan, Province of China'), ('TJ', 'Tajikistan'), ('TZ', 'Tanzania, United Republic of'), ('TH', 'Thailand'), ('TL', 'Timor-Leste'), ('TG', 'Togo'), ('TK', 'Tokelau'), ('TO', 'Tonga'), ('TT', 'Trinidad and Tobago'), ('TN', 'Tunisia'), ('TR', 'Turkey'), ('TM', 'Turkmenistan'), ('TC', 'Turks and Caicos Islands'), ('TV', 'Tuvalu'), ('UG', 'Uganda'), ('UA', 'Ukraine'), ('AE', 'United Arab Emirates'), ('US', 'United States'), ('UM', 'United States Minor Outlying Islands'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VU', 'Vanuatu'), ('VE', 'Venezuela'), ('VN', 'Viet Nam'), ('VG', 'Virgin Islands, British'), ('VI', 'Virgin Islands, U.S.'), ('WF', 'Wallis and Futuna'), ('EH', 'Western Sahara'), ('YE', 'Yemen'), ('ZM', 'Zambia'), ('ZW', 'Zimbabwe')], max_length=3, verbose_name='Client Country')), - ('subtotal', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Subtotal')), - ('discount_type', models.CharField(blank=True, choices=[('PERCENTAGE', 'Percentage (%)'), ('FIXED', 'Fixed Amount')], max_length=20, verbose_name='Discount Type')), - ('discount_value', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Discount Value')), - ('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Discount Amount')), - ('tax_rate', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Tax Rate (%)')), - ('tax_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Tax Amount')), - ('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Total Amount')), - ('currency', models.CharField(choices=[('USD', 'USD, Dollar'), ('EUR', 'EUR, Euro'), ('GBP', 'GBP, Pound'), ('INR', 'INR, Rupee'), ('CAD', 'CAD, Dollar'), ('AUD', 'AUD, Dollar'), ('JPY', 'JPY, Yen'), ('CNY', 'CNY, Yuan'), ('CHF', 'CHF, Franc'), ('SGD', 'SGD, Dollar'), ('AED', 'AED, Dirham'), ('BRL', 'BRL, Real'), ('MXN', 'MXN, Peso')], default='USD', max_length=3, verbose_name='Currency')), - ('issue_date', models.DateField(default=datetime.date.today, verbose_name='Issue Date')), - ('expiry_date', models.DateField(blank=True, null=True, verbose_name='Expiry Date')), - ('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='Sent At')), - ('viewed_at', models.DateTimeField(blank=True, null=True, verbose_name='Viewed At')), - ('accepted_at', models.DateTimeField(blank=True, null=True, verbose_name='Accepted At')), - ('declined_at', models.DateTimeField(blank=True, null=True, verbose_name='Declined At')), - ('public_token', models.CharField(blank=True, max_length=64, unique=True, verbose_name='Public Token')), - ('public_link_enabled', models.BooleanField(default=True, verbose_name='Public Link Enabled')), - ('notes', models.TextField(blank=True, verbose_name='Notes')), - ('terms', models.TextField(blank=True, verbose_name='Terms & Conditions')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "estimate_number", + models.CharField( + max_length=50, unique=True, verbose_name="Estimate Number" + ), + ), + ("title", models.CharField(max_length=100, verbose_name="Title")), + ( + "status", + models.CharField( + choices=[ + ("Draft", "Draft"), + ("Sent", "Sent"), + ("Viewed", "Viewed"), + ("Accepted", "Accepted"), + ("Declined", "Declined"), + ("Expired", "Expired"), + ], + default="Draft", + max_length=20, + verbose_name="Status", + ), + ), + ( + "client_name", + models.CharField( + default="", max_length=255, verbose_name="Client Name" + ), + ), + ( + "client_email", + models.EmailField( + default="", max_length=254, verbose_name="Client Email" + ), + ), + ( + "client_phone", + models.CharField( + blank=True, max_length=25, verbose_name="Client Phone" + ), + ), + ( + "client_address_line", + models.CharField( + blank=True, max_length=255, verbose_name="Client Address" + ), + ), + ( + "client_city", + models.CharField( + blank=True, max_length=100, verbose_name="Client City" + ), + ), + ( + "client_state", + models.CharField( + blank=True, max_length=100, verbose_name="Client State" + ), + ), + ( + "client_postcode", + models.CharField( + blank=True, max_length=20, verbose_name="Client Postal Code" + ), + ), + ( + "client_country", + models.CharField( + blank=True, + choices=[ + ("GB", "United Kingdom"), + ("AF", "Afghanistan"), + ("AX", "Aland Islands"), + ("AL", "Albania"), + ("DZ", "Algeria"), + ("AS", "American Samoa"), + ("AD", "Andorra"), + ("AO", "Angola"), + ("AI", "Anguilla"), + ("AQ", "Antarctica"), + ("AG", "Antigua and Barbuda"), + ("AR", "Argentina"), + ("AM", "Armenia"), + ("AW", "Aruba"), + ("AU", "Australia"), + ("AT", "Austria"), + ("AZ", "Azerbaijan"), + ("BS", "Bahamas"), + ("BH", "Bahrain"), + ("BD", "Bangladesh"), + ("BB", "Barbados"), + ("BY", "Belarus"), + ("BE", "Belgium"), + ("BZ", "Belize"), + ("BJ", "Benin"), + ("BM", "Bermuda"), + ("BT", "Bhutan"), + ("BO", "Bolivia"), + ("BA", "Bosnia and Herzegovina"), + ("BW", "Botswana"), + ("BV", "Bouvet Island"), + ("BR", "Brazil"), + ("IO", "British Indian Ocean Territory"), + ("BN", "Brunei Darussalam"), + ("BG", "Bulgaria"), + ("BF", "Burkina Faso"), + ("BI", "Burundi"), + ("KH", "Cambodia"), + ("CM", "Cameroon"), + ("CA", "Canada"), + ("CV", "Cape Verde"), + ("KY", "Cayman Islands"), + ("CF", "Central African Republic"), + ("TD", "Chad"), + ("CL", "Chile"), + ("CN", "China"), + ("CX", "Christmas Island"), + ("CC", "Cocos (Keeling) Islands"), + ("CO", "Colombia"), + ("KM", "Comoros"), + ("CG", "Congo"), + ("CD", "Congo, The Democratic Republic of the"), + ("CK", "Cook Islands"), + ("CR", "Costa Rica"), + ("CI", "Cote d'Ivoire"), + ("HR", "Croatia"), + ("CU", "Cuba"), + ("CY", "Cyprus"), + ("CZ", "Czech Republic"), + ("DK", "Denmark"), + ("DJ", "Djibouti"), + ("DM", "Dominica"), + ("DO", "Dominican Republic"), + ("EC", "Ecuador"), + ("EG", "Egypt"), + ("SV", "El Salvador"), + ("GQ", "Equatorial Guinea"), + ("ER", "Eritrea"), + ("EE", "Estonia"), + ("ET", "Ethiopia"), + ("FK", "Falkland Islands (Malvinas)"), + ("FO", "Faroe Islands"), + ("FJ", "Fiji"), + ("FI", "Finland"), + ("FR", "France"), + ("GF", "French Guiana"), + ("PF", "French Polynesia"), + ("TF", "French Southern Territories"), + ("GA", "Gabon"), + ("GM", "Gambia"), + ("GE", "Georgia"), + ("DE", "Germany"), + ("GH", "Ghana"), + ("GI", "Gibraltar"), + ("GR", "Greece"), + ("GL", "Greenland"), + ("GD", "Grenada"), + ("GP", "Guadeloupe"), + ("GU", "Guam"), + ("GT", "Guatemala"), + ("GG", "Guernsey"), + ("GN", "Guinea"), + ("GW", "Guinea-Bissau"), + ("GY", "Guyana"), + ("HT", "Haiti"), + ("HM", "Heard Island and McDonald Islands"), + ("VA", "Holy See (Vatican City State)"), + ("HN", "Honduras"), + ("HK", "Hong Kong"), + ("HU", "Hungary"), + ("IS", "Iceland"), + ("IN", "India"), + ("ID", "Indonesia"), + ("IR", "Iran, Islamic Republic of"), + ("IQ", "Iraq"), + ("IE", "Ireland"), + ("IM", "Isle of Man"), + ("IL", "Israel"), + ("IT", "Italy"), + ("JM", "Jamaica"), + ("JP", "Japan"), + ("JE", "Jersey"), + ("JO", "Jordan"), + ("KZ", "Kazakhstan"), + ("KE", "Kenya"), + ("KI", "Kiribati"), + ("KP", "Korea, Democratic People's Republic of"), + ("KR", "Korea, Republic of"), + ("KW", "Kuwait"), + ("KG", "Kyrgyzstan"), + ("LA", "Lao People's Democratic Republic"), + ("LV", "Latvia"), + ("LB", "Lebanon"), + ("LS", "Lesotho"), + ("LR", "Liberia"), + ("LY", "Libyan Arab Jamahiriya"), + ("LI", "Liechtenstein"), + ("LT", "Lithuania"), + ("LU", "Luxembourg"), + ("MO", "Macao"), + ("MK", "Macedonia, The Former Yugoslav Republic of"), + ("MG", "Madagascar"), + ("MW", "Malawi"), + ("MY", "Malaysia"), + ("MV", "Maldives"), + ("ML", "Mali"), + ("MT", "Malta"), + ("MH", "Marshall Islands"), + ("MQ", "Martinique"), + ("MR", "Mauritania"), + ("MU", "Mauritius"), + ("YT", "Mayotte"), + ("MX", "Mexico"), + ("FM", "Micronesia, Federated States of"), + ("MD", "Moldova"), + ("MC", "Monaco"), + ("MN", "Mongolia"), + ("ME", "Montenegro"), + ("MS", "Montserrat"), + ("MA", "Morocco"), + ("MZ", "Mozambique"), + ("MM", "Myanmar"), + ("NA", "Namibia"), + ("NR", "Nauru"), + ("NP", "Nepal"), + ("NL", "Netherlands"), + ("AN", "Netherlands Antilles"), + ("NC", "New Caledonia"), + ("NZ", "New Zealand"), + ("NI", "Nicaragua"), + ("NE", "Niger"), + ("NG", "Nigeria"), + ("NU", "Niue"), + ("NF", "Norfolk Island"), + ("MP", "Northern Mariana Islands"), + ("NO", "Norway"), + ("OM", "Oman"), + ("PK", "Pakistan"), + ("PW", "Palau"), + ("PS", "Palestinian Territory, Occupied"), + ("PA", "Panama"), + ("PG", "Papua New Guinea"), + ("PY", "Paraguay"), + ("PE", "Peru"), + ("PH", "Philippines"), + ("PN", "Pitcairn"), + ("PL", "Poland"), + ("PT", "Portugal"), + ("PR", "Puerto Rico"), + ("QA", "Qatar"), + ("RE", "Reunion"), + ("RO", "Romania"), + ("RU", "Russian Federation"), + ("RW", "Rwanda"), + ("BL", "Saint Barthelemy"), + ("SH", "Saint Helena"), + ("KN", "Saint Kitts and Nevis"), + ("LC", "Saint Lucia"), + ("MF", "Saint Martin"), + ("PM", "Saint Pierre and Miquelon"), + ("VC", "Saint Vincent and the Grenadines"), + ("WS", "Samoa"), + ("SM", "San Marino"), + ("ST", "Sao Tome and Principe"), + ("SA", "Saudi Arabia"), + ("SN", "Senegal"), + ("RS", "Serbia"), + ("SC", "Seychelles"), + ("SL", "Sierra Leone"), + ("SG", "Singapore"), + ("SK", "Slovakia"), + ("SI", "Slovenia"), + ("SB", "Solomon Islands"), + ("SO", "Somalia"), + ("ZA", "South Africa"), + ("GS", "South Georgia and the South Sandwich Islands"), + ("ES", "Spain"), + ("LK", "Sri Lanka"), + ("SD", "Sudan"), + ("SR", "Suriname"), + ("SJ", "Svalbard and Jan Mayen"), + ("SZ", "Swaziland"), + ("SE", "Sweden"), + ("CH", "Switzerland"), + ("SY", "Syrian Arab Republic"), + ("TW", "Taiwan, Province of China"), + ("TJ", "Tajikistan"), + ("TZ", "Tanzania, United Republic of"), + ("TH", "Thailand"), + ("TL", "Timor-Leste"), + ("TG", "Togo"), + ("TK", "Tokelau"), + ("TO", "Tonga"), + ("TT", "Trinidad and Tobago"), + ("TN", "Tunisia"), + ("TR", "Turkey"), + ("TM", "Turkmenistan"), + ("TC", "Turks and Caicos Islands"), + ("TV", "Tuvalu"), + ("UG", "Uganda"), + ("UA", "Ukraine"), + ("AE", "United Arab Emirates"), + ("US", "United States"), + ("UM", "United States Minor Outlying Islands"), + ("UY", "Uruguay"), + ("UZ", "Uzbekistan"), + ("VU", "Vanuatu"), + ("VE", "Venezuela"), + ("VN", "Viet Nam"), + ("VG", "Virgin Islands, British"), + ("VI", "Virgin Islands, U.S."), + ("WF", "Wallis and Futuna"), + ("EH", "Western Sahara"), + ("YE", "Yemen"), + ("ZM", "Zambia"), + ("ZW", "Zimbabwe"), + ], + max_length=3, + verbose_name="Client Country", + ), + ), + ( + "subtotal", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Subtotal", + ), + ), + ( + "discount_type", + models.CharField( + blank=True, + choices=[ + ("PERCENTAGE", "Percentage (%)"), + ("FIXED", "Fixed Amount"), + ], + max_length=20, + verbose_name="Discount Type", + ), + ), + ( + "discount_value", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Discount Value", + ), + ), + ( + "discount_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Discount Amount", + ), + ), + ( + "tax_rate", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=5, + verbose_name="Tax Rate (%)", + ), + ), + ( + "tax_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Tax Amount", + ), + ), + ( + "total_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Total Amount", + ), + ), + ( + "currency", + models.CharField( + choices=[ + ("USD", "USD, Dollar"), + ("EUR", "EUR, Euro"), + ("GBP", "GBP, Pound"), + ("INR", "INR, Rupee"), + ("CAD", "CAD, Dollar"), + ("AUD", "AUD, Dollar"), + ("JPY", "JPY, Yen"), + ("CNY", "CNY, Yuan"), + ("CHF", "CHF, Franc"), + ("SGD", "SGD, Dollar"), + ("AED", "AED, Dirham"), + ("BRL", "BRL, Real"), + ("MXN", "MXN, Peso"), + ], + default="USD", + max_length=3, + verbose_name="Currency", + ), + ), + ( + "issue_date", + models.DateField( + default=datetime.date.today, verbose_name="Issue Date" + ), + ), + ( + "expiry_date", + models.DateField(blank=True, null=True, verbose_name="Expiry Date"), + ), + ( + "sent_at", + models.DateTimeField(blank=True, null=True, verbose_name="Sent At"), + ), + ( + "viewed_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Viewed At" + ), + ), + ( + "accepted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Accepted At" + ), + ), + ( + "declined_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Declined At" + ), + ), + ( + "public_token", + models.CharField( + blank=True, + max_length=64, + unique=True, + verbose_name="Public Token", + ), + ), + ( + "public_link_enabled", + models.BooleanField( + default=True, verbose_name="Public Link Enabled" + ), + ), + ("notes", models.TextField(blank=True, verbose_name="Notes")), + ( + "terms", + models.TextField(blank=True, verbose_name="Terms & Conditions"), + ), ], options={ - 'verbose_name': 'Estimate', - 'verbose_name_plural': 'Estimates', - 'db_table': 'estimate', - 'ordering': ('-created_at',), + "verbose_name": "Estimate", + "verbose_name_plural": "Estimates", + "db_table": "estimate", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='EstimateLineItem', + name="EstimateLineItem", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('description', models.CharField(max_length=500, verbose_name='Description')), - ('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Quantity')), - ('unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Unit Price')), - ('discount_type', models.CharField(blank=True, choices=[('PERCENTAGE', 'Percentage (%)'), ('FIXED', 'Fixed Amount')], max_length=20, verbose_name='Discount Type')), - ('discount_value', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Discount Value')), - ('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Discount Amount')), - ('tax_rate', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Tax Rate (%)')), - ('tax_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Tax Amount')), - ('subtotal', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Subtotal')), - ('total', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Total')), - ('order', models.PositiveIntegerField(default=0, verbose_name='Order')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "description", + models.CharField(max_length=500, verbose_name="Description"), + ), + ( + "quantity", + models.DecimalField( + decimal_places=2, + default=1, + max_digits=10, + verbose_name="Quantity", + ), + ), + ( + "unit_price", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Unit Price", + ), + ), + ( + "discount_type", + models.CharField( + blank=True, + choices=[ + ("PERCENTAGE", "Percentage (%)"), + ("FIXED", "Fixed Amount"), + ], + max_length=20, + verbose_name="Discount Type", + ), + ), + ( + "discount_value", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Discount Value", + ), + ), + ( + "discount_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Discount Amount", + ), + ), + ( + "tax_rate", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=5, + verbose_name="Tax Rate (%)", + ), + ), + ( + "tax_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Tax Amount", + ), + ), + ( + "subtotal", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Subtotal", + ), + ), + ( + "total", + models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Total" + ), + ), + ("order", models.PositiveIntegerField(default=0, verbose_name="Order")), ], options={ - 'verbose_name': 'Estimate Line Item', - 'verbose_name_plural': 'Estimate Line Items', - 'db_table': 'estimate_line_item', - 'ordering': ('order',), + "verbose_name": "Estimate Line Item", + "verbose_name_plural": "Estimate Line Items", + "db_table": "estimate_line_item", + "ordering": ("order",), }, ), migrations.CreateModel( - name='InvoiceTemplate', + name="InvoiceTemplate", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=100, verbose_name='Template Name')), - ('logo', models.ImageField(blank=True, null=True, upload_to='invoice_templates/logos/', verbose_name='Logo')), - ('primary_color', models.CharField(default='#3B82F6', max_length=7, verbose_name='Primary Color')), - ('secondary_color', models.CharField(default='#1E40AF', max_length=7, verbose_name='Secondary Color')), - ('template_html', models.TextField(blank=True, verbose_name='Custom HTML')), - ('template_css', models.TextField(blank=True, verbose_name='Custom CSS')), - ('default_notes', models.TextField(blank=True, verbose_name='Default Notes')), - ('default_terms', models.TextField(blank=True, verbose_name='Default Terms')), - ('footer_text', models.TextField(blank=True, verbose_name='Footer Text')), - ('is_default', models.BooleanField(default=False, verbose_name='Is Default')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=100, verbose_name="Template Name"), + ), + ( + "logo", + models.ImageField( + blank=True, + null=True, + upload_to="invoice_templates/logos/", + verbose_name="Logo", + ), + ), + ( + "primary_color", + models.CharField( + default="#3B82F6", max_length=7, verbose_name="Primary Color" + ), + ), + ( + "secondary_color", + models.CharField( + default="#1E40AF", max_length=7, verbose_name="Secondary Color" + ), + ), + ( + "template_html", + models.TextField(blank=True, verbose_name="Custom HTML"), + ), + ( + "template_css", + models.TextField(blank=True, verbose_name="Custom CSS"), + ), + ( + "default_notes", + models.TextField(blank=True, verbose_name="Default Notes"), + ), + ( + "default_terms", + models.TextField(blank=True, verbose_name="Default Terms"), + ), + ( + "footer_text", + models.TextField(blank=True, verbose_name="Footer Text"), + ), + ( + "is_default", + models.BooleanField(default=False, verbose_name="Is Default"), + ), ], options={ - 'verbose_name': 'Invoice Template', - 'verbose_name_plural': 'Invoice Templates', - 'db_table': 'invoice_template', - 'ordering': ('-created_at',), + "verbose_name": "Invoice Template", + "verbose_name_plural": "Invoice Templates", + "db_table": "invoice_template", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Payment', + name="Payment", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('amount', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Amount')), - ('payment_date', models.DateField(verbose_name='Payment Date')), - ('payment_method', models.CharField(choices=[('CASH', 'Cash'), ('CHECK', 'Check'), ('CREDIT_CARD', 'Credit Card'), ('BANK_TRANSFER', 'Bank Transfer'), ('PAYPAL', 'PayPal'), ('STRIPE', 'Stripe'), ('OTHER', 'Other')], max_length=20, verbose_name='Payment Method')), - ('reference_number', models.CharField(blank=True, max_length=100, verbose_name='Reference Number')), - ('notes', models.TextField(blank=True, verbose_name='Notes')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "amount", + models.DecimalField( + decimal_places=2, max_digits=12, verbose_name="Amount" + ), + ), + ("payment_date", models.DateField(verbose_name="Payment Date")), + ( + "payment_method", + models.CharField( + choices=[ + ("CASH", "Cash"), + ("CHECK", "Check"), + ("CREDIT_CARD", "Credit Card"), + ("BANK_TRANSFER", "Bank Transfer"), + ("PAYPAL", "PayPal"), + ("STRIPE", "Stripe"), + ("OTHER", "Other"), + ], + max_length=20, + verbose_name="Payment Method", + ), + ), + ( + "reference_number", + models.CharField( + blank=True, max_length=100, verbose_name="Reference Number" + ), + ), + ("notes", models.TextField(blank=True, verbose_name="Notes")), ], options={ - 'verbose_name': 'Payment', - 'verbose_name_plural': 'Payments', - 'db_table': 'payment', - 'ordering': ('-payment_date',), + "verbose_name": "Payment", + "verbose_name_plural": "Payments", + "db_table": "payment", + "ordering": ("-payment_date",), }, ), migrations.CreateModel( - name='RecurringInvoice', + name="RecurringInvoice", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('title', models.CharField(max_length=100, verbose_name='Title')), - ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), - ('client_name', models.CharField(default='', max_length=255, verbose_name='Client Name')), - ('client_email', models.EmailField(default='', max_length=254, verbose_name='Client Email')), - ('frequency', models.CharField(choices=[('WEEKLY', 'Weekly'), ('BIWEEKLY', 'Bi-weekly'), ('MONTHLY', 'Monthly'), ('QUARTERLY', 'Quarterly'), ('SEMI_ANNUALLY', 'Semi-annually'), ('YEARLY', 'Yearly'), ('CUSTOM', 'Custom')], default='MONTHLY', max_length=20, verbose_name='Frequency')), - ('custom_days', models.PositiveIntegerField(blank=True, null=True, verbose_name='Custom Days Interval')), - ('start_date', models.DateField(default=datetime.date.today, verbose_name='Start Date')), - ('end_date', models.DateField(blank=True, null=True, verbose_name='End Date')), - ('next_generation_date', models.DateField(default=datetime.date.today, verbose_name='Next Generation Date')), - ('payment_terms', models.CharField(choices=[('DUE_ON_RECEIPT', 'Due on Receipt'), ('NET_15', 'Net 15'), ('NET_30', 'Net 30'), ('NET_45', 'Net 45'), ('NET_60', 'Net 60'), ('CUSTOM', 'Custom')], default='NET_30', max_length=20, verbose_name='Payment Terms')), - ('auto_send', models.BooleanField(default=False, verbose_name='Auto Send')), - ('currency', models.CharField(choices=[('USD', 'USD, Dollar'), ('EUR', 'EUR, Euro'), ('GBP', 'GBP, Pound'), ('INR', 'INR, Rupee'), ('CAD', 'CAD, Dollar'), ('AUD', 'AUD, Dollar'), ('JPY', 'JPY, Yen'), ('CNY', 'CNY, Yuan'), ('CHF', 'CHF, Franc'), ('SGD', 'SGD, Dollar'), ('AED', 'AED, Dirham'), ('BRL', 'BRL, Real'), ('MXN', 'MXN, Peso')], default='USD', max_length=3, verbose_name='Currency')), - ('subtotal', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Subtotal')), - ('discount_type', models.CharField(blank=True, choices=[('PERCENTAGE', 'Percentage (%)'), ('FIXED', 'Fixed Amount')], max_length=20, verbose_name='Discount Type')), - ('discount_value', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Discount Value')), - ('tax_rate', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Tax Rate (%)')), - ('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Total Amount')), - ('notes', models.TextField(blank=True, verbose_name='Notes')), - ('terms', models.TextField(blank=True, verbose_name='Terms & Conditions')), - ('invoices_generated', models.PositiveIntegerField(default=0, verbose_name='Invoices Generated')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("title", models.CharField(max_length=100, verbose_name="Title")), + ( + "is_active", + models.BooleanField(default=True, verbose_name="Is Active"), + ), + ( + "client_name", + models.CharField( + default="", max_length=255, verbose_name="Client Name" + ), + ), + ( + "client_email", + models.EmailField( + default="", max_length=254, verbose_name="Client Email" + ), + ), + ( + "frequency", + models.CharField( + choices=[ + ("WEEKLY", "Weekly"), + ("BIWEEKLY", "Bi-weekly"), + ("MONTHLY", "Monthly"), + ("QUARTERLY", "Quarterly"), + ("SEMI_ANNUALLY", "Semi-annually"), + ("YEARLY", "Yearly"), + ("CUSTOM", "Custom"), + ], + default="MONTHLY", + max_length=20, + verbose_name="Frequency", + ), + ), + ( + "custom_days", + models.PositiveIntegerField( + blank=True, null=True, verbose_name="Custom Days Interval" + ), + ), + ( + "start_date", + models.DateField( + default=datetime.date.today, verbose_name="Start Date" + ), + ), + ( + "end_date", + models.DateField(blank=True, null=True, verbose_name="End Date"), + ), + ( + "next_generation_date", + models.DateField( + default=datetime.date.today, verbose_name="Next Generation Date" + ), + ), + ( + "payment_terms", + models.CharField( + choices=[ + ("DUE_ON_RECEIPT", "Due on Receipt"), + ("NET_15", "Net 15"), + ("NET_30", "Net 30"), + ("NET_45", "Net 45"), + ("NET_60", "Net 60"), + ("CUSTOM", "Custom"), + ], + default="NET_30", + max_length=20, + verbose_name="Payment Terms", + ), + ), + ( + "auto_send", + models.BooleanField(default=False, verbose_name="Auto Send"), + ), + ( + "currency", + models.CharField( + choices=[ + ("USD", "USD, Dollar"), + ("EUR", "EUR, Euro"), + ("GBP", "GBP, Pound"), + ("INR", "INR, Rupee"), + ("CAD", "CAD, Dollar"), + ("AUD", "AUD, Dollar"), + ("JPY", "JPY, Yen"), + ("CNY", "CNY, Yuan"), + ("CHF", "CHF, Franc"), + ("SGD", "SGD, Dollar"), + ("AED", "AED, Dirham"), + ("BRL", "BRL, Real"), + ("MXN", "MXN, Peso"), + ], + default="USD", + max_length=3, + verbose_name="Currency", + ), + ), + ( + "subtotal", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Subtotal", + ), + ), + ( + "discount_type", + models.CharField( + blank=True, + choices=[ + ("PERCENTAGE", "Percentage (%)"), + ("FIXED", "Fixed Amount"), + ], + max_length=20, + verbose_name="Discount Type", + ), + ), + ( + "discount_value", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Discount Value", + ), + ), + ( + "tax_rate", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=5, + verbose_name="Tax Rate (%)", + ), + ), + ( + "total_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Total Amount", + ), + ), + ("notes", models.TextField(blank=True, verbose_name="Notes")), + ( + "terms", + models.TextField(blank=True, verbose_name="Terms & Conditions"), + ), + ( + "invoices_generated", + models.PositiveIntegerField( + default=0, verbose_name="Invoices Generated" + ), + ), ], options={ - 'verbose_name': 'Recurring Invoice', - 'verbose_name_plural': 'Recurring Invoices', - 'db_table': 'recurring_invoice', - 'ordering': ('-created_at',), + "verbose_name": "Recurring Invoice", + "verbose_name_plural": "Recurring Invoices", + "db_table": "recurring_invoice", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='RecurringInvoiceLineItem', + name="RecurringInvoiceLineItem", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('description', models.CharField(max_length=500, verbose_name='Description')), - ('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Quantity')), - ('unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Unit Price')), - ('discount_type', models.CharField(blank=True, choices=[('PERCENTAGE', 'Percentage (%)'), ('FIXED', 'Fixed Amount')], max_length=20, verbose_name='Discount Type')), - ('discount_value', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Discount Value')), - ('tax_rate', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Tax Rate (%)')), - ('order', models.PositiveIntegerField(default=0, verbose_name='Order')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "description", + models.CharField(max_length=500, verbose_name="Description"), + ), + ( + "quantity", + models.DecimalField( + decimal_places=2, + default=1, + max_digits=10, + verbose_name="Quantity", + ), + ), + ( + "unit_price", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Unit Price", + ), + ), + ( + "discount_type", + models.CharField( + blank=True, + choices=[ + ("PERCENTAGE", "Percentage (%)"), + ("FIXED", "Fixed Amount"), + ], + max_length=20, + verbose_name="Discount Type", + ), + ), + ( + "discount_value", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Discount Value", + ), + ), + ( + "tax_rate", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=5, + verbose_name="Tax Rate (%)", + ), + ), + ("order", models.PositiveIntegerField(default=0, verbose_name="Order")), ], options={ - 'verbose_name': 'Recurring Invoice Line Item', - 'verbose_name_plural': 'Recurring Invoice Line Items', - 'db_table': 'recurring_invoice_line_item', - 'ordering': ('order',), + "verbose_name": "Recurring Invoice Line Item", + "verbose_name_plural": "Recurring Invoice Line Items", + "db_table": "recurring_invoice_line_item", + "ordering": ("order",), }, ), migrations.AlterModelOptions( - name='invoicehistory', - options={'ordering': ('-created_at',), 'verbose_name': 'Invoice History', 'verbose_name_plural': 'Invoice Histories'}, + name="invoicehistory", + options={ + "ordering": ("-created_at",), + "verbose_name": "Invoice History", + "verbose_name_plural": "Invoice Histories", + }, ), migrations.RemoveField( - model_name='invoice', - name='accounts', + model_name="invoice", + name="accounts", ), migrations.RemoveField( - model_name='invoice', - name='email', + model_name="invoice", + name="email", ), migrations.RemoveField( - model_name='invoice', - name='from_address', + model_name="invoice", + name="from_address", ), migrations.RemoveField( - model_name='invoice', - name='name', + model_name="invoice", + name="name", ), migrations.RemoveField( - model_name='invoice', - name='phone', + model_name="invoice", + name="phone", ), migrations.RemoveField( - model_name='invoice', - name='tax', + model_name="invoice", + name="tax", ), migrations.RemoveField( - model_name='invoice', - name='to_address', + model_name="invoice", + name="to_address", ), migrations.RemoveField( - model_name='invoicehistory', - name='amount_paid', + model_name="invoicehistory", + name="amount_paid", ), migrations.RemoveField( - model_name='invoicehistory', - name='assigned_to', + model_name="invoicehistory", + name="assigned_to", ), migrations.RemoveField( - model_name='invoicehistory', - name='email', + model_name="invoicehistory", + name="email", ), migrations.RemoveField( - model_name='invoicehistory', - name='from_address', + model_name="invoicehistory", + name="from_address", ), migrations.RemoveField( - model_name='invoicehistory', - name='is_email_sent', + model_name="invoicehistory", + name="is_email_sent", ), migrations.RemoveField( - model_name='invoicehistory', - name='name', + model_name="invoicehistory", + name="name", ), migrations.RemoveField( - model_name='invoicehistory', - name='phone', + model_name="invoicehistory", + name="phone", ), migrations.RemoveField( - model_name='invoicehistory', - name='quantity', + model_name="invoicehistory", + name="quantity", ), migrations.RemoveField( - model_name='invoicehistory', - name='rate', + model_name="invoicehistory", + name="rate", ), migrations.RemoveField( - model_name='invoicehistory', - name='to_address', + model_name="invoicehistory", + name="to_address", ), migrations.AddField( - model_name='invoice', - name='account', - field=models.ForeignKey(blank=True, help_text='The legal entity responsible for payment (REQUIRED via validation)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to='accounts.account'), + model_name="invoice", + name="account", + field=models.ForeignKey( + blank=True, + help_text="The legal entity responsible for payment (REQUIRED via validation)", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="invoices", + to="accounts.account", + ), ), migrations.AddField( - model_name='invoice', - name='billing_address_line', - field=models.CharField(blank=True, max_length=255, verbose_name='Billing Address'), + model_name="invoice", + name="billing_address_line", + field=models.CharField( + blank=True, max_length=255, verbose_name="Billing Address" + ), ), migrations.AddField( - model_name='invoice', - name='billing_city', - field=models.CharField(blank=True, max_length=100, verbose_name='Billing City'), + model_name="invoice", + name="billing_city", + field=models.CharField( + blank=True, max_length=100, verbose_name="Billing City" + ), ), migrations.AddField( - model_name='invoice', - name='billing_country', - field=models.CharField(blank=True, choices=[('GB', 'United Kingdom'), ('AF', 'Afghanistan'), ('AX', 'Aland Islands'), ('AL', 'Albania'), ('DZ', 'Algeria'), ('AS', 'American Samoa'), ('AD', 'Andorra'), ('AO', 'Angola'), ('AI', 'Anguilla'), ('AQ', 'Antarctica'), ('AG', 'Antigua and Barbuda'), ('AR', 'Argentina'), ('AM', 'Armenia'), ('AW', 'Aruba'), ('AU', 'Australia'), ('AT', 'Austria'), ('AZ', 'Azerbaijan'), ('BS', 'Bahamas'), ('BH', 'Bahrain'), ('BD', 'Bangladesh'), ('BB', 'Barbados'), ('BY', 'Belarus'), ('BE', 'Belgium'), ('BZ', 'Belize'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BT', 'Bhutan'), ('BO', 'Bolivia'), ('BA', 'Bosnia and Herzegovina'), ('BW', 'Botswana'), ('BV', 'Bouvet Island'), ('BR', 'Brazil'), ('IO', 'British Indian Ocean Territory'), ('BN', 'Brunei Darussalam'), ('BG', 'Bulgaria'), ('BF', 'Burkina Faso'), ('BI', 'Burundi'), ('KH', 'Cambodia'), ('CM', 'Cameroon'), ('CA', 'Canada'), ('CV', 'Cape Verde'), ('KY', 'Cayman Islands'), ('CF', 'Central African Republic'), ('TD', 'Chad'), ('CL', 'Chile'), ('CN', 'China'), ('CX', 'Christmas Island'), ('CC', 'Cocos (Keeling) Islands'), ('CO', 'Colombia'), ('KM', 'Comoros'), ('CG', 'Congo'), ('CD', 'Congo, The Democratic Republic of the'), ('CK', 'Cook Islands'), ('CR', 'Costa Rica'), ('CI', "Cote d'Ivoire"), ('HR', 'Croatia'), ('CU', 'Cuba'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DK', 'Denmark'), ('DJ', 'Djibouti'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('EC', 'Ecuador'), ('EG', 'Egypt'), ('SV', 'El Salvador'), ('GQ', 'Equatorial Guinea'), ('ER', 'Eritrea'), ('EE', 'Estonia'), ('ET', 'Ethiopia'), ('FK', 'Falkland Islands (Malvinas)'), ('FO', 'Faroe Islands'), ('FJ', 'Fiji'), ('FI', 'Finland'), ('FR', 'France'), ('GF', 'French Guiana'), ('PF', 'French Polynesia'), ('TF', 'French Southern Territories'), ('GA', 'Gabon'), ('GM', 'Gambia'), ('GE', 'Georgia'), ('DE', 'Germany'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GR', 'Greece'), ('GL', 'Greenland'), ('GD', 'Grenada'), ('GP', 'Guadeloupe'), ('GU', 'Guam'), ('GT', 'Guatemala'), ('GG', 'Guernsey'), ('GN', 'Guinea'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HT', 'Haiti'), ('HM', 'Heard Island and McDonald Islands'), ('VA', 'Holy See (Vatican City State)'), ('HN', 'Honduras'), ('HK', 'Hong Kong'), ('HU', 'Hungary'), ('IS', 'Iceland'), ('IN', 'India'), ('ID', 'Indonesia'), ('IR', 'Iran, Islamic Republic of'), ('IQ', 'Iraq'), ('IE', 'Ireland'), ('IM', 'Isle of Man'), ('IL', 'Israel'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JP', 'Japan'), ('JE', 'Jersey'), ('JO', 'Jordan'), ('KZ', 'Kazakhstan'), ('KE', 'Kenya'), ('KI', 'Kiribati'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KG', 'Kyrgyzstan'), ('LA', "Lao People's Democratic Republic"), ('LV', 'Latvia'), ('LB', 'Lebanon'), ('LS', 'Lesotho'), ('LR', 'Liberia'), ('LY', 'Libyan Arab Jamahiriya'), ('LI', 'Liechtenstein'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('MO', 'Macao'), ('MK', 'Macedonia, The Former Yugoslav Republic of'), ('MG', 'Madagascar'), ('MW', 'Malawi'), ('MY', 'Malaysia'), ('MV', 'Maldives'), ('ML', 'Mali'), ('MT', 'Malta'), ('MH', 'Marshall Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MU', 'Mauritius'), ('YT', 'Mayotte'), ('MX', 'Mexico'), ('FM', 'Micronesia, Federated States of'), ('MD', 'Moldova'), ('MC', 'Monaco'), ('MN', 'Mongolia'), ('ME', 'Montenegro'), ('MS', 'Montserrat'), ('MA', 'Morocco'), ('MZ', 'Mozambique'), ('MM', 'Myanmar'), ('NA', 'Namibia'), ('NR', 'Nauru'), ('NP', 'Nepal'), ('NL', 'Netherlands'), ('AN', 'Netherlands Antilles'), ('NC', 'New Caledonia'), ('NZ', 'New Zealand'), ('NI', 'Nicaragua'), ('NE', 'Niger'), ('NG', 'Nigeria'), ('NU', 'Niue'), ('NF', 'Norfolk Island'), ('MP', 'Northern Mariana Islands'), ('NO', 'Norway'), ('OM', 'Oman'), ('PK', 'Pakistan'), ('PW', 'Palau'), ('PS', 'Palestinian Territory, Occupied'), ('PA', 'Panama'), ('PG', 'Papua New Guinea'), ('PY', 'Paraguay'), ('PE', 'Peru'), ('PH', 'Philippines'), ('PN', 'Pitcairn'), ('PL', 'Poland'), ('PT', 'Portugal'), ('PR', 'Puerto Rico'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('BL', 'Saint Barthelemy'), ('SH', 'Saint Helena'), ('KN', 'Saint Kitts and Nevis'), ('LC', 'Saint Lucia'), ('MF', 'Saint Martin'), ('PM', 'Saint Pierre and Miquelon'), ('VC', 'Saint Vincent and the Grenadines'), ('WS', 'Samoa'), ('SM', 'San Marino'), ('ST', 'Sao Tome and Principe'), ('SA', 'Saudi Arabia'), ('SN', 'Senegal'), ('RS', 'Serbia'), ('SC', 'Seychelles'), ('SL', 'Sierra Leone'), ('SG', 'Singapore'), ('SK', 'Slovakia'), ('SI', 'Slovenia'), ('SB', 'Solomon Islands'), ('SO', 'Somalia'), ('ZA', 'South Africa'), ('GS', 'South Georgia and the South Sandwich Islands'), ('ES', 'Spain'), ('LK', 'Sri Lanka'), ('SD', 'Sudan'), ('SR', 'Suriname'), ('SJ', 'Svalbard and Jan Mayen'), ('SZ', 'Swaziland'), ('SE', 'Sweden'), ('CH', 'Switzerland'), ('SY', 'Syrian Arab Republic'), ('TW', 'Taiwan, Province of China'), ('TJ', 'Tajikistan'), ('TZ', 'Tanzania, United Republic of'), ('TH', 'Thailand'), ('TL', 'Timor-Leste'), ('TG', 'Togo'), ('TK', 'Tokelau'), ('TO', 'Tonga'), ('TT', 'Trinidad and Tobago'), ('TN', 'Tunisia'), ('TR', 'Turkey'), ('TM', 'Turkmenistan'), ('TC', 'Turks and Caicos Islands'), ('TV', 'Tuvalu'), ('UG', 'Uganda'), ('UA', 'Ukraine'), ('AE', 'United Arab Emirates'), ('US', 'United States'), ('UM', 'United States Minor Outlying Islands'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VU', 'Vanuatu'), ('VE', 'Venezuela'), ('VN', 'Viet Nam'), ('VG', 'Virgin Islands, British'), ('VI', 'Virgin Islands, U.S.'), ('WF', 'Wallis and Futuna'), ('EH', 'Western Sahara'), ('YE', 'Yemen'), ('ZM', 'Zambia'), ('ZW', 'Zimbabwe')], max_length=3, verbose_name='Billing Country'), + model_name="invoice", + name="billing_country", + field=models.CharField( + blank=True, + choices=[ + ("GB", "United Kingdom"), + ("AF", "Afghanistan"), + ("AX", "Aland Islands"), + ("AL", "Albania"), + ("DZ", "Algeria"), + ("AS", "American Samoa"), + ("AD", "Andorra"), + ("AO", "Angola"), + ("AI", "Anguilla"), + ("AQ", "Antarctica"), + ("AG", "Antigua and Barbuda"), + ("AR", "Argentina"), + ("AM", "Armenia"), + ("AW", "Aruba"), + ("AU", "Australia"), + ("AT", "Austria"), + ("AZ", "Azerbaijan"), + ("BS", "Bahamas"), + ("BH", "Bahrain"), + ("BD", "Bangladesh"), + ("BB", "Barbados"), + ("BY", "Belarus"), + ("BE", "Belgium"), + ("BZ", "Belize"), + ("BJ", "Benin"), + ("BM", "Bermuda"), + ("BT", "Bhutan"), + ("BO", "Bolivia"), + ("BA", "Bosnia and Herzegovina"), + ("BW", "Botswana"), + ("BV", "Bouvet Island"), + ("BR", "Brazil"), + ("IO", "British Indian Ocean Territory"), + ("BN", "Brunei Darussalam"), + ("BG", "Bulgaria"), + ("BF", "Burkina Faso"), + ("BI", "Burundi"), + ("KH", "Cambodia"), + ("CM", "Cameroon"), + ("CA", "Canada"), + ("CV", "Cape Verde"), + ("KY", "Cayman Islands"), + ("CF", "Central African Republic"), + ("TD", "Chad"), + ("CL", "Chile"), + ("CN", "China"), + ("CX", "Christmas Island"), + ("CC", "Cocos (Keeling) Islands"), + ("CO", "Colombia"), + ("KM", "Comoros"), + ("CG", "Congo"), + ("CD", "Congo, The Democratic Republic of the"), + ("CK", "Cook Islands"), + ("CR", "Costa Rica"), + ("CI", "Cote d'Ivoire"), + ("HR", "Croatia"), + ("CU", "Cuba"), + ("CY", "Cyprus"), + ("CZ", "Czech Republic"), + ("DK", "Denmark"), + ("DJ", "Djibouti"), + ("DM", "Dominica"), + ("DO", "Dominican Republic"), + ("EC", "Ecuador"), + ("EG", "Egypt"), + ("SV", "El Salvador"), + ("GQ", "Equatorial Guinea"), + ("ER", "Eritrea"), + ("EE", "Estonia"), + ("ET", "Ethiopia"), + ("FK", "Falkland Islands (Malvinas)"), + ("FO", "Faroe Islands"), + ("FJ", "Fiji"), + ("FI", "Finland"), + ("FR", "France"), + ("GF", "French Guiana"), + ("PF", "French Polynesia"), + ("TF", "French Southern Territories"), + ("GA", "Gabon"), + ("GM", "Gambia"), + ("GE", "Georgia"), + ("DE", "Germany"), + ("GH", "Ghana"), + ("GI", "Gibraltar"), + ("GR", "Greece"), + ("GL", "Greenland"), + ("GD", "Grenada"), + ("GP", "Guadeloupe"), + ("GU", "Guam"), + ("GT", "Guatemala"), + ("GG", "Guernsey"), + ("GN", "Guinea"), + ("GW", "Guinea-Bissau"), + ("GY", "Guyana"), + ("HT", "Haiti"), + ("HM", "Heard Island and McDonald Islands"), + ("VA", "Holy See (Vatican City State)"), + ("HN", "Honduras"), + ("HK", "Hong Kong"), + ("HU", "Hungary"), + ("IS", "Iceland"), + ("IN", "India"), + ("ID", "Indonesia"), + ("IR", "Iran, Islamic Republic of"), + ("IQ", "Iraq"), + ("IE", "Ireland"), + ("IM", "Isle of Man"), + ("IL", "Israel"), + ("IT", "Italy"), + ("JM", "Jamaica"), + ("JP", "Japan"), + ("JE", "Jersey"), + ("JO", "Jordan"), + ("KZ", "Kazakhstan"), + ("KE", "Kenya"), + ("KI", "Kiribati"), + ("KP", "Korea, Democratic People's Republic of"), + ("KR", "Korea, Republic of"), + ("KW", "Kuwait"), + ("KG", "Kyrgyzstan"), + ("LA", "Lao People's Democratic Republic"), + ("LV", "Latvia"), + ("LB", "Lebanon"), + ("LS", "Lesotho"), + ("LR", "Liberia"), + ("LY", "Libyan Arab Jamahiriya"), + ("LI", "Liechtenstein"), + ("LT", "Lithuania"), + ("LU", "Luxembourg"), + ("MO", "Macao"), + ("MK", "Macedonia, The Former Yugoslav Republic of"), + ("MG", "Madagascar"), + ("MW", "Malawi"), + ("MY", "Malaysia"), + ("MV", "Maldives"), + ("ML", "Mali"), + ("MT", "Malta"), + ("MH", "Marshall Islands"), + ("MQ", "Martinique"), + ("MR", "Mauritania"), + ("MU", "Mauritius"), + ("YT", "Mayotte"), + ("MX", "Mexico"), + ("FM", "Micronesia, Federated States of"), + ("MD", "Moldova"), + ("MC", "Monaco"), + ("MN", "Mongolia"), + ("ME", "Montenegro"), + ("MS", "Montserrat"), + ("MA", "Morocco"), + ("MZ", "Mozambique"), + ("MM", "Myanmar"), + ("NA", "Namibia"), + ("NR", "Nauru"), + ("NP", "Nepal"), + ("NL", "Netherlands"), + ("AN", "Netherlands Antilles"), + ("NC", "New Caledonia"), + ("NZ", "New Zealand"), + ("NI", "Nicaragua"), + ("NE", "Niger"), + ("NG", "Nigeria"), + ("NU", "Niue"), + ("NF", "Norfolk Island"), + ("MP", "Northern Mariana Islands"), + ("NO", "Norway"), + ("OM", "Oman"), + ("PK", "Pakistan"), + ("PW", "Palau"), + ("PS", "Palestinian Territory, Occupied"), + ("PA", "Panama"), + ("PG", "Papua New Guinea"), + ("PY", "Paraguay"), + ("PE", "Peru"), + ("PH", "Philippines"), + ("PN", "Pitcairn"), + ("PL", "Poland"), + ("PT", "Portugal"), + ("PR", "Puerto Rico"), + ("QA", "Qatar"), + ("RE", "Reunion"), + ("RO", "Romania"), + ("RU", "Russian Federation"), + ("RW", "Rwanda"), + ("BL", "Saint Barthelemy"), + ("SH", "Saint Helena"), + ("KN", "Saint Kitts and Nevis"), + ("LC", "Saint Lucia"), + ("MF", "Saint Martin"), + ("PM", "Saint Pierre and Miquelon"), + ("VC", "Saint Vincent and the Grenadines"), + ("WS", "Samoa"), + ("SM", "San Marino"), + ("ST", "Sao Tome and Principe"), + ("SA", "Saudi Arabia"), + ("SN", "Senegal"), + ("RS", "Serbia"), + ("SC", "Seychelles"), + ("SL", "Sierra Leone"), + ("SG", "Singapore"), + ("SK", "Slovakia"), + ("SI", "Slovenia"), + ("SB", "Solomon Islands"), + ("SO", "Somalia"), + ("ZA", "South Africa"), + ("GS", "South Georgia and the South Sandwich Islands"), + ("ES", "Spain"), + ("LK", "Sri Lanka"), + ("SD", "Sudan"), + ("SR", "Suriname"), + ("SJ", "Svalbard and Jan Mayen"), + ("SZ", "Swaziland"), + ("SE", "Sweden"), + ("CH", "Switzerland"), + ("SY", "Syrian Arab Republic"), + ("TW", "Taiwan, Province of China"), + ("TJ", "Tajikistan"), + ("TZ", "Tanzania, United Republic of"), + ("TH", "Thailand"), + ("TL", "Timor-Leste"), + ("TG", "Togo"), + ("TK", "Tokelau"), + ("TO", "Tonga"), + ("TT", "Trinidad and Tobago"), + ("TN", "Tunisia"), + ("TR", "Turkey"), + ("TM", "Turkmenistan"), + ("TC", "Turks and Caicos Islands"), + ("TV", "Tuvalu"), + ("UG", "Uganda"), + ("UA", "Ukraine"), + ("AE", "United Arab Emirates"), + ("US", "United States"), + ("UM", "United States Minor Outlying Islands"), + ("UY", "Uruguay"), + ("UZ", "Uzbekistan"), + ("VU", "Vanuatu"), + ("VE", "Venezuela"), + ("VN", "Viet Nam"), + ("VG", "Virgin Islands, British"), + ("VI", "Virgin Islands, U.S."), + ("WF", "Wallis and Futuna"), + ("EH", "Western Sahara"), + ("YE", "Yemen"), + ("ZM", "Zambia"), + ("ZW", "Zimbabwe"), + ], + max_length=3, + verbose_name="Billing Country", + ), ), migrations.AddField( - model_name='invoice', - name='billing_postcode', - field=models.CharField(blank=True, max_length=20, verbose_name='Billing Postal Code'), + model_name="invoice", + name="billing_postcode", + field=models.CharField( + blank=True, max_length=20, verbose_name="Billing Postal Code" + ), ), migrations.AddField( - model_name='invoice', - name='billing_state', - field=models.CharField(blank=True, max_length=100, verbose_name='Billing State'), + model_name="invoice", + name="billing_state", + field=models.CharField( + blank=True, max_length=100, verbose_name="Billing State" + ), ), migrations.AddField( - model_name='invoice', - name='client_address_line', - field=models.CharField(blank=True, max_length=255, verbose_name='Client Address'), + model_name="invoice", + name="client_address_line", + field=models.CharField( + blank=True, max_length=255, verbose_name="Client Address" + ), ), migrations.AddField( - model_name='invoice', - name='client_city', - field=models.CharField(blank=True, max_length=100, verbose_name='Client City'), + model_name="invoice", + name="client_city", + field=models.CharField( + blank=True, max_length=100, verbose_name="Client City" + ), ), migrations.AddField( - model_name='invoice', - name='client_country', - field=models.CharField(blank=True, choices=[('GB', 'United Kingdom'), ('AF', 'Afghanistan'), ('AX', 'Aland Islands'), ('AL', 'Albania'), ('DZ', 'Algeria'), ('AS', 'American Samoa'), ('AD', 'Andorra'), ('AO', 'Angola'), ('AI', 'Anguilla'), ('AQ', 'Antarctica'), ('AG', 'Antigua and Barbuda'), ('AR', 'Argentina'), ('AM', 'Armenia'), ('AW', 'Aruba'), ('AU', 'Australia'), ('AT', 'Austria'), ('AZ', 'Azerbaijan'), ('BS', 'Bahamas'), ('BH', 'Bahrain'), ('BD', 'Bangladesh'), ('BB', 'Barbados'), ('BY', 'Belarus'), ('BE', 'Belgium'), ('BZ', 'Belize'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BT', 'Bhutan'), ('BO', 'Bolivia'), ('BA', 'Bosnia and Herzegovina'), ('BW', 'Botswana'), ('BV', 'Bouvet Island'), ('BR', 'Brazil'), ('IO', 'British Indian Ocean Territory'), ('BN', 'Brunei Darussalam'), ('BG', 'Bulgaria'), ('BF', 'Burkina Faso'), ('BI', 'Burundi'), ('KH', 'Cambodia'), ('CM', 'Cameroon'), ('CA', 'Canada'), ('CV', 'Cape Verde'), ('KY', 'Cayman Islands'), ('CF', 'Central African Republic'), ('TD', 'Chad'), ('CL', 'Chile'), ('CN', 'China'), ('CX', 'Christmas Island'), ('CC', 'Cocos (Keeling) Islands'), ('CO', 'Colombia'), ('KM', 'Comoros'), ('CG', 'Congo'), ('CD', 'Congo, The Democratic Republic of the'), ('CK', 'Cook Islands'), ('CR', 'Costa Rica'), ('CI', "Cote d'Ivoire"), ('HR', 'Croatia'), ('CU', 'Cuba'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DK', 'Denmark'), ('DJ', 'Djibouti'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('EC', 'Ecuador'), ('EG', 'Egypt'), ('SV', 'El Salvador'), ('GQ', 'Equatorial Guinea'), ('ER', 'Eritrea'), ('EE', 'Estonia'), ('ET', 'Ethiopia'), ('FK', 'Falkland Islands (Malvinas)'), ('FO', 'Faroe Islands'), ('FJ', 'Fiji'), ('FI', 'Finland'), ('FR', 'France'), ('GF', 'French Guiana'), ('PF', 'French Polynesia'), ('TF', 'French Southern Territories'), ('GA', 'Gabon'), ('GM', 'Gambia'), ('GE', 'Georgia'), ('DE', 'Germany'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GR', 'Greece'), ('GL', 'Greenland'), ('GD', 'Grenada'), ('GP', 'Guadeloupe'), ('GU', 'Guam'), ('GT', 'Guatemala'), ('GG', 'Guernsey'), ('GN', 'Guinea'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HT', 'Haiti'), ('HM', 'Heard Island and McDonald Islands'), ('VA', 'Holy See (Vatican City State)'), ('HN', 'Honduras'), ('HK', 'Hong Kong'), ('HU', 'Hungary'), ('IS', 'Iceland'), ('IN', 'India'), ('ID', 'Indonesia'), ('IR', 'Iran, Islamic Republic of'), ('IQ', 'Iraq'), ('IE', 'Ireland'), ('IM', 'Isle of Man'), ('IL', 'Israel'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JP', 'Japan'), ('JE', 'Jersey'), ('JO', 'Jordan'), ('KZ', 'Kazakhstan'), ('KE', 'Kenya'), ('KI', 'Kiribati'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KG', 'Kyrgyzstan'), ('LA', "Lao People's Democratic Republic"), ('LV', 'Latvia'), ('LB', 'Lebanon'), ('LS', 'Lesotho'), ('LR', 'Liberia'), ('LY', 'Libyan Arab Jamahiriya'), ('LI', 'Liechtenstein'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('MO', 'Macao'), ('MK', 'Macedonia, The Former Yugoslav Republic of'), ('MG', 'Madagascar'), ('MW', 'Malawi'), ('MY', 'Malaysia'), ('MV', 'Maldives'), ('ML', 'Mali'), ('MT', 'Malta'), ('MH', 'Marshall Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MU', 'Mauritius'), ('YT', 'Mayotte'), ('MX', 'Mexico'), ('FM', 'Micronesia, Federated States of'), ('MD', 'Moldova'), ('MC', 'Monaco'), ('MN', 'Mongolia'), ('ME', 'Montenegro'), ('MS', 'Montserrat'), ('MA', 'Morocco'), ('MZ', 'Mozambique'), ('MM', 'Myanmar'), ('NA', 'Namibia'), ('NR', 'Nauru'), ('NP', 'Nepal'), ('NL', 'Netherlands'), ('AN', 'Netherlands Antilles'), ('NC', 'New Caledonia'), ('NZ', 'New Zealand'), ('NI', 'Nicaragua'), ('NE', 'Niger'), ('NG', 'Nigeria'), ('NU', 'Niue'), ('NF', 'Norfolk Island'), ('MP', 'Northern Mariana Islands'), ('NO', 'Norway'), ('OM', 'Oman'), ('PK', 'Pakistan'), ('PW', 'Palau'), ('PS', 'Palestinian Territory, Occupied'), ('PA', 'Panama'), ('PG', 'Papua New Guinea'), ('PY', 'Paraguay'), ('PE', 'Peru'), ('PH', 'Philippines'), ('PN', 'Pitcairn'), ('PL', 'Poland'), ('PT', 'Portugal'), ('PR', 'Puerto Rico'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('BL', 'Saint Barthelemy'), ('SH', 'Saint Helena'), ('KN', 'Saint Kitts and Nevis'), ('LC', 'Saint Lucia'), ('MF', 'Saint Martin'), ('PM', 'Saint Pierre and Miquelon'), ('VC', 'Saint Vincent and the Grenadines'), ('WS', 'Samoa'), ('SM', 'San Marino'), ('ST', 'Sao Tome and Principe'), ('SA', 'Saudi Arabia'), ('SN', 'Senegal'), ('RS', 'Serbia'), ('SC', 'Seychelles'), ('SL', 'Sierra Leone'), ('SG', 'Singapore'), ('SK', 'Slovakia'), ('SI', 'Slovenia'), ('SB', 'Solomon Islands'), ('SO', 'Somalia'), ('ZA', 'South Africa'), ('GS', 'South Georgia and the South Sandwich Islands'), ('ES', 'Spain'), ('LK', 'Sri Lanka'), ('SD', 'Sudan'), ('SR', 'Suriname'), ('SJ', 'Svalbard and Jan Mayen'), ('SZ', 'Swaziland'), ('SE', 'Sweden'), ('CH', 'Switzerland'), ('SY', 'Syrian Arab Republic'), ('TW', 'Taiwan, Province of China'), ('TJ', 'Tajikistan'), ('TZ', 'Tanzania, United Republic of'), ('TH', 'Thailand'), ('TL', 'Timor-Leste'), ('TG', 'Togo'), ('TK', 'Tokelau'), ('TO', 'Tonga'), ('TT', 'Trinidad and Tobago'), ('TN', 'Tunisia'), ('TR', 'Turkey'), ('TM', 'Turkmenistan'), ('TC', 'Turks and Caicos Islands'), ('TV', 'Tuvalu'), ('UG', 'Uganda'), ('UA', 'Ukraine'), ('AE', 'United Arab Emirates'), ('US', 'United States'), ('UM', 'United States Minor Outlying Islands'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VU', 'Vanuatu'), ('VE', 'Venezuela'), ('VN', 'Viet Nam'), ('VG', 'Virgin Islands, British'), ('VI', 'Virgin Islands, U.S.'), ('WF', 'Wallis and Futuna'), ('EH', 'Western Sahara'), ('YE', 'Yemen'), ('ZM', 'Zambia'), ('ZW', 'Zimbabwe')], max_length=3, verbose_name='Client Country'), + model_name="invoice", + name="client_country", + field=models.CharField( + blank=True, + choices=[ + ("GB", "United Kingdom"), + ("AF", "Afghanistan"), + ("AX", "Aland Islands"), + ("AL", "Albania"), + ("DZ", "Algeria"), + ("AS", "American Samoa"), + ("AD", "Andorra"), + ("AO", "Angola"), + ("AI", "Anguilla"), + ("AQ", "Antarctica"), + ("AG", "Antigua and Barbuda"), + ("AR", "Argentina"), + ("AM", "Armenia"), + ("AW", "Aruba"), + ("AU", "Australia"), + ("AT", "Austria"), + ("AZ", "Azerbaijan"), + ("BS", "Bahamas"), + ("BH", "Bahrain"), + ("BD", "Bangladesh"), + ("BB", "Barbados"), + ("BY", "Belarus"), + ("BE", "Belgium"), + ("BZ", "Belize"), + ("BJ", "Benin"), + ("BM", "Bermuda"), + ("BT", "Bhutan"), + ("BO", "Bolivia"), + ("BA", "Bosnia and Herzegovina"), + ("BW", "Botswana"), + ("BV", "Bouvet Island"), + ("BR", "Brazil"), + ("IO", "British Indian Ocean Territory"), + ("BN", "Brunei Darussalam"), + ("BG", "Bulgaria"), + ("BF", "Burkina Faso"), + ("BI", "Burundi"), + ("KH", "Cambodia"), + ("CM", "Cameroon"), + ("CA", "Canada"), + ("CV", "Cape Verde"), + ("KY", "Cayman Islands"), + ("CF", "Central African Republic"), + ("TD", "Chad"), + ("CL", "Chile"), + ("CN", "China"), + ("CX", "Christmas Island"), + ("CC", "Cocos (Keeling) Islands"), + ("CO", "Colombia"), + ("KM", "Comoros"), + ("CG", "Congo"), + ("CD", "Congo, The Democratic Republic of the"), + ("CK", "Cook Islands"), + ("CR", "Costa Rica"), + ("CI", "Cote d'Ivoire"), + ("HR", "Croatia"), + ("CU", "Cuba"), + ("CY", "Cyprus"), + ("CZ", "Czech Republic"), + ("DK", "Denmark"), + ("DJ", "Djibouti"), + ("DM", "Dominica"), + ("DO", "Dominican Republic"), + ("EC", "Ecuador"), + ("EG", "Egypt"), + ("SV", "El Salvador"), + ("GQ", "Equatorial Guinea"), + ("ER", "Eritrea"), + ("EE", "Estonia"), + ("ET", "Ethiopia"), + ("FK", "Falkland Islands (Malvinas)"), + ("FO", "Faroe Islands"), + ("FJ", "Fiji"), + ("FI", "Finland"), + ("FR", "France"), + ("GF", "French Guiana"), + ("PF", "French Polynesia"), + ("TF", "French Southern Territories"), + ("GA", "Gabon"), + ("GM", "Gambia"), + ("GE", "Georgia"), + ("DE", "Germany"), + ("GH", "Ghana"), + ("GI", "Gibraltar"), + ("GR", "Greece"), + ("GL", "Greenland"), + ("GD", "Grenada"), + ("GP", "Guadeloupe"), + ("GU", "Guam"), + ("GT", "Guatemala"), + ("GG", "Guernsey"), + ("GN", "Guinea"), + ("GW", "Guinea-Bissau"), + ("GY", "Guyana"), + ("HT", "Haiti"), + ("HM", "Heard Island and McDonald Islands"), + ("VA", "Holy See (Vatican City State)"), + ("HN", "Honduras"), + ("HK", "Hong Kong"), + ("HU", "Hungary"), + ("IS", "Iceland"), + ("IN", "India"), + ("ID", "Indonesia"), + ("IR", "Iran, Islamic Republic of"), + ("IQ", "Iraq"), + ("IE", "Ireland"), + ("IM", "Isle of Man"), + ("IL", "Israel"), + ("IT", "Italy"), + ("JM", "Jamaica"), + ("JP", "Japan"), + ("JE", "Jersey"), + ("JO", "Jordan"), + ("KZ", "Kazakhstan"), + ("KE", "Kenya"), + ("KI", "Kiribati"), + ("KP", "Korea, Democratic People's Republic of"), + ("KR", "Korea, Republic of"), + ("KW", "Kuwait"), + ("KG", "Kyrgyzstan"), + ("LA", "Lao People's Democratic Republic"), + ("LV", "Latvia"), + ("LB", "Lebanon"), + ("LS", "Lesotho"), + ("LR", "Liberia"), + ("LY", "Libyan Arab Jamahiriya"), + ("LI", "Liechtenstein"), + ("LT", "Lithuania"), + ("LU", "Luxembourg"), + ("MO", "Macao"), + ("MK", "Macedonia, The Former Yugoslav Republic of"), + ("MG", "Madagascar"), + ("MW", "Malawi"), + ("MY", "Malaysia"), + ("MV", "Maldives"), + ("ML", "Mali"), + ("MT", "Malta"), + ("MH", "Marshall Islands"), + ("MQ", "Martinique"), + ("MR", "Mauritania"), + ("MU", "Mauritius"), + ("YT", "Mayotte"), + ("MX", "Mexico"), + ("FM", "Micronesia, Federated States of"), + ("MD", "Moldova"), + ("MC", "Monaco"), + ("MN", "Mongolia"), + ("ME", "Montenegro"), + ("MS", "Montserrat"), + ("MA", "Morocco"), + ("MZ", "Mozambique"), + ("MM", "Myanmar"), + ("NA", "Namibia"), + ("NR", "Nauru"), + ("NP", "Nepal"), + ("NL", "Netherlands"), + ("AN", "Netherlands Antilles"), + ("NC", "New Caledonia"), + ("NZ", "New Zealand"), + ("NI", "Nicaragua"), + ("NE", "Niger"), + ("NG", "Nigeria"), + ("NU", "Niue"), + ("NF", "Norfolk Island"), + ("MP", "Northern Mariana Islands"), + ("NO", "Norway"), + ("OM", "Oman"), + ("PK", "Pakistan"), + ("PW", "Palau"), + ("PS", "Palestinian Territory, Occupied"), + ("PA", "Panama"), + ("PG", "Papua New Guinea"), + ("PY", "Paraguay"), + ("PE", "Peru"), + ("PH", "Philippines"), + ("PN", "Pitcairn"), + ("PL", "Poland"), + ("PT", "Portugal"), + ("PR", "Puerto Rico"), + ("QA", "Qatar"), + ("RE", "Reunion"), + ("RO", "Romania"), + ("RU", "Russian Federation"), + ("RW", "Rwanda"), + ("BL", "Saint Barthelemy"), + ("SH", "Saint Helena"), + ("KN", "Saint Kitts and Nevis"), + ("LC", "Saint Lucia"), + ("MF", "Saint Martin"), + ("PM", "Saint Pierre and Miquelon"), + ("VC", "Saint Vincent and the Grenadines"), + ("WS", "Samoa"), + ("SM", "San Marino"), + ("ST", "Sao Tome and Principe"), + ("SA", "Saudi Arabia"), + ("SN", "Senegal"), + ("RS", "Serbia"), + ("SC", "Seychelles"), + ("SL", "Sierra Leone"), + ("SG", "Singapore"), + ("SK", "Slovakia"), + ("SI", "Slovenia"), + ("SB", "Solomon Islands"), + ("SO", "Somalia"), + ("ZA", "South Africa"), + ("GS", "South Georgia and the South Sandwich Islands"), + ("ES", "Spain"), + ("LK", "Sri Lanka"), + ("SD", "Sudan"), + ("SR", "Suriname"), + ("SJ", "Svalbard and Jan Mayen"), + ("SZ", "Swaziland"), + ("SE", "Sweden"), + ("CH", "Switzerland"), + ("SY", "Syrian Arab Republic"), + ("TW", "Taiwan, Province of China"), + ("TJ", "Tajikistan"), + ("TZ", "Tanzania, United Republic of"), + ("TH", "Thailand"), + ("TL", "Timor-Leste"), + ("TG", "Togo"), + ("TK", "Tokelau"), + ("TO", "Tonga"), + ("TT", "Trinidad and Tobago"), + ("TN", "Tunisia"), + ("TR", "Turkey"), + ("TM", "Turkmenistan"), + ("TC", "Turks and Caicos Islands"), + ("TV", "Tuvalu"), + ("UG", "Uganda"), + ("UA", "Ukraine"), + ("AE", "United Arab Emirates"), + ("US", "United States"), + ("UM", "United States Minor Outlying Islands"), + ("UY", "Uruguay"), + ("UZ", "Uzbekistan"), + ("VU", "Vanuatu"), + ("VE", "Venezuela"), + ("VN", "Viet Nam"), + ("VG", "Virgin Islands, British"), + ("VI", "Virgin Islands, U.S."), + ("WF", "Wallis and Futuna"), + ("EH", "Western Sahara"), + ("YE", "Yemen"), + ("ZM", "Zambia"), + ("ZW", "Zimbabwe"), + ], + max_length=3, + verbose_name="Client Country", + ), ), migrations.AddField( - model_name='invoice', - name='client_email', - field=models.EmailField(default='', max_length=254, verbose_name='Client Email'), + model_name="invoice", + name="client_email", + field=models.EmailField( + default="", max_length=254, verbose_name="Client Email" + ), ), migrations.AddField( - model_name='invoice', - name='client_name', - field=models.CharField(default='', max_length=255, verbose_name='Client Name'), + model_name="invoice", + name="client_name", + field=models.CharField( + default="", max_length=255, verbose_name="Client Name" + ), ), migrations.AddField( - model_name='invoice', - name='client_phone', - field=models.CharField(blank=True, max_length=25, verbose_name='Client Phone'), + model_name="invoice", + name="client_phone", + field=models.CharField( + blank=True, max_length=25, verbose_name="Client Phone" + ), ), migrations.AddField( - model_name='invoice', - name='client_postcode', - field=models.CharField(blank=True, max_length=20, verbose_name='Client Postal Code'), + model_name="invoice", + name="client_postcode", + field=models.CharField( + blank=True, max_length=20, verbose_name="Client Postal Code" + ), ), migrations.AddField( - model_name='invoice', - name='client_state', - field=models.CharField(blank=True, max_length=100, verbose_name='Client State'), + model_name="invoice", + name="client_state", + field=models.CharField( + blank=True, max_length=100, verbose_name="Client State" + ), ), migrations.AddField( - model_name='invoice', - name='contact', - field=models.ForeignKey(blank=True, help_text="The 'Bill To' contact (REQUIRED via validation)", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoices', to='contacts.contact'), + model_name="invoice", + name="contact", + field=models.ForeignKey( + blank=True, + help_text="The 'Bill To' contact (REQUIRED via validation)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="invoices", + to="contacts.contact", + ), ), migrations.AddField( - model_name='invoice', - name='discount_amount', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Discount Amount'), + model_name="invoice", + name="discount_amount", + field=models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Discount Amount", + ), ), migrations.AddField( - model_name='invoice', - name='discount_type', - field=models.CharField(blank=True, choices=[('PERCENTAGE', 'Percentage (%)'), ('FIXED', 'Fixed Amount')], max_length=20, verbose_name='Discount Type'), + model_name="invoice", + name="discount_type", + field=models.CharField( + blank=True, + choices=[("PERCENTAGE", "Percentage (%)"), ("FIXED", "Fixed Amount")], + max_length=20, + verbose_name="Discount Type", + ), ), migrations.AddField( - model_name='invoice', - name='discount_value', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Discount Value'), + model_name="invoice", + name="discount_value", + field=models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Discount Value", + ), ), migrations.AddField( - model_name='invoice', - name='issue_date', - field=models.DateField(default=datetime.date.today, verbose_name='Issue Date'), + model_name="invoice", + name="issue_date", + field=models.DateField( + default=datetime.date.today, verbose_name="Issue Date" + ), ), migrations.AddField( - model_name='invoice', - name='last_reminder_sent', - field=models.DateTimeField(blank=True, null=True, verbose_name='Last Reminder Sent'), + model_name="invoice", + name="last_reminder_sent", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Last Reminder Sent" + ), ), migrations.AddField( - model_name='invoice', - name='notes', - field=models.TextField(blank=True, verbose_name='Notes'), + model_name="invoice", + name="notes", + field=models.TextField(blank=True, verbose_name="Notes"), ), migrations.AddField( - model_name='invoice', - name='opportunity', - field=models.ForeignKey(blank=True, help_text='The sales opportunity this invoice relates to (optional)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoices', to='opportunity.opportunity'), + model_name="invoice", + name="opportunity", + field=models.ForeignKey( + blank=True, + help_text="The sales opportunity this invoice relates to (optional)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="invoices", + to="opportunity.opportunity", + ), ), migrations.AddField( - model_name='invoice', - name='paid_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='Paid At'), + model_name="invoice", + name="paid_at", + field=models.DateTimeField(blank=True, null=True, verbose_name="Paid At"), ), migrations.AddField( - model_name='invoice', - name='payment_terms', - field=models.CharField(choices=[('DUE_ON_RECEIPT', 'Due on Receipt'), ('NET_15', 'Net 15'), ('NET_30', 'Net 30'), ('NET_45', 'Net 45'), ('NET_60', 'Net 60'), ('CUSTOM', 'Custom')], default='NET_30', max_length=20, verbose_name='Payment Terms'), + model_name="invoice", + name="payment_terms", + field=models.CharField( + choices=[ + ("DUE_ON_RECEIPT", "Due on Receipt"), + ("NET_15", "Net 15"), + ("NET_30", "Net 30"), + ("NET_45", "Net 45"), + ("NET_60", "Net 60"), + ("CUSTOM", "Custom"), + ], + default="NET_30", + max_length=20, + verbose_name="Payment Terms", + ), ), migrations.AddField( - model_name='invoice', - name='public_link_enabled', - field=models.BooleanField(default=True, verbose_name='Public Link Enabled'), + model_name="invoice", + name="public_link_enabled", + field=models.BooleanField(default=True, verbose_name="Public Link Enabled"), ), migrations.AddField( - model_name='invoice', - name='public_token', - field=models.CharField(blank=True, max_length=64, unique=True, verbose_name='Public Token'), + model_name="invoice", + name="public_token", + field=models.CharField( + blank=True, max_length=64, unique=True, verbose_name="Public Token" + ), ), migrations.AddField( - model_name='invoice', - name='reminder_count', - field=models.PositiveIntegerField(default=0, verbose_name='Reminders Sent'), + model_name="invoice", + name="reminder_count", + field=models.PositiveIntegerField(default=0, verbose_name="Reminders Sent"), ), migrations.AddField( - model_name='invoice', - name='reminder_days_after', - field=models.PositiveIntegerField(default=7, verbose_name='Remind Days After Due'), + model_name="invoice", + name="reminder_days_after", + field=models.PositiveIntegerField( + default=7, verbose_name="Remind Days After Due" + ), ), migrations.AddField( - model_name='invoice', - name='reminder_days_before', - field=models.PositiveIntegerField(default=3, verbose_name='Remind Days Before Due'), + model_name="invoice", + name="reminder_days_before", + field=models.PositiveIntegerField( + default=3, verbose_name="Remind Days Before Due" + ), ), migrations.AddField( - model_name='invoice', - name='reminder_enabled', - field=models.BooleanField(default=True, verbose_name='Reminders Enabled'), + model_name="invoice", + name="reminder_enabled", + field=models.BooleanField(default=True, verbose_name="Reminders Enabled"), ), migrations.AddField( - model_name='invoice', - name='reminder_frequency', - field=models.CharField(choices=[('ONCE', 'Once'), ('WEEKLY', 'Weekly until paid'), ('CUSTOM', 'Custom schedule')], default='ONCE', max_length=20, verbose_name='Reminder Frequency'), + model_name="invoice", + name="reminder_frequency", + field=models.CharField( + choices=[ + ("ONCE", "Once"), + ("WEEKLY", "Weekly until paid"), + ("CUSTOM", "Custom schedule"), + ], + default="ONCE", + max_length=20, + verbose_name="Reminder Frequency", + ), ), migrations.AddField( - model_name='invoice', - name='sent_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='Sent At'), + model_name="invoice", + name="sent_at", + field=models.DateTimeField(blank=True, null=True, verbose_name="Sent At"), ), migrations.AddField( - model_name='invoice', - name='shipping_amount', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Shipping'), + model_name="invoice", + name="shipping_amount", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Shipping" + ), ), migrations.AddField( - model_name='invoice', - name='subtotal', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Subtotal'), + model_name="invoice", + name="subtotal", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Subtotal" + ), ), migrations.AddField( - model_name='invoice', - name='tax_amount', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Tax Amount'), + model_name="invoice", + name="tax_amount", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Tax Amount" + ), ), migrations.AddField( - model_name='invoice', - name='tax_rate', - field=models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Tax Rate (%)'), + model_name="invoice", + name="tax_rate", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=5, verbose_name="Tax Rate (%)" + ), ), migrations.AddField( - model_name='invoice', - name='terms', - field=models.TextField(blank=True, verbose_name='Terms & Conditions'), + model_name="invoice", + name="terms", + field=models.TextField(blank=True, verbose_name="Terms & Conditions"), ), migrations.AddField( - model_name='invoice', - name='viewed_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='Viewed At'), + model_name="invoice", + name="viewed_at", + field=models.DateTimeField(blank=True, null=True, verbose_name="Viewed At"), ), migrations.AddField( - model_name='invoicehistory', - name='client_email', - field=models.EmailField(blank=True, max_length=254, verbose_name='Client Email'), + model_name="invoicehistory", + name="client_email", + field=models.EmailField( + blank=True, max_length=254, verbose_name="Client Email" + ), ), migrations.AddField( - model_name='invoicehistory', - name='client_name', - field=models.CharField(blank=True, max_length=255, verbose_name='Client Name'), + model_name="invoicehistory", + name="client_name", + field=models.CharField( + blank=True, max_length=255, verbose_name="Client Name" + ), ), migrations.AddField( - model_name='invoicelineitem', - name='discount_amount', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Discount Amount'), + model_name="invoicelineitem", + name="discount_amount", + field=models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Discount Amount", + ), ), migrations.AddField( - model_name='invoicelineitem', - name='discount_type', - field=models.CharField(blank=True, choices=[('PERCENTAGE', 'Percentage (%)'), ('FIXED', 'Fixed Amount')], max_length=20, verbose_name='Discount Type'), + model_name="invoicelineitem", + name="discount_type", + field=models.CharField( + blank=True, + choices=[("PERCENTAGE", "Percentage (%)"), ("FIXED", "Fixed Amount")], + max_length=20, + verbose_name="Discount Type", + ), ), migrations.AddField( - model_name='invoicelineitem', - name='discount_value', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Discount Value'), + model_name="invoicelineitem", + name="discount_value", + field=models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Discount Value", + ), ), migrations.AddField( - model_name='invoicelineitem', - name='subtotal', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Subtotal'), + model_name="invoicelineitem", + name="subtotal", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Subtotal" + ), ), migrations.AddField( - model_name='invoicelineitem', - name='tax_amount', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Tax Amount'), + model_name="invoicelineitem", + name="tax_amount", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Tax Amount" + ), ), migrations.AddField( - model_name='invoicelineitem', - name='tax_rate', - field=models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Tax Rate (%)'), + model_name="invoicelineitem", + name="tax_rate", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=5, verbose_name="Tax Rate (%)" + ), ), migrations.AlterField( - model_name='invoice', - name='amount_due', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Amount Due'), + model_name="invoice", + name="amount_due", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Amount Due" + ), ), migrations.AlterField( - model_name='invoice', - name='amount_paid', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Amount Paid'), + model_name="invoice", + name="amount_paid", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Amount Paid" + ), ), migrations.AlterField( - model_name='invoice', - name='currency', - field=models.CharField(choices=[('USD', 'USD, Dollar'), ('EUR', 'EUR, Euro'), ('GBP', 'GBP, Pound'), ('INR', 'INR, Rupee'), ('CAD', 'CAD, Dollar'), ('AUD', 'AUD, Dollar'), ('JPY', 'JPY, Yen'), ('CNY', 'CNY, Yuan'), ('CHF', 'CHF, Franc'), ('SGD', 'SGD, Dollar'), ('AED', 'AED, Dirham'), ('BRL', 'BRL, Real'), ('MXN', 'MXN, Peso')], default='USD', max_length=3, verbose_name='Currency'), + model_name="invoice", + name="currency", + field=models.CharField( + choices=[ + ("USD", "USD, Dollar"), + ("EUR", "EUR, Euro"), + ("GBP", "GBP, Pound"), + ("INR", "INR, Rupee"), + ("CAD", "CAD, Dollar"), + ("AUD", "AUD, Dollar"), + ("JPY", "JPY, Yen"), + ("CNY", "CNY, Yuan"), + ("CHF", "CHF, Franc"), + ("SGD", "SGD, Dollar"), + ("AED", "AED, Dirham"), + ("BRL", "BRL, Real"), + ("MXN", "MXN, Peso"), + ], + default="USD", + max_length=3, + verbose_name="Currency", + ), ), migrations.AlterField( - model_name='invoice', - name='details', - field=models.TextField(blank=True, null=True, verbose_name='Internal Details'), + model_name="invoice", + name="details", + field=models.TextField( + blank=True, null=True, verbose_name="Internal Details" + ), ), migrations.AlterField( - model_name='invoice', - name='due_date', - field=models.DateField(blank=True, null=True, verbose_name='Due Date'), + model_name="invoice", + name="due_date", + field=models.DateField(blank=True, null=True, verbose_name="Due Date"), ), migrations.AlterField( - model_name='invoice', - name='invoice_number', - field=models.CharField(max_length=50, unique=True, verbose_name='Invoice Number'), + model_name="invoice", + name="invoice_number", + field=models.CharField( + max_length=50, unique=True, verbose_name="Invoice Number" + ), ), migrations.AlterField( - model_name='invoice', - name='invoice_title', - field=models.CharField(max_length=100, verbose_name='Invoice Title'), + model_name="invoice", + name="invoice_title", + field=models.CharField(max_length=100, verbose_name="Invoice Title"), ), migrations.AlterField( - model_name='invoice', - name='quantity', - field=models.PositiveIntegerField(default=0, verbose_name='Quantity (Hours)'), + model_name="invoice", + name="quantity", + field=models.PositiveIntegerField( + default=0, verbose_name="Quantity (Hours)" + ), ), migrations.AlterField( - model_name='invoice', - name='rate', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Rate'), + model_name="invoice", + name="rate", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Rate" + ), ), migrations.AlterField( - model_name='invoice', - name='status', - field=models.CharField(choices=[('Draft', 'Draft'), ('Sent', 'Sent'), ('Viewed', 'Viewed'), ('Paid', 'Paid'), ('Partially_Paid', 'Partially Paid'), ('Overdue', 'Overdue'), ('Pending', 'Pending'), ('Cancelled', 'Cancelled')], default='Draft', max_length=20, verbose_name='Status'), + model_name="invoice", + name="status", + field=models.CharField( + choices=[ + ("Draft", "Draft"), + ("Sent", "Sent"), + ("Viewed", "Viewed"), + ("Paid", "Paid"), + ("Partially_Paid", "Partially Paid"), + ("Overdue", "Overdue"), + ("Pending", "Pending"), + ("Cancelled", "Cancelled"), + ], + default="Draft", + max_length=20, + verbose_name="Status", + ), ), migrations.AlterField( - model_name='invoice', - name='total_amount', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Total Amount'), + model_name="invoice", + name="total_amount", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Total Amount" + ), ), migrations.AlterField( - model_name='invoicehistory', - name='amount_due', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Amount Due'), + model_name="invoicehistory", + name="amount_due", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Amount Due" + ), ), migrations.AlterField( - model_name='invoicehistory', - name='currency', - field=models.CharField(blank=True, choices=[('USD', 'USD, Dollar'), ('EUR', 'EUR, Euro'), ('GBP', 'GBP, Pound'), ('INR', 'INR, Rupee'), ('CAD', 'CAD, Dollar'), ('AUD', 'AUD, Dollar'), ('JPY', 'JPY, Yen'), ('CNY', 'CNY, Yuan'), ('CHF', 'CHF, Franc'), ('SGD', 'SGD, Dollar'), ('AED', 'AED, Dirham'), ('BRL', 'BRL, Real'), ('MXN', 'MXN, Peso')], max_length=3, null=True, verbose_name='Currency'), + model_name="invoicehistory", + name="currency", + field=models.CharField( + blank=True, + choices=[ + ("USD", "USD, Dollar"), + ("EUR", "EUR, Euro"), + ("GBP", "GBP, Pound"), + ("INR", "INR, Rupee"), + ("CAD", "CAD, Dollar"), + ("AUD", "AUD, Dollar"), + ("JPY", "JPY, Yen"), + ("CNY", "CNY, Yuan"), + ("CHF", "CHF, Franc"), + ("SGD", "SGD, Dollar"), + ("AED", "AED, Dirham"), + ("BRL", "BRL, Real"), + ("MXN", "MXN, Peso"), + ], + max_length=3, + null=True, + verbose_name="Currency", + ), ), migrations.AlterField( - model_name='invoicehistory', - name='details', - field=models.TextField(blank=True, null=True, verbose_name='Change Details'), + model_name="invoicehistory", + name="details", + field=models.TextField( + blank=True, null=True, verbose_name="Change Details" + ), ), migrations.AlterField( - model_name='invoicehistory', - name='due_date', - field=models.DateField(blank=True, null=True, verbose_name='Due Date'), + model_name="invoicehistory", + name="due_date", + field=models.DateField(blank=True, null=True, verbose_name="Due Date"), ), migrations.AlterField( - model_name='invoicehistory', - name='invoice_title', - field=models.CharField(max_length=100, verbose_name='Invoice Title'), + model_name="invoicehistory", + name="invoice_title", + field=models.CharField(max_length=100, verbose_name="Invoice Title"), ), migrations.AlterField( - model_name='invoicehistory', - name='status', - field=models.CharField(choices=[('Draft', 'Draft'), ('Sent', 'Sent'), ('Viewed', 'Viewed'), ('Paid', 'Paid'), ('Partially_Paid', 'Partially Paid'), ('Overdue', 'Overdue'), ('Pending', 'Pending'), ('Cancelled', 'Cancelled')], default='Draft', max_length=20, verbose_name='Status'), + model_name="invoicehistory", + name="status", + field=models.CharField( + choices=[ + ("Draft", "Draft"), + ("Sent", "Sent"), + ("Viewed", "Viewed"), + ("Paid", "Paid"), + ("Partially_Paid", "Partially Paid"), + ("Overdue", "Overdue"), + ("Pending", "Pending"), + ("Cancelled", "Cancelled"), + ], + default="Draft", + max_length=20, + verbose_name="Status", + ), ), migrations.AlterField( - model_name='invoicehistory', - name='total_amount', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Total Amount'), + model_name="invoicehistory", + name="total_amount", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Total Amount" + ), ), migrations.AlterField( - model_name='invoicelineitem', - name='description', - field=models.CharField(max_length=500, verbose_name='Description'), + model_name="invoicelineitem", + name="description", + field=models.CharField(max_length=500, verbose_name="Description"), ), migrations.AlterField( - model_name='invoicelineitem', - name='order', - field=models.PositiveIntegerField(default=0, verbose_name='Order'), + model_name="invoicelineitem", + name="order", + field=models.PositiveIntegerField(default=0, verbose_name="Order"), ), migrations.AlterField( - model_name='invoicelineitem', - name='quantity', - field=models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Quantity'), + model_name="invoicelineitem", + name="quantity", + field=models.DecimalField( + decimal_places=2, default=1, max_digits=10, verbose_name="Quantity" + ), ), migrations.AlterField( - model_name='invoicelineitem', - name='total', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Total'), + model_name="invoicelineitem", + name="total", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Total" + ), ), migrations.AlterField( - model_name='invoicelineitem', - name='unit_price', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Unit Price'), + model_name="invoicelineitem", + name="unit_price", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Unit Price" + ), ), migrations.AlterField( - model_name='product', - name='category', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Category'), + model_name="product", + name="category", + field=models.CharField( + blank=True, max_length=100, null=True, verbose_name="Category" + ), ), migrations.AlterField( - model_name='product', - name='currency', - field=models.CharField(blank=True, choices=[('USD', 'USD, Dollar'), ('EUR', 'EUR, Euro'), ('GBP', 'GBP, Pound'), ('INR', 'INR, Rupee'), ('CAD', 'CAD, Dollar'), ('AUD', 'AUD, Dollar'), ('JPY', 'JPY, Yen'), ('CNY', 'CNY, Yuan'), ('CHF', 'CHF, Franc'), ('SGD', 'SGD, Dollar'), ('AED', 'AED, Dirham'), ('BRL', 'BRL, Real'), ('MXN', 'MXN, Peso')], max_length=3, null=True, verbose_name='Currency'), + model_name="product", + name="currency", + field=models.CharField( + blank=True, + choices=[ + ("USD", "USD, Dollar"), + ("EUR", "EUR, Euro"), + ("GBP", "GBP, Pound"), + ("INR", "INR, Rupee"), + ("CAD", "CAD, Dollar"), + ("AUD", "AUD, Dollar"), + ("JPY", "JPY, Yen"), + ("CNY", "CNY, Yuan"), + ("CHF", "CHF, Franc"), + ("SGD", "SGD, Dollar"), + ("AED", "AED, Dirham"), + ("BRL", "BRL, Real"), + ("MXN", "MXN, Peso"), + ], + max_length=3, + null=True, + verbose_name="Currency", + ), ), migrations.AlterField( - model_name='product', - name='description', - field=models.TextField(blank=True, null=True, verbose_name='Description'), + model_name="product", + name="description", + field=models.TextField(blank=True, null=True, verbose_name="Description"), ), migrations.AlterField( - model_name='product', - name='is_active', - field=models.BooleanField(default=True, verbose_name='Is Active'), + model_name="product", + name="is_active", + field=models.BooleanField(default=True, verbose_name="Is Active"), ), migrations.AlterField( - model_name='product', - name='name', - field=models.CharField(max_length=255, verbose_name='Product Name'), + model_name="product", + name="name", + field=models.CharField(max_length=255, verbose_name="Product Name"), ), migrations.AlterField( - model_name='product', - name='price', - field=models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Price'), + model_name="product", + name="price", + field=models.DecimalField( + decimal_places=2, default=0, max_digits=12, verbose_name="Price" + ), ), migrations.AlterField( - model_name='product', - name='sku', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='SKU'), + model_name="product", + name="sku", + field=models.CharField( + blank=True, max_length=100, null=True, verbose_name="SKU" + ), ), migrations.AddIndex( - model_name='invoicehistory', - index=models.Index(fields=['invoice'], name='invoice_his_invoice_f9dc9c_idx'), + model_name="invoicehistory", + index=models.Index( + fields=["invoice"], name="invoice_his_invoice_f9dc9c_idx" + ), ), migrations.AddIndex( - model_name='product', - index=models.Index(fields=['org', 'is_active'], name='product_org_id_cfbb38_idx'), + model_name="product", + index=models.Index( + fields=["org", "is_active"], name="product_org_id_cfbb38_idx" + ), ), migrations.AddField( - model_name='estimate', - name='account', - field=models.ForeignKey(blank=True, help_text='The legal entity (REQUIRED via validation)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='estimates', to='accounts.account'), + model_name="estimate", + name="account", + field=models.ForeignKey( + blank=True, + help_text="The legal entity (REQUIRED via validation)", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="estimates", + to="accounts.account", + ), ), migrations.AddField( - model_name='estimate', - name='assigned_to', - field=models.ManyToManyField(related_name='estimate_assigned_to', to='common.profile'), + model_name="estimate", + name="assigned_to", + field=models.ManyToManyField( + related_name="estimate_assigned_to", to="common.profile" + ), ), migrations.AddField( - model_name='estimate', - name='contact', - field=models.ForeignKey(blank=True, help_text='The contact person (REQUIRED via validation)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimates', to='contacts.contact'), + model_name="estimate", + name="contact", + field=models.ForeignKey( + blank=True, + help_text="The contact person (REQUIRED via validation)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="estimates", + to="contacts.contact", + ), ), migrations.AddField( - model_name='estimate', - name='converted_to_invoice', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_estimate', to='invoices.invoice'), + model_name="estimate", + name="converted_to_invoice", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="source_estimate", + to="invoices.invoice", + ), ), migrations.AddField( - model_name='estimate', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + model_name="estimate", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), ), migrations.AddField( - model_name='estimate', - name='opportunity', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimates', to='opportunity.opportunity'), + model_name="estimate", + name="opportunity", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="estimates", + to="opportunity.opportunity", + ), ), migrations.AddField( - model_name='estimate', - name='org', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='estimates', to='common.org'), + model_name="estimate", + name="org", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="estimates", + to="common.org", + ), ), migrations.AddField( - model_name='estimate', - name='teams', - field=models.ManyToManyField(related_name='estimate_teams', to='common.teams'), + model_name="estimate", + name="teams", + field=models.ManyToManyField( + related_name="estimate_teams", to="common.teams" + ), ), migrations.AddField( - model_name='estimate', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="estimate", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.AddField( - model_name='estimatelineitem', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + model_name="estimatelineitem", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), ), migrations.AddField( - model_name='estimatelineitem', - name='estimate', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='line_items', to='invoices.estimate'), + model_name="estimatelineitem", + name="estimate", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="line_items", + to="invoices.estimate", + ), ), migrations.AddField( - model_name='estimatelineitem', - name='org', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='estimate_line_items', to='common.org'), + model_name="estimatelineitem", + name="org", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="estimate_line_items", + to="common.org", + ), ), migrations.AddField( - model_name='estimatelineitem', - name='product', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimate_line_items', to='invoices.product'), + model_name="estimatelineitem", + name="product", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="estimate_line_items", + to="invoices.product", + ), ), migrations.AddField( - model_name='estimatelineitem', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="estimatelineitem", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.AddField( - model_name='invoicetemplate', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + model_name="invoicetemplate", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), ), migrations.AddField( - model_name='invoicetemplate', - name='org', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoice_templates', to='common.org'), + model_name="invoicetemplate", + name="org", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="invoice_templates", + to="common.org", + ), ), migrations.AddField( - model_name='invoicetemplate', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="invoicetemplate", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.AddField( - model_name='invoice', - name='template', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoices', to='invoices.invoicetemplate'), + model_name="invoice", + name="template", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="invoices", + to="invoices.invoicetemplate", + ), ), migrations.AddIndex( - model_name='invoice', - index=models.Index(fields=['org', '-created_at'], name='invoice_org_id_7ae4e1_idx'), + model_name="invoice", + index=models.Index( + fields=["org", "-created_at"], name="invoice_org_id_7ae4e1_idx" + ), ), migrations.AddIndex( - model_name='invoice', - index=models.Index(fields=['org', 'status'], name='invoice_org_id_a1a9f8_idx'), + model_name="invoice", + index=models.Index( + fields=["org", "status"], name="invoice_org_id_a1a9f8_idx" + ), ), migrations.AddIndex( - model_name='invoice', - index=models.Index(fields=['account'], name='invoice_account_a00f76_idx'), + model_name="invoice", + index=models.Index(fields=["account"], name="invoice_account_a00f76_idx"), ), migrations.AddIndex( - model_name='invoice', - index=models.Index(fields=['due_date'], name='invoice_due_dat_ee8b9c_idx'), + model_name="invoice", + index=models.Index(fields=["due_date"], name="invoice_due_dat_ee8b9c_idx"), ), migrations.AddIndex( - model_name='invoice', - index=models.Index(fields=['public_token'], name='invoice_public__322f82_idx'), + model_name="invoice", + index=models.Index( + fields=["public_token"], name="invoice_public__322f82_idx" + ), ), migrations.AddField( - model_name='payment', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + model_name="payment", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), ), migrations.AddField( - model_name='payment', - name='invoice', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='invoices.invoice'), + model_name="payment", + name="invoice", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="payments", + to="invoices.invoice", + ), ), migrations.AddField( - model_name='payment', - name='org', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='common.org'), + model_name="payment", + name="org", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="payments", + to="common.org", + ), ), migrations.AddField( - model_name='payment', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="payment", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.AddField( - model_name='recurringinvoice', - name='account', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='recurring_invoices', to='accounts.account'), + model_name="recurringinvoice", + name="account", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="recurring_invoices", + to="accounts.account", + ), ), migrations.AddField( - model_name='recurringinvoice', - name='assigned_to', - field=models.ManyToManyField(related_name='recurring_invoice_assigned_to', to='common.profile'), + model_name="recurringinvoice", + name="assigned_to", + field=models.ManyToManyField( + related_name="recurring_invoice_assigned_to", to="common.profile" + ), ), migrations.AddField( - model_name='recurringinvoice', - name='contact', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recurring_invoices', to='contacts.contact'), + model_name="recurringinvoice", + name="contact", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="recurring_invoices", + to="contacts.contact", + ), ), migrations.AddField( - model_name='recurringinvoice', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + model_name="recurringinvoice", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), ), migrations.AddField( - model_name='recurringinvoice', - name='opportunity', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recurring_invoices', to='opportunity.opportunity'), + model_name="recurringinvoice", + name="opportunity", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="recurring_invoices", + to="opportunity.opportunity", + ), ), migrations.AddField( - model_name='recurringinvoice', - name='org', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recurring_invoices', to='common.org'), + model_name="recurringinvoice", + name="org", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="recurring_invoices", + to="common.org", + ), ), migrations.AddField( - model_name='recurringinvoice', - name='teams', - field=models.ManyToManyField(related_name='recurring_invoice_teams', to='common.teams'), + model_name="recurringinvoice", + name="teams", + field=models.ManyToManyField( + related_name="recurring_invoice_teams", to="common.teams" + ), ), migrations.AddField( - model_name='recurringinvoice', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="recurringinvoice", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.AddField( - model_name='recurringinvoicelineitem', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + model_name="recurringinvoicelineitem", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), ), migrations.AddField( - model_name='recurringinvoicelineitem', - name='org', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recurring_invoice_line_items', to='common.org'), + model_name="recurringinvoicelineitem", + name="org", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="recurring_invoice_line_items", + to="common.org", + ), ), migrations.AddField( - model_name='recurringinvoicelineitem', - name='product', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recurring_invoice_line_items', to='invoices.product'), + model_name="recurringinvoicelineitem", + name="product", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="recurring_invoice_line_items", + to="invoices.product", + ), ), migrations.AddField( - model_name='recurringinvoicelineitem', - name='recurring_invoice', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='line_items', to='invoices.recurringinvoice'), + model_name="recurringinvoicelineitem", + name="recurring_invoice", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="line_items", + to="invoices.recurringinvoice", + ), ), migrations.AddField( - model_name='recurringinvoicelineitem', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="recurringinvoicelineitem", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.AddIndex( - model_name='estimate', - index=models.Index(fields=['org', '-created_at'], name='estimate_org_id_d4d0fd_idx'), + model_name="estimate", + index=models.Index( + fields=["org", "-created_at"], name="estimate_org_id_d4d0fd_idx" + ), ), migrations.AddIndex( - model_name='estimate', - index=models.Index(fields=['org', 'status'], name='estimate_org_id_11a35e_idx'), + model_name="estimate", + index=models.Index( + fields=["org", "status"], name="estimate_org_id_11a35e_idx" + ), ), migrations.AddIndex( - model_name='estimate', - index=models.Index(fields=['account'], name='estimate_account_25a4f9_idx'), + model_name="estimate", + index=models.Index(fields=["account"], name="estimate_account_25a4f9_idx"), ), migrations.AddIndex( - model_name='estimate', - index=models.Index(fields=['expiry_date'], name='estimate_expiry__9e6baa_idx'), + model_name="estimate", + index=models.Index( + fields=["expiry_date"], name="estimate_expiry__9e6baa_idx" + ), ), migrations.AddIndex( - model_name='estimate', - index=models.Index(fields=['public_token'], name='estimate_public__9ac914_idx'), + model_name="estimate", + index=models.Index( + fields=["public_token"], name="estimate_public__9ac914_idx" + ), ), migrations.AddIndex( - model_name='estimatelineitem', - index=models.Index(fields=['org', 'order'], name='estimate_li_org_id_08f3d8_idx'), + model_name="estimatelineitem", + index=models.Index( + fields=["org", "order"], name="estimate_li_org_id_08f3d8_idx" + ), ), migrations.AddIndex( - model_name='invoicetemplate', - index=models.Index(fields=['org', '-created_at'], name='invoice_tem_org_id_e092e5_idx'), + model_name="invoicetemplate", + index=models.Index( + fields=["org", "-created_at"], name="invoice_tem_org_id_e092e5_idx" + ), ), migrations.AddIndex( - model_name='payment', - index=models.Index(fields=['org', '-payment_date'], name='payment_org_id_9d3110_idx'), + model_name="payment", + index=models.Index( + fields=["org", "-payment_date"], name="payment_org_id_9d3110_idx" + ), ), migrations.AddIndex( - model_name='payment', - index=models.Index(fields=['invoice'], name='payment_invoice_6a55b6_idx'), + model_name="payment", + index=models.Index(fields=["invoice"], name="payment_invoice_6a55b6_idx"), ), migrations.AddIndex( - model_name='recurringinvoice', - index=models.Index(fields=['org', '-created_at'], name='recurring_i_org_id_0389ae_idx'), + model_name="recurringinvoice", + index=models.Index( + fields=["org", "-created_at"], name="recurring_i_org_id_0389ae_idx" + ), ), migrations.AddIndex( - model_name='recurringinvoice', - index=models.Index(fields=['org', 'is_active'], name='recurring_i_org_id_264608_idx'), + model_name="recurringinvoice", + index=models.Index( + fields=["org", "is_active"], name="recurring_i_org_id_264608_idx" + ), ), migrations.AddIndex( - model_name='recurringinvoice', - index=models.Index(fields=['next_generation_date'], name='recurring_i_next_ge_bcc0ef_idx'), + model_name="recurringinvoice", + index=models.Index( + fields=["next_generation_date"], name="recurring_i_next_ge_bcc0ef_idx" + ), ), ] diff --git a/backend/invoices/migrations/0005_add_billing_period_po_number_and_lineitem_name.py b/backend/invoices/migrations/0005_add_billing_period_po_number_and_lineitem_name.py index b6147605..f1354e54 100644 --- a/backend/invoices/migrations/0005_add_billing_period_po_number_and_lineitem_name.py +++ b/backend/invoices/migrations/0005_add_billing_period_po_number_and_lineitem_name.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("invoices", "0004_estimate_estimatelineitem_invoicetemplate_payment_and_more"), ] diff --git a/backend/invoices/migrations/0006_remove_legacy_invoice_fields.py b/backend/invoices/migrations/0006_remove_legacy_invoice_fields.py index 6ce3345a..30be8951 100644 --- a/backend/invoices/migrations/0006_remove_legacy_invoice_fields.py +++ b/backend/invoices/migrations/0006_remove_legacy_invoice_fields.py @@ -4,7 +4,6 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ ("invoices", "0005_add_billing_period_po_number_and_lineitem_name"), ] diff --git a/backend/invoices/migrations/0007_add_cancelled_at_field.py b/backend/invoices/migrations/0007_add_cancelled_at_field.py index e55a8b16..51424680 100644 --- a/backend/invoices/migrations/0007_add_cancelled_at_field.py +++ b/backend/invoices/migrations/0007_add_cancelled_at_field.py @@ -4,15 +4,16 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('invoices', '0006_remove_legacy_invoice_fields'), + ("invoices", "0006_remove_legacy_invoice_fields"), ] operations = [ migrations.AddField( - model_name='invoice', - name='cancelled_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='Cancelled At'), + model_name="invoice", + name="cancelled_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Cancelled At" + ), ), ] diff --git a/backend/invoices/models.py b/backend/invoices/models.py index 974931d8..c1f3eaf4 100644 --- a/backend/invoices/models.py +++ b/backend/invoices/models.py @@ -144,9 +144,7 @@ class Product(BaseModel): name = models.CharField(_("Product Name"), max_length=255) description = models.TextField(_("Description"), blank=True, null=True) sku = models.CharField(_("SKU"), max_length=100, blank=True, null=True) - price = models.DecimalField( - _("Price"), max_digits=12, decimal_places=2, default=0 - ) + price = models.DecimalField(_("Price"), max_digits=12, decimal_places=2, default=0) currency = models.CharField( _("Currency"), max_length=3, choices=CURRENCY_CODES, blank=True, null=True ) @@ -340,9 +338,7 @@ class Invoice(AssignableMixin, BaseModel): billing_period = models.CharField( _("Billing Period"), max_length=100, blank=True, null=True ) - po_number = models.CharField( - _("PO Number"), max_length=100, blank=True, null=True - ) + po_number = models.CharField(_("PO Number"), max_length=100, blank=True, null=True) # Assignment assigned_to = models.ManyToManyField(Profile, related_name="invoice_assigned_to") @@ -409,8 +405,7 @@ class Invoice(AssignableMixin, BaseModel): .select_for_update() .annotate( seq_num=Cast( - Substr("invoice_number", prefix_len + 1), - IntegerField() + Substr("invoice_number", prefix_len + 1), IntegerField() ) ) .aggregate(max_seq=Max("seq_num")) @@ -447,7 +442,9 @@ class Invoice(AssignableMixin, BaseModel): # Calculate discount if self.discount_type == "PERCENTAGE": - self.discount_amount = self.subtotal * (self.discount_value / Decimal("100")) + self.discount_amount = self.subtotal * ( + self.discount_value / Decimal("100") + ) else: self.discount_amount = self.discount_value @@ -564,7 +561,9 @@ class InvoiceLineItem(BaseModel): # Calculate discount if self.discount_type == "PERCENTAGE": - self.discount_amount = self.subtotal * (self.discount_value / Decimal("100")) + self.discount_amount = self.subtotal * ( + self.discount_value / Decimal("100") + ) else: self.discount_amount = self.discount_value @@ -648,7 +647,9 @@ class Payment(BaseModel): def update_invoice_payment(self, invoice=None): """Update the invoice's amount_paid and status""" invoice = invoice or self.invoice - total_paid = invoice.payments.aggregate(total=models.Sum("amount"))["total"] or 0 + total_paid = ( + invoice.payments.aggregate(total=models.Sum("amount"))["total"] or 0 + ) invoice.amount_paid = total_paid invoice.amount_due = invoice.total_amount - total_paid @@ -674,9 +675,7 @@ class Estimate(AssignableMixin, BaseModel): """Estimates/Quotes - can be converted to Invoice""" # Core Info - estimate_number = models.CharField( - _("Estimate Number"), max_length=50, unique=True - ) + estimate_number = models.CharField(_("Estimate Number"), max_length=50, unique=True) title = models.CharField(_("Title"), max_length=100) status = models.CharField( _("Status"), max_length=20, choices=ESTIMATE_STATUS, default="Draft" @@ -836,8 +835,7 @@ class Estimate(AssignableMixin, BaseModel): .select_for_update() .annotate( seq_num=Cast( - Substr("estimate_number", prefix_len + 1), - IntegerField() + Substr("estimate_number", prefix_len + 1), IntegerField() ) ) .aggregate(max_seq=Max("seq_num")) @@ -854,7 +852,9 @@ class Estimate(AssignableMixin, BaseModel): self.subtotal = sum(item.subtotal for item in line_items) if self.discount_type == "PERCENTAGE": - self.discount_amount = self.subtotal * (self.discount_value / Decimal("100")) + self.discount_amount = self.subtotal * ( + self.discount_value / Decimal("100") + ) else: self.discount_amount = self.discount_value @@ -939,7 +939,9 @@ class EstimateLineItem(BaseModel): self.subtotal = self.quantity * self.unit_price if self.discount_type == "PERCENTAGE": - self.discount_amount = self.subtotal * (self.discount_value / Decimal("100")) + self.discount_amount = self.subtotal * ( + self.discount_value / Decimal("100") + ) else: self.discount_amount = self.discount_value @@ -1001,7 +1003,9 @@ class RecurringInvoice(AssignableMixin, BaseModel): ) start_date = models.DateField(_("Start Date"), default=datetime.date.today) end_date = models.DateField(_("End Date"), null=True, blank=True) - next_generation_date = models.DateField(_("Next Generation Date"), default=datetime.date.today) + next_generation_date = models.DateField( + _("Next Generation Date"), default=datetime.date.today + ) # Settings payment_terms = models.CharField( @@ -1034,9 +1038,7 @@ class RecurringInvoice(AssignableMixin, BaseModel): terms = models.TextField(_("Terms & Conditions"), blank=True) # Statistics - invoices_generated = models.PositiveIntegerField( - _("Invoices Generated"), default=0 - ) + invoices_generated = models.PositiveIntegerField(_("Invoices Generated"), default=0) # Assignment assigned_to = models.ManyToManyField( diff --git a/backend/invoices/pdf.py b/backend/invoices/pdf.py index 5a4eb8e4..cea94fda 100644 --- a/backend/invoices/pdf.py +++ b/backend/invoices/pdf.py @@ -349,6 +349,7 @@ def generate_invoice_pdf(invoice, include_payments=True): template = invoice.template if not template: from invoices.models import InvoiceTemplate + template = InvoiceTemplate.objects.filter( org=invoice.org, is_default=True ).first() @@ -357,26 +358,32 @@ def generate_invoice_pdf(invoice, include_payments=True): line_items = [] for item in invoice.line_items.all().order_by("order"): # Use name field if present, otherwise fall back to description - line_items.append({ - "name": item.name if item.name else item.description, - "description": item.description if item.name else "", # Show description as subtitle if name is set - "quantity": item.quantity, - "unit_price": format_currency(item.unit_price, invoice.currency), - "tax_rate": item.tax_rate, - "total": format_currency(item.total, invoice.currency), - }) + line_items.append( + { + "name": item.name if item.name else item.description, + "description": item.description + if item.name + else "", # Show description as subtitle if name is set + "quantity": item.quantity, + "unit_price": format_currency(item.unit_price, invoice.currency), + "tax_rate": item.tax_rate, + "total": format_currency(item.total, invoice.currency), + } + ) # Pre-format payments payments = [] if include_payments: for payment in invoice.payments.all().order_by("-payment_date"): - payments.append({ - "payment_date": payment.payment_date, - "payment_method": payment.payment_method, - "get_payment_method_display": payment.get_payment_method_display(), - "reference_number": payment.reference_number, - "amount": format_currency(payment.amount, invoice.currency), - }) + payments.append( + { + "payment_date": payment.payment_date, + "payment_method": payment.payment_method, + "get_payment_method_display": payment.get_payment_method_display(), + "reference_number": payment.reference_number, + "amount": format_currency(payment.amount, invoice.currency), + } + ) # Prepare context with pre-formatted currency values context = { @@ -442,22 +449,25 @@ def generate_estimate_pdf(estimate): # Get the org's default template for styling from invoices.models import InvoiceTemplate - template = InvoiceTemplate.objects.filter( - org=estimate.org, is_default=True - ).first() + + template = InvoiceTemplate.objects.filter(org=estimate.org, is_default=True).first() # Pre-format line items with currency line_items = [] for item in estimate.line_items.all().order_by("order"): # Use name field if present, otherwise fall back to description - line_items.append({ - "name": item.name if item.name else item.description, - "description": item.description if item.name else "", # Show description as subtitle if name is set - "quantity": item.quantity, - "unit_price": format_currency(item.unit_price, estimate.currency), - "tax_rate": item.tax_rate, - "total": format_currency(item.total, estimate.currency), - }) + line_items.append( + { + "name": item.name if item.name else item.description, + "description": item.description + if item.name + else "", # Show description as subtitle if name is set + "quantity": item.quantity, + "unit_price": format_currency(item.unit_price, estimate.currency), + "tax_rate": item.tax_rate, + "total": format_currency(item.total, estimate.currency), + } + ) # Prepare context with pre-formatted currency values context = { @@ -516,7 +526,9 @@ def render_invoice_template(template_html, context): "{{invoice.issue_date}}": str(invoice.issue_date) if invoice.issue_date else "", "{{invoice.due_date}}": str(invoice.due_date) if invoice.due_date else "", "{{invoice.subtotal}}": format_currency(invoice.subtotal, currency), - "{{invoice.discount_amount}}": format_currency(invoice.discount_amount, currency), + "{{invoice.discount_amount}}": format_currency( + invoice.discount_amount, currency + ), "{{invoice.tax_amount}}": format_currency(invoice.tax_amount, currency), "{{invoice.total_amount}}": format_currency(invoice.total_amount, currency), "{{invoice.amount_paid}}": format_currency(invoice.amount_paid, currency), @@ -528,10 +540,16 @@ def render_invoice_template(template_html, context): # Address fields address_fields = [ - "billing_address_line", "billing_city", "billing_state", - "billing_postcode", "billing_country", - "client_address_line", "client_city", "client_state", - "client_postcode", "client_country", + "billing_address_line", + "billing_city", + "billing_state", + "billing_postcode", + "billing_country", + "client_address_line", + "client_city", + "client_state", + "client_postcode", + "client_country", ] for field in address_fields: replacements[f"{{{{invoice.{field}}}}}"] = getattr(invoice, field, "") or "" diff --git a/backend/invoices/public_views.py b/backend/invoices/public_views.py index 8d8b8d61..e9db3004 100644 --- a/backend/invoices/public_views.py +++ b/backend/invoices/public_views.py @@ -313,7 +313,10 @@ class PublicEstimateAcceptView(APIView): if estimate.status not in ["Sent", "Viewed"]: return Response( - {"error": True, "message": "Estimate cannot be accepted in current state"}, + { + "error": True, + "message": "Estimate cannot be accepted in current state", + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -321,9 +324,7 @@ class PublicEstimateAcceptView(APIView): estimate.accepted_at = timezone.now() estimate.save() - return Response( - {"error": False, "message": "Estimate accepted successfully"} - ) + return Response({"error": False, "message": "Estimate accepted successfully"}) class PublicEstimateDeclineView(APIView): @@ -349,7 +350,10 @@ class PublicEstimateDeclineView(APIView): if estimate.status not in ["Sent", "Viewed"]: return Response( - {"error": True, "message": "Estimate cannot be declined in current state"}, + { + "error": True, + "message": "Estimate cannot be declined in current state", + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -357,6 +361,4 @@ class PublicEstimateDeclineView(APIView): estimate.declined_at = timezone.now() estimate.save() - return Response( - {"error": False, "message": "Estimate declined"} - ) + return Response({"error": False, "message": "Estimate declined"}) diff --git a/backend/invoices/seed.py b/backend/invoices/seed.py index 0358c127..ba0bc4c1 100644 --- a/backend/invoices/seed.py +++ b/backend/invoices/seed.py @@ -82,21 +82,73 @@ class InvoiceSeeder: # Product templates with realistic pricing PRODUCT_TEMPLATES = [ - {"name": "Consulting - Senior", "category": "Consulting Services", "price_range": (150, 350)}, - {"name": "Consulting - Standard", "category": "Consulting Services", "price_range": (75, 150)}, - {"name": "Implementation Services", "category": "Implementation", "price_range": (5000, 25000)}, - {"name": "Training Session (Full Day)", "category": "Training", "price_range": (1500, 3500)}, - {"name": "Training Session (Half Day)", "category": "Training", "price_range": (800, 1800)}, - {"name": "Enterprise License", "category": "Software License", "price_range": (10000, 50000)}, - {"name": "Professional License", "category": "Software License", "price_range": (1000, 5000)}, - {"name": "Team License (per seat)", "category": "Software License", "price_range": (50, 200)}, - {"name": "Annual Support Plan", "category": "Support", "price_range": (2000, 10000)}, - {"name": "Monthly Subscription", "category": "Subscription", "price_range": (99, 499)}, + { + "name": "Consulting - Senior", + "category": "Consulting Services", + "price_range": (150, 350), + }, + { + "name": "Consulting - Standard", + "category": "Consulting Services", + "price_range": (75, 150), + }, + { + "name": "Implementation Services", + "category": "Implementation", + "price_range": (5000, 25000), + }, + { + "name": "Training Session (Full Day)", + "category": "Training", + "price_range": (1500, 3500), + }, + { + "name": "Training Session (Half Day)", + "category": "Training", + "price_range": (800, 1800), + }, + { + "name": "Enterprise License", + "category": "Software License", + "price_range": (10000, 50000), + }, + { + "name": "Professional License", + "category": "Software License", + "price_range": (1000, 5000), + }, + { + "name": "Team License (per seat)", + "category": "Software License", + "price_range": (50, 200), + }, + { + "name": "Annual Support Plan", + "category": "Support", + "price_range": (2000, 10000), + }, + { + "name": "Monthly Subscription", + "category": "Subscription", + "price_range": (99, 499), + }, {"name": "Premium Support", "category": "Support", "price_range": (500, 2000)}, - {"name": "Custom Development (per hour)", "category": "Custom Development", "price_range": (100, 250)}, - {"name": "API Integration", "category": "Integration", "price_range": (3000, 15000)}, + { + "name": "Custom Development (per hour)", + "category": "Custom Development", + "price_range": (100, 250), + }, + { + "name": "API Integration", + "category": "Integration", + "price_range": (3000, 15000), + }, {"name": "Hardware Setup", "category": "Hardware", "price_range": (500, 2500)}, - {"name": "Maintenance (Monthly)", "category": "Maintenance", "price_range": (200, 1000)}, + { + "name": "Maintenance (Monthly)", + "category": "Maintenance", + "price_range": (200, 1000), + }, ] # Invoice template configurations @@ -158,9 +210,9 @@ class InvoiceSeeder: def _generate_invoice_date(self): """Generate realistic invoice dates weighted toward recent months.""" days_ago_weights = { - (0, 30): 35, # Last month: 35% - (31, 60): 25, # 1-2 months ago: 25% - (61, 90): 20, # 2-3 months ago: 20% + (0, 30): 35, # Last month: 35% + (31, 60): 25, # 1-2 months ago: 25% + (61, 90): 20, # 2-3 months ago: 20% (91, 120): 10, # 3-4 months ago: 10% (121, 150): 6, # 4-5 months ago: 6% (151, 180): 4, # 5-6 months ago: 4% @@ -195,8 +247,7 @@ class InvoiceSeeder: # Use product templates first templates_to_use = random.sample( - self.PRODUCT_TEMPLATES, - min(count, len(self.PRODUCT_TEMPLATES)) + self.PRODUCT_TEMPLATES, min(count, len(self.PRODUCT_TEMPLATES)) ) for i, template in enumerate(templates_to_use): @@ -247,7 +298,7 @@ class InvoiceSeeder: from invoices.models import InvoiceTemplate templates = [] - configs_to_use = self.TEMPLATE_CONFIGS[:min(count, len(self.TEMPLATE_CONFIGS))] + configs_to_use = self.TEMPLATE_CONFIGS[: min(count, len(self.TEMPLATE_CONFIGS))] for config in configs_to_use: template = InvoiceTemplate.objects.create( @@ -266,7 +317,18 @@ class InvoiceSeeder: self.stdout.write(f" Created {len(templates)} invoice templates") return templates - def create_invoices(self, org, profiles, teams, products, templates, accounts, contacts, opportunities, count): + def create_invoices( + self, + org, + profiles, + teams, + products, + templates, + accounts, + contacts, + opportunities, + count, + ): """ Create invoices with line items. @@ -299,8 +361,14 @@ class InvoiceSeeder: # Select account and contact account = random.choice(accounts) # Prefer contacts linked to this account - account_contacts = list(account.contacts.all()) if hasattr(account, 'contacts') else [] - contact = random.choice(account_contacts) if account_contacts else random.choice(contacts) + account_contacts = ( + list(account.contacts.all()) if hasattr(account, "contacts") else [] + ) + contact = ( + random.choice(account_contacts) + if account_contacts + else random.choice(contacts) + ) # Determine status status = self._weighted_choice(self.INVOICE_STATUS_WEIGHTS) @@ -313,7 +381,11 @@ class InvoiceSeeder: if won_opps and random.random() < 0.3: # Try to find an opp for this account account_opps = [o for o in won_opps if o.account_id == account.id] - opportunity = random.choice(account_opps) if account_opps else random.choice(won_opps) + opportunity = ( + random.choice(account_opps) + if account_opps + else random.choice(won_opps) + ) payment_terms = self._weighted_choice(self.PAYMENT_TERMS_WEIGHTS) tax_rate = Decimal(str(random.choice([0, 7.5, 8.25, 10, 12, 20]))) @@ -335,15 +407,19 @@ class InvoiceSeeder: client_postcode=contact.postcode or "", client_country=contact.country or "", # Billing from org (could be from org company profile) - billing_address_line=getattr(org, 'address_line', '') or "", - billing_city=getattr(org, 'city', '') or "", - billing_state=getattr(org, 'state', '') or "", - billing_postcode=getattr(org, 'postcode', '') or "", - billing_country=getattr(org, 'billing_country', '') or "", + billing_address_line=getattr(org, "address_line", "") or "", + billing_city=getattr(org, "city", "") or "", + billing_state=getattr(org, "state", "") or "", + billing_postcode=getattr(org, "postcode", "") or "", + billing_country=getattr(org, "billing_country", "") or "", # Financial currency=org.default_currency, - discount_type=random.choice(["PERCENTAGE", "FIXED", ""]) if random.random() > 0.6 else "", - discount_value=Decimal(str(random.choice([0, 5, 10, 15]))) if random.random() > 0.6 else Decimal("0"), + discount_type=random.choice(["PERCENTAGE", "FIXED", ""]) + if random.random() > 0.6 + else "", + discount_value=Decimal(str(random.choice([0, 5, 10, 15]))) + if random.random() > 0.6 + else Decimal("0"), tax_rate=tax_rate, # Dates issue_date=issue_date, @@ -361,9 +437,13 @@ class InvoiceSeeder: datetime.datetime.combine(issue_date, datetime.time(10, 0)) ) if status in ["Viewed", "Paid", "Partially_Paid", "Overdue"]: - invoice.viewed_at = invoice.sent_at + datetime.timedelta(days=random.randint(1, 5)) + invoice.viewed_at = invoice.sent_at + datetime.timedelta( + days=random.randint(1, 5) + ) if status == "Paid": - invoice.paid_at = invoice.viewed_at + datetime.timedelta(days=random.randint(1, 15)) + invoice.paid_at = invoice.viewed_at + datetime.timedelta( + days=random.randint(1, 15) + ) # Save invoice (this auto-generates public_token and calculates due_date) invoice.save() @@ -371,7 +451,11 @@ class InvoiceSeeder: # Create 2-5 line items num_items = random.randint(2, 5) for order in range(num_items): - product = random.choice(products) if products and random.random() > 0.3 else None + product = ( + random.choice(products) + if products and random.random() > 0.3 + else None + ) if product: name = product.name @@ -389,7 +473,9 @@ class InvoiceSeeder: description=description[:500], quantity=Decimal(str(random.choice([1, 1, 1, 2, 3, 5, 10]))), unit_price=unit_price, - discount_type="" if random.random() > 0.2 else random.choice(["PERCENTAGE", "FIXED"]), + discount_type="" + if random.random() > 0.2 + else random.choice(["PERCENTAGE", "FIXED"]), discount_value=Decimal("0"), tax_rate=tax_rate if random.random() > 0.3 else Decimal("0"), order=order, @@ -427,15 +513,21 @@ class InvoiceSeeder: # Only create payments for certain invoice statuses payable_invoices = [ - inv for inv in invoices + inv + for inv in invoices if inv.status in ["Paid", "Partially_Paid", "Sent", "Viewed", "Overdue"] ] for invoice in payable_invoices: if invoice.status == "Paid": # Full payment - payment_date = invoice.paid_at.date() if invoice.paid_at else ( - invoice.issue_date + datetime.timedelta(days=random.randint(5, 30)) + payment_date = ( + invoice.paid_at.date() + if invoice.paid_at + else ( + invoice.issue_date + + datetime.timedelta(days=random.randint(5, 30)) + ) ) payment = Payment.objects.create( invoice=invoice, @@ -453,7 +545,9 @@ class InvoiceSeeder: # Partial payment (30-70% of total) partial_percent = random.uniform(0.3, 0.7) payment_amount = invoice.total_amount * Decimal(str(partial_percent)) - payment_date = invoice.issue_date + datetime.timedelta(days=random.randint(5, 20)) + payment_date = invoice.issue_date + datetime.timedelta( + days=random.randint(5, 20) + ) payment = Payment.objects.create( invoice=invoice, @@ -469,8 +563,12 @@ class InvoiceSeeder: elif random.random() < 0.3: # 30% chance of payment on other statuses # Some random payments - payment_amount = invoice.total_amount * Decimal(str(random.uniform(0.2, 0.5))) - payment_date = invoice.issue_date + datetime.timedelta(days=random.randint(10, 45)) + payment_amount = invoice.total_amount * Decimal( + str(random.uniform(0.2, 0.5)) + ) + payment_date = invoice.issue_date + datetime.timedelta( + days=random.randint(10, 45) + ) payment = Payment.objects.create( invoice=invoice, @@ -487,7 +585,9 @@ class InvoiceSeeder: self.stdout.write(f" Created {len(payments)} payments") return payments - def create_estimates(self, org, profiles, teams, products, accounts, contacts, count): + def create_estimates( + self, org, profiles, teams, products, accounts, contacts, count + ): """ Create estimates with line items. @@ -512,8 +612,14 @@ class InvoiceSeeder: date_str = issue_date.strftime("%Y%m%d") account = random.choice(accounts) - account_contacts = list(account.contacts.all()) if hasattr(account, 'contacts') else [] - contact = random.choice(account_contacts) if account_contacts else random.choice(contacts) + account_contacts = ( + list(account.contacts.all()) if hasattr(account, "contacts") else [] + ) + contact = ( + random.choice(account_contacts) + if account_contacts + else random.choice(contacts) + ) status = self._weighted_choice(self.ESTIMATE_STATUS_WEIGHTS) @@ -537,7 +643,9 @@ class InvoiceSeeder: client_postcode=contact.postcode or "", client_country=contact.country or "", currency=org.default_currency, - discount_type=random.choice(["PERCENTAGE", "FIXED", ""]) if random.random() > 0.7 else "", + discount_type=random.choice(["PERCENTAGE", "FIXED", ""]) + if random.random() > 0.7 + else "", discount_value=Decimal("0"), tax_rate=tax_rate, issue_date=issue_date, @@ -553,18 +661,28 @@ class InvoiceSeeder: datetime.datetime.combine(issue_date, datetime.time(10, 0)) ) if status in ["Viewed", "Accepted", "Declined"]: - estimate.viewed_at = estimate.sent_at + datetime.timedelta(days=random.randint(1, 5)) + estimate.viewed_at = estimate.sent_at + datetime.timedelta( + days=random.randint(1, 5) + ) if status == "Accepted": - estimate.accepted_at = estimate.viewed_at + datetime.timedelta(days=random.randint(1, 10)) + estimate.accepted_at = estimate.viewed_at + datetime.timedelta( + days=random.randint(1, 10) + ) if status == "Declined": - estimate.declined_at = estimate.viewed_at + datetime.timedelta(days=random.randint(1, 10)) + estimate.declined_at = estimate.viewed_at + datetime.timedelta( + days=random.randint(1, 10) + ) estimate.save() # Create 2-4 line items num_items = random.randint(2, 4) for order in range(num_items): - product = random.choice(products) if products and random.random() > 0.3 else None + product = ( + random.choice(products) + if products and random.random() > 0.3 + else None + ) if product: name = product.name @@ -599,7 +717,9 @@ class InvoiceSeeder: self.stdout.write(f" Created {len(estimates)} estimates with line items") return estimates - def create_recurring_invoices(self, org, profiles, teams, products, accounts, contacts, count): + def create_recurring_invoices( + self, org, profiles, teams, products, accounts, contacts, count + ): """ Create recurring invoice templates. @@ -621,8 +741,14 @@ class InvoiceSeeder: for i in range(count): account = random.choice(accounts) - account_contacts = list(account.contacts.all()) if hasattr(account, 'contacts') else [] - contact = random.choice(account_contacts) if account_contacts else random.choice(contacts) + account_contacts = ( + list(account.contacts.all()) if hasattr(account, "contacts") else [] + ) + contact = ( + random.choice(account_contacts) + if account_contacts + else random.choice(contacts) + ) frequency = self._weighted_choice(self.RECURRING_FREQUENCY_WEIGHTS) start_date = self.fake.date_between(start_date="-60d", end_date="+30d") @@ -630,7 +756,9 @@ class InvoiceSeeder: # Some recurring invoices have end dates end_date = None if random.random() > 0.6: - end_date = start_date + datetime.timedelta(days=random.randint(180, 730)) + end_date = start_date + datetime.timedelta( + days=random.randint(180, 730) + ) tax_rate = Decimal(str(random.choice([0, 8, 10, 12]))) @@ -653,7 +781,9 @@ class InvoiceSeeder: discount_value=Decimal("0"), tax_rate=tax_rate, notes="Auto-generated recurring invoice", - invoices_generated=random.randint(0, 12) if start_date < timezone.now().date() else 0, + invoices_generated=random.randint(0, 12) + if start_date < timezone.now().date() + else 0, org=org, ) rec.save() diff --git a/backend/invoices/serializer.py b/backend/invoices/serializer.py index 75fd9aa1..d7a796a7 100644 --- a/backend/invoices/serializer.py +++ b/backend/invoices/serializer.py @@ -2,7 +2,12 @@ from rest_framework import serializers from accounts.models import Account from accounts.serializer import AccountSerializer -from common.serializer import OrganizationSerializer, ProfileSerializer, TeamsSerializer, UserSerializer +from common.serializer import ( + OrganizationSerializer, + ProfileSerializer, + TeamsSerializer, + UserSerializer, +) from contacts.models import Contact from contacts.serializer import ContactSerializer from invoices.models import ( @@ -389,8 +394,12 @@ class InvoiceCreateSerializer(serializers.ModelSerializer): # CRM Integration fields - accept IDs for writing account_id = serializers.UUIDField(write_only=True, required=True) contact_id = serializers.UUIDField(write_only=True, required=True) - opportunity_id = serializers.UUIDField(write_only=True, required=False, allow_null=True) - template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True) + opportunity_id = serializers.UUIDField( + write_only=True, required=False, allow_null=True + ) + template_id = serializers.UUIDField( + write_only=True, required=False, allow_null=True + ) # Line items for bulk creation line_items = InvoiceLineItemCreateSerializer(many=True, required=False) @@ -498,9 +507,7 @@ class InvoiceCreateSerializer(serializers.ModelSerializer): contact = Contact.objects.filter(id=contact_id, org=self.org).first() if contact and contact.account_id and contact.account_id != account_id: raise serializers.ValidationError( - { - "contact_id": f"Contact does not belong to the selected account" - } + {"contact_id": f"Contact does not belong to the selected account"} ) return data @@ -519,7 +526,7 @@ class InvoiceCreateSerializer(serializers.ModelSerializer): invoice=invoice, org=self.org, order=item_data.get("order", idx), - **{k: v for k, v in item_data.items() if k != "order"} + **{k: v for k, v in item_data.items() if k != "order"}, ) # Recalculate totals @@ -544,7 +551,7 @@ class InvoiceCreateSerializer(serializers.ModelSerializer): invoice=instance, org=self.org or instance.org, order=item_data.get("order", idx), - **{k: v for k, v in item_data.items() if k != "order"} + **{k: v for k, v in item_data.items() if k != "order"}, ) instance.recalculate_totals() @@ -671,7 +678,9 @@ class EstimateCreateSerializer(serializers.ModelSerializer): account_id = serializers.UUIDField(write_only=True, required=True) contact_id = serializers.UUIDField(write_only=True, required=True) - opportunity_id = serializers.UUIDField(write_only=True, required=False, allow_null=True) + opportunity_id = serializers.UUIDField( + write_only=True, required=False, allow_null=True + ) class Meta: model = Estimate @@ -819,7 +828,9 @@ class RecurringInvoiceCreateSerializer(serializers.ModelSerializer): account_id = serializers.UUIDField(write_only=True, required=True) contact_id = serializers.UUIDField(write_only=True, required=True) - opportunity_id = serializers.UUIDField(write_only=True, required=False, allow_null=True) + opportunity_id = serializers.UUIDField( + write_only=True, required=False, allow_null=True + ) class Meta: model = RecurringInvoice diff --git a/backend/invoices/tasks.py b/backend/invoices/tasks.py index 9accd519..1b38f38d 100644 --- a/backend/invoices/tasks.py +++ b/backend/invoices/tasks.py @@ -67,13 +67,17 @@ def send_email(invoice_id, recipients, org_id, domain="localhost", protocol="htt msg.content_subtype = "html" try: msg.send() - logger.info(f"Sent assignment email for invoice {invoice_id} to {profile.user.email}") + logger.info( + f"Sent assignment email for invoice {invoice_id} to {profile.user.email}" + ) except Exception as e: logger.error(f"Failed to send email: {e}") @shared_task -def send_invoice_to_client(invoice_id, org_id, domain="localhost", protocol="http", include_pdf=True): +def send_invoice_to_client( + invoice_id, org_id, domain="localhost", protocol="http", include_pdf=True +): """ Send invoice to client via email with optional PDF attachment. @@ -109,7 +113,9 @@ def send_invoice_to_client(invoice_id, org_id, domain="localhost", protocol="htt "public_url": public_url, "org": invoice.org, } - html_content = render_to_string("invoices/emails/invoice_to_client.html", context=context) + html_content = render_to_string( + "invoices/emails/invoice_to_client.html", context=context + ) msg = EmailMessage( subject=subject, @@ -163,10 +169,15 @@ def create_invoice_history(invoice_id, updated_by_user_id, changed_fields, org_i # Format changed fields message if changed_fields: - changed_data = [(" ".join(field.split("_")).title()) for field in changed_fields] + changed_data = [ + (" ".join(field.split("_")).title()) for field in changed_fields + ] if len(changed_data) > 1: changed_data = ( - ", ".join(changed_data[:-1]) + " and " + changed_data[-1] + " have changed." + ", ".join(changed_data[:-1]) + + " and " + + changed_data[-1] + + " have changed." ) elif len(changed_data) == 1: changed_data = changed_data[0] + " has changed." @@ -220,7 +231,9 @@ def generate_recurring_invoices(): if recurring.end_date and recurring.end_date < today: recurring.is_active = False recurring.save() - logger.info(f"Deactivated recurring invoice {recurring.id} - end date reached") + logger.info( + f"Deactivated recurring invoice {recurring.id} - end date reached" + ) continue # Set RLS context for this org @@ -279,11 +292,15 @@ def generate_recurring_invoices(): str(invoice.id), str(recurring.org.id), domain=getattr(settings, "DOMAIN_NAME", "localhost"), - protocol="https" if getattr(settings, "USE_HTTPS", False) else "http", + protocol="https" + if getattr(settings, "USE_HTTPS", False) + else "http", ) except Exception as e: - logger.error(f"Failed to generate invoice from recurring {recurring.id}: {e}") + logger.error( + f"Failed to generate invoice from recurring {recurring.id}: {e}" + ) logger.info("Finished recurring invoice generation") @@ -354,9 +371,13 @@ def send_payment_reminder(invoice_id, org_id, domain="localhost", protocol="http "invoice": invoice, "public_url": public_url, "org": invoice.org, - "days_overdue": (timezone.now().date() - invoice.due_date).days if invoice.due_date else 0, + "days_overdue": (timezone.now().date() - invoice.due_date).days + if invoice.due_date + else 0, } - html_content = render_to_string("invoices/emails/payment_reminder.html", context=context) + html_content = render_to_string( + "invoices/emails/payment_reminder.html", context=context + ) msg = EmailMessage( subject=subject, @@ -387,10 +408,14 @@ def process_payment_reminders(): today = timezone.now().date() # Get invoices with reminders enabled - invoices = Invoice.objects.filter( - reminder_enabled=True, - status__in=["Sent", "Viewed", "Partially_Paid", "Overdue"], - ).exclude(client_email__isnull=True).exclude(client_email="") + invoices = ( + Invoice.objects.filter( + reminder_enabled=True, + status__in=["Sent", "Viewed", "Partially_Paid", "Overdue"], + ) + .exclude(client_email__isnull=True) + .exclude(client_email="") + ) for invoice in invoices: set_rls_context(str(invoice.org.id)) @@ -408,20 +433,28 @@ def process_payment_reminders(): # Before due date reminder if days_until_due is not None and days_until_due > 0: - if invoice.reminder_days_before and days_until_due <= invoice.reminder_days_before: + if ( + invoice.reminder_days_before + and days_until_due <= invoice.reminder_days_before + ): # Check if we haven't sent a reminder recently if not invoice.last_reminder_sent or ( - (today - invoice.last_reminder_sent.date()).days >= reminder_interval_days + (today - invoice.last_reminder_sent.date()).days + >= reminder_interval_days ): should_send = True # After due date reminder elif days_until_due is not None and days_until_due < 0: days_overdue = abs(days_until_due) - if invoice.reminder_days_after and days_overdue >= invoice.reminder_days_after: + if ( + invoice.reminder_days_after + and days_overdue >= invoice.reminder_days_after + ): # Check reminder frequency if not invoice.last_reminder_sent or ( - (today - invoice.last_reminder_sent.date()).days >= reminder_interval_days + (today - invoice.last_reminder_sent.date()).days + >= reminder_interval_days ): should_send = True @@ -465,7 +498,9 @@ def check_expired_estimates(): @shared_task -def send_estimate_to_client(estimate_id, org_id, domain="localhost", protocol="http", include_pdf=True): +def send_estimate_to_client( + estimate_id, org_id, domain="localhost", protocol="http", include_pdf=True +): """ Send estimate to client via email with optional PDF attachment. @@ -500,7 +535,9 @@ def send_estimate_to_client(estimate_id, org_id, domain="localhost", protocol="h "public_url": public_url, "org": estimate.org, } - html_content = render_to_string("invoices/emails/estimate_to_client.html", context=context) + html_content = render_to_string( + "invoices/emails/estimate_to_client.html", context=context + ) msg = EmailMessage( subject=subject, diff --git a/backend/invoices/tests.py b/backend/invoices/tests.py index 90ede27a..38950753 100644 --- a/backend/invoices/tests.py +++ b/backend/invoices/tests.py @@ -329,7 +329,6 @@ class InvoiceEditTestCase(InvoiceCreateTest, TestCase): class InvoiceSendMailTestCase(InvoiceCreateTest, TestCase): def test_invoices_send_mail(self): - self.client.login(email="joeInvoice@example.com", password="password") response = self.client.get( reverse("invoices:invoice_send_mail", args=(self.invoice_1.id,)) @@ -345,7 +344,6 @@ class InvoiceSendMailTestCase(InvoiceCreateTest, TestCase): class InvoiceChangeStatusPaidTestCase(InvoiceCreateTest, TestCase): def test_invoices_change_status_to_paid(self): - self.client.login(email="joeInvoice@example.com", password="password") response = self.client.get( reverse("invoices:invoice_change_status_paid", args=(self.invoice_1.id,)) @@ -361,7 +359,6 @@ class InvoiceChangeStatusPaidTestCase(InvoiceCreateTest, TestCase): class InvoiceChangeStatusCancelledTestCase(InvoiceCreateTest, TestCase): def test_invoices_change_status_to_cancelled(self): - self.client.login(email="joeInvoice@example.com", password="password") response = self.client.get( reverse( @@ -381,7 +378,6 @@ class InvoiceChangeStatusCancelledTestCase(InvoiceCreateTest, TestCase): class InvoiceDownloadTestCase(InvoiceCreateTest, TestCase): def test_invoices_download(self): - self.client.login(email="joeInvoice@example.com", password="password") response = self.client.get( reverse("invoices:invoice_download", args=(self.invoice.id,)) @@ -397,7 +393,6 @@ class InvoiceDownloadTestCase(InvoiceCreateTest, TestCase): class AddCommentTestCase(InvoiceCreateTest, TestCase): def test_invoice_add_comment(self): - self.client.login(email="johnDoeInvoice@example.com", password="password") data = { "comment": "", @@ -420,7 +415,6 @@ class AddCommentTestCase(InvoiceCreateTest, TestCase): class UpdateCommentTestCase(InvoiceCreateTest, TestCase): def test_invoice_update_comment(self): - self.client.login(email="johnDoeInvoice@example.com", password="password") data = { "commentid": self.comment.id, @@ -445,7 +439,6 @@ class UpdateCommentTestCase(InvoiceCreateTest, TestCase): class DeleteCommentTestCase(InvoiceCreateTest, TestCase): def test_invoice_delete_comment(self): - data = { "comment_id": self.comment.id, } @@ -460,7 +453,6 @@ class DeleteCommentTestCase(InvoiceCreateTest, TestCase): class AddAttachmentTestCase(InvoiceCreateTest, TestCase): def test_invoice_add_attachment(self): - data = { "attachment": SimpleUploadedFile( "file_name.txt", bytes("file contents.", "utf-8") @@ -483,7 +475,6 @@ class AddAttachmentTestCase(InvoiceCreateTest, TestCase): class DeleteAttachmentTestCase(InvoiceCreateTest, TestCase): def test_invoice_delete_attachment(self): - data = { "attachment_id": self.attachment.id, } @@ -498,7 +489,6 @@ class DeleteAttachmentTestCase(InvoiceCreateTest, TestCase): class InvoiceDeleteTestCase(InvoiceCreateTest, TestCase): def test_invoices_delete(self): - self.client.login(email="joeInvoice@example.com", password="password") response = self.client.get( reverse("invoices:invoice_delete", args=(self.invoice_1.id,)) diff --git a/backend/invoices/tests_celery_tasks.py b/backend/invoices/tests_celery_tasks.py index 63baae02..406d32b8 100644 --- a/backend/invoices/tests_celery_tasks.py +++ b/backend/invoices/tests_celery_tasks.py @@ -13,7 +13,9 @@ class TestSendMailOnInvoiceCreationTask(InvoiceCreateTest, TestCase): ) def test_send_mail_on_invoice_creation_task(self): org_id = str(self.invoice.org.id) - task = send_email.apply((self.invoice.id, [self.user.id, self.user1.id], org_id)) + task = send_email.apply( + (self.invoice.id, [self.user.id, self.user1.id], org_id) + ) self.assertEqual("SUCCESS", task.state) task = send_invoice_email.apply((self.invoice.id, org_id)) diff --git a/backend/leads/migrations/0001_initial.py b/backend/leads/migrations/0001_initial.py index f90a8f84..1a232e8a 100644 --- a/backend/leads/migrations/0001_initial.py +++ b/backend/leads/migrations/0001_initial.py @@ -8,7 +8,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ @@ -119,9 +118,7 @@ class Migration(migrations.Migration): ("email", models.EmailField(blank=True, max_length=254, null=True)), ( "phone", - models.CharField( - blank=True, max_length=50, null=True - ), + models.CharField(blank=True, max_length=50, null=True), ), ( "contact_title", diff --git a/backend/leads/migrations/0002_add_converted_account_fk.py b/backend/leads/migrations/0002_add_converted_account_fk.py index f4cd280e..f1a8a2f1 100644 --- a/backend/leads/migrations/0002_add_converted_account_fk.py +++ b/backend/leads/migrations/0002_add_converted_account_fk.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("accounts", "0002_initial"), ("leads", "0001_initial"), diff --git a/backend/leads/migrations/0003_add_full_conversion_fields.py b/backend/leads/migrations/0003_add_full_conversion_fields.py index 50ea87ef..b46b257a 100644 --- a/backend/leads/migrations/0003_add_full_conversion_fields.py +++ b/backend/leads/migrations/0003_add_full_conversion_fields.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("contacts", "0002_change_phone_to_charfield"), ("leads", "0002_add_converted_account_fk"), diff --git a/backend/leads/migrations/0004_remove_company_model.py b/backend/leads/migrations/0004_remove_company_model.py index 46f253b3..6c42d26d 100644 --- a/backend/leads/migrations/0004_remove_company_model.py +++ b/backend/leads/migrations/0004_remove_company_model.py @@ -16,7 +16,9 @@ def reverse_migrate_company_name_to_company(apps, schema_editor): Lead = apps.get_model("leads", "Lead") Company = apps.get_model("leads", "Company") - for lead in Lead.objects.filter(company_name__isnull=False).exclude(company_name=""): + for lead in Lead.objects.filter(company_name__isnull=False).exclude( + company_name="" + ): company, _ = Company.objects.get_or_create( name=lead.company_name, org=lead.org, @@ -26,7 +28,6 @@ def reverse_migrate_company_name_to_company(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("leads", "0003_add_full_conversion_fields"), ] diff --git a/backend/leads/migrations/0005_rename_title_to_salutation.py b/backend/leads/migrations/0005_rename_title_to_salutation.py index 115f21d4..d661a990 100644 --- a/backend/leads/migrations/0005_rename_title_to_salutation.py +++ b/backend/leads/migrations/0005_rename_title_to_salutation.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("leads", "0004_remove_company_model"), ] diff --git a/backend/leads/migrations/0006_add_tags_related_name.py b/backend/leads/migrations/0006_add_tags_related_name.py index 10081724..e3dcf83a 100644 --- a/backend/leads/migrations/0006_add_tags_related_name.py +++ b/backend/leads/migrations/0006_add_tags_related_name.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0002_enable_rls"), ("leads", "0005_rename_title_to_salutation"), diff --git a/backend/leads/migrations/0007_add_title_field.py b/backend/leads/migrations/0007_add_title_field.py index 9c95243a..3d8fa950 100644 --- a/backend/leads/migrations/0007_add_title_field.py +++ b/backend/leads/migrations/0007_add_title_field.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("leads", "0006_add_tags_related_name"), ] diff --git a/backend/leads/migrations/0008_remove_unused_fields.py b/backend/leads/migrations/0008_remove_unused_fields.py index 85b7cba6..28ed35e8 100644 --- a/backend/leads/migrations/0008_remove_unused_fields.py +++ b/backend/leads/migrations/0008_remove_unused_fields.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("leads", "0007_add_title_field"), ] diff --git a/backend/leads/migrations/0009_change_phone_to_charfield.py b/backend/leads/migrations/0009_change_phone_to_charfield.py index c7b50841..7ca562ac 100644 --- a/backend/leads/migrations/0009_change_phone_to_charfield.py +++ b/backend/leads/migrations/0009_change_phone_to_charfield.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("leads", "0008_remove_unused_fields"), ] diff --git a/backend/leads/migrations/0010_add_currency_fields.py b/backend/leads/migrations/0010_add_currency_fields.py index 2b315e53..4beb83f0 100644 --- a/backend/leads/migrations/0010_add_currency_fields.py +++ b/backend/leads/migrations/0010_add_currency_fields.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("leads", "0009_change_phone_to_charfield"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/backend/leads/migrations/0011_add_enterprise_constraints.py b/backend/leads/migrations/0011_add_enterprise_constraints.py index a2e7d25b..9727d0dc 100644 --- a/backend/leads/migrations/0011_add_enterprise_constraints.py +++ b/backend/leads/migrations/0011_add_enterprise_constraints.py @@ -13,36 +13,34 @@ def cleanup_duplicate_lead_emails(apps, schema_editor): Find duplicate lead emails (case-insensitive) within each org and clear email on duplicates (keep oldest). """ - Lead = apps.get_model('leads', 'Lead') + Lead = apps.get_model("leads", "Lead") # Find duplicates: group by lower(email) + org, having count > 1 duplicates = ( - Lead.objects - .exclude(email__isnull=True) - .exclude(email='') - .annotate(email_lower=Lower('email')) - .values('email_lower', 'org_id') - .annotate(count=Count('id')) + Lead.objects.exclude(email__isnull=True) + .exclude(email="") + .annotate(email_lower=Lower("email")) + .values("email_lower", "org_id") + .annotate(count=Count("id")) .filter(count__gt=1) ) for dup in duplicates: # Get all leads with this email in this org, ordered by created_at leads = list( - Lead.objects - .filter(org_id=dup['org_id']) + Lead.objects.filter(org_id=dup["org_id"]) .exclude(email__isnull=True) - .exclude(email='') - .annotate(email_lower=Lower('email')) - .filter(email_lower=dup['email_lower']) - .order_by('created_at') + .exclude(email="") + .annotate(email_lower=Lower("email")) + .filter(email_lower=dup["email_lower"]) + .order_by("created_at") ) # Keep the first one (oldest), clear email on the rest for lead in leads[1:]: old_email = lead.email lead.email = None - lead.save(update_fields=['email']) + lead.save(update_fields=["email"]) print(f" Cleared duplicate email on lead {lead.id}: '{old_email}'") @@ -51,22 +49,24 @@ def cleanup_long_phone_numbers(apps, schema_editor): Truncate phone numbers longer than 25 characters. Phone field max_length is being reduced from 50 to 25. """ - Lead = apps.get_model('leads', 'Lead') + Lead = apps.get_model("leads", "Lead") - long_phones = Lead.objects.annotate( - phone_len=Length('phone') - ).filter(phone_len__gt=25) + long_phones = Lead.objects.annotate(phone_len=Length("phone")).filter( + phone_len__gt=25 + ) for lead in long_phones: old_phone = lead.phone lead.phone = lead.phone[:25] if lead.phone else None - lead.save(update_fields=['phone']) - print(f" Truncated long phone on lead {lead.id}: '{old_phone}' -> '{lead.phone}'") + lead.save(update_fields=["phone"]) + print( + f" Truncated long phone on lead {lead.id}: '{old_phone}' -> '{lead.phone}'" + ) def cleanup_invalid_probability(apps, schema_editor): """Fix probability values outside 0-100 range.""" - Lead = apps.get_model('leads', 'Lead') + Lead = apps.get_model("leads", "Lead") # Set negative probabilities to 0 updated = Lead.objects.filter(probability__lt=0).update(probability=0) @@ -81,8 +81,10 @@ def cleanup_invalid_probability(apps, schema_editor): def cleanup_negative_amounts(apps, schema_editor): """Set negative opportunity_amount values to NULL.""" - Lead = apps.get_model('leads', 'Lead') - updated = Lead.objects.filter(opportunity_amount__lt=0).update(opportunity_amount=None) + Lead = apps.get_model("leads", "Lead") + updated = Lead.objects.filter(opportunity_amount__lt=0).update( + opportunity_amount=None + ) if updated: print(f" Set {updated} negative opportunity_amount values to NULL") @@ -93,7 +95,6 @@ def reverse_noop(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("common", "0008_enable_rls_product_invoice_line_item"), ("contacts", "0010_add_enterprise_constraints"), diff --git a/backend/leads/migrations/0012_lead_kanban_pipeline.py b/backend/leads/migrations/0012_lead_kanban_pipeline.py index 9f3451fe..817cc6d1 100644 --- a/backend/leads/migrations/0012_lead_kanban_pipeline.py +++ b/backend/leads/migrations/0012_lead_kanban_pipeline.py @@ -7,97 +7,270 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('common', '0012_add_tag_color_description_isactive'), - ('contacts', '0010_add_enterprise_constraints'), - ('leads', '0011_add_enterprise_constraints'), + ("common", "0012_add_tag_color_description_isactive"), + ("contacts", "0010_add_enterprise_constraints"), + ("leads", "0011_add_enterprise_constraints"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='lead', - name='kanban_order', - field=models.DecimalField(decimal_places=6, default=0, help_text='Order within the kanban column for drag-drop', max_digits=15, verbose_name='Kanban Order'), + model_name="lead", + name="kanban_order", + field=models.DecimalField( + decimal_places=6, + default=0, + help_text="Order within the kanban column for drag-drop", + max_digits=15, + verbose_name="Kanban Order", + ), ), migrations.CreateModel( - name='LeadPipeline', + name="LeadPipeline", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='Pipeline Name')), - ('description', models.TextField(blank=True, null=True, verbose_name='Description')), - ('is_default', models.BooleanField(default=False, help_text='If true, new leads without explicit pipeline go here')), - ('is_active', models.BooleanField(default=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lead_pipelines', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="Pipeline Name"), + ), + ( + "description", + models.TextField(blank=True, null=True, verbose_name="Description"), + ), + ( + "is_default", + models.BooleanField( + default=False, + help_text="If true, new leads without explicit pipeline go here", + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lead_pipelines", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Lead Pipeline', - 'verbose_name_plural': 'Lead Pipelines', - 'db_table': 'lead_pipeline', - 'ordering': ('-is_default', 'name'), + "verbose_name": "Lead Pipeline", + "verbose_name_plural": "Lead Pipelines", + "db_table": "lead_pipeline", + "ordering": ("-is_default", "name"), }, ), migrations.CreateModel( - name='LeadStage', + name="LeadStage", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=100, verbose_name='Stage Name')), - ('order', models.PositiveIntegerField(default=0)), - ('color', models.CharField(default='#6B7280', max_length=7)), - ('stage_type', models.CharField(choices=[('open', 'Open'), ('won', 'Won'), ('lost', 'Lost')], default='open', max_length=10)), - ('maps_to_status', models.CharField(blank=True, choices=[('assigned', 'Assigned'), ('in process', 'In Process'), ('converted', 'Converted'), ('recycled', 'Recycled'), ('closed', 'Closed')], help_text='When lead enters this stage, also update Lead.status', max_length=255, null=True, verbose_name='Maps to Status')), - ('win_probability', models.IntegerField(default=0, help_text='Default probability when lead enters this stage', verbose_name='Default Win Probability %')), - ('wip_limit', models.PositiveIntegerField(blank=True, help_text='Maximum leads allowed in this stage (null = unlimited)', null=True, verbose_name='WIP Limit')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lead_stages', to='common.org')), - ('pipeline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='leads.leadpipeline')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=100, verbose_name="Stage Name")), + ("order", models.PositiveIntegerField(default=0)), + ("color", models.CharField(default="#6B7280", max_length=7)), + ( + "stage_type", + models.CharField( + choices=[("open", "Open"), ("won", "Won"), ("lost", "Lost")], + default="open", + max_length=10, + ), + ), + ( + "maps_to_status", + models.CharField( + blank=True, + choices=[ + ("assigned", "Assigned"), + ("in process", "In Process"), + ("converted", "Converted"), + ("recycled", "Recycled"), + ("closed", "Closed"), + ], + help_text="When lead enters this stage, also update Lead.status", + max_length=255, + null=True, + verbose_name="Maps to Status", + ), + ), + ( + "win_probability", + models.IntegerField( + default=0, + help_text="Default probability when lead enters this stage", + verbose_name="Default Win Probability %", + ), + ), + ( + "wip_limit", + models.PositiveIntegerField( + blank=True, + help_text="Maximum leads allowed in this stage (null = unlimited)", + null=True, + verbose_name="WIP Limit", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lead_stages", + to="common.org", + ), + ), + ( + "pipeline", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="stages", + to="leads.leadpipeline", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Lead Stage', - 'verbose_name_plural': 'Lead Stages', - 'db_table': 'lead_stage', - 'ordering': ('order',), + "verbose_name": "Lead Stage", + "verbose_name_plural": "Lead Stages", + "db_table": "lead_stage", + "ordering": ("order",), }, ), migrations.AddField( - model_name='lead', - name='stage', - field=models.ForeignKey(blank=True, help_text='Current pipeline stage (null = use status-based kanban)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='leads', to='leads.leadstage'), + model_name="lead", + name="stage", + field=models.ForeignKey( + blank=True, + help_text="Current pipeline stage (null = use status-based kanban)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="leads", + to="leads.leadstage", + ), ), migrations.AddIndex( - model_name='lead', - index=models.Index(fields=['stage', 'kanban_order'], name='lead_stage_i_66ca36_idx'), + model_name="lead", + index=models.Index( + fields=["stage", "kanban_order"], name="lead_stage_i_66ca36_idx" + ), ), migrations.AddIndex( - model_name='lead', - index=models.Index(fields=['status', 'kanban_order'], name='lead_status_5aa7c0_idx'), + model_name="lead", + index=models.Index( + fields=["status", "kanban_order"], name="lead_status_5aa7c0_idx" + ), ), migrations.AddIndex( - model_name='leadpipeline', - index=models.Index(fields=['org', '-created_at'], name='lead_pipeli_org_id_23ba65_idx'), + model_name="leadpipeline", + index=models.Index( + fields=["org", "-created_at"], name="lead_pipeli_org_id_23ba65_idx" + ), ), migrations.AddConstraint( - model_name='leadpipeline', - constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('org',), name='unique_default_pipeline_per_org'), + model_name="leadpipeline", + constraint=models.UniqueConstraint( + condition=models.Q(("is_default", True)), + fields=("org",), + name="unique_default_pipeline_per_org", + ), ), migrations.AddIndex( - model_name='leadstage', - index=models.Index(fields=['org', 'order'], name='lead_stage_org_id_c0f691_idx'), + model_name="leadstage", + index=models.Index( + fields=["org", "order"], name="lead_stage_org_id_c0f691_idx" + ), ), migrations.AddIndex( - model_name='leadstage', - index=models.Index(fields=['pipeline', 'order'], name='lead_stage_pipelin_63b7e0_idx'), + model_name="leadstage", + index=models.Index( + fields=["pipeline", "order"], name="lead_stage_pipelin_63b7e0_idx" + ), ), migrations.AlterUniqueTogether( - name='leadstage', - unique_together={('pipeline', 'name')}, + name="leadstage", + unique_together={("pipeline", "name")}, ), ] diff --git a/backend/leads/models.py b/backend/leads/models.py index f0b8f6ef..f0980721 100644 --- a/backend/leads/models.py +++ b/backend/leads/models.py @@ -33,12 +33,18 @@ class Lead(AssignableMixin, BaseModel): # Core Lead Information title = models.CharField( - _("Title"), max_length=255, blank=True, null=True, - help_text="Lead name/subject (e.g., 'Enterprise Deal', 'Website Inquiry')" + _("Title"), + max_length=255, + blank=True, + null=True, + help_text="Lead name/subject (e.g., 'Enterprise Deal', 'Website Inquiry')", ) salutation = models.CharField( - _("Salutation"), max_length=64, blank=True, null=True, - help_text="e.g., Mr, Mrs, Ms, Dr" + _("Salutation"), + max_length=64, + blank=True, + null=True, + help_text="e.g., Mr, Mrs, Ms, Dr", ) first_name = models.CharField(_("First name"), null=True, max_length=255) last_name = models.CharField(_("Last name"), null=True, max_length=255) @@ -51,8 +57,11 @@ class Lead(AssignableMixin, BaseModel): validators=[flexible_phone_validator], ) job_title = models.CharField( - _("Job Title"), max_length=255, blank=True, null=True, - help_text="Person's job title (e.g., 'VP of Sales', 'CTO')" + _("Job Title"), + max_length=255, + blank=True, + null=True, + help_text="Person's job title (e.g., 'VP of Sales', 'CTO')", ) website = models.CharField(_("Website"), max_length=255, blank=True, null=True) linkedin_url = models.URLField( @@ -292,9 +301,7 @@ class LeadStage(BaseModel): help_text="Maximum leads allowed in this stage (null = unlimited)", ) - org = models.ForeignKey( - Org, on_delete=models.CASCADE, related_name="lead_stages" - ) + org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="lead_stages") class Meta: verbose_name = "Lead Stage" @@ -314,4 +321,3 @@ class LeadStage(BaseModel): if not self.org_id and self.pipeline_id: self.org_id = self.pipeline.org_id super().save(*args, **kwargs) - diff --git a/backend/leads/serializer.py b/backend/leads/serializer.py index ad90d608..b48370f5 100644 --- a/backend/leads/serializer.py +++ b/backend/leads/serializer.py @@ -134,7 +134,9 @@ class LeadCreateSerializer(serializers.ModelSerializer): def create(self, validated_data): # Default currency from org if not provided and has opportunity_amount - if not validated_data.get("currency") and validated_data.get("opportunity_amount"): + if not validated_data.get("currency") and validated_data.get( + "opportunity_amount" + ): request = self.context.get("request") if request and hasattr(request, "profile") and request.profile.org: validated_data["currency"] = request.profile.org.default_currency diff --git a/backend/leads/services.py b/backend/leads/services.py index 12a9c5ac..645ed187 100644 --- a/backend/leads/services.py +++ b/backend/leads/services.py @@ -112,7 +112,9 @@ def convert_lead_to_account(lead_obj, request, create_opportunity=True): # Create Opportunity if requested and lead has opportunity data opportunity = None - if create_opportunity and (lead_obj.opportunity_amount or lead_obj.first_name or lead_obj.company_name): + if create_opportunity and ( + lead_obj.opportunity_amount or lead_obj.first_name or lead_obj.company_name + ): # Construct opportunity name from available lead data opp_name_parts = [] if lead_obj.company_name: @@ -120,7 +122,11 @@ def convert_lead_to_account(lead_obj, request, create_opportunity=True): if lead_obj.first_name or lead_obj.last_name: name = " ".join(filter(None, [lead_obj.first_name, lead_obj.last_name])) opp_name_parts.append(name) - opp_name = " - ".join(opp_name_parts) if opp_name_parts else f"Opportunity {lead_obj.id}" + opp_name = ( + " - ".join(opp_name_parts) + if opp_name_parts + else f"Opportunity {lead_obj.id}" + ) opportunity = Opportunity.objects.create( name=opp_name, diff --git a/backend/leads/urls.py b/backend/leads/urls.py index 54aa82c9..4bff2705 100644 --- a/backend/leads/urls.py +++ b/backend/leads/urls.py @@ -32,8 +32,12 @@ urlpatterns = [ # Kanban endpoints path("kanban/", LeadKanbanView.as_view(), name="lead_kanban"), # Pipeline management - path("pipelines/", LeadPipelineListCreateView.as_view(), name="pipeline_list_create"), - path("pipelines//", LeadPipelineDetailView.as_view(), name="pipeline_detail"), + path( + "pipelines/", LeadPipelineListCreateView.as_view(), name="pipeline_list_create" + ), + path( + "pipelines//", LeadPipelineDetailView.as_view(), name="pipeline_detail" + ), path( "pipelines//stages/", LeadStageCreateView.as_view(), diff --git a/backend/leads/views/kanban_views.py b/backend/leads/views/kanban_views.py index ab8fd301..671a711e 100644 --- a/backend/leads/views/kanban_views.py +++ b/backend/leads/views/kanban_views.py @@ -178,9 +178,7 @@ class LeadKanbanView(APIView): columns = [] for stage in pipeline.stages.all().order_by("order"): - leads = queryset.filter(stage=stage).order_by( - "kanban_order", "-created_at" - ) + leads = queryset.filter(stage=stage).order_by("kanban_order", "-created_at") columns.append( { diff --git a/backend/leads/views/lead_interactions.py b/backend/leads/views/lead_interactions.py index ea49485f..8112de9b 100644 --- a/backend/leads/views/lead_interactions.py +++ b/backend/leads/views/lead_interactions.py @@ -255,7 +255,9 @@ class CreateLeadFromSite(APIView): # user = User.objects.filter(is_admin=True, is_active=True).first() user = api_setting.created_by lead = Lead.objects.create( - salutation=params.get("title"), # 'title' param maps to salutation for backwards compatibility + salutation=params.get( + "title" + ), # 'title' param maps to salutation for backwards compatibility first_name=params.get("first_name"), last_name=params.get("last_name"), status="assigned", @@ -270,7 +272,9 @@ class CreateLeadFromSite(APIView): lead.assigned_to.add(user) # Send Email to Assigned Users site_address = request.scheme + "://" + request.META["HTTP_HOST"] - send_lead_assigned_emails.delay(lead.id, [user.id], site_address, str(api_setting.org.id)) + send_lead_assigned_emails.delay( + lead.id, [user.id], site_address, str(api_setting.org.id) + ) # Create Contact try: contact = Contact.objects.create( diff --git a/backend/leads/views/lead_views.py b/backend/leads/views/lead_views.py index 0a066683..098d6a11 100644 --- a/backend/leads/views/lead_views.py +++ b/backend/leads/views/lead_views.py @@ -59,7 +59,9 @@ class LeadListView(APIView, LimitOffsetPagination): & Q(last_name__icontains=params.get("name")) ) if params.get("salutation"): - queryset = queryset.filter(salutation__icontains=params.get("salutation")) + queryset = queryset.filter( + salutation__icontains=params.get("salutation") + ) if params.get("source"): queryset = queryset.filter(source=params.get("source")) if params.getlist("assigned_to"): @@ -85,13 +87,21 @@ class LeadListView(APIView, LimitOffsetPagination): | Q(email__icontains=search) ) if params.get("created_at__gte"): - queryset = queryset.filter(created_at__gte=params.get("created_at__gte")) + queryset = queryset.filter( + created_at__gte=params.get("created_at__gte") + ) if params.get("created_at__lte"): - queryset = queryset.filter(created_at__lte=params.get("created_at__lte")) + queryset = queryset.filter( + created_at__lte=params.get("created_at__lte") + ) if params.get("close_date__gte"): - queryset = queryset.filter(close_date__gte=params.get("close_date__gte")) + queryset = queryset.filter( + close_date__gte=params.get("close_date__gte") + ) if params.get("close_date__lte"): - queryset = queryset.filter(close_date__lte=params.get("close_date__lte")) + queryset = queryset.filter( + close_date__lte=params.get("close_date__lte") + ) context = {} queryset_open = queryset.exclude(status="closed") results_leads_open = self.paginate_queryset( @@ -189,8 +199,12 @@ class LeadListView(APIView, LimitOffsetPagination): "error": serializers.BooleanField(), "message": serializers.CharField(), "account_id": serializers.CharField(required=False), - "contact_id": serializers.CharField(required=False, allow_null=True), - "opportunity_id": serializers.CharField(required=False, allow_null=True), + "contact_id": serializers.CharField( + required=False, allow_null=True + ), + "opportunity_id": serializers.CharField( + required=False, allow_null=True + ), }, ) }, @@ -204,7 +218,9 @@ class LeadListView(APIView, LimitOffsetPagination): ) if data.get("tags", None): tags = data.get("tags") - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) lead_obj.tags.add(*tag_objs) if data.get("contacts", None): @@ -244,7 +260,9 @@ class LeadListView(APIView, LimitOffsetPagination): if data.get("status") == "converted": from leads.services import convert_lead_to_account - account, contact, opportunity = convert_lead_to_account(lead_obj, request) + account, contact, opportunity = convert_lead_to_account( + lead_obj, request + ) if data.get("assigned_to", None): assigned_to_list = data.getlist("assigned_to") @@ -501,8 +519,12 @@ class LeadDetailView(APIView): "error": serializers.BooleanField(), "message": serializers.CharField(), "account_id": serializers.CharField(required=False), - "contact_id": serializers.CharField(required=False, allow_null=True), - "opportunity_id": serializers.CharField(required=False, allow_null=True), + "contact_id": serializers.CharField( + required=False, allow_null=True + ), + "opportunity_id": serializers.CharField( + required=False, allow_null=True + ), }, ) }, @@ -531,7 +553,9 @@ class LeadDetailView(APIView): lead_obj.tags.clear() if params.get("tags"): tags = params.get("tags") - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) lead_obj.tags.add(*tag_objs) assigned_to_list = list( @@ -576,7 +600,9 @@ class LeadDetailView(APIView): if params.get("status") == "converted": from leads.services import convert_lead_to_account - account, contact, opportunity = convert_lead_to_account(lead_obj, request) + account, contact, opportunity = convert_lead_to_account( + lead_obj, request + ) if params.get("assigned_to"): assigned_to_list = params.get("assigned_to") @@ -617,8 +643,12 @@ class LeadDetailView(APIView): "error": serializers.BooleanField(), "message": serializers.CharField(), "account_id": serializers.CharField(required=False), - "contact_id": serializers.CharField(required=False, allow_null=True), - "opportunity_id": serializers.CharField(required=False, allow_null=True), + "contact_id": serializers.CharField( + required=False, allow_null=True + ), + "opportunity_id": serializers.CharField( + required=False, allow_null=True + ), }, ) }, @@ -654,7 +684,9 @@ class LeadDetailView(APIView): if params.get("status") == "converted" or params.get("is_converted"): from leads.services import convert_lead_to_account - account, contact, opportunity = convert_lead_to_account(self.lead_obj, request) + account, contact, opportunity = convert_lead_to_account( + self.lead_obj, request + ) return Response( { @@ -682,7 +714,9 @@ class LeadDetailView(APIView): lead_obj.tags.clear() tags = params.get("tags") if tags: - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) lead_obj.tags.add(*tag_objs) if "contacts" in params: @@ -698,7 +732,9 @@ class LeadDetailView(APIView): lead_obj.teams.clear() teams_list = params.get("teams") if teams_list: - teams = Teams.objects.filter(id__in=teams_list, org=request.profile.org) + teams = Teams.objects.filter( + id__in=teams_list, org=request.profile.org + ) lead_obj.teams.add(*teams) if "assigned_to" in params: diff --git a/backend/opportunity/migrations/0001_initial.py b/backend/opportunity/migrations/0001_initial.py index 771cc1b8..e51af724 100644 --- a/backend/opportunity/migrations/0001_initial.py +++ b/backend/opportunity/migrations/0001_initial.py @@ -8,7 +8,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/backend/opportunity/migrations/0002_add_tags_related_name.py b/backend/opportunity/migrations/0002_add_tags_related_name.py index 89141fd2..6e805ac8 100644 --- a/backend/opportunity/migrations/0002_add_tags_related_name.py +++ b/backend/opportunity/migrations/0002_add_tags_related_name.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0002_enable_rls"), ("opportunity", "0001_initial"), diff --git a/backend/opportunity/migrations/0003_alter_opportunity_assigned_to.py b/backend/opportunity/migrations/0003_alter_opportunity_assigned_to.py index a16daf03..91b75513 100644 --- a/backend/opportunity/migrations/0003_alter_opportunity_assigned_to.py +++ b/backend/opportunity/migrations/0003_alter_opportunity_assigned_to.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0002_enable_rls"), ("opportunity", "0002_add_tags_related_name"), diff --git a/backend/opportunity/migrations/0004_alter_opportunity_created_by_and_more.py b/backend/opportunity/migrations/0004_alter_opportunity_created_by_and_more.py index 1931b1eb..4c439e1c 100644 --- a/backend/opportunity/migrations/0004_alter_opportunity_created_by_and_more.py +++ b/backend/opportunity/migrations/0004_alter_opportunity_created_by_and_more.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("opportunity", "0003_alter_opportunity_assigned_to"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/backend/opportunity/migrations/0005_add_enterprise_constraints.py b/backend/opportunity/migrations/0005_add_enterprise_constraints.py index d61a0964..945ef5ac 100644 --- a/backend/opportunity/migrations/0005_add_enterprise_constraints.py +++ b/backend/opportunity/migrations/0005_add_enterprise_constraints.py @@ -6,7 +6,7 @@ from django.db import migrations, models def cleanup_invalid_probability(apps, schema_editor): """Fix probability values outside 0-100 range.""" - Opportunity = apps.get_model('opportunity', 'Opportunity') + Opportunity = apps.get_model("opportunity", "Opportunity") # Set negative probabilities to 0 updated = Opportunity.objects.filter(probability__lt=0).update(probability=0) @@ -21,7 +21,7 @@ def cleanup_invalid_probability(apps, schema_editor): def cleanup_negative_amounts(apps, schema_editor): """Set negative amount values to NULL.""" - Opportunity = apps.get_model('opportunity', 'Opportunity') + Opportunity = apps.get_model("opportunity", "Opportunity") updated = Opportunity.objects.filter(amount__lt=0).update(amount=None) if updated: print(f" Set {updated} negative amount values to NULL") @@ -33,7 +33,6 @@ def reverse_noop(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("accounts", "0006_add_enterprise_constraints"), ("common", "0008_enable_rls_product_invoice_line_item"), diff --git a/backend/opportunity/migrations/0006_add_opportunity_line_item.py b/backend/opportunity/migrations/0006_add_opportunity_line_item.py index b6643608..f48ea53b 100644 --- a/backend/opportunity/migrations/0006_add_opportunity_line_item.py +++ b/backend/opportunity/migrations/0006_add_opportunity_line_item.py @@ -6,7 +6,6 @@ import uuid class Migration(migrations.Migration): - dependencies = [ ("common", "0001_initial"), ("invoices", "0004_estimate_estimatelineitem_invoicetemplate_payment_and_more"), @@ -19,7 +18,10 @@ class Migration(migrations.Migration): model_name="opportunity", name="amount_source", field=models.CharField( - choices=[("MANUAL", "Manual"), ("CALCULATED", "Calculated from Products")], + choices=[ + ("MANUAL", "Manual"), + ("CALCULATED", "Calculated from Products"), + ], default="MANUAL", max_length=20, verbose_name="Amount Source", @@ -96,7 +98,10 @@ class Migration(migrations.Migration): "discount_type", models.CharField( blank=True, - choices=[("PERCENTAGE", "Percentage (%)"), ("FIXED", "Fixed Amount")], + choices=[ + ("PERCENTAGE", "Percentage (%)"), + ("FIXED", "Fixed Amount"), + ], max_length=20, verbose_name="Discount Type", ), diff --git a/backend/opportunity/migrations/0007_rename_opportunity__opportu_5e4c7e_idx_opportunity_opportu_456e8f_idx_and_more.py b/backend/opportunity/migrations/0007_rename_opportunity__opportu_5e4c7e_idx_opportunity_opportu_456e8f_idx_and_more.py index ed0bc71b..3c50dca6 100644 --- a/backend/opportunity/migrations/0007_rename_opportunity__opportu_5e4c7e_idx_opportunity_opportu_456e8f_idx_and_more.py +++ b/backend/opportunity/migrations/0007_rename_opportunity__opportu_5e4c7e_idx_opportunity_opportu_456e8f_idx_and_more.py @@ -5,35 +5,41 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('opportunity', '0006_add_opportunity_line_item'), + ("opportunity", "0006_add_opportunity_line_item"), ] operations = [ migrations.RenameIndex( - model_name='opportunitylineitem', - new_name='opportunity_opportu_456e8f_idx', - old_name='opportunity__opportu_5e4c7e_idx', + model_name="opportunitylineitem", + new_name="opportunity_opportu_456e8f_idx", + old_name="opportunity__opportu_5e4c7e_idx", ), migrations.RenameIndex( - model_name='opportunitylineitem', - new_name='opportunity_org_id_c76c2c_idx', - old_name='opportunity__org_id_a1b2c3_idx', + model_name="opportunitylineitem", + new_name="opportunity_org_id_c76c2c_idx", + old_name="opportunity__org_id_a1b2c3_idx", ), migrations.AlterField( - model_name='opportunitylineitem', - name='created_at', - field=models.DateTimeField(auto_now_add=True, verbose_name='Created At'), + model_name="opportunitylineitem", + name="created_at", + field=models.DateTimeField(auto_now_add=True, verbose_name="Created At"), ), migrations.AlterField( - model_name='opportunitylineitem', - name='id', - field=models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True), + model_name="opportunitylineitem", + name="id", + field=models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), ), migrations.AlterField( - model_name='opportunitylineitem', - name='updated_at', - field=models.DateTimeField(auto_now=True, verbose_name='Last Modified At'), + model_name="opportunitylineitem", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last Modified At"), ), ] diff --git a/backend/opportunity/urls.py b/backend/opportunity/urls.py index 86d75816..04444bc1 100644 --- a/backend/opportunity/urls.py +++ b/backend/opportunity/urls.py @@ -8,7 +8,10 @@ from opportunity.views.opportunity_interactions import ( OpportunityAttachmentView, OpportunityCommentView, ) -from opportunity.views.opportunity_views import OpportunityDetailView, OpportunityListView +from opportunity.views.opportunity_views import ( + OpportunityDetailView, + OpportunityListView, +) app_name = "api_opportunities" diff --git a/backend/opportunity/views/line_item_views.py b/backend/opportunity/views/line_item_views.py index d7f6afc1..0a86c506 100644 --- a/backend/opportunity/views/line_item_views.py +++ b/backend/opportunity/views/line_item_views.py @@ -25,9 +25,7 @@ class OpportunityLineItemListView(APIView): def get_opportunity(self, pk): """Get opportunity and verify access""" - return Opportunity.objects.filter( - id=pk, org=self.request.profile.org - ).first() + return Opportunity.objects.filter(id=pk, org=self.request.profile.org).first() def check_opportunity_access(self, opportunity): """Check if user has access to the opportunity""" @@ -42,7 +40,10 @@ class OpportunityLineItemListView(APIView): or (self.request.profile in opportunity.assigned_to.all()) ): return Response( - {"error": True, "message": "You do not have permission to access this opportunity"}, + { + "error": True, + "message": "You do not have permission to access this opportunity", + }, status=status.HTTP_403_FORBIDDEN, ) return None @@ -56,7 +57,9 @@ class OpportunityLineItemListView(APIView): fields={ "line_items": OpportunityLineItemSerializer(many=True), "products": ProductSerializer(many=True), - "opportunity_amount": serializers.DecimalField(max_digits=12, decimal_places=2), + "opportunity_amount": serializers.DecimalField( + max_digits=12, decimal_places=2 + ), "opportunity_amount_source": serializers.CharField(), }, ) @@ -78,12 +81,14 @@ class OpportunityLineItemListView(APIView): org=request.profile.org, is_active=True ).order_by("name") - return Response({ - "line_items": OpportunityLineItemSerializer(line_items, many=True).data, - "products": ProductSerializer(products, many=True).data, - "opportunity_amount": opportunity.amount, - "opportunity_amount_source": opportunity.amount_source, - }) + return Response( + { + "line_items": OpportunityLineItemSerializer(line_items, many=True).data, + "products": ProductSerializer(products, many=True).data, + "opportunity_amount": opportunity.amount, + "opportunity_amount_source": opportunity.amount_source, + } + ) @extend_schema( operation_id="opportunity_line_items_create", @@ -96,7 +101,9 @@ class OpportunityLineItemListView(APIView): "error": serializers.BooleanField(), "message": serializers.CharField(), "line_item": OpportunityLineItemSerializer(), - "opportunity_amount": serializers.DecimalField(max_digits=12, decimal_places=2), + "opportunity_amount": serializers.DecimalField( + max_digits=12, decimal_places=2 + ), }, ) }, @@ -143,9 +150,7 @@ class OpportunityLineItemDetailView(APIView): def get_opportunity(self, pk): """Get opportunity and verify access""" - return Opportunity.objects.filter( - id=pk, org=self.request.profile.org - ).first() + return Opportunity.objects.filter(id=pk, org=self.request.profile.org).first() def get_line_item(self, opportunity, line_item_id): """Get line item""" @@ -168,7 +173,10 @@ class OpportunityLineItemDetailView(APIView): or (self.request.profile in opportunity.assigned_to.all()) ): return Response( - {"error": True, "message": "You do not have permission to access this opportunity"}, + { + "error": True, + "message": "You do not have permission to access this opportunity", + }, status=status.HTTP_403_FORBIDDEN, ) return None @@ -205,7 +213,9 @@ class OpportunityLineItemDetailView(APIView): "error": serializers.BooleanField(), "message": serializers.CharField(), "line_item": OpportunityLineItemSerializer(), - "opportunity_amount": serializers.DecimalField(max_digits=12, decimal_places=2), + "opportunity_amount": serializers.DecimalField( + max_digits=12, decimal_places=2 + ), }, ) }, @@ -254,7 +264,9 @@ class OpportunityLineItemDetailView(APIView): fields={ "error": serializers.BooleanField(), "message": serializers.CharField(), - "opportunity_amount": serializers.DecimalField(max_digits=12, decimal_places=2), + "opportunity_amount": serializers.DecimalField( + max_digits=12, decimal_places=2 + ), }, ) }, diff --git a/backend/opportunity/views/opportunity_views.py b/backend/opportunity/views/opportunity_views.py index b944db61..81ad3af0 100644 --- a/backend/opportunity/views/opportunity_views.py +++ b/backend/opportunity/views/opportunity_views.py @@ -31,7 +31,6 @@ from opportunity.tasks import send_email_to_assigned_user class OpportunityListView(APIView, LimitOffsetPagination): - permission_classes = (IsAuthenticated, HasOrgContext) model = Opportunity @@ -76,9 +75,13 @@ class OpportunityListView(APIView, LimitOffsetPagination): if params.get("search"): queryset = queryset.filter(name__icontains=params.get("search")) if params.get("created_at__gte"): - queryset = queryset.filter(created_at__gte=params.get("created_at__gte")) + queryset = queryset.filter( + created_at__gte=params.get("created_at__gte") + ) if params.get("created_at__lte"): - queryset = queryset.filter(created_at__lte=params.get("created_at__lte")) + queryset = queryset.filter( + created_at__lte=params.get("created_at__lte") + ) if params.get("closed_on__gte"): queryset = queryset.filter(closed_on__gte=params.get("closed_on__gte")) if params.get("closed_on__lte"): @@ -181,7 +184,9 @@ class OpportunityListView(APIView, LimitOffsetPagination): if params.get("tags"): tags = params.get("tags") - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) opportunity_obj.tags.add(*tag_objs) if params.get("stage"): @@ -297,7 +302,9 @@ class OpportunityDetailView(APIView): opportunity_object.tags.clear() if params.get("tags"): tags = params.get("tags") - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) opportunity_object.tags.add(*tag_objs) if params.get("stage"): @@ -581,7 +588,10 @@ class OpportunityDetailView(APIView): opportunity_object = self.get_object(pk=pk) if opportunity_object.org != request.profile.org: return Response( - {"error": True, "errors": "User company does not match with header...."}, + { + "error": True, + "errors": "User company does not match with header....", + }, status=status.HTTP_403_FORBIDDEN, ) if self.request.profile.role != "ADMIN" and not self.request.user.is_superuser: @@ -606,7 +616,9 @@ class OpportunityDetailView(APIView): if serializer.is_valid(): opportunity_object = serializer.save( - closed_on=params.get("closed_on") if "closed_on" in params else opportunity_object.closed_on + closed_on=params.get("closed_on") + if "closed_on" in params + else opportunity_object.closed_on ) # Handle M2M fields if present in request @@ -623,14 +635,18 @@ class OpportunityDetailView(APIView): opportunity_object.tags.clear() tags = params.get("tags") if tags: - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) opportunity_object.tags.add(*tag_objs) if "teams" in params: opportunity_object.teams.clear() teams_list = params.get("teams") if teams_list: - teams = Teams.objects.filter(id__in=teams_list, org=request.profile.org) + teams = Teams.objects.filter( + id__in=teams_list, org=request.profile.org + ) opportunity_object.teams.add(*teams) if "assigned_to" in params: diff --git a/backend/ruff.toml b/backend/ruff.toml new file mode 100644 index 00000000..c7944acc --- /dev/null +++ b/backend/ruff.toml @@ -0,0 +1,10 @@ +indent-width = 4 + +[format] +indent-style = "space" +line-ending = "lf" +quote-style = "double" + +[lint] +select = ["E", "F", "I"] +ignore = ["E501"] diff --git a/backend/tasks/celery_tasks.py b/backend/tasks/celery_tasks.py index 7cff28a7..c6afc47c 100644 --- a/backend/tasks/celery_tasks.py +++ b/backend/tasks/celery_tasks.py @@ -14,7 +14,9 @@ app = Celery("redis://") @app.task -def send_email(task_id, recipients, org_id, domain="demo.django-crm.io", protocol="http"): +def send_email( + task_id, recipients, org_id, domain="demo.django-crm.io", protocol="http" +): set_rls_context(org_id) task = Task.objects.filter(id=task_id).first() created_by = task.created_by diff --git a/backend/tasks/migrations/0001_initial.py b/backend/tasks/migrations/0001_initial.py index 81da08da..1978bc86 100644 --- a/backend/tasks/migrations/0001_initial.py +++ b/backend/tasks/migrations/0001_initial.py @@ -8,7 +8,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/backend/tasks/migrations/0002_add_opportunity_case_lead_fks.py b/backend/tasks/migrations/0002_add_opportunity_case_lead_fks.py index e8ee8fc7..ededf39a 100644 --- a/backend/tasks/migrations/0002_add_opportunity_case_lead_fks.py +++ b/backend/tasks/migrations/0002_add_opportunity_case_lead_fks.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("cases", "0002_initial"), ("leads", "0002_add_converted_account_fk"), diff --git a/backend/tasks/migrations/0003_fix_contacts_related_name.py b/backend/tasks/migrations/0003_fix_contacts_related_name.py index 7c3202eb..a4b0c283 100644 --- a/backend/tasks/migrations/0003_fix_contacts_related_name.py +++ b/backend/tasks/migrations/0003_fix_contacts_related_name.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("tasks", "0002_add_opportunity_case_lead_fks"), ("contacts", "0004_change_phone_to_phonenumberfield"), diff --git a/backend/tasks/migrations/0004_add_tags_field.py b/backend/tasks/migrations/0004_add_tags_field.py index 8980605a..e98efd70 100644 --- a/backend/tasks/migrations/0004_add_tags_field.py +++ b/backend/tasks/migrations/0004_add_tags_field.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0002_enable_rls"), ("tasks", "0003_fix_contacts_related_name"), diff --git a/backend/tasks/migrations/0005_add_tags_related_name.py b/backend/tasks/migrations/0005_add_tags_related_name.py index 599c598c..856d6ec1 100644 --- a/backend/tasks/migrations/0005_add_tags_related_name.py +++ b/backend/tasks/migrations/0005_add_tags_related_name.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0002_enable_rls"), ("tasks", "0004_add_tags_field"), diff --git a/backend/tasks/migrations/0006_alter_task_assigned_to.py b/backend/tasks/migrations/0006_alter_task_assigned_to.py index 02f9d924..441ba2a8 100644 --- a/backend/tasks/migrations/0006_alter_task_assigned_to.py +++ b/backend/tasks/migrations/0006_alter_task_assigned_to.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0002_enable_rls"), ("tasks", "0005_add_tags_related_name"), diff --git a/backend/tasks/migrations/0007_alter_board_created_by_alter_board_updated_by_and_more.py b/backend/tasks/migrations/0007_alter_board_created_by_alter_board_updated_by_and_more.py index e270393f..a3ea0f1b 100644 --- a/backend/tasks/migrations/0007_alter_board_created_by_alter_board_updated_by_and_more.py +++ b/backend/tasks/migrations/0007_alter_board_created_by_alter_board_updated_by_and_more.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("tasks", "0006_alter_task_assigned_to"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/backend/tasks/migrations/0008_add_enterprise_constraints.py b/backend/tasks/migrations/0008_add_enterprise_constraints.py index f5480527..ab7fb898 100644 --- a/backend/tasks/migrations/0008_add_enterprise_constraints.py +++ b/backend/tasks/migrations/0008_add_enterprise_constraints.py @@ -4,7 +4,6 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ ("tasks", "0007_alter_board_created_by_alter_board_updated_by_and_more"), ] diff --git a/backend/tasks/migrations/0009_task_kanban.py b/backend/tasks/migrations/0009_task_kanban.py index cde4cf26..10bc4bf8 100644 --- a/backend/tasks/migrations/0009_task_kanban.py +++ b/backend/tasks/migrations/0009_task_kanban.py @@ -11,25 +11,24 @@ def assign_kanban_order(apps, schema_editor): Assign initial kanban_order values to existing tasks. Group by org and status, order by created_at DESC (newest first). """ - Task = apps.get_model('tasks', 'Task') + Task = apps.get_model("tasks", "Task") # Get all unique (org_id, status) combinations - combinations = Task.objects.values('org_id', 'status').distinct() + combinations = Task.objects.values("org_id", "status").distinct() for combo in combinations: tasks = Task.objects.filter( - org_id=combo['org_id'], - status=combo['status'] - ).order_by('-created_at') + org_id=combo["org_id"], status=combo["status"] + ).order_by("-created_at") for index, task in enumerate(tasks): task.kanban_order = (index + 1) * 1000 - task.save(update_fields=['kanban_order']) + task.save(update_fields=["kanban_order"]) def reverse_kanban_order(apps, schema_editor): """Reverse: set all kanban_order to 0.""" - Task = apps.get_model('tasks', 'Task') + Task = apps.get_model("tasks", "Task") Task.objects.all().update(kanban_order=0) @@ -38,103 +37,267 @@ class Migration(migrations.Migration): atomic = False dependencies = [ - ('common', '0012_add_tag_color_description_isactive'), - ('tasks', '0008_add_enterprise_constraints'), + ("common", "0012_add_tag_color_description_isactive"), + ("tasks", "0008_add_enterprise_constraints"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ # Create TaskPipeline model migrations.CreateModel( - name='TaskPipeline', + name="TaskPipeline", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='Pipeline Name')), - ('description', models.TextField(blank=True, null=True, verbose_name='Description')), - ('is_default', models.BooleanField(default=False, help_text='If true, new tasks without explicit pipeline go here')), - ('is_active', models.BooleanField(default=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_pipelines', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="Pipeline Name"), + ), + ( + "description", + models.TextField(blank=True, null=True, verbose_name="Description"), + ), + ( + "is_default", + models.BooleanField( + default=False, + help_text="If true, new tasks without explicit pipeline go here", + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="task_pipelines", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Task Pipeline', - 'verbose_name_plural': 'Task Pipelines', - 'db_table': 'task_pipeline', - 'ordering': ('-is_default', 'name'), + "verbose_name": "Task Pipeline", + "verbose_name_plural": "Task Pipelines", + "db_table": "task_pipeline", + "ordering": ("-is_default", "name"), }, ), # Create TaskStage model migrations.CreateModel( - name='TaskStage', + name="TaskStage", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=100, verbose_name='Stage Name')), - ('order', models.PositiveIntegerField(default=0)), - ('color', models.CharField(default='#6B7280', max_length=7)), - ('stage_type', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('completed', 'Completed')], default='open', max_length=20)), - ('maps_to_status', models.CharField(blank=True, help_text='When task enters this stage, also update Task.status', max_length=50, null=True, verbose_name='Maps to Status')), - ('wip_limit', models.PositiveIntegerField(blank=True, help_text='Maximum tasks allowed in this stage (null = unlimited)', null=True, verbose_name='WIP Limit')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_stages', to='common.org')), - ('pipeline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='tasks.taskpipeline')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=100, verbose_name="Stage Name")), + ("order", models.PositiveIntegerField(default=0)), + ("color", models.CharField(default="#6B7280", max_length=7)), + ( + "stage_type", + models.CharField( + choices=[ + ("open", "Open"), + ("in_progress", "In Progress"), + ("completed", "Completed"), + ], + default="open", + max_length=20, + ), + ), + ( + "maps_to_status", + models.CharField( + blank=True, + help_text="When task enters this stage, also update Task.status", + max_length=50, + null=True, + verbose_name="Maps to Status", + ), + ), + ( + "wip_limit", + models.PositiveIntegerField( + blank=True, + help_text="Maximum tasks allowed in this stage (null = unlimited)", + null=True, + verbose_name="WIP Limit", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="task_stages", + to="common.org", + ), + ), + ( + "pipeline", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="stages", + to="tasks.taskpipeline", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Task Stage', - 'verbose_name_plural': 'Task Stages', - 'db_table': 'task_stage', - 'ordering': ('order',), + "verbose_name": "Task Stage", + "verbose_name_plural": "Task Stages", + "db_table": "task_stage", + "ordering": ("order",), }, ), # Add kanban_order field to Task migrations.AddField( - model_name='task', - name='kanban_order', - field=models.DecimalField(decimal_places=6, default=0, help_text='Order within the kanban column for drag-drop positioning', max_digits=15, verbose_name='Kanban Order'), + model_name="task", + name="kanban_order", + field=models.DecimalField( + decimal_places=6, + default=0, + help_text="Order within the kanban column for drag-drop positioning", + max_digits=15, + verbose_name="Kanban Order", + ), ), # Add stage field to Task migrations.AddField( - model_name='task', - name='stage', - field=models.ForeignKey(blank=True, help_text='Custom pipeline stage (if using pipeline mode)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tasks', to='tasks.taskstage'), + model_name="task", + name="stage", + field=models.ForeignKey( + blank=True, + help_text="Custom pipeline stage (if using pipeline mode)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="tasks", + to="tasks.taskstage", + ), ), # Add indexes for kanban queries migrations.AddIndex( - model_name='task', - index=models.Index(fields=['status', 'kanban_order'], name='task_status_kanban_idx'), + model_name="task", + index=models.Index( + fields=["status", "kanban_order"], name="task_status_kanban_idx" + ), ), migrations.AddIndex( - model_name='task', - index=models.Index(fields=['stage', 'kanban_order'], name='task_stage_kanban_idx'), + model_name="task", + index=models.Index( + fields=["stage", "kanban_order"], name="task_stage_kanban_idx" + ), ), # Add indexes for TaskPipeline migrations.AddIndex( - model_name='taskpipeline', - index=models.Index(fields=['org', '-created_at'], name='task_pipeline_org_idx'), + model_name="taskpipeline", + index=models.Index( + fields=["org", "-created_at"], name="task_pipeline_org_idx" + ), ), # Add constraint for unique default pipeline per org migrations.AddConstraint( - model_name='taskpipeline', - constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('org',), name='unique_default_task_pipeline_per_org'), + model_name="taskpipeline", + constraint=models.UniqueConstraint( + condition=models.Q(("is_default", True)), + fields=("org",), + name="unique_default_task_pipeline_per_org", + ), ), # Add indexes for TaskStage migrations.AddIndex( - model_name='taskstage', - index=models.Index(fields=['org', 'order'], name='task_stage_org_order_idx'), + model_name="taskstage", + index=models.Index( + fields=["org", "order"], name="task_stage_org_order_idx" + ), ), migrations.AddIndex( - model_name='taskstage', - index=models.Index(fields=['pipeline', 'order'], name='task_stage_pipeline_idx'), + model_name="taskstage", + index=models.Index( + fields=["pipeline", "order"], name="task_stage_pipeline_idx" + ), ), # Add unique constraint for stage names within pipeline migrations.AlterUniqueTogether( - name='taskstage', - unique_together={('pipeline', 'name')}, + name="taskstage", + unique_together={("pipeline", "name")}, ), # Data migration to assign kanban_order to existing tasks migrations.RunPython(assign_kanban_order, reverse_kanban_order), diff --git a/backend/tasks/migrations/0010_rename_task_status_kanban_idx_task_status_e6f973_idx_and_more.py b/backend/tasks/migrations/0010_rename_task_status_kanban_idx_task_status_e6f973_idx_and_more.py index 1164b50f..c92514d3 100644 --- a/backend/tasks/migrations/0010_rename_task_status_kanban_idx_task_status_e6f973_idx_and_more.py +++ b/backend/tasks/migrations/0010_rename_task_status_kanban_idx_task_status_e6f973_idx_and_more.py @@ -4,7 +4,6 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ ("tasks", "0009_task_kanban"), ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 63b50158..2bb6ae19 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -196,7 +196,6 @@ class BoardTask(BaseModel): return False - class TaskPipeline(BaseModel): """ Custom pipeline for organizing tasks into stages (Kanban columns). @@ -273,9 +272,7 @@ class TaskStage(BaseModel): help_text="Maximum tasks allowed in this stage (null = unlimited)", ) - org = models.ForeignKey( - Org, on_delete=models.CASCADE, related_name="task_stages" - ) + org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="task_stages") class Meta: verbose_name = "Task Stage" @@ -298,7 +295,6 @@ class TaskStage(BaseModel): class Task(AssignableMixin, BaseModel): - STATUS_CHOICES = ( ("New", "New"), ("In Progress", "In Progress"), diff --git a/backend/tasks/serializer.py b/backend/tasks/serializer.py index 0248dc11..cb247105 100644 --- a/backend/tasks/serializer.py +++ b/backend/tasks/serializer.py @@ -10,7 +10,15 @@ from common.serializer import ( UserSerializer, ) from contacts.serializer import ContactSerializer -from tasks.models import Board, BoardColumn, BoardMember, BoardTask, Task, TaskPipeline, TaskStage +from tasks.models import ( + Board, + BoardColumn, + BoardMember, + BoardTask, + Task, + TaskPipeline, + TaskStage, +) class BoardMemberSerializer(serializers.ModelSerializer): @@ -385,7 +393,11 @@ class TaskKanbanCardSerializer(serializers.ModelSerializer): if obj.lead_id: return {"id": obj.lead_id, "name": str(obj.lead), "type": "lead"} if obj.opportunity_id: - return {"id": obj.opportunity_id, "name": obj.opportunity.name, "type": "opportunity"} + return { + "id": obj.opportunity_id, + "name": obj.opportunity.name, + "type": "opportunity", + } if obj.case_id: return {"id": obj.case_id, "name": obj.case.name, "type": "case"} return None diff --git a/backend/tasks/urls.py b/backend/tasks/urls.py index ed8bb31a..1a33f76c 100644 --- a/backend/tasks/urls.py +++ b/backend/tasks/urls.py @@ -30,10 +30,22 @@ urlpatterns = [ path("", TaskListView.as_view()), # Kanban endpoints (must be before / to avoid conflicts) path("kanban/", TaskKanbanView.as_view(), name="task_kanban"), - path("pipelines/", TaskPipelineListCreateView.as_view(), name="pipeline_list_create"), - path("pipelines//", TaskPipelineDetailView.as_view(), name="pipeline_detail"), - path("pipelines//stages/", TaskStageCreateView.as_view(), name="stage_create"), - path("pipelines//stages/reorder/", TaskStageReorderView.as_view(), name="stage_reorder"), + path( + "pipelines/", TaskPipelineListCreateView.as_view(), name="pipeline_list_create" + ), + path( + "pipelines//", TaskPipelineDetailView.as_view(), name="pipeline_detail" + ), + path( + "pipelines//stages/", + TaskStageCreateView.as_view(), + name="stage_create", + ), + path( + "pipelines//stages/reorder/", + TaskStageReorderView.as_view(), + name="stage_reorder", + ), path("stages//", TaskStageDetailView.as_view(), name="stage_detail"), # Task detail and move endpoints path("/", TaskDetailView.as_view()), diff --git a/backend/tasks/views/kanban_views.py b/backend/tasks/views/kanban_views.py index 63d11430..b2ce670a 100644 --- a/backend/tasks/views/kanban_views.py +++ b/backend/tasks/views/kanban_views.py @@ -111,7 +111,9 @@ class TaskKanbanView(APIView): # Base queryset with filters queryset = ( Task.objects.filter(org=org) - .select_related("created_by", "stage", "account", "lead", "opportunity", "case") + .select_related( + "created_by", "stage", "account", "lead", "opportunity", "case" + ) .prefetch_related("assigned_to", "tags") ) @@ -212,9 +214,7 @@ class TaskKanbanView(APIView): columns = [] for stage in pipeline.stages.all().order_by("order"): - tasks = queryset.filter(stage=stage).order_by( - "kanban_order", "-created_at" - ) + tasks = queryset.filter(stage=stage).order_by("kanban_order", "-created_at") columns.append( { diff --git a/backend/tasks/views/task_views.py b/backend/tasks/views/task_views.py index adeba82c..97518386 100644 --- a/backend/tasks/views/task_views.py +++ b/backend/tasks/views/task_views.py @@ -70,7 +70,9 @@ class TaskListView(APIView, LimitOffsetPagination): assigned_to__id__in=params.getlist("assigned_to") ).distinct() if params.get("tags"): - queryset = queryset.filter(tags__id__in=params.getlist("tags")).distinct() + queryset = queryset.filter( + tags__id__in=params.getlist("tags") + ).distinct() if params.get("search"): queryset = queryset.filter(title__icontains=params.get("search")) if params.get("due_date__gte"): @@ -78,9 +80,13 @@ class TaskListView(APIView, LimitOffsetPagination): if params.get("due_date__lte"): queryset = queryset.filter(due_date__lte=params.get("due_date__lte")) if params.get("created_at__gte"): - queryset = queryset.filter(created_at__gte=params.get("created_at__gte")) + queryset = queryset.filter( + created_at__gte=params.get("created_at__gte") + ) if params.get("created_at__lte"): - queryset = queryset.filter(created_at__lte=params.get("created_at__lte")) + queryset = queryset.filter( + created_at__lte=params.get("created_at__lte") + ) if params.get("account"): queryset = queryset.filter(account_id=params.get("account")) if params.get("opportunity"): @@ -183,7 +189,9 @@ class TaskListView(APIView, LimitOffsetPagination): tags = params.get("tags") if isinstance(tags, str): tags = json.loads(tags) - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) task_obj.tags.add(*tag_objs) # Handle new FK relationships with org validation @@ -312,7 +320,9 @@ class TaskDetailView(APIView): context["users_excluding_team"] = ProfileSerializer( users_excluding_team, many=True ).data - context["teams"] = TeamsSerializer(Teams.objects.filter(org=self.request.profile.org), many=True).data + context["teams"] = TeamsSerializer( + Teams.objects.filter(org=self.request.profile.org), many=True + ).data return context @extend_schema( @@ -464,7 +474,9 @@ class TaskDetailView(APIView): tags = params.get("tags") if isinstance(tags, str): tags = json.loads(tags) - tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags, org=request.profile.org, is_active=True + ) task_obj.tags.add(*tag_objs) # Handle FK relationships with org validation @@ -564,7 +576,9 @@ class TaskDetailView(APIView): task_obj.teams.clear() teams_list = params.get("teams") if teams_list: - teams = Teams.objects.filter(id__in=teams_list, org=request.profile.org) + teams = Teams.objects.filter( + id__in=teams_list, org=request.profile.org + ) task_obj.teams.add(*teams) if "assigned_to" in params: @@ -582,7 +596,9 @@ class TaskDetailView(APIView): if tags_list: if isinstance(tags_list, str): tags_list = json.loads(tags_list) - tag_objs = Tags.objects.filter(id__in=tags_list, org=request.profile.org, is_active=True) + tag_objs = Tags.objects.filter( + id__in=tags_list, org=request.profile.org, is_active=True + ) task_obj.tags.add(*tag_objs) # Handle FK relationships with org validation diff --git a/frontend/.prettierrc b/frontend/.prettierrc index 7ebb855b..ef3063bc 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -1,15 +1,16 @@ { - "useTabs": true, - "singleQuote": true, - "trailingComma": "none", - "printWidth": 100, - "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], - "overrides": [ - { - "files": "*.svelte", - "options": { - "parser": "svelte" - } - } - ] + "useTabs": false, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] } diff --git a/frontend/components.json b/frontend/components.json index 9a871019..f027956f 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -1,16 +1,16 @@ { - "$schema": "https://next.shadcn-svelte.com/schema.json", - "style": "default", - "tailwind": { - "config": "", - "css": "src/app.css", - "baseColor": "neutral" - }, - "aliases": { - "components": "$lib/components", - "utils": "$lib/utils", - "ui": "$lib/components/ui", - "hooks": "$lib/hooks" - }, - "typescript": false + "$schema": "https://next.shadcn-svelte.com/schema.json", + "style": "default", + "tailwind": { + "config": "", + "css": "src/app.css", + "baseColor": "neutral" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks" + }, + "typescript": false } diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 81b0525d..3d88f0b1 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -9,18 +9,18 @@ import svelteConfig from './svelte.config.js'; const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); export default [ - includeIgnoreFile(gitignorePath), - js.configs.recommended, - ...svelte.configs.recommended, - prettier, - ...svelte.configs.prettier, - { - languageOptions: { - globals: { ...globals.browser, ...globals.node } - } - }, - { - files: ['**/*.svelte', '**/*.svelte.js'], - languageOptions: { parserOptions: { svelteConfig } } - } + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + } + }, + { + files: ['**/*.svelte', '**/*.svelte.js'], + languageOptions: { parserOptions: { svelteConfig } } + } ]; diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json index e6092646..bfc3d3b9 100644 --- a/frontend/jsconfig.json +++ b/frontend/jsconfig.json @@ -1,22 +1,22 @@ { - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, - "noImplicitAny": false, - "useUnknownInCatchVariables": false, - "strictNullChecks": false, - "moduleResolution": "bundler" - } - // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias - // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files - // - // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes - // from the referenced tsconfig.json - TypeScript does not merge them in + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "noImplicitAny": false, + "useUnknownInCatchVariables": false, + "strictNullChecks": false, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in } diff --git a/frontend/package.json b/frontend/package.json index 8000d773..737e16b0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,57 +1,57 @@ { - "name": "svelte-crm", - "private": true, - "version": "0.0.1", - "type": "module", - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch", - "format": "prettier --write .", - "lint": "prettier --check . && eslint ." - }, - "devDependencies": { - "@eslint/compat": "^2.0.0", - "@eslint/js": "^9.39.1", - "@internationalized/date": "^3.10.0", - "@lucide/svelte": "^0.544.0", - "@sveltejs/adapter-node": "^5.4.0", - "@sveltejs/kit": "^2.49.1", - "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.1.17", - "bits-ui": "^2.14.4", - "eslint": "^9.39.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-svelte": "^3.13.1", - "globals": "^16.5.0", - "mode-watcher": "^1.1.0", - "prettier": "^3.7.4", - "prettier-plugin-svelte": "^3.4.0", - "prettier-plugin-tailwindcss": "^0.7.2", - "svelte": "^5.45.5", - "svelte-check": "^4.3.4", - "svelte-sonner": "^1.0.7", - "tailwindcss": "^4.1.17", - "typescript": "^5.9.3", - "vite": "^7.2.6" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] - }, - "dependencies": { - "axios": "^1.13.2", - "clsx": "^2.1.1", - "date-fns": "^4.1.0", - "libphonenumber-js": "^1.12.31", - "svelte-dnd-action": "^0.9.49", - "tailwind-merge": "^3.4.0", - "tailwind-variants": "^3.2.2", - "tw-animate-css": "^1.4.0" - } + "name": "svelte-crm", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint ." + }, + "devDependencies": { + "@eslint/compat": "^2.0.0", + "@eslint/js": "^9.39.1", + "@internationalized/date": "^3.10.0", + "@lucide/svelte": "^0.544.0", + "@sveltejs/adapter-node": "^5.4.0", + "@sveltejs/kit": "^2.49.1", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.1.17", + "bits-ui": "^2.14.4", + "eslint": "^9.39.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.13.1", + "globals": "^16.5.0", + "mode-watcher": "^1.1.0", + "prettier": "^3.7.4", + "prettier-plugin-svelte": "^3.4.0", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.45.5", + "svelte-check": "^4.3.4", + "svelte-sonner": "^1.0.7", + "tailwindcss": "^4.1.17", + "typescript": "^5.9.3", + "vite": "^7.2.6" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + }, + "dependencies": { + "axios": "^1.13.2", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "libphonenumber-js": "^1.12.31", + "svelte-dnd-action": "^0.9.49", + "tailwind-merge": "^3.4.0", + "tailwind-variants": "^3.2.2", + "tw-animate-css": "^1.4.0" + } } diff --git a/frontend/src/app.css b/frontend/src/app.css index dc4a939c..e899af15 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5,237 +5,236 @@ @custom-variant dark (&:is(.dark *)); :root { - /* Base radius for components */ - --radius: 0.5rem; + /* Base radius for components */ + --radius: 0.5rem; - /* shadcn color tokens (Neutral base) */ - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + /* shadcn color tokens (Neutral base) */ + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); - /* Chart colors */ - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); + /* Chart colors */ + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); - /* Sidebar colors */ - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + /* Sidebar colors */ + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + /* Background Layers */ + --bg-base: #ffffff; + --bg-subtle: #fafafa; + --bg-muted: #f5f5f5; + --bg-emphasis: #ededed; - /* Background Layers */ - --bg-base: #ffffff; - --bg-subtle: #fafafa; - --bg-muted: #f5f5f5; - --bg-emphasis: #ededed; + /* Border Colors */ + --border-default: #e5e5e5; + --border-strong: #d4d4d4; + --border-subtle: #f0f0f0; - /* Border Colors */ - --border-default: #e5e5e5; - --border-strong: #d4d4d4; - --border-subtle: #f0f0f0; + /* Text Colors */ + --text-primary: #171717; + --text-secondary: #525252; + --text-tertiary: #a3a3a3; + --text-inverse: #ffffff; - /* Text Colors */ - --text-primary: #171717; - --text-secondary: #525252; - --text-tertiary: #a3a3a3; - --text-inverse: #ffffff; + /* Accent Colors */ + --accent-primary: #2563eb; + --accent-primary-hover: #1d4ed8; + --accent-primary-subtle: #eff6ff; - /* Accent Colors */ - --accent-primary: #2563eb; - --accent-primary-hover: #1d4ed8; - --accent-primary-subtle: #eff6ff; + /* Status Colors */ + --status-success: #16a34a; + --status-success-bg: #f0fdf4; + --status-warning: #ca8a04; + --status-warning-bg: #fefce8; + --status-danger: #dc2626; + --status-danger-bg: #fef2f2; + --status-info: #0284c7; + --status-info-bg: #f0f9ff; - /* Status Colors */ - --status-success: #16a34a; - --status-success-bg: #f0fdf4; - --status-warning: #ca8a04; - --status-warning-bg: #fefce8; - --status-danger: #dc2626; - --status-danger-bg: #fef2f2; - --status-info: #0284c7; - --status-info-bg: #f0f9ff; + /* Stage Colors (Pipeline) */ + --stage-prospecting: #6b7280; + --stage-qualified: #2563eb; + --stage-proposal: #7c3aed; + --stage-negotiation: #ea580c; + --stage-won: #16a34a; + --stage-lost: #dc2626; - /* Stage Colors (Pipeline) */ - --stage-prospecting: #6b7280; - --stage-qualified: #2563eb; - --stage-proposal: #7c3aed; - --stage-negotiation: #ea580c; - --stage-won: #16a34a; - --stage-lost: #dc2626; + /* Spacing Scale (4px base) */ + --space-0: 0px; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; - /* Spacing Scale (4px base) */ - --space-0: 0px; - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 20px; - --space-6: 24px; - --space-8: 32px; - --space-10: 40px; - --space-12: 48px; + /* Border Radius */ + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + --radius-full: 9999px; - /* Border Radius */ - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 8px; - --radius-xl: 12px; - --radius-full: 9999px; + /* Z-Index Layers */ + --z-base: 0; + --z-sidebar: 10; + --z-drawer-backdrop: 40; + --z-drawer: 50; + --z-dropdown: 60; + --z-modal: 70; + --z-toast: 80; + --z-tooltip: 90; - /* Z-Index Layers */ - --z-base: 0; - --z-sidebar: 10; - --z-drawer-backdrop: 40; - --z-drawer: 50; - --z-dropdown: 60; - --z-modal: 70; - --z-toast: 80; - --z-tooltip: 90; + /* Animation Timing */ + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); - /* Animation Timing */ - --ease-out: cubic-bezier(0, 0, 0.2, 1); - --ease-in: cubic-bezier(0.4, 0, 1, 1); - --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); - --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); + /* Animation Durations */ + --duration-instant: 0ms; + --duration-fast: 100ms; + --duration-normal: 200ms; + --duration-slow: 300ms; + --duration-slower: 400ms; - /* Animation Durations */ - --duration-instant: 0ms; - --duration-fast: 100ms; - --duration-normal: 200ms; - --duration-slow: 300ms; - --duration-slower: 400ms; + /* Layout Dimensions */ + --sidebar-width: 240px; + --sidebar-width-collapsed: 64px; + --drawer-width: 480px; + --header-height: 56px; + --content-padding: 24px; + --content-max-width: 1200px; - /* Layout Dimensions */ - --sidebar-width: 240px; - --sidebar-width-collapsed: 64px; - --drawer-width: 480px; - --header-height: 56px; - --content-padding: 24px; - --content-max-width: 1200px; - - /* Typography */ - --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + /* Typography */ + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; } /* Dark Mode */ .dark { - /* shadcn dark mode colors */ - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.269 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.371 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.439 0 0); + /* shadcn dark mode colors */ + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.269 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.371 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.439 0 0); - /* BottleCRM dark mode overrides */ - --bg-base: #191919; - --bg-subtle: #212121; - --bg-muted: #2a2a2a; - --bg-emphasis: #333333; - --border-default: #333333; - --border-strong: #404040; - --border-subtle: #262626; - --text-primary: #fafafa; - --text-secondary: #a3a3a3; - --text-tertiary: #737373; + /* BottleCRM dark mode overrides */ + --bg-base: #191919; + --bg-subtle: #212121; + --bg-muted: #2a2a2a; + --bg-emphasis: #333333; + --border-default: #333333; + --border-strong: #404040; + --border-subtle: #262626; + --text-primary: #fafafa; + --text-secondary: #a3a3a3; + --text-tertiary: #737373; } /* Tailwind theme inline configuration */ @theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); } /* Base layer styles */ @layer base { - * { - @apply border-border outline-ring/50; - } + * { + @apply border-border outline-ring/50; + } - body { - @apply bg-background text-foreground; - font-family: var(--font-sans); - } -} \ No newline at end of file + body { + @apply bg-background text-foreground; + font-family: var(--font-sans); + } +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index a9be9d47..7c9ce88f 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,26 +1,26 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { - namespace App { - // interface Error {} - interface Locals { - user?: any; // You might want to replace 'any' with a more specific type for user - org?: any; // You might want to replace 'any' with a more specific type for org - org_name?: string; - org_settings?: { - default_currency?: string; - currency_symbol?: string; - default_country?: string | null; - }; - profile?: { - role?: string; - is_organization_admin?: boolean; - }; - } - // interface PageData {} - // interface PageState {} - // interface Platform {} - } + namespace App { + // interface Error {} + interface Locals { + user?: any; // You might want to replace 'any' with a more specific type for user + org?: any; // You might want to replace 'any' with a more specific type for org + org_name?: string; + org_settings?: { + default_currency?: string; + currency_symbol?: string; + default_country?: string | null; + }; + profile?: { + role?: string; + is_organization_admin?: boolean; + }; + } + // interface PageData {} + // interface PageState {} + // interface Platform {} + } } export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html index f485343d..258b3dba 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -1,30 +1,30 @@ - - - - - - - - + - - - - %sveltekit.head% - - -
%sveltekit.body%
- + gtag('config', 'G-JNWHD22PPN'); + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ diff --git a/frontend/src/hooks.server.js b/frontend/src/hooks.server.js index 10b88892..f734eedb 100644 --- a/frontend/src/hooks.server.js +++ b/frontend/src/hooks.server.js @@ -29,18 +29,18 @@ const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api`; * @returns {JWTPayload|null} Decoded payload or null if invalid */ function decodeJwtPayload(token) { - try { - const parts = token.split('.'); - if (parts.length !== 3) return null; + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; - const payload = parts[1]; - // Handle base64url encoding - const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); - const jsonPayload = Buffer.from(base64, 'base64').toString('utf8'); - return /** @type {JWTPayload} */ (JSON.parse(jsonPayload)); - } catch (error) { - return null; - } + const payload = parts[1]; + // Handle base64url encoding + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = Buffer.from(base64, 'base64').toString('utf8'); + return /** @type {JWTPayload} */ (JSON.parse(jsonPayload)); + } catch (error) { + return null; + } } /** @@ -50,8 +50,8 @@ function decodeJwtPayload(token) { * @returns {boolean} True if token has the org context */ function tokenHasOrgContext(token, orgId) { - const payload = decodeJwtPayload(token); - return Boolean(payload && payload.org_id === orgId); + const payload = decodeJwtPayload(token); + return Boolean(payload && payload.org_id === orgId); } /** @@ -60,15 +60,15 @@ function tokenHasOrgContext(token, orgId) { * @returns {JWTPayload|null} JWT payload or null if expired/invalid */ function verifyTokenLocally(accessToken) { - const payload = decodeJwtPayload(accessToken); - if (!payload) return null; + const payload = decodeJwtPayload(accessToken); + if (!payload) return null; - // Check if token is expired - if (payload.exp && payload.exp * 1000 < Date.now()) { - return null; - } + // Check if token is expired + if (payload.exp && payload.exp * 1000 < Date.now()) { + return null; + } - return payload; + return payload; } /** @@ -77,16 +77,16 @@ function verifyTokenLocally(accessToken) { * @returns {Promise} New access token or null if refresh failed */ async function refreshAccessToken(refreshToken) { - try { - const response = await axios.post(`${API_BASE_URL}/auth/refresh-token/`, { - refresh: refreshToken - }); + try { + const response = await axios.post(`${API_BASE_URL}/auth/refresh-token/`, { + refresh: refreshToken + }); - return response.data.access; - } catch (error) { - console.error('Token refresh failed:', error); - return null; - } + return response.data.access; + } catch (error) { + console.error('Token refresh failed:', error); + return null; + } } // Profile info is now embedded in JWT - no API call needed @@ -98,166 +98,166 @@ async function refreshAccessToken(refreshToken) { * @returns {Promise} New tokens and org data or null if failed */ async function switchOrg(accessToken, orgId) { - try { - const response = await axios.post( - `${API_BASE_URL}/auth/switch-org/`, - { org_id: orgId }, - { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json' - } - } - ); + try { + const response = await axios.post( + `${API_BASE_URL}/auth/switch-org/`, + { org_id: orgId }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + } + ); - return response.data; - } catch (error) { - console.error('Org switch failed:', error); - return null; - } + return response.data; + } catch (error) { + console.error('Org switch failed:', error); + return null; + } } /** @type {import('@sveltejs/kit').Handle} */ export async function handle({ event, resolve }) { - // Get tokens from cookies - /** @type {string | undefined} */ - let accessToken = event.cookies.get('jwt_access'); - const refreshToken = event.cookies.get('jwt_refresh'); - const orgId = event.cookies.get('org'); + // Get tokens from cookies + /** @type {string | undefined} */ + let accessToken = event.cookies.get('jwt_access'); + const refreshToken = event.cookies.get('jwt_refresh'); + const orgId = event.cookies.get('org'); - /** @type {JWTPayload | null} */ - let jwtPayload = null; + /** @type {JWTPayload | null} */ + let jwtPayload = null; - // Try to authenticate user (LOCAL JWT DECODE - no API call!) - if (accessToken) { - jwtPayload = verifyTokenLocally(accessToken); + // Try to authenticate user (LOCAL JWT DECODE - no API call!) + if (accessToken) { + jwtPayload = verifyTokenLocally(accessToken); - // If access token expired, try to refresh - if (!jwtPayload && refreshToken) { - const newAccessToken = await refreshAccessToken(refreshToken); - if (newAccessToken) { - // Update cookie with new access token - event.cookies.set('jwt_access', newAccessToken, { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - maxAge: 60 * 60 * 24 // 1 day - }); - accessToken = newAccessToken; - jwtPayload = verifyTokenLocally(newAccessToken); - } else { - // Refresh failed, clear cookies - event.cookies.delete('jwt_access', { path: '/' }); - event.cookies.delete('jwt_refresh', { path: '/' }); - event.cookies.delete('org', { path: '/' }); - } - } - } + // If access token expired, try to refresh + if (!jwtPayload && refreshToken) { + const newAccessToken = await refreshAccessToken(refreshToken); + if (newAccessToken) { + // Update cookie with new access token + event.cookies.set('jwt_access', newAccessToken, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 // 1 day + }); + accessToken = newAccessToken; + jwtPayload = verifyTokenLocally(newAccessToken); + } else { + // Refresh failed, clear cookies + event.cookies.delete('jwt_access', { path: '/' }); + event.cookies.delete('jwt_refresh', { path: '/' }); + event.cookies.delete('org', { path: '/' }); + } + } + } - // Set user in locals from JWT payload (no API call needed!) - if (jwtPayload) { - // Build user object from JWT claims - event.locals.user = { id: jwtPayload.user_id }; + // Set user in locals from JWT payload (no API call needed!) + if (jwtPayload) { + // Build user object from JWT claims + event.locals.user = { id: jwtPayload.user_id }; - // Check if org cookie is set and token has org context - if (orgId) { - const token = /** @type {string} */ (accessToken); + // Check if org cookie is set and token has org context + if (orgId) { + const token = /** @type {string} */ (accessToken); - if (tokenHasOrgContext(token, orgId)) { - // Token has org context - extract org info from JWT (no API call!) - event.locals.org = { - id: jwtPayload.org_id || orgId, - name: jwtPayload.org_name || 'Organization' - }; - /** @type {any} */ (event.locals).profile = { - org: event.locals.org, - role: jwtPayload.role || 'USER' - }; - event.locals.org_name = jwtPayload.org_name || 'Organization'; - // Extract org settings for currency/locale - event.locals.org_settings = jwtPayload.org_settings || { - default_currency: 'USD', - currency_symbol: '$', - default_country: null - }; - } else { - // Token doesn't have org context, need to switch (1 API call) - const switchResult = await switchOrg(token, orgId); + if (tokenHasOrgContext(token, orgId)) { + // Token has org context - extract org info from JWT (no API call!) + event.locals.org = { + id: jwtPayload.org_id || orgId, + name: jwtPayload.org_name || 'Organization' + }; + /** @type {any} */ (event.locals).profile = { + org: event.locals.org, + role: jwtPayload.role || 'USER' + }; + event.locals.org_name = jwtPayload.org_name || 'Organization'; + // Extract org settings for currency/locale + event.locals.org_settings = jwtPayload.org_settings || { + default_currency: 'USD', + currency_symbol: '$', + default_country: null + }; + } else { + // Token doesn't have org context, need to switch (1 API call) + const switchResult = await switchOrg(token, orgId); - if (switchResult) { - // Update cookies with new tokens that have org context - event.cookies.set('jwt_access', switchResult.access_token, { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - maxAge: 60 * 60 * 24 // 1 day - }); - event.cookies.set('jwt_refresh', switchResult.refresh_token, { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - maxAge: 60 * 60 * 24 * 365 // 1 year - }); + if (switchResult) { + // Update cookies with new tokens that have org context + event.cookies.set('jwt_access', switchResult.access_token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 // 1 day + }); + event.cookies.set('jwt_refresh', switchResult.refresh_token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 365 // 1 year + }); - // Extract org info from switch result (no additional API call!) - event.locals.org = switchResult.current_org; - /** @type {any} */ (event.locals).profile = { - org: switchResult.current_org, - role: 'USER' // Will be in new JWT - }; - event.locals.org_name = switchResult.current_org?.name || 'Organization'; - // Decode new token to get org_settings - const newPayload = decodeJwtPayload(switchResult.access_token); - event.locals.org_settings = newPayload?.org_settings || { - default_currency: 'USD', - currency_symbol: '$', - default_country: null - }; - } else { - // Org switch failed, clear org cookie and redirect - event.cookies.delete('org', { path: '/' }); - throw redirect(307, '/org'); - } - } - } - } + // Extract org info from switch result (no additional API call!) + event.locals.org = switchResult.current_org; + /** @type {any} */ (event.locals).profile = { + org: switchResult.current_org, + role: 'USER' // Will be in new JWT + }; + event.locals.org_name = switchResult.current_org?.name || 'Organization'; + // Decode new token to get org_settings + const newPayload = decodeJwtPayload(switchResult.access_token); + event.locals.org_settings = newPayload?.org_settings || { + default_currency: 'USD', + currency_symbol: '$', + default_country: null + }; + } else { + // Org switch failed, clear org cookie and redirect + event.cookies.delete('org', { path: '/' }); + throw redirect(307, '/org'); + } + } + } + } - // Route protection - const pathname = event.url.pathname; + // Route protection + const pathname = event.url.pathname; - // Define public routes (no auth required) - const PUBLIC_ROUTES = ['/login', '/logout', '/bounce']; + // Define public routes (no auth required) + const PUBLIC_ROUTES = ['/login', '/logout', '/bounce']; - // Define semi-protected routes (auth required, but no org) - const AUTH_ONLY_ROUTES = ['/org']; + // Define semi-protected routes (auth required, but no org) + const AUTH_ONLY_ROUTES = ['/org']; - // Check if public route - const isPublicRoute = PUBLIC_ROUTES.some( - (route) => pathname === route || pathname.startsWith(route + '/') - ); + // Check if public route + const isPublicRoute = PUBLIC_ROUTES.some( + (route) => pathname === route || pathname.startsWith(route + '/') + ); - // Check if auth-only route - const isAuthOnlyRoute = AUTH_ONLY_ROUTES.some( - (route) => pathname === route || pathname.startsWith(route + '/') - ); + // Check if auth-only route + const isAuthOnlyRoute = AUTH_ONLY_ROUTES.some( + (route) => pathname === route || pathname.startsWith(route + '/') + ); - if (isAuthOnlyRoute) { - // Auth-only route - require user - if (!jwtPayload) { - throw redirect(307, '/login'); - } - } else if (!isPublicRoute) { - // Protected route - require user + org - if (!jwtPayload) { - throw redirect(307, '/login'); - } - if (!event.locals.org) { - throw redirect(307, '/org'); - } - } + if (isAuthOnlyRoute) { + // Auth-only route - require user + if (!jwtPayload) { + throw redirect(307, '/login'); + } + } else if (!isPublicRoute) { + // Protected route - require user + org + if (!jwtPayload) { + throw redirect(307, '/login'); + } + if (!event.locals.org) { + throw redirect(307, '/org'); + } + } - return resolve(event); + return resolve(event); } diff --git a/frontend/src/lib/api-helpers.js b/frontend/src/lib/api-helpers.js index cc61b1df..df298bc8 100644 --- a/frontend/src/lib/api-helpers.js +++ b/frontend/src/lib/api-helpers.js @@ -26,102 +26,102 @@ const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api`; * @throws {Error} If request fails */ export async function apiRequest(endpoint, options = {}, locals) { - const { method = 'GET', body, headers = {} } = options; + const { method = 'GET', body, headers = {} } = options; - // Get access token from cookies (server-side) - // In server load functions, cookies is passed directly - /** @type {Cookies | undefined} */ - const cookies = /** @type {Cookies | undefined} */ ( - /** @type {{ cookies?: Cookies }} */ (locals).cookies || locals - ); - // Support both naming conventions (jwt_access from login, access_token from legacy) - const accessToken = cookies?.get?.('jwt_access') || cookies?.get?.('access_token'); + // Get access token from cookies (server-side) + // In server load functions, cookies is passed directly + /** @type {Cookies | undefined} */ + const cookies = /** @type {Cookies | undefined} */ ( + /** @type {{ cookies?: Cookies }} */ (locals).cookies || locals + ); + // Support both naming conventions (jwt_access from login, access_token from legacy) + const accessToken = cookies?.get?.('jwt_access') || cookies?.get?.('access_token'); - // Build request headers - /** @type {Record} */ - const requestHeaders = { - 'Content-Type': 'application/json', - ...headers - }; + // Build request headers + /** @type {Record} */ + const requestHeaders = { + 'Content-Type': 'application/json', + ...headers + }; - // Add authentication - // Note: Organization context is now embedded in JWT token, not sent as header - if (accessToken) { - requestHeaders['Authorization'] = `Bearer ${accessToken}`; - } + // Add authentication + // Note: Organization context is now embedded in JWT token, not sent as header + if (accessToken) { + requestHeaders['Authorization'] = `Bearer ${accessToken}`; + } - // Build request options - /** @type {RequestInit} */ - const requestOptions = { - method, - headers: requestHeaders - }; + // Build request options + /** @type {RequestInit} */ + const requestOptions = { + method, + headers: requestHeaders + }; - // Add body for non-GET requests - if (body && method !== 'GET') { - requestOptions.body = JSON.stringify(body); - } + // Add body for non-GET requests + if (body && method !== 'GET') { + requestOptions.body = JSON.stringify(body); + } - const url = `${API_BASE_URL}${endpoint}`; + const url = `${API_BASE_URL}${endpoint}`; - try { - const response = await fetch(url, requestOptions); + try { + const response = await fetch(url, requestOptions); - // Handle error responses - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); + // Handle error responses + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); - // Extract meaningful error message from various Django error formats - let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + // Extract meaningful error message from various Django error formats + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; - if (errorData.detail) { - errorMessage = errorData.detail; - } else if (errorData.error && typeof errorData.error === 'string') { - errorMessage = errorData.error; - } else if (errorData.errors && typeof errorData.errors === 'object') { - // Handle field-level errors - const fieldErrors = Object.entries(errorData.errors) - .map(([field, msgs]) => `${field}: ${Array.isArray(msgs) ? msgs.join(', ') : msgs}`) - .join('; '); - errorMessage = fieldErrors || errorMessage; - } else if (errorData.non_field_errors) { - errorMessage = Array.isArray(errorData.non_field_errors) - ? errorData.non_field_errors.join(', ') - : errorData.non_field_errors; - } else if (typeof errorData === 'object' && Object.keys(errorData).length > 0) { - // Handle field-level validation errors from DRF serializers - const fieldErrors = Object.entries(errorData) - .filter(([key, value]) => key !== 'error' || typeof value === 'string') - .map(([field, msgs]) => { - if (Array.isArray(msgs)) { - return `${field}: ${msgs.join(', ')}`; - } else if (typeof msgs === 'string') { - return `${field}: ${msgs}`; - } - return null; - }) - .filter(Boolean) - .join('; '); - if (fieldErrors) { - errorMessage = fieldErrors; - } - } + if (errorData.detail) { + errorMessage = errorData.detail; + } else if (errorData.error && typeof errorData.error === 'string') { + errorMessage = errorData.error; + } else if (errorData.errors && typeof errorData.errors === 'object') { + // Handle field-level errors + const fieldErrors = Object.entries(errorData.errors) + .map(([field, msgs]) => `${field}: ${Array.isArray(msgs) ? msgs.join(', ') : msgs}`) + .join('; '); + errorMessage = fieldErrors || errorMessage; + } else if (errorData.non_field_errors) { + errorMessage = Array.isArray(errorData.non_field_errors) + ? errorData.non_field_errors.join(', ') + : errorData.non_field_errors; + } else if (typeof errorData === 'object' && Object.keys(errorData).length > 0) { + // Handle field-level validation errors from DRF serializers + const fieldErrors = Object.entries(errorData) + .filter(([key, value]) => key !== 'error' || typeof value === 'string') + .map(([field, msgs]) => { + if (Array.isArray(msgs)) { + return `${field}: ${msgs.join(', ')}`; + } else if (typeof msgs === 'string') { + return `${field}: ${msgs}`; + } + return null; + }) + .filter(Boolean) + .join('; '); + if (fieldErrors) { + errorMessage = fieldErrors; + } + } - console.error(`API Error Response:`, errorData); - throw new Error(errorMessage); - } + console.error(`API Error Response:`, errorData); + throw new Error(errorMessage); + } - // Handle 204 No Content - if (response.status === 204) { - return null; - } + // Handle 204 No Content + if (response.status === 204) { + return null; + } - // Return JSON response - return await response.json(); - } catch (error) { - console.error(`API request failed: ${method} ${endpoint}`, error); - throw error; - } + // Return JSON response + return await response.json(); + } catch (error) { + console.error(`API request failed: ${method} ${endpoint}`, error); + throw error; + } } /** @@ -131,17 +131,17 @@ export async function apiRequest(endpoint, options = {}, locals) { * @returns {any} Object with camelCase fields */ export function transformFromDjango(obj) { - if (!obj) return obj; - if (Array.isArray(obj)) return obj.map(transformFromDjango); - if (typeof obj !== 'object') return obj; + if (!obj) return obj; + if (Array.isArray(obj)) return obj.map(transformFromDjango); + if (typeof obj !== 'object') return obj; - /** @type {Record} */ - const transformed = {}; - for (const [key, value] of Object.entries(obj)) { - const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); - transformed[camelKey] = typeof value === 'object' ? transformFromDjango(value) : value; - } - return transformed; + /** @type {Record} */ + const transformed = {}; + for (const [key, value] of Object.entries(obj)) { + const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + transformed[camelKey] = typeof value === 'object' ? transformFromDjango(value) : value; + } + return transformed; } /** @@ -151,17 +151,17 @@ export function transformFromDjango(obj) { * @returns {any} Object with snake_case fields */ export function transformToDjango(obj) { - if (!obj) return obj; - if (Array.isArray(obj)) return obj.map(transformToDjango); - if (typeof obj !== 'object') return obj; + if (!obj) return obj; + if (Array.isArray(obj)) return obj.map(transformToDjango); + if (typeof obj !== 'object') return obj; - /** @type {Record} */ - const transformed = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); - transformed[snakeKey] = typeof value === 'object' ? transformToDjango(value) : value; - } - return transformed; + /** @type {Record} */ + const transformed = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); + transformed[snakeKey] = typeof value === 'object' ? transformToDjango(value) : value; + } + return transformed; } /** @@ -175,28 +175,28 @@ export function transformToDjango(obj) { * @returns {URLSearchParams} Query parameters for Django API */ export function buildQueryParams(filters = {}) { - const { page = 1, limit = 10, sort, order = 'asc', ...otherFilters } = filters; + const { page = 1, limit = 10, sort, order = 'asc', ...otherFilters } = filters; - const params = new URLSearchParams(); + const params = new URLSearchParams(); - // Pagination - params.append('limit', limit.toString()); - params.append('offset', ((page - 1) * limit).toString()); + // Pagination + params.append('limit', limit.toString()); + params.append('offset', ((page - 1) * limit).toString()); - // Sorting (Django uses - prefix for descending) - if (sort) { - const ordering = order === 'desc' ? `-${sort}` : sort; - params.append('ordering', ordering); - } + // Sorting (Django uses - prefix for descending) + if (sort) { + const ordering = order === 'desc' ? `-${sort}` : sort; + params.append('ordering', ordering); + } - // Other filters - for (const [key, value] of Object.entries(otherFilters)) { - if (value !== null && value !== undefined && value !== '') { - params.append(key, value.toString()); - } - } + // Other filters + for (const [key, value] of Object.entries(otherFilters)) { + if (value !== null && value !== undefined && value !== '') { + params.append(key, value.toString()); + } + } - return params; + return params; } /** @@ -207,27 +207,27 @@ export function buildQueryParams(filters = {}) { * @returns {{ page: number, limit: number, total: number, totalPages: number, hasNext: boolean, hasPrevious: boolean }} Pagination info compatible with SvelteKit */ export function extractPagination(response, limit = 10) { - const total = response.count || 0; - const totalPages = Math.ceil(total / limit); + const total = response.count || 0; + const totalPages = Math.ceil(total / limit); - // Determine current page from offset in next/previous URLs - let currentPage = 1; - if (response.next) { - const url = new URL(response.next, 'http://localhost'); - const offset = parseInt(url.searchParams.get('offset') || '0'); - currentPage = Math.floor(offset / limit); - } else if (response.previous) { - const url = new URL(response.previous, 'http://localhost'); - const offset = parseInt(url.searchParams.get('offset') || '0'); - currentPage = Math.floor(offset / limit) + 2; - } + // Determine current page from offset in next/previous URLs + let currentPage = 1; + if (response.next) { + const url = new URL(response.next, 'http://localhost'); + const offset = parseInt(url.searchParams.get('offset') || '0'); + currentPage = Math.floor(offset / limit); + } else if (response.previous) { + const url = new URL(response.previous, 'http://localhost'); + const offset = parseInt(url.searchParams.get('offset') || '0'); + currentPage = Math.floor(offset / limit) + 2; + } - return { - page: currentPage, - limit, - total, - totalPages, - hasNext: response.next !== null, - hasPrevious: response.previous !== null - }; + return { + page: currentPage, + limit, + total, + totalPages, + hasNext: response.next !== null, + hasPrevious: response.previous !== null + }; } diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 534cfaf8..dc45b234 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -13,17 +13,17 @@ import { goto } from '$app/navigation'; // API Base URL from environment variables // Note: VITE_ prefix is required for client-side env vars const API_BASE_URL = env.PUBLIC_DJANGO_API_URL - ? `${env.PUBLIC_DJANGO_API_URL}/api` - : 'http://localhost:8000/api'; + ? `${env.PUBLIC_DJANGO_API_URL}/api` + : 'http://localhost:8000/api'; /** * Storage keys for tokens and org */ const STORAGE_KEYS = { - ACCESS_TOKEN: 'access_token', - REFRESH_TOKEN: 'refresh_token', - ORG_ID: 'org_id', - USER: 'user' + ACCESS_TOKEN: 'access_token', + REFRESH_TOKEN: 'refresh_token', + ORG_ID: 'org_id', + USER: 'user' }; /** @@ -32,8 +32,8 @@ const STORAGE_KEYS = { * @returns {string|null} */ function getFromStorage(key) { - if (typeof window === 'undefined') return null; - return localStorage.getItem(key); + if (typeof window === 'undefined') return null; + return localStorage.getItem(key); } /** @@ -42,9 +42,9 @@ function getFromStorage(key) { * @param {string} value - Value to store */ function setInStorage(key, value) { - if (typeof window !== 'undefined') { - localStorage.setItem(key, value); - } + if (typeof window !== 'undefined') { + localStorage.setItem(key, value); + } } /** @@ -52,19 +52,19 @@ function setInStorage(key, value) { * @param {string} key - Storage key */ function removeFromStorage(key) { - if (typeof window !== 'undefined') { - localStorage.removeItem(key); - } + if (typeof window !== 'undefined') { + localStorage.removeItem(key); + } } /** * Clear all auth data from storage */ export function clearAuthData() { - removeFromStorage(STORAGE_KEYS.ACCESS_TOKEN); - removeFromStorage(STORAGE_KEYS.REFRESH_TOKEN); - removeFromStorage(STORAGE_KEYS.ORG_ID); - removeFromStorage(STORAGE_KEYS.USER); + removeFromStorage(STORAGE_KEYS.ACCESS_TOKEN); + removeFromStorage(STORAGE_KEYS.REFRESH_TOKEN); + removeFromStorage(STORAGE_KEYS.ORG_ID); + removeFromStorage(STORAGE_KEYS.USER); } /** @@ -72,7 +72,7 @@ export function clearAuthData() { * @returns {string|null} */ export function getAccessToken() { - return getFromStorage(STORAGE_KEYS.ACCESS_TOKEN); + return getFromStorage(STORAGE_KEYS.ACCESS_TOKEN); } /** @@ -80,7 +80,7 @@ export function getAccessToken() { * @returns {string|null} */ export function getRefreshToken() { - return getFromStorage(STORAGE_KEYS.REFRESH_TOKEN); + return getFromStorage(STORAGE_KEYS.REFRESH_TOKEN); } /** @@ -88,7 +88,7 @@ export function getRefreshToken() { * @returns {string|null} */ export function getOrgId() { - return getFromStorage(STORAGE_KEYS.ORG_ID); + return getFromStorage(STORAGE_KEYS.ORG_ID); } /** @@ -96,7 +96,7 @@ export function getOrgId() { * @param {string} orgId - Organization UUID */ export function setOrgId(orgId) { - setInStorage(STORAGE_KEYS.ORG_ID, orgId); + setInStorage(STORAGE_KEYS.ORG_ID, orgId); } /** @@ -104,8 +104,8 @@ export function setOrgId(orgId) { * @returns {Object|null} */ export function getCurrentUser() { - const userData = getFromStorage(STORAGE_KEYS.USER); - return userData ? JSON.parse(userData) : null; + const userData = getFromStorage(STORAGE_KEYS.USER); + return userData ? JSON.parse(userData) : null; } /** @@ -113,37 +113,37 @@ export function getCurrentUser() { * @returns {Promise} New access token or null if refresh failed */ async function refreshAccessToken() { - const refreshToken = getRefreshToken(); - if (!refreshToken) { - return null; - } + const refreshToken = getRefreshToken(); + if (!refreshToken) { + return null; + } - try { - const response = await fetch(`${API_BASE_URL}/auth/refresh-token/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ refresh: refreshToken }) - }); + try { + const response = await fetch(`${API_BASE_URL}/auth/refresh-token/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refresh: refreshToken }) + }); - if (response.ok) { - const data = await response.json(); - setInStorage(STORAGE_KEYS.ACCESS_TOKEN, data.access); - return data.access; - } + if (response.ok) { + const data = await response.json(); + setInStorage(STORAGE_KEYS.ACCESS_TOKEN, data.access); + return data.access; + } - // Refresh token expired or invalid - clearAuthData(); - if (typeof window !== 'undefined') { - goto('/login'); - } - return null; - } catch (error) { - console.error('Token refresh failed:', error); - clearAuthData(); - return null; - } + // Refresh token expired or invalid + clearAuthData(); + if (typeof window !== 'undefined') { + goto('/login'); + } + return null; + } catch (error) { + console.error('Token refresh failed:', error); + clearAuthData(); + return null; + } } /** @@ -155,173 +155,173 @@ async function refreshAccessToken() { * @throws {Error} If request fails */ export async function apiRequest(endpoint, options = {}) { - const { method = 'GET', body = null, headers = {}, requiresAuth = true } = options; + const { method = 'GET', body = null, headers = {}, requiresAuth = true } = options; - const url = `${API_BASE_URL}${endpoint}`; + const url = `${API_BASE_URL}${endpoint}`; - // Build headers - /** @type {Record} */ - const requestHeaders = { - 'Content-Type': 'application/json', - ...headers - }; + // Build headers + /** @type {Record} */ + const requestHeaders = { + 'Content-Type': 'application/json', + ...headers + }; - // Add authentication if required - // Note: Organization context is now embedded in JWT token, not sent as header - if (requiresAuth) { - const accessToken = getAccessToken(); - if (accessToken) { - requestHeaders['Authorization'] = `Bearer ${accessToken}`; - } - } + // Add authentication if required + // Note: Organization context is now embedded in JWT token, not sent as header + if (requiresAuth) { + const accessToken = getAccessToken(); + if (accessToken) { + requestHeaders['Authorization'] = `Bearer ${accessToken}`; + } + } - // Build fetch options - /** @type {RequestInit} */ - const fetchOptions = { - method, - headers: requestHeaders - }; + // Build fetch options + /** @type {RequestInit} */ + const fetchOptions = { + method, + headers: requestHeaders + }; - if (body && method !== 'GET') { - fetchOptions.body = JSON.stringify(body); - } + if (body && method !== 'GET') { + fetchOptions.body = JSON.stringify(body); + } - try { - let response = await fetch(url, fetchOptions); + try { + let response = await fetch(url, fetchOptions); - // If unauthorized and we have a refresh token, try to refresh - if (response.status === 401 && requiresAuth) { - const newAccessToken = await refreshAccessToken(); - if (newAccessToken) { - // Retry request with new token - requestHeaders['Authorization'] = `Bearer ${newAccessToken}`; - fetchOptions.headers = requestHeaders; - response = await fetch(url, fetchOptions); - } - } + // If unauthorized and we have a refresh token, try to refresh + if (response.status === 401 && requiresAuth) { + const newAccessToken = await refreshAccessToken(); + if (newAccessToken) { + // Retry request with new token + requestHeaders['Authorization'] = `Bearer ${newAccessToken}`; + fetchOptions.headers = requestHeaders; + response = await fetch(url, fetchOptions); + } + } - // Handle response - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.detail || errorData.error || `HTTP ${response.status}: ${response.statusText}` - ); - } + // Handle response + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.detail || errorData.error || `HTTP ${response.status}: ${response.statusText}` + ); + } - // Return JSON response - return await response.json(); - } catch (error) { - console.error(`API request failed: ${method} ${endpoint}`, error); - throw error; - } + // Return JSON response + return await response.json(); + } catch (error) { + console.error(`API request failed: ${method} ${endpoint}`, error); + throw error; + } } /** * Authentication API methods */ export const auth = { - /** - * Login with email and password - * @param {string} email - User email - * @param {string} password - User password - * @param {string} [orgId] - Optional organization ID to login to - * @returns {Promise} User data with tokens and current_org - */ - async login(email, password, orgId = undefined) { - /** @type {Record} */ - const body = { email, password }; - if (orgId) { - body.org_id = orgId; - } + /** + * Login with email and password + * @param {string} email - User email + * @param {string} password - User password + * @param {string} [orgId] - Optional organization ID to login to + * @returns {Promise} User data with tokens and current_org + */ + async login(email, password, orgId = undefined) { + /** @type {Record} */ + const body = { email, password }; + if (orgId) { + body.org_id = orgId; + } - /** @type {any} */ - const data = await apiRequest('/auth/login/', { - method: 'POST', - body, - requiresAuth: false - }); + /** @type {any} */ + const data = await apiRequest('/auth/login/', { + method: 'POST', + body, + requiresAuth: false + }); - // Store tokens and user data - setInStorage(STORAGE_KEYS.ACCESS_TOKEN, data.access_token); - setInStorage(STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token); - setInStorage(STORAGE_KEYS.USER, JSON.stringify(data.user)); + // Store tokens and user data + setInStorage(STORAGE_KEYS.ACCESS_TOKEN, data.access_token); + setInStorage(STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token); + setInStorage(STORAGE_KEYS.USER, JSON.stringify(data.user)); - // Store current org if provided - if (data.current_org) { - setInStorage(STORAGE_KEYS.ORG_ID, data.current_org.id); - } + // Store current org if provided + if (data.current_org) { + setInStorage(STORAGE_KEYS.ORG_ID, data.current_org.id); + } - return data; - }, + return data; + }, - /** - * Register a new user - * @param {string} email - User email - * @param {string} password - User password - * @param {string} confirmPassword - Password confirmation - * @returns {Promise} Registration response - */ - async register(email, password, confirmPassword) { - return await apiRequest('/auth/register/', { - method: 'POST', - body: { email, password, confirm_password: confirmPassword }, - requiresAuth: false - }); - }, + /** + * Register a new user + * @param {string} email - User email + * @param {string} password - User password + * @param {string} confirmPassword - Password confirmation + * @returns {Promise} Registration response + */ + async register(email, password, confirmPassword) { + return await apiRequest('/auth/register/', { + method: 'POST', + body: { email, password, confirm_password: confirmPassword }, + requiresAuth: false + }); + }, - /** - * Switch to a different organization - * This issues new JWT tokens with the new org context - * @param {string} orgId - Organization UUID to switch to - * @returns {Promise} New tokens and org data - */ - async switchOrg(orgId) { - /** @type {any} */ - const data = await apiRequest('/auth/switch-org/', { - method: 'POST', - body: { org_id: orgId } - }); + /** + * Switch to a different organization + * This issues new JWT tokens with the new org context + * @param {string} orgId - Organization UUID to switch to + * @returns {Promise} New tokens and org data + */ + async switchOrg(orgId) { + /** @type {any} */ + const data = await apiRequest('/auth/switch-org/', { + method: 'POST', + body: { org_id: orgId } + }); - // Update tokens with new org context - setInStorage(STORAGE_KEYS.ACCESS_TOKEN, data.access_token); - setInStorage(STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token); - setInStorage(STORAGE_KEYS.ORG_ID, data.current_org.id); + // Update tokens with new org context + setInStorage(STORAGE_KEYS.ACCESS_TOKEN, data.access_token); + setInStorage(STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token); + setInStorage(STORAGE_KEYS.ORG_ID, data.current_org.id); - return data; - }, + return data; + }, - /** - * Get current user data - * @returns {Promise} Current user with organizations - */ - async me() { - const data = await apiRequest('/auth/me/'); + /** + * Get current user data + * @returns {Promise} Current user with organizations + */ + async me() { + const data = await apiRequest('/auth/me/'); - // Update stored user data - setInStorage(STORAGE_KEYS.USER, JSON.stringify(data)); + // Update stored user data + setInStorage(STORAGE_KEYS.USER, JSON.stringify(data)); - return data; - }, + return data; + }, - /** - * Get profile for current organization - * @returns {Promise} Profile data for current org - */ - async profile() { - return await apiRequest('/auth/profile/'); - }, + /** + * Get profile for current organization + * @returns {Promise} Profile data for current org + */ + async profile() { + return await apiRequest('/auth/profile/'); + }, - /** - * Logout (clear local data) - * Note: Django simplejwt doesn't have server-side logout by default - * Token blacklisting can be added later if needed - */ - logout() { - clearAuthData(); - if (typeof window !== 'undefined') { - goto('/login'); - } - } + /** + * Logout (clear local data) + * Note: Django simplejwt doesn't have server-side logout by default + * Token blacklisting can be added later if needed + */ + logout() { + clearAuthData(); + if (typeof window !== 'undefined') { + goto('/login'); + } + } }; /** @@ -332,107 +332,107 @@ export const auth = { * @returns {Object} CRUD methods */ function createCrudApi(entityPath) { - return { - /** - * List entities with pagination and filters - * @param {Record} [params] - Query parameters - * @returns {Promise} Paginated list of entities - */ - async list(params = {}) { - const queryString = new URLSearchParams(params).toString(); - const endpoint = `/${entityPath}/${queryString ? `?${queryString}` : ''}`; - return await apiRequest(endpoint); - }, + return { + /** + * List entities with pagination and filters + * @param {Record} [params] - Query parameters + * @returns {Promise} Paginated list of entities + */ + async list(params = {}) { + const queryString = new URLSearchParams(params).toString(); + const endpoint = `/${entityPath}/${queryString ? `?${queryString}` : ''}`; + return await apiRequest(endpoint); + }, - /** - * Get a single entity by ID - * @param {string} id - Entity UUID - * @returns {Promise} Entity data - */ - async get(id) { - return await apiRequest(`/${entityPath}/${id}/`); - }, + /** + * Get a single entity by ID + * @param {string} id - Entity UUID + * @returns {Promise} Entity data + */ + async get(id) { + return await apiRequest(`/${entityPath}/${id}/`); + }, - /** - * Create a new entity - * @param {Record} data - Entity data - * @returns {Promise} Created entity - */ - async create(data) { - return await apiRequest(`/${entityPath}/`, { - method: 'POST', - body: data - }); - }, + /** + * Create a new entity + * @param {Record} data - Entity data + * @returns {Promise} Created entity + */ + async create(data) { + return await apiRequest(`/${entityPath}/`, { + method: 'POST', + body: data + }); + }, - /** - * Update an entity - * @param {string} id - Entity UUID - * @param {Record} data - Updated entity data - * @returns {Promise} Updated entity - */ - async update(id, data) { - return await apiRequest(`/${entityPath}/${id}/`, { - method: 'PUT', - body: data - }); - }, + /** + * Update an entity + * @param {string} id - Entity UUID + * @param {Record} data - Updated entity data + * @returns {Promise} Updated entity + */ + async update(id, data) { + return await apiRequest(`/${entityPath}/${id}/`, { + method: 'PUT', + body: data + }); + }, - /** - * Partially update an entity - * @param {string} id - Entity UUID - * @param {Record} data - Partial entity data - * @returns {Promise} Updated entity - */ - async patch(id, data) { - return await apiRequest(`/${entityPath}/${id}/`, { - method: 'PATCH', - body: data - }); - }, + /** + * Partially update an entity + * @param {string} id - Entity UUID + * @param {Record} data - Partial entity data + * @returns {Promise} Updated entity + */ + async patch(id, data) { + return await apiRequest(`/${entityPath}/${id}/`, { + method: 'PATCH', + body: data + }); + }, - /** - * Delete an entity - * @param {string} id - Entity UUID - * @returns {Promise} - */ - async delete(id) { - return await apiRequest(`/${entityPath}/${id}/`, { - method: 'DELETE' - }); - }, + /** + * Delete an entity + * @param {string} id - Entity UUID + * @returns {Promise} + */ + async delete(id) { + return await apiRequest(`/${entityPath}/${id}/`, { + method: 'DELETE' + }); + }, - /** - * Get comments for an entity - * @param {string} id - Entity UUID - * @returns {Promise} List of comments - */ - async getComments(id) { - return await apiRequest(`/${entityPath}/comment/${id}/`); - }, + /** + * Get comments for an entity + * @param {string} id - Entity UUID + * @returns {Promise} List of comments + */ + async getComments(id) { + return await apiRequest(`/${entityPath}/comment/${id}/`); + }, - /** - * Add comment to an entity - * @param {string} id - Entity UUID - * @param {string} comment - Comment text - * @returns {Promise} Created comment - */ - async addComment(id, comment) { - return await apiRequest(`/${entityPath}/comment/${id}/`, { - method: 'POST', - body: { comment } - }); - }, + /** + * Add comment to an entity + * @param {string} id - Entity UUID + * @param {string} comment - Comment text + * @returns {Promise} Created comment + */ + async addComment(id, comment) { + return await apiRequest(`/${entityPath}/comment/${id}/`, { + method: 'POST', + body: { comment } + }); + }, - /** - * Get attachments for an entity - * @param {string} id - Entity UUID - * @returns {Promise} List of attachments - */ - async getAttachments(id) { - return await apiRequest(`/${entityPath}/attachment/${id}/`); - } - }; + /** + * Get attachments for an entity + * @param {string} id - Entity UUID + * @returns {Promise} List of attachments + */ + async getAttachments(id) { + return await apiRequest(`/${entityPath}/attachment/${id}/`); + } + }; } /** @@ -451,20 +451,20 @@ export const invoices = createCrudApi('invoices'); * Export all as default */ export default { - auth, - accounts, - leads, - contacts, - opportunities, - cases, - tasks, - events, - invoices, - apiRequest, - getAccessToken, - getRefreshToken, - getOrgId, - setOrgId, - getCurrentUser, - clearAuthData + auth, + accounts, + leads, + contacts, + opportunities, + cases, + tasks, + events, + invoices, + apiRequest, + getAccessToken, + getRefreshToken, + getOrgId, + setOrgId, + getCurrentUser, + clearAuthData }; diff --git a/frontend/src/lib/components/dashboard/ActivityFeed.svelte b/frontend/src/lib/components/dashboard/ActivityFeed.svelte index 05570fa0..47ae5a8c 100644 --- a/frontend/src/lib/components/dashboard/ActivityFeed.svelte +++ b/frontend/src/lib/components/dashboard/ActivityFeed.svelte @@ -1,168 +1,181 @@ - -
- - Recent Activity -
- -
- - {#if activities.length === 0} -
- -

No recent activity

-
- {:else} -
- {#each groupedActivities as group} -
-

- {group.category} -

-
- {#each group.items as activity (activity.id)} - {@const Icon = actionIcons[activity.action || ''] || Activity} - {@const colorClass = actionColors[activity.action || ''] || 'bg-muted text-muted-foreground'} -
-
- -
-
-

- {activity.description || activity.message || `${activity.action} ${activity.entityType}: ${activity.entityName}`} -

-

- {activity.user?.name || 'System'} · {formatTime(activity.timestamp || activity.createdAt)} -

-
-
- {/each} -
-
- {/each} -
- {#if activities.length > 5 && !showAll} - - {/if} - {/if} -
+ +
+ + Recent Activity +
+ +
+ + {#if activities.length === 0} +
+ +

No recent activity

+
+ {:else} +
+ {#each groupedActivities as group} +
+

+ {group.category} +

+
+ {#each group.items as activity (activity.id)} + {@const Icon = actionIcons[activity.action || ''] || Activity} + {@const colorClass = + actionColors[activity.action || ''] || 'bg-muted text-muted-foreground'} +
+
+ +
+
+

+ {activity.description || + activity.message || + `${activity.action} ${activity.entityType}: ${activity.entityName}`} +

+

+ {activity.user?.name || 'System'} · {formatTime( + activity.timestamp || activity.createdAt + )} +

+
+
+ {/each} +
+
+ {/each} +
+ {#if activities.length > 5 && !showAll} + + {/if} + {/if} +
diff --git a/frontend/src/lib/components/dashboard/FocusBar.svelte b/frontend/src/lib/components/dashboard/FocusBar.svelte index 18b065f8..807454e7 100644 --- a/frontend/src/lib/components/dashboard/FocusBar.svelte +++ b/frontend/src/lib/components/dashboard/FocusBar.svelte @@ -1,82 +1,77 @@ {#if visibleItems.length > 0} -
- -
- {#each visibleItems as item} - - - {item.count} - - - {/each} -
-
+ ? 'border-red-200 bg-red-50/50 dark:border-red-900/50 dark:bg-red-950/20' + : 'border-border/50 bg-muted/30'}" + > + +
+ {#each visibleItems as item} + + + {item.count} + + + {/each} +
+ {/if} diff --git a/frontend/src/lib/components/dashboard/HotLeadsPanel.svelte b/frontend/src/lib/components/dashboard/HotLeadsPanel.svelte index 0b707668..b7f696d0 100644 --- a/frontend/src/lib/components/dashboard/HotLeadsPanel.svelte +++ b/frontend/src/lib/components/dashboard/HotLeadsPanel.svelte @@ -1,141 +1,147 @@ - -
- - Hot Leads -
- -
- - {#if leads.length === 0} -
- -

No hot leads

-

Mark leads as "Hot" to see them here

-
- {:else} - - {/if} -
+ +
+ + Hot Leads +
+ +
+ + {#if leads.length === 0} +
+ +

No hot leads

+

Mark leads as "Hot" to see them here

+
+ {:else} + + {/if} +
diff --git a/frontend/src/lib/components/dashboard/KPICard.svelte b/frontend/src/lib/components/dashboard/KPICard.svelte index a1eddc7b..58f34b55 100644 --- a/frontend/src/lib/components/dashboard/KPICard.svelte +++ b/frontend/src/lib/components/dashboard/KPICard.svelte @@ -1,86 +1,86 @@ -
-
-

- {label} -

-

- {value} -

- {#if subtitle} -

{subtitle}

- {/if} - {#if hasTrend} -
- {#if isPositive} - - - +{trend}% - - {:else} - - - {trend}% - - {/if} - {#if trendLabel} - {trendLabel} - {/if} -
- {/if} -
- {#if icon} -
-
- {@render icon()} -
-
- {/if} -
+
+
+

+ {label} +

+

+ {value} +

+ {#if subtitle} +

{subtitle}

+ {/if} + {#if hasTrend} +
+ {#if isPositive} + + + +{trend}% + + {:else} + + + {trend}% + + {/if} + {#if trendLabel} + {trendLabel} + {/if} +
+ {/if} +
+ {#if icon} +
+
+ {@render icon()} +
+
+ {/if} +
diff --git a/frontend/src/lib/components/dashboard/MiniPipeline.svelte b/frontend/src/lib/components/dashboard/MiniPipeline.svelte index 82a15a35..9545970d 100644 --- a/frontend/src/lib/components/dashboard/MiniPipeline.svelte +++ b/frontend/src/lib/components/dashboard/MiniPipeline.svelte @@ -1,57 +1,57 @@
-
- {#each stages as stage, index} - {@const data = pipelineData[stage.id] || { count: 0, value: 0, label: stage.id }} - -
-
- {data.label || stage.id} - {data.count} -
-

- {formatCurrency(data.value, currency, true)} -

-
- {#if index < stages.length - 1 && index !== 3} - - {/if} - {#if index === 3} -
- {/if} - {/each} -
+
+ {#each stages as stage, index} + {@const data = pipelineData[stage.id] || { count: 0, value: 0, label: stage.id }} + +
+
+ {data.label || stage.id} + {data.count} +
+

+ {formatCurrency(data.value, currency, true)} +

+
+ {#if index < stages.length - 1 && index !== 3} + + {/if} + {#if index === 3} +
+ {/if} + {/each} +
diff --git a/frontend/src/lib/components/dashboard/OpportunitiesTable.svelte b/frontend/src/lib/components/dashboard/OpportunitiesTable.svelte index 2f96835f..b8803b93 100644 --- a/frontend/src/lib/components/dashboard/OpportunitiesTable.svelte +++ b/frontend/src/lib/components/dashboard/OpportunitiesTable.svelte @@ -1,139 +1,159 @@ - -
- - My Opportunities -
- -
- - {#if openOpportunities.length === 0} -
- -

No open opportunities

-
- {:else} - - {/if} -
+ +
+ + My Opportunities +
+ +
+ + {#if openOpportunities.length === 0} +
+ +

No open opportunities

+
+ {:else} + + {/if} +
diff --git a/frontend/src/lib/components/dashboard/PipelineChart.svelte b/frontend/src/lib/components/dashboard/PipelineChart.svelte index 484e5009..cfa8655a 100644 --- a/frontend/src/lib/components/dashboard/PipelineChart.svelte +++ b/frontend/src/lib/components/dashboard/PipelineChart.svelte @@ -1,69 +1,69 @@ - - Pipeline by Stage - - -
- {#each stages as stage} - {@const data = pipelineData[stage.id] || { count: 0, value: 0, label: stage.id }} - {@const percentage = maxValue > 0 ? (data.value / maxValue) * 100 : 0} -
-
- {data.label || stage.id} - {formatCurrency(data.value, currency, true)} -
-
-
-
-
- {/each} -
- -
- Total Open Pipeline - - {formatCurrency( - stages.reduce((sum, s) => sum + (pipelineData[s.id]?.value || 0), 0), - currency, - true - )} - -
-
+ + Pipeline by Stage + + +
+ {#each stages as stage} + {@const data = pipelineData[stage.id] || { count: 0, value: 0, label: stage.id }} + {@const percentage = maxValue > 0 ? (data.value / maxValue) * 100 : 0} +
+
+ {data.label || stage.id} + {formatCurrency(data.value, currency, true)} +
+
+
+
+
+ {/each} +
+ +
+ Total Open Pipeline + + {formatCurrency( + stages.reduce((sum, s) => sum + (pipelineData[s.id]?.value || 0), 0), + currency, + true + )} + +
+
diff --git a/frontend/src/lib/components/dashboard/TaskList.svelte b/frontend/src/lib/components/dashboard/TaskList.svelte index e674e657..5372b69c 100644 --- a/frontend/src/lib/components/dashboard/TaskList.svelte +++ b/frontend/src/lib/components/dashboard/TaskList.svelte @@ -1,200 +1,205 @@ - - My Tasks - - -
- - - - -
- - {#if filteredTasks().length === 0} -
- No tasks found -
- {:else} - - {/if} -
+ + My Tasks + + +
+ + + + +
+ + {#if filteredTasks().length === 0} +
+ No tasks found +
+ {:else} + + {/if} +
diff --git a/frontend/src/lib/components/invoices/InvoiceViewPage.svelte b/frontend/src/lib/components/invoices/InvoiceViewPage.svelte index d7bf22a9..40212684 100644 --- a/frontend/src/lib/components/invoices/InvoiceViewPage.svelte +++ b/frontend/src/lib/components/invoices/InvoiceViewPage.svelte @@ -1,627 +1,649 @@ -
-
- -
-
-

{companyDisplayName}

- {#if company?.companyName && company?.name && company.companyName !== company.name} -

{company.name}

- {/if} - {#if company?.addressLine} -

{company.addressLine}

- {/if} - {#if companyCityLine} -

{companyCityLine}

- {/if} - {#if company?.phone} -

Tel: {company.phone}

- {/if} - {#if company?.email} -

Email: {company.email}

- {/if} - {#if company?.website} -

Web: {company.website.replace(/^https?:\/\//, '')}

- {/if} -
+
+
+ +
+
+

{companyDisplayName}

+ {#if company?.companyName && company?.name && company.companyName !== company.name} +

{company.name}

+ {/if} + {#if company?.addressLine} +

{company.addressLine}

+ {/if} + {#if companyCityLine} +

{companyCityLine}

+ {/if} + {#if company?.phone} +

Tel: {company.phone}

+ {/if} + {#if company?.email} +

Email: {company.email}

+ {/if} + {#if company?.website} +

+ Web: {company.website.replace(/^https?:\/\//, '')} +

+ {/if} +
-
-
Invoice
- {#if company?.logoUrl} -
- -
- {:else} -
- {companyDisplayName.substring(0, 4).toUpperCase()} -
- {/if} -
-
+
+
Invoice
+ {#if company?.logoUrl} +
+ +
+ {:else} +
+ {companyDisplayName.substring(0, 4).toUpperCase()} +
+ {/if} +
+
- -
-
-

{invoice.clientName || 'Client Name'}

- {#if invoice.clientAddressLine} -

{invoice.clientAddressLine}

- {/if} - {#if clientCityLine} -

{clientCityLine}

- {/if} - {#if invoice.clientCountry} -

{invoice.clientCountry}

- {/if} -
+ +
+
+

{invoice.clientName || 'Client Name'}

+ {#if invoice.clientAddressLine} +

{invoice.clientAddressLine}

+ {/if} + {#if clientCityLine} +

{clientCityLine}

+ {/if} + {#if invoice.clientCountry} +

{invoice.clientCountry}

+ {/if} +
-
- {#if invoice.invoiceTitle} -
- Reference: - {invoice.invoiceTitle} -
- {/if} -
- Invoice #: - {invoice.invoiceNumber} -
- {#if invoice.poNumber} -
- PO Number: - {invoice.poNumber} -
- {/if} - {#if invoice.billingPeriod} -
- Billing Period: - {invoice.billingPeriod} -
- {/if} - {#if invoice.paymentTerms} -
- Terms: - {formatPaymentTerms(invoice.paymentTerms)} -
- {/if} - {#if invoice.issueDate} -
- Invoice Date: - {formatDate(invoice.issueDate)} -
- {/if} - {#if invoice.dueDate} -
- Due Date: - {formatDate(invoice.dueDate)} -
- {/if} -
-
+
+ {#if invoice.invoiceTitle} +
+ Reference: + {invoice.invoiceTitle} +
+ {/if} +
+ Invoice #: + {invoice.invoiceNumber} +
+ {#if invoice.poNumber} +
+ PO Number: + {invoice.poNumber} +
+ {/if} + {#if invoice.billingPeriod} +
+ Billing Period: + {invoice.billingPeriod} +
+ {/if} + {#if invoice.paymentTerms} +
+ Terms: + {formatPaymentTerms(invoice.paymentTerms)} +
+ {/if} + {#if invoice.issueDate} +
+ Invoice Date: + {formatDate(invoice.issueDate)} +
+ {/if} + {#if invoice.dueDate} +
+ Due Date: + {formatDate(invoice.dueDate)} +
+ {/if} +
+
- - - - - - - - - - - - {#if invoice.lineItems && invoice.lineItems.length > 0} - {#each invoice.lineItems as item} - - - - - - - {/each} - {:else} - - - - {/if} - -
DescriptionQuantityPriceTotal
- {#if item.name} - {item.name} - {#if item.description} - {item.description} - {/if} - {:else} - {item.description || 'Item'} - {/if} - {item.quantity?.toFixed(2) || '1.00'}{formatCurrency(Number(item.rate || 0), invoice.currency)}{formatCurrency(Number(item.amount || 0), invoice.currency)}
No line items
+ + + + + + + + + + + + {#if invoice.lineItems && invoice.lineItems.length > 0} + {#each invoice.lineItems as item} + + + + + + + {/each} + {:else} + + + + {/if} + +
DescriptionQuantityPriceTotal
+ {#if item.name} + {item.name} + {#if item.description} + {item.description} + {/if} + {:else} + {item.description || 'Item'} + {/if} + {item.quantity?.toFixed(2) || '1.00'}{formatCurrency(Number(item.rate || 0), invoice.currency)}{formatCurrency(Number(item.amount || 0), invoice.currency)}
No line items
- -
- - - - - - - - - - - {#if Number(invoice.discountAmount || 0) > 0} - - - - - {/if} - - - - - - - - - {#if Number(invoice.amountPaid || 0) > 0} - - - - - - - - - {/if} - -
Subtotal{formatCurrency(Number(invoice.subtotal || 0), invoice.currency)}
Shipping - {formatCurrency(Number(invoice.shippingAmount || 0), invoice.currency)} -
Discount-{formatCurrency(Number(invoice.discountAmount), invoice.currency)}
Tax - {formatCurrency(Number(invoice.taxAmount || 0), invoice.currency)} -
Total{formatCurrency(Number(invoice.totalAmount || 0), invoice.currency)}
Amount Paid
Amount Due{formatCurrency(Number(invoice.amountDue || 0), invoice.currency)}
-
+ +
+ + + + + + + + + + + {#if Number(invoice.discountAmount || 0) > 0} + + + + + {/if} + + + + + + + + + {#if Number(invoice.amountPaid || 0) > 0} + + + + + + + + + {/if} + +
Subtotal{formatCurrency(Number(invoice.subtotal || 0), invoice.currency)}
Shipping + {formatCurrency(Number(invoice.shippingAmount || 0), invoice.currency)} +
Discount-{formatCurrency(Number(invoice.discountAmount), invoice.currency)}
Tax + {formatCurrency(Number(invoice.taxAmount || 0), invoice.currency)} +
Total{formatCurrency(Number(invoice.totalAmount || 0), invoice.currency)}
Amount Paid
Amount Due{formatCurrency(Number(invoice.amountDue || 0), invoice.currency)}
+
- - {#if invoice.notes} - - {/if} + + {#if invoice.notes} + + {/if} - -
-

Yours Sincerely,

-
-

{companyDisplayName}

- {#if invoice.clientName} -

{invoice.clientName}

- {/if} -
+ +
+

Yours Sincerely,

+
+

{companyDisplayName}

+ {#if invoice.clientName} +

{invoice.clientName}

+ {/if} +
- - -
+ + +
diff --git a/frontend/src/lib/components/layout/AppShell.svelte b/frontend/src/lib/components/layout/AppShell.svelte index 07a63c56..ed4b226f 100644 --- a/frontend/src/lib/components/layout/AppShell.svelte +++ b/frontend/src/lib/components/layout/AppShell.svelte @@ -1,35 +1,35 @@ - - - {@render children?.()} - + + + {@render children?.()} + diff --git a/frontend/src/lib/components/layout/AppSidebar.svelte b/frontend/src/lib/components/layout/AppSidebar.svelte index 3be1d70a..bf9325c2 100644 --- a/frontend/src/lib/components/layout/AppSidebar.svelte +++ b/frontend/src/lib/components/layout/AppSidebar.svelte @@ -1,453 +1,453 @@ - - - - - {#snippet child({ props })} - -
- Logo -
-
- {org_name} - CRM -
-
- {/snippet} -
- - -
- - -
-
+ + + + + {#snippet child({ props })} + +
+ Logo +
+
+ {org_name} + CRM +
+
+ {/snippet} +
+ + +
+ + +
+
- - - - - {#each navigationItems as item} - {#if item.type === 'link'} - - - {#snippet child({ props })} - - - {item.label} - - {/snippet} - - - {:else if item.type === 'dropdown' && item.children} - { - if (item.key) openDropdowns[item.key] = open; - }} - class="group/collapsible" - > - - - {#snippet child({ props })} - - - {item.label} - - - {/snippet} - - - - {#each item.children as navChild} - - - {#snippet child({ props })} - - - {navChild.label} - - {/snippet} - - - {/each} - - - - - {/if} - {/each} - - - - + + + + + {#each navigationItems as item} + {#if item.type === 'link'} + + + {#snippet child({ props })} + + + {item.label} + + {/snippet} + + + {:else if item.type === 'dropdown' && item.children} + { + if (item.key) openDropdowns[item.key] = open; + }} + class="group/collapsible" + > + + + {#snippet child({ props })} + + + {item.label} + + + {/snippet} + + + + {#each item.children as navChild} + + + {#snippet child({ props })} + + + {navChild.label} + + {/snippet} + + + {/each} + + + + + {/if} + {/each} + + + + - - - - - - - {#snippet child({ props })} - - {#if theme === 'dark'} - - {:else if theme === 'light'} - - {:else} - - {/if} - Theme - {theme} - - {/snippet} - - - setTheme('light')}> - - Light - - setTheme('dark')}> - - Dark - - setTheme('system')}> - - System - - - - + + + + + + + {#snippet child({ props })} + + {#if theme === 'dark'} + + {:else if theme === 'light'} + + {:else} + + {/if} + Theme + {theme} + + {/snippet} + + + setTheme('light')}> + + Light + + setTheme('dark')}> + + Dark + + setTheme('system')}> + + System + + + + - - - - - {#snippet child({ props })} - - User avatar -
- {user.name} - {user.email} -
- -
- {/snippet} -
- - My Account - - - navigateTo('/profile')}> - - Profile - - navigateTo('/users')}> - - Users & Teams - - navigateTo('/org')}> - - Organizations - - navigateTo('/settings/organization')} - > - - Settings - - navigateTo('/settings/tags')} - > - - Tags - - - - navigateTo('/logout')} - > - - Sign out - - -
-
-
-
+ + + + + {#snippet child({ props })} + + User avatar +
+ {user.name} + {user.email} +
+ +
+ {/snippet} +
+ + My Account + + + navigateTo('/profile')}> + + Profile + + navigateTo('/users')}> + + Users & Teams + + navigateTo('/org')}> + + Organizations + + navigateTo('/settings/organization')} + > + + Settings + + navigateTo('/settings/tags')} + > + + Tags + + + + navigateTo('/logout')} + > + + Sign out + + +
+
+
+
- +
diff --git a/frontend/src/lib/components/layout/FilterPopover.svelte b/frontend/src/lib/components/layout/FilterPopover.svelte index 796689a0..f3c609a2 100644 --- a/frontend/src/lib/components/layout/FilterPopover.svelte +++ b/frontend/src/lib/components/layout/FilterPopover.svelte @@ -1,42 +1,40 @@ - - {#snippet child({ props })} - - {/snippet} - - -
-

Filters

- {@render children?.()} - {#if onClear} - - {/if} -
-
+ + {#snippet child({ props })} + + {/snippet} + + +
+

Filters

+ {@render children?.()} + {#if onClear} + + {/if} +
+
diff --git a/frontend/src/lib/components/layout/PageHeader.svelte b/frontend/src/lib/components/layout/PageHeader.svelte index 92d1f309..6b7f0e72 100644 --- a/frontend/src/lib/components/layout/PageHeader.svelte +++ b/frontend/src/lib/components/layout/PageHeader.svelte @@ -1,29 +1,29 @@
-
-
-

{title}

- {#if subtitle} -

{subtitle}

- {/if} -
- {#if actions} -
- {@render actions()} -
- {/if} -
+
+
+

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} +
+ {#if actions} +
+ {@render actions()} +
+ {/if} +
diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte index cd261020..c5af80ba 100644 --- a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte @@ -1,21 +1,21 @@ diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte index d4f185d7..0687e944 100644 --- a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte @@ -1,21 +1,21 @@ diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte index bfee09c8..36a2c9c7 100644 --- a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte @@ -1,37 +1,37 @@ - - - {@render children?.()} - + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte index cb66de55..bfd032e2 100644 --- a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte @@ -1,20 +1,20 @@ diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte index 560b0410..96c40e20 100644 --- a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte @@ -1,22 +1,22 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte index 813c9a46..f2687c39 100644 --- a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte @@ -1,22 +1,22 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte index 5e85feb9..0d53b5a2 100644 --- a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte @@ -1,23 +1,23 @@ diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte index 2fa1b4b8..b98c833e 100644 --- a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte @@ -1,20 +1,20 @@ diff --git a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte index a2d7793c..a45e563a 100644 --- a/frontend/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte +++ b/frontend/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/ui/alert-dialog/index.js b/frontend/src/lib/components/ui/alert-dialog/index.js index 66a6d794..54fb20bf 100644 --- a/frontend/src/lib/components/ui/alert-dialog/index.js +++ b/frontend/src/lib/components/ui/alert-dialog/index.js @@ -13,27 +13,27 @@ const Root = AlertDialogPrimitive.Root; const Portal = AlertDialogPrimitive.Portal; export { - Root, - Trigger, - Portal, - Overlay, - Content, - Header, - Footer, - Title, - Description, - Cancel, - Action, - // - Root as AlertDialog, - Trigger as AlertDialogTrigger, - Portal as AlertDialogPortal, - Overlay as AlertDialogOverlay, - Content as AlertDialogContent, - Header as AlertDialogHeader, - Footer as AlertDialogFooter, - Title as AlertDialogTitle, - Description as AlertDialogDescription, - Cancel as AlertDialogCancel, - Action as AlertDialogAction + Root, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + Cancel, + Action, + // + Root as AlertDialog, + Trigger as AlertDialogTrigger, + Portal as AlertDialogPortal, + Overlay as AlertDialogOverlay, + Content as AlertDialogContent, + Header as AlertDialogHeader, + Footer as AlertDialogFooter, + Title as AlertDialogTitle, + Description as AlertDialogDescription, + Cancel as AlertDialogCancel, + Action as AlertDialogAction }; diff --git a/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte index 9431ca97..a81c5a32 100644 --- a/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte +++ b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -1,13 +1,13 @@ diff --git a/frontend/src/lib/components/ui/avatar/avatar-image.svelte b/frontend/src/lib/components/ui/avatar/avatar-image.svelte index a296b92f..cdb46330 100644 --- a/frontend/src/lib/components/ui/avatar/avatar-image.svelte +++ b/frontend/src/lib/components/ui/avatar/avatar-image.svelte @@ -1,13 +1,13 @@ diff --git a/frontend/src/lib/components/ui/avatar/avatar.svelte b/frontend/src/lib/components/ui/avatar/avatar.svelte index 749a7bc3..b2297fb2 100644 --- a/frontend/src/lib/components/ui/avatar/avatar.svelte +++ b/frontend/src/lib/components/ui/avatar/avatar.svelte @@ -1,32 +1,32 @@ diff --git a/frontend/src/lib/components/ui/avatar/index.js b/frontend/src/lib/components/ui/avatar/index.js index 9585f8ad..9cf5cc18 100644 --- a/frontend/src/lib/components/ui/avatar/index.js +++ b/frontend/src/lib/components/ui/avatar/index.js @@ -3,11 +3,11 @@ import Image from './avatar-image.svelte'; import Fallback from './avatar-fallback.svelte'; export { - Root, - Image, - Fallback, - // - Root as Avatar, - Image as AvatarImage, - Fallback as AvatarFallback + Root, + Image, + Fallback, + // + Root as Avatar, + Image as AvatarImage, + Fallback as AvatarFallback }; diff --git a/frontend/src/lib/components/ui/badge/badge.svelte b/frontend/src/lib/components/ui/badge/badge.svelte index 0cc2c571..fc8fa27b 100644 --- a/frontend/src/lib/components/ui/badge/badge.svelte +++ b/frontend/src/lib/components/ui/badge/badge.svelte @@ -1,54 +1,54 @@ - {@render children?.()} + {@render children?.()} diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte index 1269dc51..30fefd40 100644 --- a/frontend/src/lib/components/ui/button/button.svelte +++ b/frontend/src/lib/components/ui/button/button.svelte @@ -1,85 +1,85 @@ {#if href} - - {@render children?.()} - + + {@render children?.()} + {:else} - + {/if} diff --git a/frontend/src/lib/components/ui/button/index.js b/frontend/src/lib/components/ui/button/index.js index 10d4bae9..8c24eec7 100644 --- a/frontend/src/lib/components/ui/button/index.js +++ b/frontend/src/lib/components/ui/button/index.js @@ -1,9 +1,9 @@ import Root, { buttonVariants } from './button.svelte'; export { - Root, + Root, - // - Root as Button, - buttonVariants + // + Root as Button, + buttonVariants }; diff --git a/frontend/src/lib/components/ui/calendar/Calendar.svelte b/frontend/src/lib/components/ui/calendar/Calendar.svelte index 410d0195..65d45b69 100644 --- a/frontend/src/lib/components/ui/calendar/Calendar.svelte +++ b/frontend/src/lib/components/ui/calendar/Calendar.svelte @@ -1,135 +1,135 @@ - {#snippet children({ months: calendarMonths, weekdays })} - - - - + {#snippet children({ months: calendarMonths, weekdays })} + + + + -
- - +
+ + - - -
+ + +
- - - -
+ + + +
- {#each calendarMonths as month} - - - - {#each weekdays as day} - - {day.slice(0, 2)} - - {/each} - - - - {#each month.weeks as week} - - {#each week as date} - - - {date.day} - - - {/each} - - {/each} - - - {/each} - {/snippet} + {#each calendarMonths as month} + + + + {#each weekdays as day} + + {day.slice(0, 2)} + + {/each} + + + + {#each month.weeks as week} + + {#each week as date} + + + {date.day} + + + {/each} + + {/each} + + + {/each} + {/snippet}
diff --git a/frontend/src/lib/components/ui/card/card-content.svelte b/frontend/src/lib/components/ui/card/card-content.svelte index 2eb98b74..c1e4424c 100644 --- a/frontend/src/lib/components/ui/card/card-content.svelte +++ b/frontend/src/lib/components/ui/card/card-content.svelte @@ -1,17 +1,17 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/card/card-description.svelte b/frontend/src/lib/components/ui/card/card-description.svelte index ce98b76b..d1b6fa92 100644 --- a/frontend/src/lib/components/ui/card/card-description.svelte +++ b/frontend/src/lib/components/ui/card/card-description.svelte @@ -1,14 +1,14 @@

- {@render children?.()} + {@render children?.()}

diff --git a/frontend/src/lib/components/ui/card/card-header.svelte b/frontend/src/lib/components/ui/card/card-header.svelte index 9fd83370..28c41aeb 100644 --- a/frontend/src/lib/components/ui/card/card-header.svelte +++ b/frontend/src/lib/components/ui/card/card-header.svelte @@ -1,16 +1,16 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/card/card-title.svelte b/frontend/src/lib/components/ui/card/card-title.svelte index 0107047c..c0517323 100644 --- a/frontend/src/lib/components/ui/card/card-title.svelte +++ b/frontend/src/lib/components/ui/card/card-title.svelte @@ -1,14 +1,14 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/card/card.svelte b/frontend/src/lib/components/ui/card/card.svelte index 96ddf3c8..4287dd43 100644 --- a/frontend/src/lib/components/ui/card/card.svelte +++ b/frontend/src/lib/components/ui/card/card.svelte @@ -1,25 +1,25 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/card/index.js b/frontend/src/lib/components/ui/card/index.js index 8c42b4f9..0fcc220f 100644 --- a/frontend/src/lib/components/ui/card/index.js +++ b/frontend/src/lib/components/ui/card/index.js @@ -5,15 +5,15 @@ import Header from './card-header.svelte'; import Title from './card-title.svelte'; export { - Root, - Content, - Description, - Header, - Title, - // - Root as Card, - Content as CardContent, - Description as CardDescription, - Header as CardHeader, - Title as CardTitle + Root, + Content, + Description, + Header, + Title, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Header as CardHeader, + Title as CardTitle }; diff --git a/frontend/src/lib/components/ui/case-kanban/CaseCard.svelte b/frontend/src/lib/components/ui/case-kanban/CaseCard.svelte index d4b0880c..4502b0af 100644 --- a/frontend/src/lib/components/ui/case-kanban/CaseCard.svelte +++ b/frontend/src/lib/components/ui/case-kanban/CaseCard.svelte @@ -1,161 +1,161 @@
- -
-
- {name} -
- {#if isSlaBreached} - - - - {/if} -
+ +
+
+ {name} +
+ {#if isSlaBreached} + + + + {/if} +
- - {#if accountName} -
- - {accountName} -
- {/if} + + {#if accountName} +
+ + {accountName} +
+ {/if} - -
-
- {#if caseType} - - {caseTypeLabels[caseType] || caseType} - - {/if} - {#if priority} - - {priority} - - {/if} -
-
+ +
+
+ {#if caseType} + + {caseTypeLabels[caseType] || caseType} + + {/if} + {#if priority} + + {priority} + + {/if} +
+
- - {#if assignees.length > 0} -
- {#each assignees.slice(0, 3) as assignee (assignee.id)} -
- {getAssigneeInitials(assignee)} -
- {/each} - {#if assignees.length > 3} -
- +{assignees.length - 3} -
- {/if} -
- {/if} + + {#if assignees.length > 0} +
+ {#each assignees.slice(0, 3) as assignee (assignee.id)} +
+ {getAssigneeInitials(assignee)} +
+ {/each} + {#if assignees.length > 3} +
+ +{assignees.length - 3} +
+ {/if} +
+ {/if}
diff --git a/frontend/src/lib/components/ui/case-kanban/CaseKanban.svelte b/frontend/src/lib/components/ui/case-kanban/CaseKanban.svelte index e744de4c..8845cb77 100644 --- a/frontend/src/lib/components/ui/case-kanban/CaseKanban.svelte +++ b/frontend/src/lib/components/ui/case-kanban/CaseKanban.svelte @@ -1,62 +1,62 @@ diff --git a/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte b/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte index cc06e004..dd42e915 100644 --- a/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte +++ b/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte b/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte index 0135bbb5..800613d9 100644 --- a/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte +++ b/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/ui/collapsible/collapsible.svelte b/frontend/src/lib/components/ui/collapsible/collapsible.svelte index 147b512c..1f79ad64 100644 --- a/frontend/src/lib/components/ui/collapsible/collapsible.svelte +++ b/frontend/src/lib/components/ui/collapsible/collapsible.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/ui/collapsible/index.js b/frontend/src/lib/components/ui/collapsible/index.js index 8181f644..0ecd6270 100644 --- a/frontend/src/lib/components/ui/collapsible/index.js +++ b/frontend/src/lib/components/ui/collapsible/index.js @@ -3,11 +3,11 @@ import Trigger from './collapsible-trigger.svelte'; import Content from './collapsible-content.svelte'; export { - Root, - Content, - Trigger, - // - Root as Collapsible, - Content as CollapsibleContent, - Trigger as CollapsibleTrigger + Root, + Content, + Trigger, + // + Root as Collapsible, + Content as CollapsibleContent, + Trigger as CollapsibleTrigger }; diff --git a/frontend/src/lib/components/ui/crm-drawer/CrmDrawer.svelte b/frontend/src/lib/components/ui/crm-drawer/CrmDrawer.svelte index 37f8294f..8519db47 100644 --- a/frontend/src/lib/components/ui/crm-drawer/CrmDrawer.svelte +++ b/frontend/src/lib/components/ui/crm-drawer/CrmDrawer.svelte @@ -1,332 +1,342 @@ onOpenChange?.(value)}> - - {#if loading} - -
-
- - -
-
-
- -
-
- {#each { length: 6 } as _} -
- - -
- {/each} -
-
-
- {:else} -
- -
- {headerLabel} - -
+ + {#if loading} + +
+
+ + +
+
+
+ +
+
+ {#each { length: 6 } as _} +
+ + +
+ {/each} +
+
+
+ {:else} +
+ +
+ {headerLabel} + +
- -
- -
- {#if titleEditable} - - {:else} -

- {titleValue || titlePlaceholder} -

- {/if} -
+ +
+ +
+ {#if titleEditable} + + {:else} +

+ {titleValue || titlePlaceholder} +

+ {/if} +
- -
- {#if enableProgressiveDisclosure && essentialColumns.length > 0} - - {#each essentialColumns as col (col.key)} - handleFieldChange(col.key, value)} - /> - {/each} + +
+ {#if enableProgressiveDisclosure && essentialColumns.length > 0} + + {#each essentialColumns as col (col.key)} + handleFieldChange(col.key, value)} + /> + {/each} - - {#if hiddenFieldsCount > 0 || showAllFields} - - {/if} + + {#if hiddenFieldsCount > 0 || showAllFields} + + {/if} - - {#each visibleAdditionalColumns as col (col.key)} - handleFieldChange(col.key, value)} - /> - {/each} - {:else} - - {#each columns as col (col.key)} - {#if col.key !== titleKey} - handleFieldChange(col.key, value)} - /> - {/if} - {/each} - {/if} -
+ + {#each visibleAdditionalColumns as col (col.key)} + handleFieldChange(col.key, value)} + /> + {/each} + {:else} + + {#each columns as col (col.key)} + {#if col.key !== titleKey} + handleFieldChange(col.key, value)} + /> + {/if} + {/each} + {/if} +
- - {#if activitySection} - -
- {@render activitySection()} -
- {/if} -
+ + {#if activitySection} + +
+ {@render activitySection()} +
+ {/if} +
- -
-
- - {#if onDelete && mode !== 'create'} - - {:else} -
- {/if} + +
+
+ + {#if onDelete && mode !== 'create'} + + {:else} +
+ {/if} - - {#if footerActions} -
- {@render footerActions()} -
- {/if} -
-
-
- {/if} - + + {#if footerActions} +
+ {@render footerActions()} +
+ {/if} +
+
+
+ {/if} +
diff --git a/frontend/src/lib/components/ui/crm-drawer/CrmPropertyRow.svelte b/frontend/src/lib/components/ui/crm-drawer/CrmPropertyRow.svelte index a1f4f020..1fa4edb0 100644 --- a/frontend/src/lib/components/ui/crm-drawer/CrmPropertyRow.svelte +++ b/frontend/src/lib/components/ui/crm-drawer/CrmPropertyRow.svelte @@ -1,327 +1,329 @@
- -
- {#if Icon} - - {/if} - {label} -
+ +
+ {#if Icon} + + {/if} + {label} +
- -
- {#if type === 'text' || type === 'email'} - - {:else if type === 'number'} -
- {#if prefix} - {prefix} - {/if} - -
- {:else if type === 'date'} - - - {#snippet child({ props })} - - {/snippet} - - - - - - {:else if type === 'textarea'} - - {:else if type === 'select'} - - - {#snippet child({ props })} - - {/snippet} - - - {#each options as option (option.value)} - handleSelectChange(option.value)} - class="flex items-center gap-2" - > - - {option.label} - {#if value === option.value} - - {/if} - - {/each} - - - {:else if type === 'checkbox' || type === 'boolean'} - - {:else if type === 'color'} -
- - {value || '#3B82F6'} -
- {:else if type === 'multiselect'} - - {:else if type === 'readonly'} - - {#if value} - {value} - {:else} - {emptyText || 'Empty'} - {/if} - - {/if} -
+ +
+ {#if type === 'text' || type === 'email'} + + {:else if type === 'number'} +
+ {#if prefix} + {prefix} + {/if} + +
+ {:else if type === 'date'} + + + {#snippet child({ props })} + + {/snippet} + + + + + + {:else if type === 'textarea'} + + {:else if type === 'select'} + + + {#snippet child({ props })} + + {/snippet} + + + {#each options as option (option.value)} + handleSelectChange(option.value)} + class="flex items-center gap-2" + > + + {option.label} + {#if value === option.value} + + {/if} + + {/each} + + + {:else if type === 'checkbox' || type === 'boolean'} + + {:else if type === 'color'} +
+ + {value || '#3B82F6'} +
+ {:else if type === 'multiselect'} + + {:else if type === 'readonly'} + + {#if value} + {value} + {:else} + {emptyText || 'Empty'} + {/if} + + {/if} +
diff --git a/frontend/src/lib/components/ui/crm-table/CrmTable.svelte b/frontend/src/lib/components/ui/crm-table/CrmTable.svelte index e831905c..f12e181d 100644 --- a/frontend/src/lib/components/ui/crm-table/CrmTable.svelte +++ b/frontend/src/lib/components/ui/crm-table/CrmTable.svelte @@ -1,426 +1,427 @@ {#snippet tableBody()} - {#if data.length === 0} - {#if emptyState} - {@render emptyState()} - {:else} -
-

No items found

-
- {/if} - {:else} - - - - {#if onRowClick} - - {/if} - {#each visibleColumnDefs as column, colIndex (column.key)} - - {/each} - - - - {#each data as row (row.id)} - { - if (!onRowClick) return; - // Don't trigger row click if clicking on interactive elements - const target = /** @type {HTMLElement} */ (e.target); - if ( - target.closest('button') || - target.closest('input') || - target.closest('[data-dropdown-trigger]') || - target.closest('[role="menu"]') - ) { - return; - } - onRowClick(row); - }} - > - {#if onRowClick} - - {/if} + {#if data.length === 0} + {#if emptyState} + {@render emptyState()} + {:else} +
+

No items found

+
+ {/if} + {:else} +
- {column.label} -
- -
+ + + {#if onRowClick} + + {/if} + {#each visibleColumnDefs as column, colIndex (column.key)} + + {/each} + + + + {#each data as row (row.id)} + { + if (!onRowClick) return; + // Don't trigger row click if clicking on interactive elements + const target = /** @type {HTMLElement} */ (e.target); + if ( + target.closest('button') || + target.closest('input') || + target.closest('[data-dropdown-trigger]') || + target.closest('[role="menu"]') + ) { + return; + } + onRowClick(row); + }} + > + {#if onRowClick} + + {/if} - {#each visibleColumnDefs as column, colIndex (column.key)} - {@const value = getCellValue(row, column)} - {@const formattedValue = formatValue(value, column, row)} - - {/each} - - {/each} - -
+ {column.label} +
+ + - {#if cellContent} - {@render cellContent(row, column)} - {:else if column.type === 'text' || column.type === 'email' || column.type === 'number' || !column.type} - {#if editingCell?.rowId === row.id && editingCell?.columnKey === column.key && onRowChange} - stopEditing(true)} - data-edit-input="{row.id}-{column.key}" - class="w-full rounded px-2 py-1.5 text-sm bg-white dark:bg-gray-900 outline-none ring-1 ring-gray-200 dark:ring-gray-700 focus:ring-blue-300 shadow-sm transition-shadow duration-100" - /> - {:else if onRowChange && column.editable !== false} - - {:else} - - {#if formattedValue} - {formattedValue} - {:else} - {column.emptyText || 'Empty'} - {/if} - - {/if} - {:else if column.type === 'date'} - {#if editingCell?.rowId === row.id && editingCell?.columnKey === column.key && onRowChange} - stopEditing(true)} - data-edit-input="{row.id}-{column.key}" - class="w-full rounded px-2 py-1.5 text-sm bg-white dark:bg-gray-900 outline-none ring-1 ring-gray-200 dark:ring-gray-700 focus:ring-blue-300 shadow-sm transition-shadow duration-100" - /> - {:else if onRowChange && column.editable !== false} - - {:else} - - {#if formattedValue} - {formattedValue} - {:else} - {column.emptyText || 'Empty'} - {/if} - - {/if} - {:else if column.type === 'select' && column.options} - {#if onRowChange} - - - {#snippet child({ props })} - - {/snippet} - - - {#each column.options as option (option.value)} - updateSelectValue(row.id, column.key, option.value)} - class="flex items-center gap-2" - > - - {option.label} - {#if value === option.value} - - {/if} - - {/each} - - - {:else} - - {getOptionLabel(value, column.options)} - - {/if} - {:else if column.type === 'checkbox'} - {#if onRowChange} - - {:else} - - {#if value} - - {/if} - - {/if} - {:else if column.type === 'relation'} - - {/if} -
- {/if} + {#each visibleColumnDefs as column, colIndex (column.key)} + {@const value = getCellValue(row, column)} + {@const formattedValue = formatValue(value, column, row)} + + {#if cellContent} + {@render cellContent(row, column)} + {:else if column.type === 'text' || column.type === 'email' || column.type === 'number' || !column.type} + {#if editingCell?.rowId === row.id && editingCell?.columnKey === column.key && onRowChange} + stopEditing(true)} + data-edit-input="{row.id}-{column.key}" + class="w-full rounded bg-white px-2 py-1.5 text-sm shadow-sm ring-1 ring-gray-200 transition-shadow duration-100 outline-none focus:ring-blue-300 dark:bg-gray-900 dark:ring-gray-700" + /> + {:else if onRowChange && column.editable !== false} + + {:else} + + {#if formattedValue} + {formattedValue} + {:else} + {column.emptyText || 'Empty'} + {/if} + + {/if} + {:else if column.type === 'date'} + {#if editingCell?.rowId === row.id && editingCell?.columnKey === column.key && onRowChange} + stopEditing(true)} + data-edit-input="{row.id}-{column.key}" + class="w-full rounded bg-white px-2 py-1.5 text-sm shadow-sm ring-1 ring-gray-200 transition-shadow duration-100 outline-none focus:ring-blue-300 dark:bg-gray-900 dark:ring-gray-700" + /> + {:else if onRowChange && column.editable !== false} + + {:else} + + {#if formattedValue} + {formattedValue} + {:else} + {column.emptyText || 'Empty'} + {/if} + + {/if} + {:else if column.type === 'select' && column.options} + {#if onRowChange} + + + {#snippet child({ props })} + + {/snippet} + + + {#each column.options as option (option.value)} + updateSelectValue(row.id, column.key, option.value)} + class="flex items-center gap-2" + > + + {option.label} + {#if value === option.value} + + {/if} + + {/each} + + + {:else} + + {getOptionLabel(value, column.options)} + + {/if} + {:else if column.type === 'checkbox'} + {#if onRowChange} + + {:else} + + {#if value} + + {/if} + + {/if} + {:else if column.type === 'relation'} + + {/if} + + {/each} + + {/each} + + + {/if} {/snippet} {@render tableBody()} diff --git a/frontend/src/lib/components/ui/dialog/dialog-close.svelte b/frontend/src/lib/components/ui/dialog/dialog-close.svelte index 94266a63..75986854 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-close.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-close.svelte @@ -1,7 +1,7 @@ - \ No newline at end of file + diff --git a/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/components/ui/dialog/dialog-content.svelte index bb239be3..1c15d510 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-content.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-content.svelte @@ -1,38 +1,38 @@ - - - {@render children?.()} - {#if showCloseButton} - - - Close - - {/if} - - \ No newline at end of file + + + {@render children?.()} + {#if showCloseButton} + + + Close + + {/if} + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-description.svelte b/frontend/src/lib/components/ui/dialog/dialog-description.svelte index 7f156049..16373894 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-description.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-description.svelte @@ -1,17 +1,13 @@ \ No newline at end of file + bind:ref + data-slot="dialog-description" + class={cn('text-muted-foreground text-sm', className)} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/dialog/dialog-footer.svelte b/frontend/src/lib/components/ui/dialog/dialog-footer.svelte index 45460648..f10ace90 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-footer.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-footer.svelte @@ -1,18 +1,13 @@
- {@render children?.()} -
\ No newline at end of file + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/dialog/dialog-header.svelte b/frontend/src/lib/components/ui/dialog/dialog-header.svelte index 163eb208..6bd01ec6 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-header.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-header.svelte @@ -1,19 +1,14 @@
- {@render children?.()} -
\ No newline at end of file + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte b/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte index adb8a38e..4fa21ae6 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -1,20 +1,16 @@ \ No newline at end of file + bind:ref + data-slot="dialog-overlay" + class={cn( + 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50', + className + )} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/dialog/dialog-portal.svelte b/frontend/src/lib/components/ui/dialog/dialog-portal.svelte index 2f418e99..fb0e9baf 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-portal.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-portal.svelte @@ -1,7 +1,7 @@ - \ No newline at end of file + diff --git a/frontend/src/lib/components/ui/dialog/dialog-title.svelte b/frontend/src/lib/components/ui/dialog/dialog-title.svelte index fb139001..156b5393 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-title.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-title.svelte @@ -1,17 +1,13 @@ \ No newline at end of file + bind:ref + data-slot="dialog-title" + class={cn('text-lg leading-none font-semibold', className)} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte b/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte index 7a38ff9e..eab79710 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -1,7 +1,7 @@ - \ No newline at end of file + diff --git a/frontend/src/lib/components/ui/dialog/dialog.svelte b/frontend/src/lib/components/ui/dialog/dialog.svelte index d48b822e..5488fb2e 100644 --- a/frontend/src/lib/components/ui/dialog/dialog.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog.svelte @@ -1,7 +1,7 @@ - \ No newline at end of file + diff --git a/frontend/src/lib/components/ui/dialog/index.js b/frontend/src/lib/components/ui/dialog/index.js index 4703a644..40ff747c 100644 --- a/frontend/src/lib/components/ui/dialog/index.js +++ b/frontend/src/lib/components/ui/dialog/index.js @@ -1,34 +1,34 @@ -import Root from "./dialog.svelte"; -import Portal from "./dialog-portal.svelte"; -import Title from "./dialog-title.svelte"; -import Footer from "./dialog-footer.svelte"; -import Header from "./dialog-header.svelte"; -import Overlay from "./dialog-overlay.svelte"; -import Content from "./dialog-content.svelte"; -import Description from "./dialog-description.svelte"; -import Trigger from "./dialog-trigger.svelte"; -import Close from "./dialog-close.svelte"; +import Root from './dialog.svelte'; +import Portal from './dialog-portal.svelte'; +import Title from './dialog-title.svelte'; +import Footer from './dialog-footer.svelte'; +import Header from './dialog-header.svelte'; +import Overlay from './dialog-overlay.svelte'; +import Content from './dialog-content.svelte'; +import Description from './dialog-description.svelte'; +import Trigger from './dialog-trigger.svelte'; +import Close from './dialog-close.svelte'; export { - Root, - Title, - Portal, - Footer, - Header, - Trigger, - Overlay, - Content, - Description, - Close, - // - Root as Dialog, - Title as DialogTitle, - Portal as DialogPortal, - Footer as DialogFooter, - Header as DialogHeader, - Trigger as DialogTrigger, - Overlay as DialogOverlay, - Content as DialogContent, - Description as DialogDescription, - Close as DialogClose, -}; \ No newline at end of file + Root, + Title, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose +}; diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte index 4bd06017..0cee891d 100644 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte @@ -1,12 +1,12 @@ diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte index 33a62af1..fec345d0 100644 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -1,37 +1,37 @@ - {#snippet children({ checked, indeterminate })} - - {#if indeterminate} - - {:else} - - {/if} - - {@render childrenProp?.()} - {/snippet} + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else} + + {/if} + + {@render childrenProp?.()} + {/snippet} diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte index 8ac90a45..666def8f 100644 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -1,37 +1,37 @@ - + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte index 168d23b8..bcba08b3 100644 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte index ff14f6f0..5b2c1e48 100644 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -1,34 +1,34 @@ diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte index e229625e..bfe2f7ae 100644 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -1,23 +1,23 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte index 1224f33a..726b9c09 100644 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -1,20 +1,20 @@ diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte index a6ffaf26..825e0d41 100644 --- a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/ui/dropdown-menu/index.js b/frontend/src/lib/components/ui/dropdown-menu/index.js index 90b34f01..6c2d04a7 100644 --- a/frontend/src/lib/components/ui/dropdown-menu/index.js +++ b/frontend/src/lib/components/ui/dropdown-menu/index.js @@ -11,24 +11,24 @@ const Sub = DropdownMenuPrimitive.Sub; const Root = DropdownMenuPrimitive.Root; export { - CheckboxGroup, - CheckboxItem, - Content, - Root as DropdownMenu, - CheckboxGroup as DropdownMenuCheckboxGroup, - CheckboxItem as DropdownMenuCheckboxItem, - Content as DropdownMenuContent, - Group as DropdownMenuGroup, - Item as DropdownMenuItem, - Label as DropdownMenuLabel, - Separator as DropdownMenuSeparator, - Sub as DropdownMenuSub, - Trigger as DropdownMenuTrigger, - Group, - Item, - Label, - Root, - Separator, - Sub, - Trigger + CheckboxGroup, + CheckboxItem, + Content, + Root as DropdownMenu, + CheckboxGroup as DropdownMenuCheckboxGroup, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + Separator as DropdownMenuSeparator, + Sub as DropdownMenuSub, + Trigger as DropdownMenuTrigger, + Group, + Item, + Label, + Root, + Separator, + Sub, + Trigger }; diff --git a/frontend/src/lib/components/ui/editable-field/EditableMultiSelect.svelte b/frontend/src/lib/components/ui/editable-field/EditableMultiSelect.svelte index ba1f7cce..9a8e8c70 100644 --- a/frontend/src/lib/components/ui/editable-field/EditableMultiSelect.svelte +++ b/frontend/src/lib/components/ui/editable-field/EditableMultiSelect.svelte @@ -1,142 +1,142 @@
- - - - {/if} - - {/each} - {#if remainingCount > 0} - +{remainingCount} - {/if} - {/if} -
- - - - - {#if options.length === 0} -
No options available
- {:else} - - {#each options as opt (opt.id)} - toggleOption(opt.id)} - class="" - > -
- {opt.name} - {#if opt.email} - {opt.email} - {/if} -
-
- {/each} -
- {/if} -
- + + + + {/if} + + {/each} + {#if remainingCount > 0} + +{remainingCount} + {/if} + {/if} + + + + + + {#if options.length === 0} +
No options available
+ {:else} + + {#each options as opt (opt.id)} + toggleOption(opt.id)} + class="" + > +
+ {opt.name} + {#if opt.email} + {opt.email} + {/if} +
+
+ {/each} +
+ {/if} +
+
diff --git a/frontend/src/lib/components/ui/filter/DateRangeFilter.svelte b/frontend/src/lib/components/ui/filter/DateRangeFilter.svelte index 8364bfd7..874886da 100644 --- a/frontend/src/lib/components/ui/filter/DateRangeFilter.svelte +++ b/frontend/src/lib/components/ui/filter/DateRangeFilter.svelte @@ -1,231 +1,239 @@
- {#if label} - {label} - {/if} - { if (!o) selectingEnd = false; }}> - - {#snippet child({ props })} - - {/snippet} - - -
- -
- {#each presets as preset} - - {/each} -
- -
-
- {#if selectingEnd} - Select end date - {:else} - Select start date - {/if} -
- - {#if startDate && !endDate} -
- Start: {formatDate(startDate)} -
- {/if} -
-
-
-
+ {#if label} + {label} + {/if} + { + if (!o) selectingEnd = false; + }} + > + + {#snippet child({ props })} + + {/snippet} + + +
+ +
+ {#each presets as preset} + + {/each} +
+ +
+
+ {#if selectingEnd} + Select end date + {:else} + Select start date + {/if} +
+ + {#if startDate && !endDate} +
+ Start: {formatDate(startDate)} +
+ {/if} +
+
+
+
diff --git a/frontend/src/lib/components/ui/filter/FilterBar.svelte b/frontend/src/lib/components/ui/filter/FilterBar.svelte index 8fcb5421..0df25850 100644 --- a/frontend/src/lib/components/ui/filter/FilterBar.svelte +++ b/frontend/src/lib/components/ui/filter/FilterBar.svelte @@ -1,90 +1,90 @@ {#if minimal} - - {#if isExpanded} -
- {@render children?.()} - {#if activeCount > 0 && onClear} - - {/if} -
- {/if} + + {#if isExpanded} +
+ {@render children?.()} + {#if activeCount > 0 && onClear} + + {/if} +
+ {/if} {:else} -
- -
- - {#if activeCount > 0 && onClear} - - {/if} -
+
+ +
+ + {#if activeCount > 0 && onClear} + + {/if} +
- - {#if !collapsible || isExpanded} -
-
- {@render children?.()} -
-
- {/if} -
+ + {#if !collapsible || isExpanded} +
+
+ {@render children?.()} +
+
+ {/if} +
{/if} diff --git a/frontend/src/lib/components/ui/filter/SearchInput.svelte b/frontend/src/lib/components/ui/filter/SearchInput.svelte index f5843374..3d74a86e 100644 --- a/frontend/src/lib/components/ui/filter/SearchInput.svelte +++ b/frontend/src/lib/components/ui/filter/SearchInput.svelte @@ -1,92 +1,92 @@
- - - {#if inputValue} - - {/if} + + + {#if inputValue} + + {/if}
diff --git a/frontend/src/lib/components/ui/filter/SelectFilter.svelte b/frontend/src/lib/components/ui/filter/SelectFilter.svelte index fd82b132..5d2d53e3 100644 --- a/frontend/src/lib/components/ui/filter/SelectFilter.svelte +++ b/frontend/src/lib/components/ui/filter/SelectFilter.svelte @@ -1,192 +1,192 @@
- {#if label} - {label} - {/if} - - - {#snippet child({ props })} - - {/snippet} - - - {#if !multiple} - - {/if} - {#each options as option} - {@const isSelected = multiple - ? selectedValues.includes(option.value) - : value === option.value} - - {/each} - - + {#if label} + {label} + {/if} + + + {#snippet child({ props })} + + {/snippet} + + + {#if !multiple} + + {/if} + {#each options as option} + {@const isSelected = multiple + ? selectedValues.includes(option.value) + : value === option.value} + + {/each} + +
{#if multiple && selectedValues.length > 0} -
- {#each selectedValues as val} - {@const opt = options.find((o) => o.value === val)} - - {opt?.label || val} - - - {/each} -
+
+ {#each selectedValues as val} + {@const opt = options.find((o) => o.value === val)} + + {opt?.label || val} + + + {/each} +
{/if} diff --git a/frontend/src/lib/components/ui/filter/TagFilter.svelte b/frontend/src/lib/components/ui/filter/TagFilter.svelte index 57df4816..a8ea7ecb 100644 --- a/frontend/src/lib/components/ui/filter/TagFilter.svelte +++ b/frontend/src/lib/components/ui/filter/TagFilter.svelte @@ -1,152 +1,156 @@
- {#if label} - {label} - {/if} - - - {#snippet child({ props })} - - {/snippet} - - - {#if tags.length === 0} -

No tags available

- {:else} - {#each tags as tag (tag.id)} - {@const isSelected = selectedIds.includes(tag.id)} - - {/each} - {/if} -
-
+ {#if label} + {label} + {/if} + + + {#snippet child({ props })} + + {/snippet} + + + {#if tags.length === 0} +

No tags available

+ {:else} + {#each tags as tag (tag.id)} + {@const isSelected = selectedIds.includes(tag.id)} + + {/each} + {/if} +
+
{#if selectedTags.length > 0} -
- {#each selectedTags as tag (tag.id)} - - {tag.name} - - - {/each} -
+
+ {#each selectedTags as tag (tag.id)} + + {tag.name} + + + {/each} +
{/if} diff --git a/frontend/src/lib/components/ui/input/index.js b/frontend/src/lib/components/ui/input/index.js index 15c0933d..be0783a3 100644 --- a/frontend/src/lib/components/ui/input/index.js +++ b/frontend/src/lib/components/ui/input/index.js @@ -1,7 +1,7 @@ import Root from './input.svelte'; export { - Root, - // - Root as Input + Root, + // + Root as Input }; diff --git a/frontend/src/lib/components/ui/input/input.svelte b/frontend/src/lib/components/ui/input/input.svelte index afaaeb81..4b435533 100644 --- a/frontend/src/lib/components/ui/input/input.svelte +++ b/frontend/src/lib/components/ui/input/input.svelte @@ -1,55 +1,55 @@ {#if type === 'file'} - + {:else} - + {/if} diff --git a/frontend/src/lib/components/ui/kanban/KanbanBoard.svelte b/frontend/src/lib/components/ui/kanban/KanbanBoard.svelte index ff2aaf52..29f96056 100644 --- a/frontend/src/lib/components/ui/kanban/KanbanBoard.svelte +++ b/frontend/src/lib/components/ui/kanban/KanbanBoard.svelte @@ -1,295 +1,291 @@ {#if loading} -
- -
+
+ +
{:else if !data || sortedColumns.length === 0} -
-

{emptyMessage}

-
+
+

{emptyMessage}

+
{:else} - - + + - -
- -
- -
+ +
+ +
+ +
- - {#if activeMobileColumn} -
- {#each activeMobileColumn.items as item (item.id)} - {#if CardComponent} - onCardClick(item)} - /> - {:else} -
onCardClick(item)} - onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onCardClick(item)} - role="button" - tabindex="0" - > -
- {item.title || item.name || 'Untitled'} -
-
- {/if} - {/each} + + {#if activeMobileColumn} +
+ {#each activeMobileColumn.items as item (item.id)} + {#if CardComponent} + onCardClick(item)} /> + {:else} +
onCardClick(item)} + onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onCardClick(item)} + role="button" + tabindex="0" + > +
+ {item.title || item.name || 'Untitled'} +
+
+ {/if} + {/each} - {#if activeMobileColumn.items.length === 0} -
- No {itemNamePlural} in this column -
- {/if} -
- {/if} -
+ {#if activeMobileColumn.items.length === 0} +
+ No {itemNamePlural} in this column +
+ {/if} +
+ {/if} +
- -
- {totalCount} {totalCount !== 1 ? itemNamePlural : itemName} total -
+ +
+ {totalCount} + {totalCount !== 1 ? itemNamePlural : itemName} total +
{/if} diff --git a/frontend/src/lib/components/ui/kanban/KanbanColumn.svelte b/frontend/src/lib/components/ui/kanban/KanbanColumn.svelte index 363899a6..5ecbb886 100644 --- a/frontend/src/lib/components/ui/kanban/KanbanColumn.svelte +++ b/frontend/src/lib/components/ui/kanban/KanbanColumn.svelte @@ -1,164 +1,164 @@
- -
-

- {column.name} -

-
- - {itemCount} - - {#if column.wip_limit} - - /{column.wip_limit} - - {/if} -
-
+ +
+

+ {column.name} +

+
+ + {itemCount} + + {#if column.wip_limit} + + /{column.wip_limit} + + {/if} +
+
- -
- {#each column.items as item (item.id)} - {#if CardComponent} - onCardClick(item)} - ondragstart={(e) => onCardDragStart(e, item)} - ondragend={onCardDragEnd} - /> - {:else} - -
onCardClick(item)} - onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onCardClick(item)} - ondragstart={(e) => onCardDragStart(e, item)} - ondragend={onCardDragEnd} - role="button" - tabindex="0" - > -
- {item.title || item.name || 'Untitled'} -
-
- {/if} - {/each} + +
+ {#each column.items as item (item.id)} + {#if CardComponent} + onCardClick(item)} + ondragstart={(e) => onCardDragStart(e, item)} + ondragend={onCardDragEnd} + /> + {:else} + +
onCardClick(item)} + onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onCardClick(item)} + ondragstart={(e) => onCardDragStart(e, item)} + ondragend={onCardDragEnd} + role="button" + tabindex="0" + > +
+ {item.title || item.name || 'Untitled'} +
+
+ {/if} + {/each} - {#if column.items.length === 0} -
- No {itemName}s -
- {/if} -
+ {#if column.items.length === 0} +
+ No {itemName}s +
+ {/if} +
diff --git a/frontend/src/lib/components/ui/label/index.js b/frontend/src/lib/components/ui/label/index.js index 808d1415..d1907754 100644 --- a/frontend/src/lib/components/ui/label/index.js +++ b/frontend/src/lib/components/ui/label/index.js @@ -1,7 +1,7 @@ import Root from './label.svelte'; export { - Root, - // - Root as Label + Root, + // + Root as Label }; diff --git a/frontend/src/lib/components/ui/label/label.svelte b/frontend/src/lib/components/ui/label/label.svelte index 2d84d942..3675e6ea 100644 --- a/frontend/src/lib/components/ui/label/label.svelte +++ b/frontend/src/lib/components/ui/label/label.svelte @@ -1,16 +1,16 @@ diff --git a/frontend/src/lib/components/ui/lead-kanban/LeadCard.svelte b/frontend/src/lib/components/ui/lead-kanban/LeadCard.svelte index a5ebdae7..b89c23f7 100644 --- a/frontend/src/lib/components/ui/lead-kanban/LeadCard.svelte +++ b/frontend/src/lib/components/ui/lead-kanban/LeadCard.svelte @@ -1,177 +1,184 @@
- -
- {title} -
+ +
+ {title} +
- - {#if company} -
- - {company} -
- {/if} + + {#if company} +
+ + {company} +
+ {/if} - - {#if amount} -
- - {formatAmount(amount, currency)} -
- {/if} + + {#if amount} +
+ + {formatAmount(amount, currency)} +
+ {/if} - -
- {#if rating} - - {rating} - - {:else} - - {/if} + +
+ {#if rating} + + {rating} + + {:else} + + {/if} - {#if followUp} - - - {formatDate(followUp)} - - {/if} -
+ {#if followUp} + + + {formatDate(followUp)} + + {/if} +
- - {#if assignees.length > 0} -
- {#each assignees.slice(0, 3) as assignee (assignee.id)} -
- {getAssigneeInitials(assignee)} -
- {/each} - {#if assignees.length > 3} -
- +{assignees.length - 3} -
- {/if} -
- {/if} + + {#if assignees.length > 0} +
+ {#each assignees.slice(0, 3) as assignee (assignee.id)} +
+ {getAssigneeInitials(assignee)} +
+ {/each} + {#if assignees.length > 3} +
+ +{assignees.length - 3} +
+ {/if} +
+ {/if}
diff --git a/frontend/src/lib/components/ui/lead-kanban/LeadKanban.svelte b/frontend/src/lib/components/ui/lead-kanban/LeadKanban.svelte index c98405f2..3bc71430 100644 --- a/frontend/src/lib/components/ui/lead-kanban/LeadKanban.svelte +++ b/frontend/src/lib/components/ui/lead-kanban/LeadKanban.svelte @@ -1,62 +1,62 @@ diff --git a/frontend/src/lib/components/ui/pagination/Pagination.svelte b/frontend/src/lib/components/ui/pagination/Pagination.svelte index 9d785fc5..819293e5 100644 --- a/frontend/src/lib/components/ui/pagination/Pagination.svelte +++ b/frontend/src/lib/components/ui/pagination/Pagination.svelte @@ -1,138 +1,137 @@ {#if total > 0} -
- -
-

- Showing {startItem} - to {endItem} - of {total} results -

-
+
+ +
+

+ Showing {startItem} + to {endItem} + of {total} results +

+
- -
- -
- Rows per page: - - - {#snippet child({ props })} - - {/snippet} - - - {#each limitOptions as option} - - {/each} - - -
+ +
+ +
+ Rows per page: + + + {#snippet child({ props })} + + {/snippet} + + + {#each limitOptions as option} + + {/each} + + +
- -
- + +
+ - - Page {page} of {totalPages} - + + Page {page} of + {totalPages} + - -
-
-
+ +
+
+
{/if} diff --git a/frontend/src/lib/components/ui/popover/index.js b/frontend/src/lib/components/ui/popover/index.js index 2f6d5cbf..3d237eb1 100644 --- a/frontend/src/lib/components/ui/popover/index.js +++ b/frontend/src/lib/components/ui/popover/index.js @@ -1,17 +1,17 @@ -import { Popover as PopoverPrimitive } from "bits-ui"; -import Content from "./popover-content.svelte"; -import Trigger from "./popover-trigger.svelte"; +import { Popover as PopoverPrimitive } from 'bits-ui'; +import Content from './popover-content.svelte'; +import Trigger from './popover-trigger.svelte'; const Root = PopoverPrimitive.Root; const Close = PopoverPrimitive.Close; export { - Root, - Content, - Trigger, - Close, - // - Root as Popover, - Content as PopoverContent, - Trigger as PopoverTrigger, - Close as PopoverClose, -}; \ No newline at end of file + Root, + Content, + Trigger, + Close, + // + Root as Popover, + Content as PopoverContent, + Trigger as PopoverTrigger, + Close as PopoverClose +}; diff --git a/frontend/src/lib/components/ui/popover/popover-content.svelte b/frontend/src/lib/components/ui/popover/popover-content.svelte index 7a8855aa..14747162 100644 --- a/frontend/src/lib/components/ui/popover/popover-content.svelte +++ b/frontend/src/lib/components/ui/popover/popover-content.svelte @@ -1,37 +1,37 @@ - - \ No newline at end of file + + diff --git a/frontend/src/lib/components/ui/popover/popover-trigger.svelte b/frontend/src/lib/components/ui/popover/popover-trigger.svelte index 4dd286b8..778d32f4 100644 --- a/frontend/src/lib/components/ui/popover/popover-trigger.svelte +++ b/frontend/src/lib/components/ui/popover/popover-trigger.svelte @@ -1,17 +1,13 @@ \ No newline at end of file + bind:ref + data-slot="popover-trigger" + class={cn('', className)} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/progress/index.js b/frontend/src/lib/components/ui/progress/index.js index 1ebbb6b0..8685886f 100644 --- a/frontend/src/lib/components/ui/progress/index.js +++ b/frontend/src/lib/components/ui/progress/index.js @@ -1,7 +1,7 @@ -import Root from "./progress.svelte"; +import Root from './progress.svelte'; export { - Root, - // - Root as Progress, -}; \ No newline at end of file + Root, + // + Root as Progress +}; diff --git a/frontend/src/lib/components/ui/progress/progress.svelte b/frontend/src/lib/components/ui/progress/progress.svelte index 266abaa5..6a13a23d 100644 --- a/frontend/src/lib/components/ui/progress/progress.svelte +++ b/frontend/src/lib/components/ui/progress/progress.svelte @@ -1,27 +1,21 @@ -
-
\ No newline at end of file +
+ diff --git a/frontend/src/lib/components/ui/select/index.js b/frontend/src/lib/components/ui/select/index.js index a3d99fbf..312f827c 100644 --- a/frontend/src/lib/components/ui/select/index.js +++ b/frontend/src/lib/components/ui/select/index.js @@ -1,37 +1,37 @@ -import Root from "./select.svelte"; -import Group from "./select-group.svelte"; -import Label from "./select-label.svelte"; -import Item from "./select-item.svelte"; -import Content from "./select-content.svelte"; -import Trigger from "./select-trigger.svelte"; -import Separator from "./select-separator.svelte"; -import ScrollDownButton from "./select-scroll-down-button.svelte"; -import ScrollUpButton from "./select-scroll-up-button.svelte"; -import GroupHeading from "./select-group-heading.svelte"; -import Portal from "./select-portal.svelte"; +import Root from './select.svelte'; +import Group from './select-group.svelte'; +import Label from './select-label.svelte'; +import Item from './select-item.svelte'; +import Content from './select-content.svelte'; +import Trigger from './select-trigger.svelte'; +import Separator from './select-separator.svelte'; +import ScrollDownButton from './select-scroll-down-button.svelte'; +import ScrollUpButton from './select-scroll-up-button.svelte'; +import GroupHeading from './select-group-heading.svelte'; +import Portal from './select-portal.svelte'; export { - Root, - Group, - Label, - Item, - Content, - Trigger, - Separator, - ScrollDownButton, - ScrollUpButton, - GroupHeading, - Portal, - // - Root as Select, - Group as SelectGroup, - Label as SelectLabel, - Item as SelectItem, - Content as SelectContent, - Trigger as SelectTrigger, - Separator as SelectSeparator, - ScrollDownButton as SelectScrollDownButton, - ScrollUpButton as SelectScrollUpButton, - GroupHeading as SelectGroupHeading, - Portal as SelectPortal, -}; \ No newline at end of file + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + Portal, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, + Portal as SelectPortal +}; diff --git a/frontend/src/lib/components/ui/select/select-content.svelte b/frontend/src/lib/components/ui/select/select-content.svelte index d33b7956..862948ad 100644 --- a/frontend/src/lib/components/ui/select/select-content.svelte +++ b/frontend/src/lib/components/ui/select/select-content.svelte @@ -1,40 +1,40 @@ - - - - {@render children?.()} - - - - \ No newline at end of file + + + + {@render children?.()} + + + + diff --git a/frontend/src/lib/components/ui/select/select-group-heading.svelte b/frontend/src/lib/components/ui/select/select-group-heading.svelte index cfb1dc0d..eef826ba 100644 --- a/frontend/src/lib/components/ui/select/select-group-heading.svelte +++ b/frontend/src/lib/components/ui/select/select-group-heading.svelte @@ -1,19 +1,14 @@ - {@render children?.()} - \ No newline at end of file + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/select/select-group.svelte b/frontend/src/lib/components/ui/select/select-group.svelte index fc3b4974..45729701 100644 --- a/frontend/src/lib/components/ui/select/select-group.svelte +++ b/frontend/src/lib/components/ui/select/select-group.svelte @@ -1,7 +1,7 @@ - \ No newline at end of file + diff --git a/frontend/src/lib/components/ui/select/select-item.svelte b/frontend/src/lib/components/ui/select/select-item.svelte index e191d703..6e13afdd 100644 --- a/frontend/src/lib/components/ui/select/select-item.svelte +++ b/frontend/src/lib/components/ui/select/select-item.svelte @@ -1,38 +1,38 @@ - {#snippet children({ selected, highlighted })} - - {#if selected} - - {/if} - - {#if childrenProp} - {@render childrenProp({ selected, highlighted })} - {:else} - {label || value} - {/if} - {/snippet} - \ No newline at end of file + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/frontend/src/lib/components/ui/select/select-label.svelte b/frontend/src/lib/components/ui/select/select-label.svelte index 1b21a800..063bebb2 100644 --- a/frontend/src/lib/components/ui/select/select-label.svelte +++ b/frontend/src/lib/components/ui/select/select-label.svelte @@ -1,18 +1,13 @@
- {@render children?.()} -
\ No newline at end of file + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/select/select-portal.svelte b/frontend/src/lib/components/ui/select/select-portal.svelte index 2e172774..6f23aeda 100644 --- a/frontend/src/lib/components/ui/select/select-portal.svelte +++ b/frontend/src/lib/components/ui/select/select-portal.svelte @@ -1,7 +1,7 @@ - \ No newline at end of file + diff --git a/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte index 960a442a..7504c80b 100644 --- a/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte +++ b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -1,20 +1,16 @@ - - \ No newline at end of file + + diff --git a/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte index 1ff0b14b..34daaf11 100644 --- a/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte +++ b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -1,20 +1,16 @@ - - \ No newline at end of file + + diff --git a/frontend/src/lib/components/ui/select/select-separator.svelte b/frontend/src/lib/components/ui/select/select-separator.svelte index 37a047b7..c9dee2d9 100644 --- a/frontend/src/lib/components/ui/select/select-separator.svelte +++ b/frontend/src/lib/components/ui/select/select-separator.svelte @@ -1,17 +1,13 @@ \ No newline at end of file + bind:ref + data-slot="select-separator" + class={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/select/select-trigger.svelte b/frontend/src/lib/components/ui/select/select-trigger.svelte index 9c07c29a..fed084c4 100644 --- a/frontend/src/lib/components/ui/select/select-trigger.svelte +++ b/frontend/src/lib/components/ui/select/select-trigger.svelte @@ -1,27 +1,27 @@ - {@render children?.()} - - \ No newline at end of file + {@render children?.()} + + diff --git a/frontend/src/lib/components/ui/select/select.svelte b/frontend/src/lib/components/ui/select/select.svelte index bda449f0..80f9cec4 100644 --- a/frontend/src/lib/components/ui/select/select.svelte +++ b/frontend/src/lib/components/ui/select/select.svelte @@ -1,12 +1,12 @@ - \ No newline at end of file + diff --git a/frontend/src/lib/components/ui/separator/index.js b/frontend/src/lib/components/ui/separator/index.js index 51e3fd5e..2402e13e 100644 --- a/frontend/src/lib/components/ui/separator/index.js +++ b/frontend/src/lib/components/ui/separator/index.js @@ -1,7 +1,7 @@ -import Root from "./separator.svelte"; +import Root from './separator.svelte'; export { - Root, - // - Root as Separator, -}; \ No newline at end of file + Root, + // + Root as Separator +}; diff --git a/frontend/src/lib/components/ui/separator/separator.svelte b/frontend/src/lib/components/ui/separator/separator.svelte index 30a13c92..b7629d7b 100644 --- a/frontend/src/lib/components/ui/separator/separator.svelte +++ b/frontend/src/lib/components/ui/separator/separator.svelte @@ -1,21 +1,21 @@ \ No newline at end of file + bind:ref + data-slot={dataSlot} + class={cn( + 'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px', + className + )} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/sheet/index.js b/frontend/src/lib/components/ui/sheet/index.js index 713abb85..d83a16aa 100644 --- a/frontend/src/lib/components/ui/sheet/index.js +++ b/frontend/src/lib/components/ui/sheet/index.js @@ -9,19 +9,19 @@ const Root = SheetPrimitive.Root; const Portal = SheetPrimitive.Portal; export { - Root, - Portal, - Overlay, - Content, - Header, - Title, - Description, - // - Root as Sheet, - Portal as SheetPortal, - Overlay as SheetOverlay, - Content as SheetContent, - Header as SheetHeader, - Title as SheetTitle, - Description as SheetDescription + Root, + Portal, + Overlay, + Content, + Header, + Title, + Description, + // + Root as Sheet, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Title as SheetTitle, + Description as SheetDescription }; diff --git a/frontend/src/lib/components/ui/sheet/sheet-content.svelte b/frontend/src/lib/components/ui/sheet/sheet-content.svelte index 909831f0..8b7b6d9a 100644 --- a/frontend/src/lib/components/ui/sheet/sheet-content.svelte +++ b/frontend/src/lib/components/ui/sheet/sheet-content.svelte @@ -1,56 +1,56 @@ - - - {@render children?.()} - + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/sheet/sheet-description.svelte b/frontend/src/lib/components/ui/sheet/sheet-description.svelte index 590de8a1..bdf5824c 100644 --- a/frontend/src/lib/components/ui/sheet/sheet-description.svelte +++ b/frontend/src/lib/components/ui/sheet/sheet-description.svelte @@ -1,21 +1,21 @@ diff --git a/frontend/src/lib/components/ui/sheet/sheet-header.svelte b/frontend/src/lib/components/ui/sheet/sheet-header.svelte index 959e5859..ee80cff8 100644 --- a/frontend/src/lib/components/ui/sheet/sheet-header.svelte +++ b/frontend/src/lib/components/ui/sheet/sheet-header.svelte @@ -1,22 +1,22 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/sheet/sheet-overlay.svelte b/frontend/src/lib/components/ui/sheet/sheet-overlay.svelte index 18950638..42580d5f 100644 --- a/frontend/src/lib/components/ui/sheet/sheet-overlay.svelte +++ b/frontend/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -1,23 +1,23 @@ diff --git a/frontend/src/lib/components/ui/sheet/sheet-title.svelte b/frontend/src/lib/components/ui/sheet/sheet-title.svelte index aef254df..6ce651d5 100644 --- a/frontend/src/lib/components/ui/sheet/sheet-title.svelte +++ b/frontend/src/lib/components/ui/sheet/sheet-title.svelte @@ -1,21 +1,21 @@ diff --git a/frontend/src/lib/components/ui/sidebar/context.svelte.js b/frontend/src/lib/components/ui/sidebar/context.svelte.js index 218e9930..5d5c7b6e 100644 --- a/frontend/src/lib/components/ui/sidebar/context.svelte.js +++ b/frontend/src/lib/components/ui/sidebar/context.svelte.js @@ -3,40 +3,40 @@ import { getContext, setContext } from 'svelte'; import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants.js'; class SidebarState { - props; - open = $derived.by(() => this.props.open()); - openMobile = $state(false); - setOpen; - #isMobile; - state = $derived.by(() => (this.open ? 'expanded' : 'collapsed')); + props; + open = $derived.by(() => this.props.open()); + openMobile = $state(false); + setOpen; + #isMobile; + state = $derived.by(() => (this.open ? 'expanded' : 'collapsed')); - constructor(props) { - this.setOpen = props.setOpen; - this.#isMobile = new IsMobile(); - this.props = props; - } + constructor(props) { + this.setOpen = props.setOpen; + this.#isMobile = new IsMobile(); + this.props = props; + } - // Convenience getter for checking if the sidebar is mobile - // without this, we would need to use `sidebar.isMobile.current` everywhere - get isMobile() { - return this.#isMobile.current; - } + // Convenience getter for checking if the sidebar is mobile + // without this, we would need to use `sidebar.isMobile.current` everywhere + get isMobile() { + return this.#isMobile.current; + } - // Event handler to apply to the `` - handleShortcutKeydown = (e) => { - if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - this.toggle(); - } - }; + // Event handler to apply to the `` + handleShortcutKeydown = (e) => { + if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + } + }; - setOpenMobile = (value) => { - this.openMobile = value; - }; + setOpenMobile = (value) => { + this.openMobile = value; + }; - toggle = () => { - return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open); - }; + toggle = () => { + return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open); + }; } const SYMBOL_KEY = 'scn-sidebar'; @@ -48,7 +48,7 @@ const SYMBOL_KEY = 'scn-sidebar'; * @returns The `SidebarState` instance. */ export function setSidebar(props) { - return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); + return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); } /** @@ -57,5 +57,5 @@ export function setSidebar(props) { * @returns The `SidebarState` instance. */ export function useSidebar() { - return getContext(Symbol.for(SYMBOL_KEY)); + return getContext(Symbol.for(SYMBOL_KEY)); } diff --git a/frontend/src/lib/components/ui/sidebar/index.js b/frontend/src/lib/components/ui/sidebar/index.js index 3cb58711..320030f0 100644 --- a/frontend/src/lib/components/ui/sidebar/index.js +++ b/frontend/src/lib/components/ui/sidebar/index.js @@ -17,38 +17,38 @@ import Rail from './sidebar-rail.svelte'; import Root from './sidebar.svelte'; export { - Content, - Footer, - Group, - GroupContent, - GroupLabel, - Header, - Inset, - Menu, - MenuButton, - MenuItem, - MenuSub, - MenuSubButton, - MenuSubItem, - Provider, - Rail, - Root, - // - Root as Sidebar, - Content as SidebarContent, - Footer as SidebarFooter, - Group as SidebarGroup, - GroupContent as SidebarGroupContent, - GroupLabel as SidebarGroupLabel, - Header as SidebarHeader, - Inset as SidebarInset, - Menu as SidebarMenu, - MenuButton as SidebarMenuButton, - MenuItem as SidebarMenuItem, - MenuSub as SidebarMenuSub, - MenuSubButton as SidebarMenuSubButton, - MenuSubItem as SidebarMenuSubItem, - Provider as SidebarProvider, - Rail as SidebarRail, - useSidebar + Content, + Footer, + Group, + GroupContent, + GroupLabel, + Header, + Inset, + Menu, + MenuButton, + MenuItem, + MenuSub, + MenuSubButton, + MenuSubItem, + Provider, + Rail, + Root, + // + Root as Sidebar, + Content as SidebarContent, + Footer as SidebarFooter, + Group as SidebarGroup, + GroupContent as SidebarGroupContent, + GroupLabel as SidebarGroupLabel, + Header as SidebarHeader, + Inset as SidebarInset, + Menu as SidebarMenu, + MenuButton as SidebarMenuButton, + MenuItem as SidebarMenuItem, + MenuSub as SidebarMenuSub, + MenuSubButton as SidebarMenuSubButton, + MenuSubItem as SidebarMenuSubItem, + Provider as SidebarProvider, + Rail as SidebarRail, + useSidebar }; diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-content.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-content.svelte index 0f2dbf0f..d66c2d1d 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-content.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-content.svelte @@ -1,26 +1,26 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte index 29238f15..6077f10b 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte @@ -1,23 +1,23 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-group-content.svelte index 2236ab9b..46b42620 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-group-content.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-group-content.svelte @@ -1,22 +1,22 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-group-label.svelte index ce7943ce..93bd6e25 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-group-label.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-group-label.svelte @@ -1,32 +1,32 @@ {#if child} - {@render child({ props: mergedProps })} + {@render child({ props: mergedProps })} {:else} -
- {@render children?.()} -
+
+ {@render children?.()} +
{/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-group.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-group.svelte index 931f1024..b238a0bb 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-group.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-group.svelte @@ -1,23 +1,23 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-header.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-header.svelte index 6f11c6aa..8e873951 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-header.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-header.svelte @@ -1,23 +1,23 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-inset.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-inset.svelte index bc33527a..3c33b775 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-inset.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-inset.svelte @@ -1,17 +1,17 @@
- {@render children?.()} + {@render children?.()}
diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte index 53f1e6ee..c9243c6a 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -1,103 +1,103 @@ {#snippet Button({ props })} - {@const mergedProps = mergeProps(buttonProps, props)} - {#if child} - {@render child({ props: mergedProps })} - {:else} - - {/if} + {@const mergedProps = mergeProps(buttonProps, props)} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} {/snippet} {#if !tooltipContent} - {@render Button({ props: {} })} + {@render Button({ props: {} })} {:else} - - - {#snippet child({ props })} - {@render Button({ props })} - {/snippet} - - - + + + {#snippet child({ props })} + {@render Button({ props })} + {/snippet} + + + {/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-item.svelte index 8387b5e8..6bc89ac3 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-menu-item.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-item.svelte @@ -1,22 +1,22 @@
  • - {@render children?.()} + {@render children?.()}
  • diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte index 52bf51a1..de76d374 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -1,47 +1,47 @@ {#if child} - {@render child({ props: mergedProps })} + {@render child({ props: mergedProps })} {:else} - - {@render children?.()} - + + {@render children?.()} + {/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte index d662317a..1ffbccb5 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte @@ -1,22 +1,22 @@
  • - {@render children?.()} + {@render children?.()}
  • diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte index 62b46572..8e1ca5e1 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte @@ -1,26 +1,26 @@
      - {@render children?.()} + {@render children?.()}
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu.svelte index 05c3be2c..7b35ae6e 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-menu.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu.svelte @@ -1,22 +1,22 @@
      - {@render children?.()} + {@render children?.()}
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-provider.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-provider.svelte index b26b6d64..cac3cc97 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-provider.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-provider.svelte @@ -1,49 +1,49 @@ -
    - {@render children?.()} -
    +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte index d26d1f7b..1d796e12 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte @@ -1,38 +1,38 @@ diff --git a/frontend/src/lib/components/ui/sidebar/sidebar.svelte b/frontend/src/lib/components/ui/sidebar/sidebar.svelte index cb85cede..22aa2850 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar.svelte @@ -1,107 +1,107 @@ {#if collapsible === 'none'} -
    - {@render children?.()} -
    +
    + {@render children?.()} +
    {:else if sidebar.isMobile} - sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}> - - - Sidebar - Displays the mobile sidebar. - -
    - {@render children?.()} -
    -
    -
    + sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}> + + + Sidebar + Displays the mobile sidebar. + +
    + {@render children?.()} +
    +
    +
    {:else} - + {/if} diff --git a/frontend/src/lib/components/ui/skeleton/index.js b/frontend/src/lib/components/ui/skeleton/index.js index 3120ce12..df169ced 100644 --- a/frontend/src/lib/components/ui/skeleton/index.js +++ b/frontend/src/lib/components/ui/skeleton/index.js @@ -1,7 +1,7 @@ import Root from './skeleton.svelte'; export { - Root, - // - Root as Skeleton + Root, + // + Root as Skeleton }; diff --git a/frontend/src/lib/components/ui/skeleton/skeleton.svelte b/frontend/src/lib/components/ui/skeleton/skeleton.svelte index c841963d..26f1cd43 100644 --- a/frontend/src/lib/components/ui/skeleton/skeleton.svelte +++ b/frontend/src/lib/components/ui/skeleton/skeleton.svelte @@ -1,11 +1,11 @@
    diff --git a/frontend/src/lib/components/ui/sonner/sonner.svelte b/frontend/src/lib/components/ui/sonner/sonner.svelte index 53d85b33..b08248da 100644 --- a/frontend/src/lib/components/ui/sonner/sonner.svelte +++ b/frontend/src/lib/components/ui/sonner/sonner.svelte @@ -1,13 +1,13 @@ diff --git a/frontend/src/lib/components/ui/table/index.js b/frontend/src/lib/components/ui/table/index.js index 931902c1..4aaee157 100644 --- a/frontend/src/lib/components/ui/table/index.js +++ b/frontend/src/lib/components/ui/table/index.js @@ -6,17 +6,17 @@ import Header from './table-header.svelte'; import Row from './table-row.svelte'; export { - Root, - Body, - Cell, - Head, - Header, - Row, - // - Root as Table, - Body as TableBody, - Cell as TableCell, - Head as TableHead, - Header as TableHeader, - Row as TableRow + Root, + Body, + Cell, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Cell as TableCell, + Head as TableHead, + Header as TableHeader, + Row as TableRow }; diff --git a/frontend/src/lib/components/ui/table/table-body.svelte b/frontend/src/lib/components/ui/table/table-body.svelte index 701d3a7d..3b1a81b9 100644 --- a/frontend/src/lib/components/ui/table/table-body.svelte +++ b/frontend/src/lib/components/ui/table/table-body.svelte @@ -1,21 +1,21 @@ - {@render children?.()} + {@render children?.()} diff --git a/frontend/src/lib/components/ui/table/table-cell.svelte b/frontend/src/lib/components/ui/table/table-cell.svelte index d97f7b92..f8f29e14 100644 --- a/frontend/src/lib/components/ui/table/table-cell.svelte +++ b/frontend/src/lib/components/ui/table/table-cell.svelte @@ -1,24 +1,24 @@ - {@render children?.()} + {@render children?.()} diff --git a/frontend/src/lib/components/ui/table/table-head.svelte b/frontend/src/lib/components/ui/table/table-head.svelte index ca0be1ef..2b2f725f 100644 --- a/frontend/src/lib/components/ui/table/table-head.svelte +++ b/frontend/src/lib/components/ui/table/table-head.svelte @@ -1,24 +1,24 @@ - {@render children?.()} + {@render children?.()} diff --git a/frontend/src/lib/components/ui/table/table-header.svelte b/frontend/src/lib/components/ui/table/table-header.svelte index 94bd3ef2..e2a89e27 100644 --- a/frontend/src/lib/components/ui/table/table-header.svelte +++ b/frontend/src/lib/components/ui/table/table-header.svelte @@ -1,21 +1,21 @@ - {@render children?.()} + {@render children?.()} diff --git a/frontend/src/lib/components/ui/table/table-row.svelte b/frontend/src/lib/components/ui/table/table-row.svelte index b555267c..5020dc36 100644 --- a/frontend/src/lib/components/ui/table/table-row.svelte +++ b/frontend/src/lib/components/ui/table/table-row.svelte @@ -1,24 +1,24 @@ svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', - className - )} - {...restProps} + bind:this={ref} + data-slot="table-row" + class={cn( + 'hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', + className + )} + {...restProps} > - {@render children?.()} + {@render children?.()} diff --git a/frontend/src/lib/components/ui/table/table.svelte b/frontend/src/lib/components/ui/table/table.svelte index affeafb3..861b54cf 100644 --- a/frontend/src/lib/components/ui/table/table.svelte +++ b/frontend/src/lib/components/ui/table/table.svelte @@ -1,24 +1,24 @@
    - - {@render children?.()} -
    + + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/tabs/index.js b/frontend/src/lib/components/ui/tabs/index.js index 60e33034..047a4ce6 100644 --- a/frontend/src/lib/components/ui/tabs/index.js +++ b/frontend/src/lib/components/ui/tabs/index.js @@ -1,16 +1,16 @@ -import Root from "./tabs.svelte"; -import Content from "./tabs-content.svelte"; -import List from "./tabs-list.svelte"; -import Trigger from "./tabs-trigger.svelte"; +import Root from './tabs.svelte'; +import Content from './tabs-content.svelte'; +import List from './tabs-list.svelte'; +import Trigger from './tabs-trigger.svelte'; export { - Root, - Content, - List, - Trigger, - // - Root as Tabs, - Content as TabsContent, - List as TabsList, - Trigger as TabsTrigger, -}; \ No newline at end of file + Root, + Content, + List, + Trigger, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger +}; diff --git a/frontend/src/lib/components/ui/tabs/tabs-content.svelte b/frontend/src/lib/components/ui/tabs/tabs-content.svelte index f7d228c8..75f2c524 100644 --- a/frontend/src/lib/components/ui/tabs/tabs-content.svelte +++ b/frontend/src/lib/components/ui/tabs/tabs-content.svelte @@ -1,19 +1,14 @@ \ No newline at end of file + bind:ref + data-slot="tabs-content" + {value} + class={cn('flex-1 outline-none', className)} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/tabs/tabs-list.svelte b/frontend/src/lib/components/ui/tabs/tabs-list.svelte index 3b839d73..74746481 100644 --- a/frontend/src/lib/components/ui/tabs/tabs-list.svelte +++ b/frontend/src/lib/components/ui/tabs/tabs-list.svelte @@ -1,20 +1,16 @@ \ No newline at end of file + bind:ref + data-slot="tabs-list" + class={cn( + 'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]', + className + )} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte index de235b5c..e124635f 100644 --- a/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte +++ b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -1,22 +1,17 @@ \ No newline at end of file + bind:ref + data-slot="tabs-trigger" + {value} + class={cn( + "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/tabs/tabs.svelte b/frontend/src/lib/components/ui/tabs/tabs.svelte index 65a71170..244b2bf9 100644 --- a/frontend/src/lib/components/ui/tabs/tabs.svelte +++ b/frontend/src/lib/components/ui/tabs/tabs.svelte @@ -1,19 +1,14 @@ \ No newline at end of file + bind:ref + bind:value + data-slot="tabs" + class={cn('flex flex-col gap-2', className)} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/task-kanban/TaskCard.svelte b/frontend/src/lib/components/ui/task-kanban/TaskCard.svelte index c4052c03..c2b568cb 100644 --- a/frontend/src/lib/components/ui/task-kanban/TaskCard.svelte +++ b/frontend/src/lib/components/ui/task-kanban/TaskCard.svelte @@ -1,161 +1,161 @@
    - -
    - {title} -
    + +
    + {title} +
    - - {#if relatedEntity} -
    - - - {entityLabels[relatedEntity.type] || relatedEntity.type}: {relatedEntity.name} - -
    - {/if} + + {#if relatedEntity} +
    + + + {entityLabels[relatedEntity.type] || relatedEntity.type}: {relatedEntity.name} + +
    + {/if} - -
    - {#if priority} - - {priority} - - {:else} - - {/if} + +
    + {#if priority} + + {priority} + + {:else} + + {/if} - {#if dueDate} - - {#if isOverdue} - - {:else} - - {/if} - {formatDate(dueDate)} - - {/if} -
    + {#if dueDate} + + {#if isOverdue} + + {:else} + + {/if} + {formatDate(dueDate)} + + {/if} +
    - - {#if assignees.length > 0} -
    - {#each assignees.slice(0, 3) as assignee (assignee.id)} -
    - {getAssigneeInitials(assignee)} -
    - {/each} - {#if assignees.length > 3} -
    - +{assignees.length - 3} -
    - {/if} -
    - {/if} + + {#if assignees.length > 0} +
    + {#each assignees.slice(0, 3) as assignee (assignee.id)} +
    + {getAssigneeInitials(assignee)} +
    + {/each} + {#if assignees.length > 3} +
    + +{assignees.length - 3} +
    + {/if} +
    + {/if}
    diff --git a/frontend/src/lib/components/ui/task-kanban/TaskKanban.svelte b/frontend/src/lib/components/ui/task-kanban/TaskKanban.svelte index 67d18b71..c1225e78 100644 --- a/frontend/src/lib/components/ui/task-kanban/TaskKanban.svelte +++ b/frontend/src/lib/components/ui/task-kanban/TaskKanban.svelte @@ -1,62 +1,62 @@ diff --git a/frontend/src/lib/components/ui/textarea/index.js b/frontend/src/lib/components/ui/textarea/index.js index 43a24c92..f802ddff 100644 --- a/frontend/src/lib/components/ui/textarea/index.js +++ b/frontend/src/lib/components/ui/textarea/index.js @@ -1,7 +1,7 @@ -import Root from "./textarea.svelte"; +import Root from './textarea.svelte'; export { - Root, - // - Root as Textarea, -}; \ No newline at end of file + Root, + // + Root as Textarea +}; diff --git a/frontend/src/lib/components/ui/textarea/textarea.svelte b/frontend/src/lib/components/ui/textarea/textarea.svelte index c29eabeb..ec14d221 100644 --- a/frontend/src/lib/components/ui/textarea/textarea.svelte +++ b/frontend/src/lib/components/ui/textarea/textarea.svelte @@ -1,21 +1,21 @@ \ No newline at end of file + bind:this={ref} + data-slot={dataSlot} + class={cn( + 'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', + className + )} + bind:value + {...restProps} +> diff --git a/frontend/src/lib/components/ui/tooltip/index.js b/frontend/src/lib/components/ui/tooltip/index.js index 273d831e..3edd0407 100644 --- a/frontend/src/lib/components/ui/tooltip/index.js +++ b/frontend/src/lib/components/ui/tooltip/index.js @@ -7,15 +7,15 @@ const Provider = TooltipPrimitive.Provider; const Portal = TooltipPrimitive.Portal; export { - Root, - Trigger, - Content, - Provider, - Portal, - // - Root as Tooltip, - Content as TooltipContent, - Trigger as TooltipTrigger, - Provider as TooltipProvider, - Portal as TooltipPortal + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal }; diff --git a/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte b/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte index 0acec144..a74b6f92 100644 --- a/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte +++ b/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -1,58 +1,58 @@ - - {@render children?.()} - - {#snippet child({ props })} -
    - {/snippet} -
    -
    + + {@render children?.()} + + {#snippet child({ props })} +
    + {/snippet} +
    +
    diff --git a/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte index 3945e47e..7363610a 100644 --- a/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte +++ b/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/ui/view-toggle/ViewToggle.svelte b/frontend/src/lib/components/ui/view-toggle/ViewToggle.svelte index 42e51c65..1d9a030a 100644 --- a/frontend/src/lib/components/ui/view-toggle/ViewToggle.svelte +++ b/frontend/src/lib/components/ui/view-toggle/ViewToggle.svelte @@ -1,53 +1,53 @@
    - - + +
    diff --git a/frontend/src/lib/components/users/TeamCard.svelte b/frontend/src/lib/components/users/TeamCard.svelte index b76a91de..1243971a 100644 --- a/frontend/src/lib/components/users/TeamCard.svelte +++ b/frontend/src/lib/components/users/TeamCard.svelte @@ -1,135 +1,135 @@ - -
    -
    -
    - -
    -
    - {team.name} -

    - {members.length} member{members.length !== 1 ? 's' : ''} -

    -
    -
    -
    - - - - - - - - Delete Team - - Are you sure you want to delete {team.name}? This will remove the - team from all assigned records. This action cannot be undone. - - - - Cancel - - - - -
    -
    -
    + +
    +
    +
    + +
    +
    + {team.name} +

    + {members.length} member{members.length !== 1 ? 's' : ''} +

    +
    +
    +
    + + + + + + + + Delete Team + + Are you sure you want to delete {team.name}? This will remove the + team from all assigned records. This action cannot be undone. + + + + Cancel + + + + +
    +
    +
    - {#if team.description} - -

    {team.description}

    -
    - {/if} + {#if team.description} + +

    {team.description}

    +
    + {/if} - -
    -
    - {#each displayMembers as member (member.id)} - - {#if getMemberAvatar(member)} - - {/if} - - {getInitials(getMemberName(member))} - - - {/each} - {#if remainingCount > 0} -
    - +{remainingCount} -
    - {/if} -
    - {#if members.length === 0} - No members assigned - {/if} -
    -
    + +
    +
    + {#each displayMembers as member (member.id)} + + {#if getMemberAvatar(member)} + + {/if} + + {getInitials(getMemberName(member))} + + + {/each} + {#if remainingCount > 0} +
    + +{remainingCount} +
    + {/if} +
    + {#if members.length === 0} + No members assigned + {/if} +
    +
    diff --git a/frontend/src/lib/components/users/TeamFormDialog.svelte b/frontend/src/lib/components/users/TeamFormDialog.svelte index aa031984..50fb4a61 100644 --- a/frontend/src/lib/components/users/TeamFormDialog.svelte +++ b/frontend/src/lib/components/users/TeamFormDialog.svelte @@ -1,152 +1,159 @@ !o && handleClose()}> - - - {dialogTitle} - - {isEditing - ? 'Update team details and member assignments.' - : 'Create a new team to group users for assignments.'} - - + + + {dialogTitle} + + {isEditing + ? 'Update team details and member assignments.' + : 'Create a new team to group users for assignments.'} + + -
    -
    - - -
    + +
    + + +
    -
    - - -
    +
    + + +
    -
    - - -

    - Team members will be able to access records assigned to this team. -

    -
    +
    + + +

    + Team members will be able to access records assigned to this team. +

    +
    - - - - -
    -
    + + + + + +
    diff --git a/frontend/src/lib/constants/colors.js b/frontend/src/lib/constants/colors.js index 98f83829..edc23fc9 100644 --- a/frontend/src/lib/constants/colors.js +++ b/frontend/src/lib/constants/colors.js @@ -13,96 +13,96 @@ /** @type {TagColor[]} */ export const TAG_COLORS = [ - { - value: 'gray', - label: 'Gray', - class: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' - }, - { - value: 'red', - label: 'Red', - class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' - }, - { - value: 'orange', - label: 'Orange', - class: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' - }, - { - value: 'amber', - label: 'Amber', - class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' - }, - { - value: 'yellow', - label: 'Yellow', - class: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' - }, - { - value: 'lime', - label: 'Lime', - class: 'bg-lime-100 text-lime-700 dark:bg-lime-900/30 dark:text-lime-400' - }, - { - value: 'green', - label: 'Green', - class: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' - }, - { - value: 'emerald', - label: 'Emerald', - class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' - }, - { - value: 'teal', - label: 'Teal', - class: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400' - }, - { - value: 'cyan', - label: 'Cyan', - class: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400' - }, - { - value: 'sky', - label: 'Sky', - class: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400' - }, - { - value: 'blue', - label: 'Blue', - class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' - }, - { - value: 'indigo', - label: 'Indigo', - class: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' - }, - { - value: 'violet', - label: 'Violet', - class: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' - }, - { - value: 'purple', - label: 'Purple', - class: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' - }, - { - value: 'fuchsia', - label: 'Fuchsia', - class: 'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-900/30 dark:text-fuchsia-400' - }, - { - value: 'pink', - label: 'Pink', - class: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400' - }, - { - value: 'rose', - label: 'Rose', - class: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400' - } + { + value: 'gray', + label: 'Gray', + class: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' + }, + { + value: 'red', + label: 'Red', + class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' + }, + { + value: 'orange', + label: 'Orange', + class: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + }, + { + value: 'amber', + label: 'Amber', + class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' + }, + { + value: 'yellow', + label: 'Yellow', + class: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + }, + { + value: 'lime', + label: 'Lime', + class: 'bg-lime-100 text-lime-700 dark:bg-lime-900/30 dark:text-lime-400' + }, + { + value: 'green', + label: 'Green', + class: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + }, + { + value: 'emerald', + label: 'Emerald', + class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' + }, + { + value: 'teal', + label: 'Teal', + class: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400' + }, + { + value: 'cyan', + label: 'Cyan', + class: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400' + }, + { + value: 'sky', + label: 'Sky', + class: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400' + }, + { + value: 'blue', + label: 'Blue', + class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + }, + { + value: 'indigo', + label: 'Indigo', + class: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' + }, + { + value: 'violet', + label: 'Violet', + class: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' + }, + { + value: 'purple', + label: 'Purple', + class: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' + }, + { + value: 'fuchsia', + label: 'Fuchsia', + class: 'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-900/30 dark:text-fuchsia-400' + }, + { + value: 'pink', + label: 'Pink', + class: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400' + }, + { + value: 'rose', + label: 'Rose', + class: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400' + } ]; /** @@ -111,6 +111,6 @@ export const TAG_COLORS = [ * @returns {string} Combined Tailwind classes */ export function getTagColorClass(color) { - const colorDef = TAG_COLORS.find((c) => c.value === color); - return colorDef?.class || TAG_COLORS[11].class; // Default to blue + const colorDef = TAG_COLORS.find((c) => c.value === color); + return colorDef?.class || TAG_COLORS[11].class; // Default to blue } diff --git a/frontend/src/lib/constants/countries.js b/frontend/src/lib/constants/countries.js index adad45e8..ea75a54d 100644 --- a/frontend/src/lib/constants/countries.js +++ b/frontend/src/lib/constants/countries.js @@ -3,252 +3,252 @@ * Matches Django backend COUNTRIES in common/utils.py */ export const COUNTRIES = [ - { code: 'GB', name: 'United Kingdom' }, - { code: 'AF', name: 'Afghanistan' }, - { code: 'AX', name: 'Aland Islands' }, - { code: 'AL', name: 'Albania' }, - { code: 'DZ', name: 'Algeria' }, - { code: 'AS', name: 'American Samoa' }, - { code: 'AD', name: 'Andorra' }, - { code: 'AO', name: 'Angola' }, - { code: 'AI', name: 'Anguilla' }, - { code: 'AQ', name: 'Antarctica' }, - { code: 'AG', name: 'Antigua and Barbuda' }, - { code: 'AR', name: 'Argentina' }, - { code: 'AM', name: 'Armenia' }, - { code: 'AW', name: 'Aruba' }, - { code: 'AU', name: 'Australia' }, - { code: 'AT', name: 'Austria' }, - { code: 'AZ', name: 'Azerbaijan' }, - { code: 'BS', name: 'Bahamas' }, - { code: 'BH', name: 'Bahrain' }, - { code: 'BD', name: 'Bangladesh' }, - { code: 'BB', name: 'Barbados' }, - { code: 'BY', name: 'Belarus' }, - { code: 'BE', name: 'Belgium' }, - { code: 'BZ', name: 'Belize' }, - { code: 'BJ', name: 'Benin' }, - { code: 'BM', name: 'Bermuda' }, - { code: 'BT', name: 'Bhutan' }, - { code: 'BO', name: 'Bolivia' }, - { code: 'BA', name: 'Bosnia and Herzegovina' }, - { code: 'BW', name: 'Botswana' }, - { code: 'BV', name: 'Bouvet Island' }, - { code: 'BR', name: 'Brazil' }, - { code: 'IO', name: 'British Indian Ocean Territory' }, - { code: 'BN', name: 'Brunei Darussalam' }, - { code: 'BG', name: 'Bulgaria' }, - { code: 'BF', name: 'Burkina Faso' }, - { code: 'BI', name: 'Burundi' }, - { code: 'KH', name: 'Cambodia' }, - { code: 'CM', name: 'Cameroon' }, - { code: 'CA', name: 'Canada' }, - { code: 'CV', name: 'Cape Verde' }, - { code: 'KY', name: 'Cayman Islands' }, - { code: 'CF', name: 'Central African Republic' }, - { code: 'TD', name: 'Chad' }, - { code: 'CL', name: 'Chile' }, - { code: 'CN', name: 'China' }, - { code: 'CX', name: 'Christmas Island' }, - { code: 'CC', name: 'Cocos (Keeling) Islands' }, - { code: 'CO', name: 'Colombia' }, - { code: 'KM', name: 'Comoros' }, - { code: 'CG', name: 'Congo' }, - { code: 'CD', name: 'Congo, The Democratic Republic of the' }, - { code: 'CK', name: 'Cook Islands' }, - { code: 'CR', name: 'Costa Rica' }, - { code: 'CI', name: "Cote d'Ivoire" }, - { code: 'HR', name: 'Croatia' }, - { code: 'CU', name: 'Cuba' }, - { code: 'CY', name: 'Cyprus' }, - { code: 'CZ', name: 'Czech Republic' }, - { code: 'DK', name: 'Denmark' }, - { code: 'DJ', name: 'Djibouti' }, - { code: 'DM', name: 'Dominica' }, - { code: 'DO', name: 'Dominican Republic' }, - { code: 'EC', name: 'Ecuador' }, - { code: 'EG', name: 'Egypt' }, - { code: 'SV', name: 'El Salvador' }, - { code: 'GQ', name: 'Equatorial Guinea' }, - { code: 'ER', name: 'Eritrea' }, - { code: 'EE', name: 'Estonia' }, - { code: 'ET', name: 'Ethiopia' }, - { code: 'FK', name: 'Falkland Islands (Malvinas)' }, - { code: 'FO', name: 'Faroe Islands' }, - { code: 'FJ', name: 'Fiji' }, - { code: 'FI', name: 'Finland' }, - { code: 'FR', name: 'France' }, - { code: 'GF', name: 'French Guiana' }, - { code: 'PF', name: 'French Polynesia' }, - { code: 'TF', name: 'French Southern Territories' }, - { code: 'GA', name: 'Gabon' }, - { code: 'GM', name: 'Gambia' }, - { code: 'GE', name: 'Georgia' }, - { code: 'DE', name: 'Germany' }, - { code: 'GH', name: 'Ghana' }, - { code: 'GI', name: 'Gibraltar' }, - { code: 'GR', name: 'Greece' }, - { code: 'GL', name: 'Greenland' }, - { code: 'GD', name: 'Grenada' }, - { code: 'GP', name: 'Guadeloupe' }, - { code: 'GU', name: 'Guam' }, - { code: 'GT', name: 'Guatemala' }, - { code: 'GG', name: 'Guernsey' }, - { code: 'GN', name: 'Guinea' }, - { code: 'GW', name: 'Guinea-Bissau' }, - { code: 'GY', name: 'Guyana' }, - { code: 'HT', name: 'Haiti' }, - { code: 'HM', name: 'Heard Island and McDonald Islands' }, - { code: 'VA', name: 'Holy See (Vatican City State)' }, - { code: 'HN', name: 'Honduras' }, - { code: 'HK', name: 'Hong Kong' }, - { code: 'HU', name: 'Hungary' }, - { code: 'IS', name: 'Iceland' }, - { code: 'IN', name: 'India' }, - { code: 'ID', name: 'Indonesia' }, - { code: 'IR', name: 'Iran, Islamic Republic of' }, - { code: 'IQ', name: 'Iraq' }, - { code: 'IE', name: 'Ireland' }, - { code: 'IM', name: 'Isle of Man' }, - { code: 'IL', name: 'Israel' }, - { code: 'IT', name: 'Italy' }, - { code: 'JM', name: 'Jamaica' }, - { code: 'JP', name: 'Japan' }, - { code: 'JE', name: 'Jersey' }, - { code: 'JO', name: 'Jordan' }, - { code: 'KZ', name: 'Kazakhstan' }, - { code: 'KE', name: 'Kenya' }, - { code: 'KI', name: 'Kiribati' }, - { code: 'KP', name: "Korea, Democratic People's Republic of" }, - { code: 'KR', name: 'Korea, Republic of' }, - { code: 'KW', name: 'Kuwait' }, - { code: 'KG', name: 'Kyrgyzstan' }, - { code: 'LA', name: "Lao People's Democratic Republic" }, - { code: 'LV', name: 'Latvia' }, - { code: 'LB', name: 'Lebanon' }, - { code: 'LS', name: 'Lesotho' }, - { code: 'LR', name: 'Liberia' }, - { code: 'LY', name: 'Libyan Arab Jamahiriya' }, - { code: 'LI', name: 'Liechtenstein' }, - { code: 'LT', name: 'Lithuania' }, - { code: 'LU', name: 'Luxembourg' }, - { code: 'MO', name: 'Macao' }, - { code: 'MK', name: 'Macedonia, The Former Yugoslav Republic of' }, - { code: 'MG', name: 'Madagascar' }, - { code: 'MW', name: 'Malawi' }, - { code: 'MY', name: 'Malaysia' }, - { code: 'MV', name: 'Maldives' }, - { code: 'ML', name: 'Mali' }, - { code: 'MT', name: 'Malta' }, - { code: 'MH', name: 'Marshall Islands' }, - { code: 'MQ', name: 'Martinique' }, - { code: 'MR', name: 'Mauritania' }, - { code: 'MU', name: 'Mauritius' }, - { code: 'YT', name: 'Mayotte' }, - { code: 'MX', name: 'Mexico' }, - { code: 'FM', name: 'Micronesia, Federated States of' }, - { code: 'MD', name: 'Moldova' }, - { code: 'MC', name: 'Monaco' }, - { code: 'MN', name: 'Mongolia' }, - { code: 'ME', name: 'Montenegro' }, - { code: 'MS', name: 'Montserrat' }, - { code: 'MA', name: 'Morocco' }, - { code: 'MZ', name: 'Mozambique' }, - { code: 'MM', name: 'Myanmar' }, - { code: 'NA', name: 'Namibia' }, - { code: 'NR', name: 'Nauru' }, - { code: 'NP', name: 'Nepal' }, - { code: 'NL', name: 'Netherlands' }, - { code: 'AN', name: 'Netherlands Antilles' }, - { code: 'NC', name: 'New Caledonia' }, - { code: 'NZ', name: 'New Zealand' }, - { code: 'NI', name: 'Nicaragua' }, - { code: 'NE', name: 'Niger' }, - { code: 'NG', name: 'Nigeria' }, - { code: 'NU', name: 'Niue' }, - { code: 'NF', name: 'Norfolk Island' }, - { code: 'MP', name: 'Northern Mariana Islands' }, - { code: 'NO', name: 'Norway' }, - { code: 'OM', name: 'Oman' }, - { code: 'PK', name: 'Pakistan' }, - { code: 'PW', name: 'Palau' }, - { code: 'PS', name: 'Palestinian Territory, Occupied' }, - { code: 'PA', name: 'Panama' }, - { code: 'PG', name: 'Papua New Guinea' }, - { code: 'PY', name: 'Paraguay' }, - { code: 'PE', name: 'Peru' }, - { code: 'PH', name: 'Philippines' }, - { code: 'PN', name: 'Pitcairn' }, - { code: 'PL', name: 'Poland' }, - { code: 'PT', name: 'Portugal' }, - { code: 'PR', name: 'Puerto Rico' }, - { code: 'QA', name: 'Qatar' }, - { code: 'RE', name: 'Reunion' }, - { code: 'RO', name: 'Romania' }, - { code: 'RU', name: 'Russian Federation' }, - { code: 'RW', name: 'Rwanda' }, - { code: 'BL', name: 'Saint Barthelemy' }, - { code: 'SH', name: 'Saint Helena' }, - { code: 'KN', name: 'Saint Kitts and Nevis' }, - { code: 'LC', name: 'Saint Lucia' }, - { code: 'MF', name: 'Saint Martin' }, - { code: 'PM', name: 'Saint Pierre and Miquelon' }, - { code: 'VC', name: 'Saint Vincent and the Grenadines' }, - { code: 'WS', name: 'Samoa' }, - { code: 'SM', name: 'San Marino' }, - { code: 'ST', name: 'Sao Tome and Principe' }, - { code: 'SA', name: 'Saudi Arabia' }, - { code: 'SN', name: 'Senegal' }, - { code: 'RS', name: 'Serbia' }, - { code: 'SC', name: 'Seychelles' }, - { code: 'SL', name: 'Sierra Leone' }, - { code: 'SG', name: 'Singapore' }, - { code: 'SK', name: 'Slovakia' }, - { code: 'SI', name: 'Slovenia' }, - { code: 'SB', name: 'Solomon Islands' }, - { code: 'SO', name: 'Somalia' }, - { code: 'ZA', name: 'South Africa' }, - { code: 'GS', name: 'South Georgia and the South Sandwich Islands' }, - { code: 'ES', name: 'Spain' }, - { code: 'LK', name: 'Sri Lanka' }, - { code: 'SD', name: 'Sudan' }, - { code: 'SR', name: 'Suriname' }, - { code: 'SJ', name: 'Svalbard and Jan Mayen' }, - { code: 'SZ', name: 'Swaziland' }, - { code: 'SE', name: 'Sweden' }, - { code: 'CH', name: 'Switzerland' }, - { code: 'SY', name: 'Syrian Arab Republic' }, - { code: 'TW', name: 'Taiwan, Province of China' }, - { code: 'TJ', name: 'Tajikistan' }, - { code: 'TZ', name: 'Tanzania, United Republic of' }, - { code: 'TH', name: 'Thailand' }, - { code: 'TL', name: 'Timor-Leste' }, - { code: 'TG', name: 'Togo' }, - { code: 'TK', name: 'Tokelau' }, - { code: 'TO', name: 'Tonga' }, - { code: 'TT', name: 'Trinidad and Tobago' }, - { code: 'TN', name: 'Tunisia' }, - { code: 'TR', name: 'Turkey' }, - { code: 'TM', name: 'Turkmenistan' }, - { code: 'TC', name: 'Turks and Caicos Islands' }, - { code: 'TV', name: 'Tuvalu' }, - { code: 'UG', name: 'Uganda' }, - { code: 'UA', name: 'Ukraine' }, - { code: 'AE', name: 'United Arab Emirates' }, - { code: 'US', name: 'United States' }, - { code: 'UM', name: 'United States Minor Outlying Islands' }, - { code: 'UY', name: 'Uruguay' }, - { code: 'UZ', name: 'Uzbekistan' }, - { code: 'VU', name: 'Vanuatu' }, - { code: 'VE', name: 'Venezuela' }, - { code: 'VN', name: 'Viet Nam' }, - { code: 'VG', name: 'Virgin Islands, British' }, - { code: 'VI', name: 'Virgin Islands, U.S.' }, - { code: 'WF', name: 'Wallis and Futuna' }, - { code: 'EH', name: 'Western Sahara' }, - { code: 'YE', name: 'Yemen' }, - { code: 'ZM', name: 'Zambia' }, - { code: 'ZW', name: 'Zimbabwe' } + { code: 'GB', name: 'United Kingdom' }, + { code: 'AF', name: 'Afghanistan' }, + { code: 'AX', name: 'Aland Islands' }, + { code: 'AL', name: 'Albania' }, + { code: 'DZ', name: 'Algeria' }, + { code: 'AS', name: 'American Samoa' }, + { code: 'AD', name: 'Andorra' }, + { code: 'AO', name: 'Angola' }, + { code: 'AI', name: 'Anguilla' }, + { code: 'AQ', name: 'Antarctica' }, + { code: 'AG', name: 'Antigua and Barbuda' }, + { code: 'AR', name: 'Argentina' }, + { code: 'AM', name: 'Armenia' }, + { code: 'AW', name: 'Aruba' }, + { code: 'AU', name: 'Australia' }, + { code: 'AT', name: 'Austria' }, + { code: 'AZ', name: 'Azerbaijan' }, + { code: 'BS', name: 'Bahamas' }, + { code: 'BH', name: 'Bahrain' }, + { code: 'BD', name: 'Bangladesh' }, + { code: 'BB', name: 'Barbados' }, + { code: 'BY', name: 'Belarus' }, + { code: 'BE', name: 'Belgium' }, + { code: 'BZ', name: 'Belize' }, + { code: 'BJ', name: 'Benin' }, + { code: 'BM', name: 'Bermuda' }, + { code: 'BT', name: 'Bhutan' }, + { code: 'BO', name: 'Bolivia' }, + { code: 'BA', name: 'Bosnia and Herzegovina' }, + { code: 'BW', name: 'Botswana' }, + { code: 'BV', name: 'Bouvet Island' }, + { code: 'BR', name: 'Brazil' }, + { code: 'IO', name: 'British Indian Ocean Territory' }, + { code: 'BN', name: 'Brunei Darussalam' }, + { code: 'BG', name: 'Bulgaria' }, + { code: 'BF', name: 'Burkina Faso' }, + { code: 'BI', name: 'Burundi' }, + { code: 'KH', name: 'Cambodia' }, + { code: 'CM', name: 'Cameroon' }, + { code: 'CA', name: 'Canada' }, + { code: 'CV', name: 'Cape Verde' }, + { code: 'KY', name: 'Cayman Islands' }, + { code: 'CF', name: 'Central African Republic' }, + { code: 'TD', name: 'Chad' }, + { code: 'CL', name: 'Chile' }, + { code: 'CN', name: 'China' }, + { code: 'CX', name: 'Christmas Island' }, + { code: 'CC', name: 'Cocos (Keeling) Islands' }, + { code: 'CO', name: 'Colombia' }, + { code: 'KM', name: 'Comoros' }, + { code: 'CG', name: 'Congo' }, + { code: 'CD', name: 'Congo, The Democratic Republic of the' }, + { code: 'CK', name: 'Cook Islands' }, + { code: 'CR', name: 'Costa Rica' }, + { code: 'CI', name: "Cote d'Ivoire" }, + { code: 'HR', name: 'Croatia' }, + { code: 'CU', name: 'Cuba' }, + { code: 'CY', name: 'Cyprus' }, + { code: 'CZ', name: 'Czech Republic' }, + { code: 'DK', name: 'Denmark' }, + { code: 'DJ', name: 'Djibouti' }, + { code: 'DM', name: 'Dominica' }, + { code: 'DO', name: 'Dominican Republic' }, + { code: 'EC', name: 'Ecuador' }, + { code: 'EG', name: 'Egypt' }, + { code: 'SV', name: 'El Salvador' }, + { code: 'GQ', name: 'Equatorial Guinea' }, + { code: 'ER', name: 'Eritrea' }, + { code: 'EE', name: 'Estonia' }, + { code: 'ET', name: 'Ethiopia' }, + { code: 'FK', name: 'Falkland Islands (Malvinas)' }, + { code: 'FO', name: 'Faroe Islands' }, + { code: 'FJ', name: 'Fiji' }, + { code: 'FI', name: 'Finland' }, + { code: 'FR', name: 'France' }, + { code: 'GF', name: 'French Guiana' }, + { code: 'PF', name: 'French Polynesia' }, + { code: 'TF', name: 'French Southern Territories' }, + { code: 'GA', name: 'Gabon' }, + { code: 'GM', name: 'Gambia' }, + { code: 'GE', name: 'Georgia' }, + { code: 'DE', name: 'Germany' }, + { code: 'GH', name: 'Ghana' }, + { code: 'GI', name: 'Gibraltar' }, + { code: 'GR', name: 'Greece' }, + { code: 'GL', name: 'Greenland' }, + { code: 'GD', name: 'Grenada' }, + { code: 'GP', name: 'Guadeloupe' }, + { code: 'GU', name: 'Guam' }, + { code: 'GT', name: 'Guatemala' }, + { code: 'GG', name: 'Guernsey' }, + { code: 'GN', name: 'Guinea' }, + { code: 'GW', name: 'Guinea-Bissau' }, + { code: 'GY', name: 'Guyana' }, + { code: 'HT', name: 'Haiti' }, + { code: 'HM', name: 'Heard Island and McDonald Islands' }, + { code: 'VA', name: 'Holy See (Vatican City State)' }, + { code: 'HN', name: 'Honduras' }, + { code: 'HK', name: 'Hong Kong' }, + { code: 'HU', name: 'Hungary' }, + { code: 'IS', name: 'Iceland' }, + { code: 'IN', name: 'India' }, + { code: 'ID', name: 'Indonesia' }, + { code: 'IR', name: 'Iran, Islamic Republic of' }, + { code: 'IQ', name: 'Iraq' }, + { code: 'IE', name: 'Ireland' }, + { code: 'IM', name: 'Isle of Man' }, + { code: 'IL', name: 'Israel' }, + { code: 'IT', name: 'Italy' }, + { code: 'JM', name: 'Jamaica' }, + { code: 'JP', name: 'Japan' }, + { code: 'JE', name: 'Jersey' }, + { code: 'JO', name: 'Jordan' }, + { code: 'KZ', name: 'Kazakhstan' }, + { code: 'KE', name: 'Kenya' }, + { code: 'KI', name: 'Kiribati' }, + { code: 'KP', name: "Korea, Democratic People's Republic of" }, + { code: 'KR', name: 'Korea, Republic of' }, + { code: 'KW', name: 'Kuwait' }, + { code: 'KG', name: 'Kyrgyzstan' }, + { code: 'LA', name: "Lao People's Democratic Republic" }, + { code: 'LV', name: 'Latvia' }, + { code: 'LB', name: 'Lebanon' }, + { code: 'LS', name: 'Lesotho' }, + { code: 'LR', name: 'Liberia' }, + { code: 'LY', name: 'Libyan Arab Jamahiriya' }, + { code: 'LI', name: 'Liechtenstein' }, + { code: 'LT', name: 'Lithuania' }, + { code: 'LU', name: 'Luxembourg' }, + { code: 'MO', name: 'Macao' }, + { code: 'MK', name: 'Macedonia, The Former Yugoslav Republic of' }, + { code: 'MG', name: 'Madagascar' }, + { code: 'MW', name: 'Malawi' }, + { code: 'MY', name: 'Malaysia' }, + { code: 'MV', name: 'Maldives' }, + { code: 'ML', name: 'Mali' }, + { code: 'MT', name: 'Malta' }, + { code: 'MH', name: 'Marshall Islands' }, + { code: 'MQ', name: 'Martinique' }, + { code: 'MR', name: 'Mauritania' }, + { code: 'MU', name: 'Mauritius' }, + { code: 'YT', name: 'Mayotte' }, + { code: 'MX', name: 'Mexico' }, + { code: 'FM', name: 'Micronesia, Federated States of' }, + { code: 'MD', name: 'Moldova' }, + { code: 'MC', name: 'Monaco' }, + { code: 'MN', name: 'Mongolia' }, + { code: 'ME', name: 'Montenegro' }, + { code: 'MS', name: 'Montserrat' }, + { code: 'MA', name: 'Morocco' }, + { code: 'MZ', name: 'Mozambique' }, + { code: 'MM', name: 'Myanmar' }, + { code: 'NA', name: 'Namibia' }, + { code: 'NR', name: 'Nauru' }, + { code: 'NP', name: 'Nepal' }, + { code: 'NL', name: 'Netherlands' }, + { code: 'AN', name: 'Netherlands Antilles' }, + { code: 'NC', name: 'New Caledonia' }, + { code: 'NZ', name: 'New Zealand' }, + { code: 'NI', name: 'Nicaragua' }, + { code: 'NE', name: 'Niger' }, + { code: 'NG', name: 'Nigeria' }, + { code: 'NU', name: 'Niue' }, + { code: 'NF', name: 'Norfolk Island' }, + { code: 'MP', name: 'Northern Mariana Islands' }, + { code: 'NO', name: 'Norway' }, + { code: 'OM', name: 'Oman' }, + { code: 'PK', name: 'Pakistan' }, + { code: 'PW', name: 'Palau' }, + { code: 'PS', name: 'Palestinian Territory, Occupied' }, + { code: 'PA', name: 'Panama' }, + { code: 'PG', name: 'Papua New Guinea' }, + { code: 'PY', name: 'Paraguay' }, + { code: 'PE', name: 'Peru' }, + { code: 'PH', name: 'Philippines' }, + { code: 'PN', name: 'Pitcairn' }, + { code: 'PL', name: 'Poland' }, + { code: 'PT', name: 'Portugal' }, + { code: 'PR', name: 'Puerto Rico' }, + { code: 'QA', name: 'Qatar' }, + { code: 'RE', name: 'Reunion' }, + { code: 'RO', name: 'Romania' }, + { code: 'RU', name: 'Russian Federation' }, + { code: 'RW', name: 'Rwanda' }, + { code: 'BL', name: 'Saint Barthelemy' }, + { code: 'SH', name: 'Saint Helena' }, + { code: 'KN', name: 'Saint Kitts and Nevis' }, + { code: 'LC', name: 'Saint Lucia' }, + { code: 'MF', name: 'Saint Martin' }, + { code: 'PM', name: 'Saint Pierre and Miquelon' }, + { code: 'VC', name: 'Saint Vincent and the Grenadines' }, + { code: 'WS', name: 'Samoa' }, + { code: 'SM', name: 'San Marino' }, + { code: 'ST', name: 'Sao Tome and Principe' }, + { code: 'SA', name: 'Saudi Arabia' }, + { code: 'SN', name: 'Senegal' }, + { code: 'RS', name: 'Serbia' }, + { code: 'SC', name: 'Seychelles' }, + { code: 'SL', name: 'Sierra Leone' }, + { code: 'SG', name: 'Singapore' }, + { code: 'SK', name: 'Slovakia' }, + { code: 'SI', name: 'Slovenia' }, + { code: 'SB', name: 'Solomon Islands' }, + { code: 'SO', name: 'Somalia' }, + { code: 'ZA', name: 'South Africa' }, + { code: 'GS', name: 'South Georgia and the South Sandwich Islands' }, + { code: 'ES', name: 'Spain' }, + { code: 'LK', name: 'Sri Lanka' }, + { code: 'SD', name: 'Sudan' }, + { code: 'SR', name: 'Suriname' }, + { code: 'SJ', name: 'Svalbard and Jan Mayen' }, + { code: 'SZ', name: 'Swaziland' }, + { code: 'SE', name: 'Sweden' }, + { code: 'CH', name: 'Switzerland' }, + { code: 'SY', name: 'Syrian Arab Republic' }, + { code: 'TW', name: 'Taiwan, Province of China' }, + { code: 'TJ', name: 'Tajikistan' }, + { code: 'TZ', name: 'Tanzania, United Republic of' }, + { code: 'TH', name: 'Thailand' }, + { code: 'TL', name: 'Timor-Leste' }, + { code: 'TG', name: 'Togo' }, + { code: 'TK', name: 'Tokelau' }, + { code: 'TO', name: 'Tonga' }, + { code: 'TT', name: 'Trinidad and Tobago' }, + { code: 'TN', name: 'Tunisia' }, + { code: 'TR', name: 'Turkey' }, + { code: 'TM', name: 'Turkmenistan' }, + { code: 'TC', name: 'Turks and Caicos Islands' }, + { code: 'TV', name: 'Tuvalu' }, + { code: 'UG', name: 'Uganda' }, + { code: 'UA', name: 'Ukraine' }, + { code: 'AE', name: 'United Arab Emirates' }, + { code: 'US', name: 'United States' }, + { code: 'UM', name: 'United States Minor Outlying Islands' }, + { code: 'UY', name: 'Uruguay' }, + { code: 'UZ', name: 'Uzbekistan' }, + { code: 'VU', name: 'Vanuatu' }, + { code: 'VE', name: 'Venezuela' }, + { code: 'VN', name: 'Viet Nam' }, + { code: 'VG', name: 'Virgin Islands, British' }, + { code: 'VI', name: 'Virgin Islands, U.S.' }, + { code: 'WF', name: 'Wallis and Futuna' }, + { code: 'EH', name: 'Western Sahara' }, + { code: 'YE', name: 'Yemen' }, + { code: 'ZM', name: 'Zambia' }, + { code: 'ZW', name: 'Zimbabwe' } ]; /** @@ -257,7 +257,7 @@ export const COUNTRIES = [ * @returns {string} Country name or the code if not found */ export function getCountryName(code) { - if (!code) return ''; - const country = COUNTRIES.find((c) => c.code === code); - return country?.name || code; + if (!code) return ''; + const country = COUNTRIES.find((c) => c.code === code); + return country?.name || code; } diff --git a/frontend/src/lib/constants/filters.js b/frontend/src/lib/constants/filters.js index 1c132cc4..b002aa51 100644 --- a/frontend/src/lib/constants/filters.js +++ b/frontend/src/lib/constants/filters.js @@ -5,135 +5,135 @@ /** @type {{ value: string, label: string }[]} */ export const LEAD_STATUSES = [ - { value: 'ALL', label: 'All Statuses' }, - { value: 'ASSIGNED', label: 'Assigned' }, - { value: 'IN_PROCESS', label: 'In Process' }, - { value: 'CONVERTED', label: 'Converted' }, - { value: 'RECYCLED', label: 'Recycled' }, - { value: 'CLOSED', label: 'Closed' } + { value: 'ALL', label: 'All Statuses' }, + { value: 'ASSIGNED', label: 'Assigned' }, + { value: 'IN_PROCESS', label: 'In Process' }, + { value: 'CONVERTED', label: 'Converted' }, + { value: 'RECYCLED', label: 'Recycled' }, + { value: 'CLOSED', label: 'Closed' } ]; /** @type {{ value: string, label: string }[]} */ export const LEAD_SOURCES = [ - { value: 'ALL', label: 'All Sources' }, - { value: 'call', label: 'Call' }, - { value: 'email', label: 'Email' }, - { value: 'existing customer', label: 'Existing Customer' }, - { value: 'partner', label: 'Partner' }, - { value: 'public relations', label: 'Public Relations' }, - { value: 'compaign', label: 'Campaign' }, - { value: 'other', label: 'Other' } + { value: 'ALL', label: 'All Sources' }, + { value: 'call', label: 'Call' }, + { value: 'email', label: 'Email' }, + { value: 'existing customer', label: 'Existing Customer' }, + { value: 'partner', label: 'Partner' }, + { value: 'public relations', label: 'Public Relations' }, + { value: 'compaign', label: 'Campaign' }, + { value: 'other', label: 'Other' } ]; /** @type {{ value: string, label: string }[]} */ export const LEAD_RATINGS = [ - { value: 'ALL', label: 'All Ratings' }, - { value: 'HOT', label: 'Hot' }, - { value: 'WARM', label: 'Warm' }, - { value: 'COLD', label: 'Cold' } + { value: 'ALL', label: 'All Ratings' }, + { value: 'HOT', label: 'Hot' }, + { value: 'WARM', label: 'Warm' }, + { value: 'COLD', label: 'Cold' } ]; /** @type {{ value: string, label: string }[]} */ export const CASE_STATUSES = [ - { value: 'ALL', label: 'All Statuses' }, - { value: 'New', label: 'New' }, - { value: 'Assigned', label: 'Assigned' }, - { value: 'Pending', label: 'Pending' }, - { value: 'Closed', label: 'Closed' }, - { value: 'Rejected', label: 'Rejected' }, - { value: 'Duplicate', label: 'Duplicate' } + { value: 'ALL', label: 'All Statuses' }, + { value: 'New', label: 'New' }, + { value: 'Assigned', label: 'Assigned' }, + { value: 'Pending', label: 'Pending' }, + { value: 'Closed', label: 'Closed' }, + { value: 'Rejected', label: 'Rejected' }, + { value: 'Duplicate', label: 'Duplicate' } ]; /** @type {{ value: string, label: string }[]} */ export const CASE_TYPES = [ - { value: '', label: 'Select Type' }, - { value: 'Question', label: 'Question' }, - { value: 'Incident', label: 'Incident' }, - { value: 'Problem', label: 'Problem' } + { value: '', label: 'Select Type' }, + { value: 'Question', label: 'Question' }, + { value: 'Incident', label: 'Incident' }, + { value: 'Problem', label: 'Problem' } ]; /** @type {{ value: string, label: string }[]} */ export const PRIORITIES = [ - { value: 'ALL', label: 'All Priorities' }, - { value: 'High', label: 'High' }, - { value: 'Medium', label: 'Medium' }, - { value: 'Low', label: 'Low' } + { value: 'ALL', label: 'All Priorities' }, + { value: 'High', label: 'High' }, + { value: 'Medium', label: 'Medium' }, + { value: 'Low', label: 'Low' } ]; /** @type {{ value: string, label: string }[]} */ export const OPPORTUNITY_STAGES = [ - { value: 'ALL', label: 'All Stages' }, - { value: 'PROSPECTING', label: 'Prospecting' }, - { value: 'QUALIFICATION', label: 'Qualification' }, - { value: 'PROPOSAL', label: 'Proposal' }, - { value: 'NEGOTIATION', label: 'Negotiation' }, - { value: 'CLOSED_WON', label: 'Closed Won' }, - { value: 'CLOSED_LOST', label: 'Closed Lost' } + { value: 'ALL', label: 'All Stages' }, + { value: 'PROSPECTING', label: 'Prospecting' }, + { value: 'QUALIFICATION', label: 'Qualification' }, + { value: 'PROPOSAL', label: 'Proposal' }, + { value: 'NEGOTIATION', label: 'Negotiation' }, + { value: 'CLOSED_WON', label: 'Closed Won' }, + { value: 'CLOSED_LOST', label: 'Closed Lost' } ]; /** @type {{ value: string, label: string }[]} */ export const TASK_STATUSES = [ - { value: 'ALL', label: 'All Statuses' }, - { value: 'New', label: 'New' }, - { value: 'In Progress', label: 'In Progress' }, - { value: 'Completed', label: 'Completed' } + { value: 'ALL', label: 'All Statuses' }, + { value: 'New', label: 'New' }, + { value: 'In Progress', label: 'In Progress' }, + { value: 'Completed', label: 'Completed' } ]; /** @type {{ value: string, label: string }[]} */ export const OPPORTUNITY_TYPES = [ - { value: '', label: 'Select Type' }, - { value: 'NEW_BUSINESS', label: 'New Business' }, - { value: 'EXISTING_BUSINESS', label: 'Existing Business' }, - { value: 'RENEWAL', label: 'Renewal' }, - { value: 'UPSELL', label: 'Upsell' }, - { value: 'CROSS_SELL', label: 'Cross-sell' } + { value: '', label: 'Select Type' }, + { value: 'NEW_BUSINESS', label: 'New Business' }, + { value: 'EXISTING_BUSINESS', label: 'Existing Business' }, + { value: 'RENEWAL', label: 'Renewal' }, + { value: 'UPSELL', label: 'Upsell' }, + { value: 'CROSS_SELL', label: 'Cross-sell' } ]; /** @type {{ value: string, label: string }[]} */ export const OPPORTUNITY_SOURCES = [ - { value: '', label: 'Select Source' }, - { value: 'NONE', label: 'None' }, - { value: 'CALL', label: 'Call' }, - { value: 'EMAIL', label: 'Email' }, - { value: 'EXISTING CUSTOMER', label: 'Existing Customer' }, - { value: 'PARTNER', label: 'Partner' }, - { value: 'PUBLIC RELATIONS', label: 'Public Relations' }, - { value: 'CAMPAIGN', label: 'Campaign' }, - { value: 'WEBSITE', label: 'Website' }, - { value: 'OTHER', label: 'Other' } + { value: '', label: 'Select Source' }, + { value: 'NONE', label: 'None' }, + { value: 'CALL', label: 'Call' }, + { value: 'EMAIL', label: 'Email' }, + { value: 'EXISTING CUSTOMER', label: 'Existing Customer' }, + { value: 'PARTNER', label: 'Partner' }, + { value: 'PUBLIC RELATIONS', label: 'Public Relations' }, + { value: 'CAMPAIGN', label: 'Campaign' }, + { value: 'WEBSITE', label: 'Website' }, + { value: 'OTHER', label: 'Other' } ]; /** @type {{ value: string, label: string }[]} */ export const CURRENCY_CODES = [ - { value: '', label: 'Select Currency' }, - { value: 'USD', label: 'USD - Dollar' }, - { value: 'EUR', label: 'EUR - Euro' }, - { value: 'GBP', label: 'GBP - Pound' }, - { value: 'INR', label: 'INR - Rupee' }, - { value: 'CAD', label: 'CAD - Dollar' }, - { value: 'AUD', label: 'AUD - Dollar' }, - { value: 'JPY', label: 'JPY - Yen' }, - { value: 'CNY', label: 'CNY - Yuan' }, - { value: 'CHF', label: 'CHF - Franc' }, - { value: 'SGD', label: 'SGD - Dollar' }, - { value: 'AED', label: 'AED - Dirham' }, - { value: 'BRL', label: 'BRL - Real' }, - { value: 'MXN', label: 'MXN - Peso' } + { value: '', label: 'Select Currency' }, + { value: 'USD', label: 'USD - Dollar' }, + { value: 'EUR', label: 'EUR - Euro' }, + { value: 'GBP', label: 'GBP - Pound' }, + { value: 'INR', label: 'INR - Rupee' }, + { value: 'CAD', label: 'CAD - Dollar' }, + { value: 'AUD', label: 'AUD - Dollar' }, + { value: 'JPY', label: 'JPY - Yen' }, + { value: 'CNY', label: 'CNY - Yuan' }, + { value: 'CHF', label: 'CHF - Franc' }, + { value: 'SGD', label: 'SGD - Dollar' }, + { value: 'AED', label: 'AED - Dirham' }, + { value: 'BRL', label: 'BRL - Real' }, + { value: 'MXN', label: 'MXN - Peso' } ]; /** @type {Record} */ export const CURRENCY_SYMBOLS = { - USD: '$', - EUR: '€', - GBP: '£', - INR: '₹', - CAD: 'CA$', - AUD: 'A$', - JPY: '¥', - CNY: '¥', - CHF: 'CHF', - SGD: 'S$', - AED: 'د.إ', - BRL: 'R$', - MXN: 'MX$' + USD: '$', + EUR: '€', + GBP: '£', + INR: '₹', + CAD: 'CA$', + AUD: 'A$', + JPY: '¥', + CNY: '¥', + CHF: 'CHF', + SGD: 'S$', + AED: 'د.إ', + BRL: 'R$', + MXN: 'MX$' }; diff --git a/frontend/src/lib/constants/lead-choices.js b/frontend/src/lib/constants/lead-choices.js index b5ce49e9..00a154b9 100644 --- a/frontend/src/lib/constants/lead-choices.js +++ b/frontend/src/lib/constants/lead-choices.js @@ -5,127 +5,127 @@ /** @type {{ value: string, label: string }[]} */ export const INDUSTRIES = [ - { value: '', label: 'Select Industry' }, - { value: 'ADVERTISING', label: 'Advertising' }, - { value: 'AGRICULTURE', label: 'Agriculture' }, - { value: 'APPAREL & ACCESSORIES', label: 'Apparel & Accessories' }, - { value: 'AUTOMOTIVE', label: 'Automotive' }, - { value: 'BANKING', label: 'Banking' }, - { value: 'BIOTECHNOLOGY', label: 'Biotechnology' }, - { value: 'BUILDING MATERIALS & EQUIPMENT', label: 'Building Materials & Equipment' }, - { value: 'CHEMICAL', label: 'Chemical' }, - { value: 'COMPUTER', label: 'Computer' }, - { value: 'EDUCATION', label: 'Education' }, - { value: 'ELECTRONICS', label: 'Electronics' }, - { value: 'ENERGY', label: 'Energy' }, - { value: 'ENTERTAINMENT & LEISURE', label: 'Entertainment & Leisure' }, - { value: 'FINANCE', label: 'Finance' }, - { value: 'FOOD & BEVERAGE', label: 'Food & Beverage' }, - { value: 'GROCERY', label: 'Grocery' }, - { value: 'HEALTHCARE', label: 'Healthcare' }, - { value: 'INSURANCE', label: 'Insurance' }, - { value: 'LEGAL', label: 'Legal' }, - { value: 'MANUFACTURING', label: 'Manufacturing' }, - { value: 'PUBLISHING', label: 'Publishing' }, - { value: 'REAL ESTATE', label: 'Real Estate' }, - { value: 'SERVICE', label: 'Service' }, - { value: 'SOFTWARE', label: 'Software' }, - { value: 'SPORTS', label: 'Sports' }, - { value: 'TECHNOLOGY', label: 'Technology' }, - { value: 'TELECOMMUNICATIONS', label: 'Telecommunications' }, - { value: 'TELEVISION', label: 'Television' }, - { value: 'TRANSPORTATION', label: 'Transportation' }, - { value: 'VENTURE CAPITAL', label: 'Venture Capital' } + { value: '', label: 'Select Industry' }, + { value: 'ADVERTISING', label: 'Advertising' }, + { value: 'AGRICULTURE', label: 'Agriculture' }, + { value: 'APPAREL & ACCESSORIES', label: 'Apparel & Accessories' }, + { value: 'AUTOMOTIVE', label: 'Automotive' }, + { value: 'BANKING', label: 'Banking' }, + { value: 'BIOTECHNOLOGY', label: 'Biotechnology' }, + { value: 'BUILDING MATERIALS & EQUIPMENT', label: 'Building Materials & Equipment' }, + { value: 'CHEMICAL', label: 'Chemical' }, + { value: 'COMPUTER', label: 'Computer' }, + { value: 'EDUCATION', label: 'Education' }, + { value: 'ELECTRONICS', label: 'Electronics' }, + { value: 'ENERGY', label: 'Energy' }, + { value: 'ENTERTAINMENT & LEISURE', label: 'Entertainment & Leisure' }, + { value: 'FINANCE', label: 'Finance' }, + { value: 'FOOD & BEVERAGE', label: 'Food & Beverage' }, + { value: 'GROCERY', label: 'Grocery' }, + { value: 'HEALTHCARE', label: 'Healthcare' }, + { value: 'INSURANCE', label: 'Insurance' }, + { value: 'LEGAL', label: 'Legal' }, + { value: 'MANUFACTURING', label: 'Manufacturing' }, + { value: 'PUBLISHING', label: 'Publishing' }, + { value: 'REAL ESTATE', label: 'Real Estate' }, + { value: 'SERVICE', label: 'Service' }, + { value: 'SOFTWARE', label: 'Software' }, + { value: 'SPORTS', label: 'Sports' }, + { value: 'TECHNOLOGY', label: 'Technology' }, + { value: 'TELECOMMUNICATIONS', label: 'Telecommunications' }, + { value: 'TELEVISION', label: 'Television' }, + { value: 'TRANSPORTATION', label: 'Transportation' }, + { value: 'VENTURE CAPITAL', label: 'Venture Capital' } ]; /** @type {{ value: string, label: string }[]} */ export const COUNTRIES = [ - { value: '', label: 'Select Country' }, - { value: 'US', label: 'United States' }, - { value: 'GB', label: 'United Kingdom' }, - { value: 'CA', label: 'Canada' }, - { value: 'AU', label: 'Australia' }, - { value: 'DE', label: 'Germany' }, - { value: 'FR', label: 'France' }, - { value: 'IN', label: 'India' }, - { value: 'CN', label: 'China' }, - { value: 'JP', label: 'Japan' }, - { value: 'BR', label: 'Brazil' }, - { value: 'MX', label: 'Mexico' }, - { value: 'ES', label: 'Spain' }, - { value: 'IT', label: 'Italy' }, - { value: 'NL', label: 'Netherlands' }, - { value: 'SE', label: 'Sweden' }, - { value: 'CH', label: 'Switzerland' }, - { value: 'SG', label: 'Singapore' }, - { value: 'AE', label: 'United Arab Emirates' }, - { value: 'AF', label: 'Afghanistan' }, - { value: 'AL', label: 'Albania' }, - { value: 'DZ', label: 'Algeria' }, - { value: 'AR', label: 'Argentina' }, - { value: 'AM', label: 'Armenia' }, - { value: 'AT', label: 'Austria' }, - { value: 'AZ', label: 'Azerbaijan' }, - { value: 'BH', label: 'Bahrain' }, - { value: 'BD', label: 'Bangladesh' }, - { value: 'BY', label: 'Belarus' }, - { value: 'BE', label: 'Belgium' }, - { value: 'BG', label: 'Bulgaria' }, - { value: 'CL', label: 'Chile' }, - { value: 'CO', label: 'Colombia' }, - { value: 'CR', label: 'Costa Rica' }, - { value: 'HR', label: 'Croatia' }, - { value: 'CY', label: 'Cyprus' }, - { value: 'CZ', label: 'Czech Republic' }, - { value: 'DK', label: 'Denmark' }, - { value: 'EG', label: 'Egypt' }, - { value: 'EE', label: 'Estonia' }, - { value: 'FI', label: 'Finland' }, - { value: 'GE', label: 'Georgia' }, - { value: 'GR', label: 'Greece' }, - { value: 'HK', label: 'Hong Kong' }, - { value: 'HU', label: 'Hungary' }, - { value: 'IS', label: 'Iceland' }, - { value: 'ID', label: 'Indonesia' }, - { value: 'IR', label: 'Iran' }, - { value: 'IQ', label: 'Iraq' }, - { value: 'IE', label: 'Ireland' }, - { value: 'IL', label: 'Israel' }, - { value: 'JO', label: 'Jordan' }, - { value: 'KZ', label: 'Kazakhstan' }, - { value: 'KE', label: 'Kenya' }, - { value: 'KW', label: 'Kuwait' }, - { value: 'LV', label: 'Latvia' }, - { value: 'LB', label: 'Lebanon' }, - { value: 'LT', label: 'Lithuania' }, - { value: 'LU', label: 'Luxembourg' }, - { value: 'MY', label: 'Malaysia' }, - { value: 'MT', label: 'Malta' }, - { value: 'MA', label: 'Morocco' }, - { value: 'NZ', label: 'New Zealand' }, - { value: 'NG', label: 'Nigeria' }, - { value: 'NO', label: 'Norway' }, - { value: 'OM', label: 'Oman' }, - { value: 'PK', label: 'Pakistan' }, - { value: 'PE', label: 'Peru' }, - { value: 'PH', label: 'Philippines' }, - { value: 'PL', label: 'Poland' }, - { value: 'PT', label: 'Portugal' }, - { value: 'QA', label: 'Qatar' }, - { value: 'RO', label: 'Romania' }, - { value: 'RU', label: 'Russia' }, - { value: 'SA', label: 'Saudi Arabia' }, - { value: 'RS', label: 'Serbia' }, - { value: 'SK', label: 'Slovakia' }, - { value: 'SI', label: 'Slovenia' }, - { value: 'ZA', label: 'South Africa' }, - { value: 'KR', label: 'South Korea' }, - { value: 'LK', label: 'Sri Lanka' }, - { value: 'TW', label: 'Taiwan' }, - { value: 'TH', label: 'Thailand' }, - { value: 'TR', label: 'Turkey' }, - { value: 'UA', label: 'Ukraine' }, - { value: 'UY', label: 'Uruguay' }, - { value: 'VE', label: 'Venezuela' }, - { value: 'VN', label: 'Vietnam' } + { value: '', label: 'Select Country' }, + { value: 'US', label: 'United States' }, + { value: 'GB', label: 'United Kingdom' }, + { value: 'CA', label: 'Canada' }, + { value: 'AU', label: 'Australia' }, + { value: 'DE', label: 'Germany' }, + { value: 'FR', label: 'France' }, + { value: 'IN', label: 'India' }, + { value: 'CN', label: 'China' }, + { value: 'JP', label: 'Japan' }, + { value: 'BR', label: 'Brazil' }, + { value: 'MX', label: 'Mexico' }, + { value: 'ES', label: 'Spain' }, + { value: 'IT', label: 'Italy' }, + { value: 'NL', label: 'Netherlands' }, + { value: 'SE', label: 'Sweden' }, + { value: 'CH', label: 'Switzerland' }, + { value: 'SG', label: 'Singapore' }, + { value: 'AE', label: 'United Arab Emirates' }, + { value: 'AF', label: 'Afghanistan' }, + { value: 'AL', label: 'Albania' }, + { value: 'DZ', label: 'Algeria' }, + { value: 'AR', label: 'Argentina' }, + { value: 'AM', label: 'Armenia' }, + { value: 'AT', label: 'Austria' }, + { value: 'AZ', label: 'Azerbaijan' }, + { value: 'BH', label: 'Bahrain' }, + { value: 'BD', label: 'Bangladesh' }, + { value: 'BY', label: 'Belarus' }, + { value: 'BE', label: 'Belgium' }, + { value: 'BG', label: 'Bulgaria' }, + { value: 'CL', label: 'Chile' }, + { value: 'CO', label: 'Colombia' }, + { value: 'CR', label: 'Costa Rica' }, + { value: 'HR', label: 'Croatia' }, + { value: 'CY', label: 'Cyprus' }, + { value: 'CZ', label: 'Czech Republic' }, + { value: 'DK', label: 'Denmark' }, + { value: 'EG', label: 'Egypt' }, + { value: 'EE', label: 'Estonia' }, + { value: 'FI', label: 'Finland' }, + { value: 'GE', label: 'Georgia' }, + { value: 'GR', label: 'Greece' }, + { value: 'HK', label: 'Hong Kong' }, + { value: 'HU', label: 'Hungary' }, + { value: 'IS', label: 'Iceland' }, + { value: 'ID', label: 'Indonesia' }, + { value: 'IR', label: 'Iran' }, + { value: 'IQ', label: 'Iraq' }, + { value: 'IE', label: 'Ireland' }, + { value: 'IL', label: 'Israel' }, + { value: 'JO', label: 'Jordan' }, + { value: 'KZ', label: 'Kazakhstan' }, + { value: 'KE', label: 'Kenya' }, + { value: 'KW', label: 'Kuwait' }, + { value: 'LV', label: 'Latvia' }, + { value: 'LB', label: 'Lebanon' }, + { value: 'LT', label: 'Lithuania' }, + { value: 'LU', label: 'Luxembourg' }, + { value: 'MY', label: 'Malaysia' }, + { value: 'MT', label: 'Malta' }, + { value: 'MA', label: 'Morocco' }, + { value: 'NZ', label: 'New Zealand' }, + { value: 'NG', label: 'Nigeria' }, + { value: 'NO', label: 'Norway' }, + { value: 'OM', label: 'Oman' }, + { value: 'PK', label: 'Pakistan' }, + { value: 'PE', label: 'Peru' }, + { value: 'PH', label: 'Philippines' }, + { value: 'PL', label: 'Poland' }, + { value: 'PT', label: 'Portugal' }, + { value: 'QA', label: 'Qatar' }, + { value: 'RO', label: 'Romania' }, + { value: 'RU', label: 'Russia' }, + { value: 'SA', label: 'Saudi Arabia' }, + { value: 'RS', label: 'Serbia' }, + { value: 'SK', label: 'Slovakia' }, + { value: 'SI', label: 'Slovenia' }, + { value: 'ZA', label: 'South Africa' }, + { value: 'KR', label: 'South Korea' }, + { value: 'LK', label: 'Sri Lanka' }, + { value: 'TW', label: 'Taiwan' }, + { value: 'TH', label: 'Thailand' }, + { value: 'TR', label: 'Turkey' }, + { value: 'UA', label: 'Ukraine' }, + { value: 'UY', label: 'Uruguay' }, + { value: 'VE', label: 'Venezuela' }, + { value: 'VN', label: 'Vietnam' } ]; diff --git a/frontend/src/lib/hooks/is-mobile.svelte.js b/frontend/src/lib/hooks/is-mobile.svelte.js index 18f2bbac..7c500d82 100644 --- a/frontend/src/lib/hooks/is-mobile.svelte.js +++ b/frontend/src/lib/hooks/is-mobile.svelte.js @@ -7,7 +7,7 @@ const MOBILE_BREAKPOINT = 768; * Uses Svelte 5's MediaQuery to reactively check the viewport width. */ export class IsMobile extends MediaQuery { - constructor() { - super(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); - } + constructor() { + super(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + } } diff --git a/frontend/src/lib/hooks/use-drawer-state.svelte.js b/frontend/src/lib/hooks/use-drawer-state.svelte.js index c40ef779..8d7486c1 100644 --- a/frontend/src/lib/hooks/use-drawer-state.svelte.js +++ b/frontend/src/lib/hooks/use-drawer-state.svelte.js @@ -11,178 +11,178 @@ import { goto } from '$app/navigation'; * @param {DrawerStateOptions} [options={}] */ export function useDrawerState(options = {}) { - const { page, syncUrl = false } = options; + const { page, syncUrl = false } = options; - // Core state - let detailOpen = $state(false); - let formOpen = $state(false); - /** @type {any} */ - let selected = $state(null); - let mode = $state(/** @type {'create' | 'edit'} */ ('create')); - let loading = $state(false); + // Core state + let detailOpen = $state(false); + let formOpen = $state(false); + /** @type {any} */ + let selected = $state(null); + let mode = $state(/** @type {'create' | 'edit'} */ ('create')); + let loading = $state(false); - /** - * Update URL without triggering navigation - * @param {string | null} viewId - * @param {string | null} action - */ - function updateUrl(viewId, action) { - if (!syncUrl || !page) return; + /** + * Update URL without triggering navigation + * @param {string | null} viewId + * @param {string | null} action + */ + function updateUrl(viewId, action) { + if (!syncUrl || !page) return; - const url = new URL(page.url); - if (viewId) { - url.searchParams.set('view', viewId); - url.searchParams.delete('action'); - } else if (action) { - url.searchParams.set('action', action); - url.searchParams.delete('view'); - } else { - url.searchParams.delete('view'); - url.searchParams.delete('action'); - } - goto(url.toString(), { replaceState: true, noScroll: true }); - } + const url = new URL(page.url); + if (viewId) { + url.searchParams.set('view', viewId); + url.searchParams.delete('action'); + } else if (action) { + url.searchParams.set('action', action); + url.searchParams.delete('view'); + } else { + url.searchParams.delete('view'); + url.searchParams.delete('action'); + } + goto(url.toString(), { replaceState: true, noScroll: true }); + } - /** - * Open detail drawer with item - * @param {any} item - */ - function openDetail(item) { - selected = item; - mode = 'edit'; // Set to edit mode (view with inline editing) - detailOpen = true; - updateUrl(item?.id, null); - } + /** + * Open detail drawer with item + * @param {any} item + */ + function openDetail(item) { + selected = item; + mode = 'edit'; // Set to edit mode (view with inline editing) + detailOpen = true; + updateUrl(item?.id, null); + } - /** - * Open create form - */ - function openCreate() { - selected = null; - mode = 'create'; - detailOpen = true; - formOpen = true; - updateUrl(null, 'create'); - } + /** + * Open create form + */ + function openCreate() { + selected = null; + mode = 'create'; + detailOpen = true; + formOpen = true; + updateUrl(null, 'create'); + } - /** - * Open edit form (switches from detail to form) - */ - function openEdit() { - mode = 'edit'; - detailOpen = false; - formOpen = true; - } + /** + * Open edit form (switches from detail to form) + */ + function openEdit() { + mode = 'edit'; + detailOpen = false; + formOpen = true; + } - /** - * Close detail drawer - */ - function closeDetail() { - detailOpen = false; - updateUrl(null, null); - } + /** + * Close detail drawer + */ + function closeDetail() { + detailOpen = false; + updateUrl(null, null); + } - /** - * Close form drawer - */ - function closeForm() { - formOpen = false; - updateUrl(null, null); - } + /** + * Close form drawer + */ + function closeForm() { + formOpen = false; + updateUrl(null, null); + } - /** - * Close all drawers - */ - function closeAll() { - detailOpen = false; - formOpen = false; - updateUrl(null, null); - } + /** + * Close all drawers + */ + function closeAll() { + detailOpen = false; + formOpen = false; + updateUrl(null, null); + } - /** - * Handle detail drawer open state change - * @param {boolean} open - */ - function handleDetailChange(open) { - detailOpen = open; - if (!open) updateUrl(null, null); - } + /** + * Handle detail drawer open state change + * @param {boolean} open + */ + function handleDetailChange(open) { + detailOpen = open; + if (!open) updateUrl(null, null); + } - /** - * Handle form drawer open state change - * @param {boolean} open - */ - function handleFormChange(open) { - formOpen = open; - if (!open) updateUrl(null, null); - } + /** + * Handle form drawer open state change + * @param {boolean} open + */ + function handleFormChange(open) { + formOpen = open; + if (!open) updateUrl(null, null); + } - /** - * Initialize URL sync - call this with an effect and provide items list - * @param {any[]} items - List of items to search for viewId - */ - function initUrlSync(items) { - if (!syncUrl || !page) return; + /** + * Initialize URL sync - call this with an effect and provide items list + * @param {any[]} items - List of items to search for viewId + */ + function initUrlSync(items) { + if (!syncUrl || !page) return; - const viewId = page.url.searchParams.get('view'); - const action = page.url.searchParams.get('action'); + const viewId = page.url.searchParams.get('view'); + const action = page.url.searchParams.get('action'); - if (action === 'create') { - selected = null; - mode = 'create'; - detailOpen = true; - formOpen = true; - } else if (viewId) { - const item = items.find((i) => i.id === viewId); - if (item) { - selected = item; - detailOpen = true; - } - } - } + if (action === 'create') { + selected = null; + mode = 'create'; + detailOpen = true; + formOpen = true; + } else if (viewId) { + const item = items.find((i) => i.id === viewId); + if (item) { + selected = item; + detailOpen = true; + } + } + } - return { - // State getters/setters - get detailOpen() { - return detailOpen; - }, - set detailOpen(v) { - detailOpen = v; - }, - get formOpen() { - return formOpen; - }, - set formOpen(v) { - formOpen = v; - }, - get selected() { - return selected; - }, - set selected(v) { - selected = v; - }, - get mode() { - return mode; - }, - set mode(v) { - mode = v; - }, - get loading() { - return loading; - }, - set loading(v) { - loading = v; - }, + return { + // State getters/setters + get detailOpen() { + return detailOpen; + }, + set detailOpen(v) { + detailOpen = v; + }, + get formOpen() { + return formOpen; + }, + set formOpen(v) { + formOpen = v; + }, + get selected() { + return selected; + }, + set selected(v) { + selected = v; + }, + get mode() { + return mode; + }, + set mode(v) { + mode = v; + }, + get loading() { + return loading; + }, + set loading(v) { + loading = v; + }, - // Actions - openDetail, - openCreate, - openEdit, - closeDetail, - closeForm, - closeAll, - handleDetailChange, - handleFormChange, - initUrlSync - }; + // Actions + openDetail, + openCreate, + openEdit, + closeDetail, + closeForm, + closeAll, + handleDetailChange, + handleFormChange, + initUrlSync + }; } diff --git a/frontend/src/lib/hooks/use-list-filters.svelte.js b/frontend/src/lib/hooks/use-list-filters.svelte.js index 2b57ba66..d176a61e 100644 --- a/frontend/src/lib/hooks/use-list-filters.svelte.js +++ b/frontend/src/lib/hooks/use-list-filters.svelte.js @@ -20,7 +20,7 @@ * @returns {any} */ function getNestedValue(obj, path) { - return path.split('.').reduce((current, key) => current?.[key], obj); + return path.split('.').reduce((current, key) => current?.[key], obj); } /** @@ -28,188 +28,188 @@ function getNestedValue(obj, path) { * @param {ListFiltersOptions} [options={}] */ export function useListFilters(options = {}) { - const { - searchFields = [], - filters = [], - defaultSortColumn = 'createdAt', - defaultSortDirection = 'desc' - } = options; + const { + searchFields = [], + filters = [], + defaultSortColumn = 'createdAt', + defaultSortDirection = 'desc' + } = options; - // Search state - let searchQuery = $state(''); - let showFilters = $state(false); + // Search state + let searchQuery = $state(''); + let showFilters = $state(false); - // Sort state - let sortColumn = $state(defaultSortColumn); - let sortDirection = $state(/** @type {'asc' | 'desc'} */ (defaultSortDirection)); + // Sort state + let sortColumn = $state(defaultSortColumn); + let sortDirection = $state(/** @type {'asc' | 'desc'} */ (defaultSortDirection)); - // Build initial filter state from definitions - /** @type {Record} */ - const initialFilterState = {}; - /** @type {Record} */ - const filterDefaults = {}; - for (const filter of filters) { - initialFilterState[filter.key] = filter.defaultValue; - filterDefaults[filter.key] = filter.defaultValue; - } + // Build initial filter state from definitions + /** @type {Record} */ + const initialFilterState = {}; + /** @type {Record} */ + const filterDefaults = {}; + for (const filter of filters) { + initialFilterState[filter.key] = filter.defaultValue; + filterDefaults[filter.key] = filter.defaultValue; + } - // Filter state as a reactive object - let filterState = $state(initialFilterState); + // Filter state as a reactive object + let filterState = $state(initialFilterState); - /** - * Toggle sort column/direction - * @param {string} column - */ - function toggleSort(column) { - if (sortColumn === column) { - sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; - } else { - sortColumn = column; - sortDirection = 'desc'; - } - } + /** + * Toggle sort column/direction + * @param {string} column + */ + function toggleSort(column) { + if (sortColumn === column) { + sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + sortColumn = column; + sortDirection = 'desc'; + } + } - /** - * Clear all filters - */ - function clearFilters() { - searchQuery = ''; - for (const filter of filters) { - filterState[filter.key] = filter.defaultValue; - } - } + /** + * Clear all filters + */ + function clearFilters() { + searchQuery = ''; + for (const filter of filters) { + filterState[filter.key] = filter.defaultValue; + } + } - /** - * Get count of active filters - * @returns {number} - */ - function getActiveFilterCount() { - let count = 0; - for (const filter of filters) { - if (filterState[filter.key] !== filter.defaultValue) { - count++; - } - } - return count; - } + /** + * Get count of active filters + * @returns {number} + */ + function getActiveFilterCount() { + let count = 0; + for (const filter of filters) { + if (filterState[filter.key] !== filter.defaultValue) { + count++; + } + } + return count; + } - /** - * Apply search filter to item - * @param {any} item - * @returns {boolean} - */ - function matchesSearch(item) { - if (searchQuery === '') return true; + /** + * Apply search filter to item + * @param {any} item + * @returns {boolean} + */ + function matchesSearch(item) { + if (searchQuery === '') return true; - const query = searchQuery.toLowerCase(); - return searchFields.some((field) => { - const value = getNestedValue(item, field); - return value && String(value).toLowerCase().includes(query); - }); - } + const query = searchQuery.toLowerCase(); + return searchFields.some((field) => { + const value = getNestedValue(item, field); + return value && String(value).toLowerCase().includes(query); + }); + } - /** - * Apply custom filters to item - * @param {any} item - * @returns {boolean} - */ - function matchesFilters(item) { - for (const filter of filters) { - const filterValue = filterState[filter.key]; + /** + * Apply custom filters to item + * @param {any} item + * @returns {boolean} + */ + function matchesFilters(item) { + for (const filter of filters) { + const filterValue = filterState[filter.key]; - // Skip if filter is at default value - if (filterValue === filter.defaultValue) continue; + // Skip if filter is at default value + if (filterValue === filter.defaultValue) continue; - // Use custom match function if provided - if (filter.match) { - if (!filter.match(item, filterValue)) return false; - } else { - // Default: direct field comparison - if (getNestedValue(item, filter.key) !== filterValue) return false; - } - } - return true; - } + // Use custom match function if provided + if (filter.match) { + if (!filter.match(item, filterValue)) return false; + } else { + // Default: direct field comparison + if (getNestedValue(item, filter.key) !== filterValue) return false; + } + } + return true; + } - /** - * Apply filters to items array - * @param {any[]} items - * @returns {any[]} - */ - function applyFilters(items) { - return items.filter((item) => matchesSearch(item) && matchesFilters(item)); - } + /** + * Apply filters to items array + * @param {any[]} items + * @returns {any[]} + */ + function applyFilters(items) { + return items.filter((item) => matchesSearch(item) && matchesFilters(item)); + } - /** - * Apply sorting to items array - * @param {any[]} items - * @returns {any[]} - */ - function applySort(items) { - return [...items].sort((a, b) => { - const aValue = getNestedValue(a, sortColumn); - const bValue = getNestedValue(b, sortColumn); + /** + * Apply sorting to items array + * @param {any[]} items + * @returns {any[]} + */ + function applySort(items) { + return [...items].sort((a, b) => { + const aValue = getNestedValue(a, sortColumn); + const bValue = getNestedValue(b, sortColumn); - if (aValue == null && bValue == null) return 0; - if (aValue == null) return 1; - if (bValue == null) return -1; + if (aValue == null && bValue == null) return 0; + if (aValue == null) return 1; + if (bValue == null) return -1; - const comparison = aValue > bValue ? 1 : aValue < bValue ? -1 : 0; - return sortDirection === 'asc' ? comparison : -comparison; - }); - } + const comparison = aValue > bValue ? 1 : aValue < bValue ? -1 : 0; + return sortDirection === 'asc' ? comparison : -comparison; + }); + } - /** - * Apply both filtering and sorting - * @param {any[]} items - * @returns {any[]} - */ - function filterAndSort(items) { - return applySort(applyFilters(items)); - } + /** + * Apply both filtering and sorting + * @param {any[]} items + * @returns {any[]} + */ + function filterAndSort(items) { + return applySort(applyFilters(items)); + } - return { - // Search state - get searchQuery() { - return searchQuery; - }, - set searchQuery(v) { - searchQuery = v; - }, - get showFilters() { - return showFilters; - }, - set showFilters(v) { - showFilters = v; - }, + return { + // Search state + get searchQuery() { + return searchQuery; + }, + set searchQuery(v) { + searchQuery = v; + }, + get showFilters() { + return showFilters; + }, + set showFilters(v) { + showFilters = v; + }, - // Sort state - get sortColumn() { - return sortColumn; - }, - set sortColumn(v) { - sortColumn = v; - }, - get sortDirection() { - return sortDirection; - }, - set sortDirection(v) { - sortDirection = v; - }, + // Sort state + get sortColumn() { + return sortColumn; + }, + set sortColumn(v) { + sortColumn = v; + }, + get sortDirection() { + return sortDirection; + }, + set sortDirection(v) { + sortDirection = v; + }, - // Filter state (reactive object) - get filters() { - return filterState; - }, + // Filter state (reactive object) + get filters() { + return filterState; + }, - // Actions - toggleSort, - clearFilters, + // Actions + toggleSort, + clearFilters, - // Computed helpers - getActiveFilterCount, - applyFilters, - applySort, - filterAndSort - }; + // Computed helpers + getActiveFilterCount, + applyFilters, + applySort, + filterAndSort + }; } diff --git a/frontend/src/lib/stores/org.js b/frontend/src/lib/stores/org.js index a27d9938..d4a6e5e3 100644 --- a/frontend/src/lib/stores/org.js +++ b/frontend/src/lib/stores/org.js @@ -9,9 +9,9 @@ import { writable } from 'svelte/store'; /** @type {import('svelte/store').Writable} */ export const orgSettings = writable({ - default_currency: 'USD', - currency_symbol: '$', - default_country: null + default_currency: 'USD', + currency_symbol: '$', + default_country: null }); /** @@ -19,9 +19,9 @@ export const orgSettings = writable({ * @param {Partial} settings */ export function initOrgSettings(settings) { - orgSettings.set({ - default_currency: settings.default_currency || 'USD', - currency_symbol: settings.currency_symbol || '$', - default_country: settings.default_country || null - }); + orgSettings.set({ + default_currency: settings.default_currency || 'USD', + currency_symbol: settings.currency_symbol || '$', + default_country: settings.default_country || null + }); } diff --git a/frontend/src/lib/utils.js b/frontend/src/lib/utils.js index 846c6437..f75c1667 100644 --- a/frontend/src/lib/utils.js +++ b/frontend/src/lib/utils.js @@ -7,5 +7,5 @@ import { twMerge } from 'tailwind-merge'; * @returns {string} */ export function cn(...inputs) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); } diff --git a/frontend/src/lib/utils/drag-drop.js b/frontend/src/lib/utils/drag-drop.js index 61a9d46a..f26ce6f7 100644 --- a/frontend/src/lib/utils/drag-drop.js +++ b/frontend/src/lib/utils/drag-drop.js @@ -19,11 +19,11 @@ * @returns {DragState} */ export function createDragState() { - return { - draggedId: null, - dragOverId: null, - dropPosition: null - }; + return { + draggedId: null, + dragOverId: null, + dropPosition: null + }; } /** @@ -32,10 +32,10 @@ export function createDragState() { * @returns {DropPosition} */ export function calculateDropPosition(event) { - const target = /** @type {HTMLElement} */ (event.currentTarget); - const rect = target.getBoundingClientRect(); - const midpoint = rect.top + rect.height / 2; - return event.clientY < midpoint ? 'before' : 'after'; + const target = /** @type {HTMLElement} */ (event.currentTarget); + const rect = target.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + return event.clientY < midpoint ? 'before' : 'after'; } /** @@ -49,30 +49,30 @@ export function calculateDropPosition(event) { * @returns {T[]} Reordered array */ export function reorderItems(items, draggedId, targetId, position, getId) { - if (!draggedId || !targetId || draggedId === targetId || !position) { - return items; - } + if (!draggedId || !targetId || draggedId === targetId || !position) { + return items; + } - const draggedIndex = items.findIndex((item) => getId(item) === draggedId); - const targetIndex = items.findIndex((item) => getId(item) === targetId); + const draggedIndex = items.findIndex((item) => getId(item) === draggedId); + const targetIndex = items.findIndex((item) => getId(item) === targetId); - if (draggedIndex === -1 || targetIndex === -1) { - return items; - } + if (draggedIndex === -1 || targetIndex === -1) { + return items; + } - const newItems = [...items]; - const [draggedItem] = newItems.splice(draggedIndex, 1); + const newItems = [...items]; + const [draggedItem] = newItems.splice(draggedIndex, 1); - // Calculate insert position - let insertIndex; - if (position === 'after') { - insertIndex = draggedIndex < targetIndex ? targetIndex : targetIndex + 1; - } else { - insertIndex = draggedIndex < targetIndex ? targetIndex - 1 : targetIndex; - } + // Calculate insert position + let insertIndex; + if (position === 'after') { + insertIndex = draggedIndex < targetIndex ? targetIndex : targetIndex + 1; + } else { + insertIndex = draggedIndex < targetIndex ? targetIndex - 1 : targetIndex; + } - newItems.splice(insertIndex, 0, draggedItem); - return newItems; + newItems.splice(insertIndex, 0, draggedItem); + return newItems; } /** @@ -81,23 +81,23 @@ export function reorderItems(items, draggedId, targetId, position, getId) { * @param {string} itemId - ID of the item being dragged */ export function handleDragStart(event, itemId) { - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('text/plain', itemId); - } + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', itemId); + } } /** * Drag handle styles that show on row hover */ export const dragHandleClasses = - 'flex items-center justify-center w-6 h-6 rounded opacity-0 group-hover:opacity-40 hover:!opacity-70 hover:bg-gray-200 transition-all cursor-grab active:cursor-grabbing'; + 'flex items-center justify-center w-6 h-6 rounded opacity-0 group-hover:opacity-40 hover:!opacity-70 hover:bg-gray-200 transition-all cursor-grab active:cursor-grabbing'; /** * Expand button styles that show on row hover */ export const expandButtonClasses = - 'flex items-center justify-center w-6 h-6 rounded opacity-0 group-hover:opacity-100 hover:bg-gray-200 transition-all duration-75'; + 'flex items-center justify-center w-6 h-6 rounded opacity-0 group-hover:opacity-100 hover:bg-gray-200 transition-all duration-75'; /** * Drop indicator line styles diff --git a/frontend/src/lib/utils/formatting.js b/frontend/src/lib/utils/formatting.js index 3c5a8a05..0f05c5fa 100644 --- a/frontend/src/lib/utils/formatting.js +++ b/frontend/src/lib/utils/formatting.js @@ -12,12 +12,12 @@ import { format, formatDistanceToNow } from 'date-fns'; * @returns {string} Formatted date string or '-' if no date */ export function formatDate(date, formatStr = 'MMM d, yyyy') { - if (!date) return '-'; - try { - return format(new Date(date), formatStr); - } catch { - return '-'; - } + if (!date) return '-'; + try { + return format(new Date(date), formatStr); + } catch { + return '-'; + } } /** @@ -26,12 +26,12 @@ export function formatDate(date, formatStr = 'MMM d, yyyy') { * @returns {string} Relative time string or '-' if no date */ export function formatRelativeDate(date) { - if (!date) return '-'; - try { - return formatDistanceToNow(new Date(date), { addSuffix: true }); - } catch { - return '-'; - } + if (!date) return '-'; + try { + return formatDistanceToNow(new Date(date), { addSuffix: true }); + } catch { + return '-'; + } } /** @@ -42,33 +42,33 @@ export function formatRelativeDate(date) { * @returns {string} Formatted currency string or '-' if no amount */ export function formatCurrency(amount, currency = 'USD', compact = false) { - if (amount === null || amount === undefined) return '-'; + if (amount === null || amount === undefined) return '-'; - // Convert to number if string - const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount; + // Convert to number if string + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount; - // Handle NaN or invalid numbers - if (isNaN(numAmount)) return '-'; + // Handle NaN or invalid numbers + if (isNaN(numAmount)) return '-'; - // Ensure valid currency code (fallback to USD) - const currencyCode = currency && currency.length === 3 ? currency : 'USD'; + // Ensure valid currency code (fallback to USD) + const currencyCode = currency && currency.length === 3 ? currency : 'USD'; - try { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currencyCode, - notation: compact ? 'compact' : 'standard', - maximumFractionDigits: compact ? 1 : 2 - }).format(numAmount); - } catch { - // Fallback if currency code is invalid - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - notation: compact ? 'compact' : 'standard', - maximumFractionDigits: compact ? 1 : 2 - }).format(numAmount); - } + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currencyCode, + notation: compact ? 'compact' : 'standard', + maximumFractionDigits: compact ? 1 : 2 + }).format(numAmount); + } catch { + // Fallback if currency code is invalid + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: compact ? 'compact' : 'standard', + maximumFractionDigits: compact ? 1 : 2 + }).format(numAmount); + } } /** @@ -77,8 +77,8 @@ export function formatCurrency(amount, currency = 'USD', compact = false) { * @returns {string} Formatted number string or '-' if no number */ export function formatNumber(num) { - if (num === null || num === undefined) return '-'; - return new Intl.NumberFormat('en-US').format(num); + if (num === null || num === undefined) return '-'; + return new Intl.NumberFormat('en-US').format(num); } /** @@ -88,13 +88,13 @@ export function formatNumber(num) { * @returns {string} Uppercase initials */ export function getInitials(name, maxLength = 2) { - if (!name) return ''; - return name - .split(' ') - .map((n) => n[0]) - .join('') - .toUpperCase() - .slice(0, maxLength); + if (!name) return ''; + return name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, maxLength); } /** @@ -104,9 +104,9 @@ export function getInitials(name, maxLength = 2) { * @returns {string} Uppercase initials */ export function getNameInitials(firstName, lastName) { - const first = firstName?.[0] || ''; - const last = lastName?.[0] || ''; - return `${first}${last}`.toUpperCase(); + const first = firstName?.[0] || ''; + const last = lastName?.[0] || ''; + return `${first}${last}`.toUpperCase(); } /** @@ -115,13 +115,13 @@ export function getNameInitials(firstName, lastName) { * @returns {string} Formatted phone number or empty string */ export function formatPhone(phone) { - if (!phone) return ''; - // Remove non-digits - const digits = phone.replace(/\D/g, ''); - // Format as (XXX) XXX-XXXX if 10 digits - if (digits.length === 10) { - return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; - } - // Return original if not 10 digits - return phone; + if (!phone) return ''; + // Remove non-digits + const digits = phone.replace(/\D/g, ''); + // Format as (XXX) XXX-XXXX if 10 digits + if (digits.length === 10) { + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; + } + // Return original if not 10 digits + return phone; } diff --git a/frontend/src/lib/utils/phone.js b/frontend/src/lib/utils/phone.js index 819dcbd1..b5ae99d4 100644 --- a/frontend/src/lib/utils/phone.js +++ b/frontend/src/lib/utils/phone.js @@ -7,81 +7,81 @@ import { isValidPhoneNumber, parsePhoneNumber, isPossiblePhoneNumber } from 'lib * @returns {{ isValid: boolean, formatted?: string, error?: string }} */ export function validatePhoneNumber(phoneNumber, defaultCountry) { - if (!phoneNumber || phoneNumber.trim() === '') { - return { isValid: true }; // Allow empty phone numbers - } + if (!phoneNumber || phoneNumber.trim() === '') { + return { isValid: true }; // Allow empty phone numbers + } - const trimmed = phoneNumber.trim(); + const trimmed = phoneNumber.trim(); - try { - // If the number starts with +, validate as international - if (trimmed.startsWith('+')) { - const isValid = isValidPhoneNumber(trimmed); - if (!isValid) { - return { - isValid: false, - error: 'Please enter a valid phone number with country code' - }; - } - const parsed = parsePhoneNumber(trimmed); - return { - isValid: true, - formatted: parsed.formatInternational() - }; - } + try { + // If the number starts with +, validate as international + if (trimmed.startsWith('+')) { + const isValid = isValidPhoneNumber(trimmed); + if (!isValid) { + return { + isValid: false, + error: 'Please enter a valid phone number with country code' + }; + } + const parsed = parsePhoneNumber(trimmed); + return { + isValid: true, + formatted: parsed.formatInternational() + }; + } - // For numbers without country code, try with default country if provided - if (defaultCountry) { - // @ts-ignore - defaultCountry is a valid CountryCode - const isValid = isValidPhoneNumber(trimmed, defaultCountry); - if (isValid) { - // @ts-ignore - const parsed = parsePhoneNumber(trimmed, defaultCountry); - return { - isValid: true, - formatted: parsed.formatInternational() - }; - } - } + // For numbers without country code, try with default country if provided + if (defaultCountry) { + // @ts-ignore - defaultCountry is a valid CountryCode + const isValid = isValidPhoneNumber(trimmed, defaultCountry); + if (isValid) { + // @ts-ignore + const parsed = parsePhoneNumber(trimmed, defaultCountry); + return { + isValid: true, + formatted: parsed.formatInternational() + }; + } + } - // Try common countries for numbers without + prefix - const commonCountries = ['IN', 'US', 'GB', 'AU', 'CA', 'DE', 'FR']; - for (const country of commonCountries) { - try { - // @ts-ignore - if (isValidPhoneNumber(trimmed, country)) { - // @ts-ignore - const parsed = parsePhoneNumber(trimmed, country); - return { - isValid: true, - formatted: parsed.formatInternational() - }; - } - } catch { - continue; - } - } + // Try common countries for numbers without + prefix + const commonCountries = ['IN', 'US', 'GB', 'AU', 'CA', 'DE', 'FR']; + for (const country of commonCountries) { + try { + // @ts-ignore + if (isValidPhoneNumber(trimmed, country)) { + // @ts-ignore + const parsed = parsePhoneNumber(trimmed, country); + return { + isValid: true, + formatted: parsed.formatInternational() + }; + } + } catch { + continue; + } + } - // If nothing worked, check if it's at least a possible phone number - // (has right length, starts with valid digits, etc.) - if (isPossiblePhoneNumber(trimmed, 'IN') || isPossiblePhoneNumber(trimmed, 'US')) { - return { - isValid: true, - formatted: trimmed - }; - } + // If nothing worked, check if it's at least a possible phone number + // (has right length, starts with valid digits, etc.) + if (isPossiblePhoneNumber(trimmed, 'IN') || isPossiblePhoneNumber(trimmed, 'US')) { + return { + isValid: true, + formatted: trimmed + }; + } - return { - isValid: false, - error: - 'Please enter a valid phone number (include country code like +91 for India or +1 for US)' - }; - } catch (error) { - return { - isValid: false, - error: 'Please enter a valid phone number' - }; - } + return { + isValid: false, + error: + 'Please enter a valid phone number (include country code like +91 for India or +1 for US)' + }; + } catch (error) { + return { + isValid: false, + error: 'Please enter a valid phone number' + }; + } } /** @@ -91,15 +91,15 @@ export function validatePhoneNumber(phoneNumber, defaultCountry) { * @returns {string} Formatted phone number or original if invalid */ export function formatPhoneNumber(phoneNumber, defaultCountry = 'US') { - if (!phoneNumber) return ''; + if (!phoneNumber) return ''; - try { - // @ts-ignore - defaultCountry is a valid CountryCode - const parsed = parsePhoneNumber(phoneNumber, { defaultCountry }); - return parsed.formatInternational(); - } catch { - return phoneNumber; // Return original if parsing fails - } + try { + // @ts-ignore - defaultCountry is a valid CountryCode + const parsed = parsePhoneNumber(phoneNumber, { defaultCountry }); + return parsed.formatInternational(); + } catch { + return phoneNumber; // Return original if parsing fails + } } /** @@ -109,46 +109,46 @@ export function formatPhoneNumber(phoneNumber, defaultCountry = 'US') { * @returns {string} E.164 formatted phone number or original if invalid */ export function formatPhoneForStorage(phoneNumber, defaultCountry) { - if (!phoneNumber) return ''; + if (!phoneNumber) return ''; - const trimmed = phoneNumber.trim(); + const trimmed = phoneNumber.trim(); - try { - // If starts with +, parse as international - if (trimmed.startsWith('+')) { - const parsed = parsePhoneNumber(trimmed); - return parsed.format('E.164'); - } + try { + // If starts with +, parse as international + if (trimmed.startsWith('+')) { + const parsed = parsePhoneNumber(trimmed); + return parsed.format('E.164'); + } - // Try with default country first - if (defaultCountry) { - try { - // @ts-ignore - const parsed = parsePhoneNumber(trimmed, defaultCountry); - if (parsed.isValid()) { - return parsed.format('E.164'); - } - } catch { - // Continue to try other countries - } - } + // Try with default country first + if (defaultCountry) { + try { + // @ts-ignore + const parsed = parsePhoneNumber(trimmed, defaultCountry); + if (parsed.isValid()) { + return parsed.format('E.164'); + } + } catch { + // Continue to try other countries + } + } - // Try common countries - const commonCountries = ['IN', 'US', 'GB', 'AU', 'CA', 'DE', 'FR']; - for (const country of commonCountries) { - try { - // @ts-ignore - const parsed = parsePhoneNumber(trimmed, country); - if (parsed.isValid()) { - return parsed.format('E.164'); - } - } catch { - continue; - } - } + // Try common countries + const commonCountries = ['IN', 'US', 'GB', 'AU', 'CA', 'DE', 'FR']; + for (const country of commonCountries) { + try { + // @ts-ignore + const parsed = parsePhoneNumber(trimmed, country); + if (parsed.isValid()) { + return parsed.format('E.164'); + } + } catch { + continue; + } + } - return phoneNumber; // Return original if parsing fails - } catch { - return phoneNumber; // Return original if parsing fails - } + return phoneNumber; // Return original if parsing fails + } catch { + return phoneNumber; // Return original if parsing fails + } } diff --git a/frontend/src/lib/utils/pkce.js b/frontend/src/lib/utils/pkce.js index a1b5f397..04550865 100644 --- a/frontend/src/lib/utils/pkce.js +++ b/frontend/src/lib/utils/pkce.js @@ -8,9 +8,9 @@ * @returns {string} Random code verifier string */ export function generateCodeVerifier() { - const array = new Uint8Array(32); - crypto.getRandomValues(array); - return base64URLEncode(array); + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64URLEncode(array); } /** @@ -19,10 +19,10 @@ export function generateCodeVerifier() { * @returns {Promise} Code challenge */ export async function generateCodeChallenge(verifier) { - const encoder = new TextEncoder(); - const data = encoder.encode(verifier); - const digest = await crypto.subtle.digest('SHA-256', data); - return base64URLEncode(new Uint8Array(digest)); + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest('SHA-256', data); + return base64URLEncode(new Uint8Array(digest)); } /** @@ -30,9 +30,9 @@ export async function generateCodeChallenge(verifier) { * @returns {string} Random state string */ export function generateState() { - const array = new Uint8Array(16); - crypto.getRandomValues(array); - return base64URLEncode(array); + const array = new Uint8Array(16); + crypto.getRandomValues(array); + return base64URLEncode(array); } /** @@ -41,8 +41,8 @@ export function generateState() { * @returns {string} Base64 URL encoded string */ function base64URLEncode(buffer) { - return btoa(String.fromCharCode(...buffer)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + return btoa(String.fromCharCode(...buffer)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); } diff --git a/frontend/src/lib/utils/table-helpers.js b/frontend/src/lib/utils/table-helpers.js index e41dd309..9df89ab7 100644 --- a/frontend/src/lib/utils/table-helpers.js +++ b/frontend/src/lib/utils/table-helpers.js @@ -17,8 +17,8 @@ * @returns {string} Tailwind CSS classes */ export function getOptionStyle(value, options) { - const option = options.find((o) => o.value === value); - return option?.color ?? 'bg-gray-100 text-gray-600'; + const option = options.find((o) => o.value === value); + return option?.color ?? 'bg-gray-100 text-gray-600'; } /** @@ -28,8 +28,8 @@ export function getOptionStyle(value, options) { * @returns {string} Display label */ export function getOptionLabel(value, options) { - const option = options.find((o) => o.value === value); - return option?.label ?? value ?? ''; + const option = options.find((o) => o.value === value); + return option?.label ?? value ?? ''; } /** @@ -39,8 +39,8 @@ export function getOptionLabel(value, options) { * @returns {string} Background color class */ export function getOptionBgColor(value, options) { - const style = getOptionStyle(value, options); - return style.split(' ')[0] ?? 'bg-gray-100'; + const style = getOptionStyle(value, options); + return style.split(' ')[0] ?? 'bg-gray-100'; } /** @@ -52,7 +52,7 @@ export function getOptionBgColor(value, options) { * @returns {{ key: string, label: string, visible: boolean, canHide: boolean }} */ export function createColumnConfig(key, label, visible = true, canHide = true) { - return { key, label, visible, canHide }; + return { key, label, visible, canHide }; } /** @@ -62,20 +62,20 @@ export function createColumnConfig(key, label, visible = true, canHide = true) { * @returns {{ key: string, label: string, visible: boolean, canHide: boolean }[]} */ export function loadColumnConfig(storageKey, defaultColumns) { - if (typeof window === 'undefined') return defaultColumns; - try { - const saved = localStorage.getItem(storageKey); - if (saved) { - const parsed = JSON.parse(saved); - return defaultColumns.map((def) => { - const savedCol = parsed.find((/** @type {{ key: string }} */ p) => p.key === def.key); - return savedCol ? { ...def, visible: savedCol.visible } : def; - }); - } - } catch (e) { - console.error('Failed to load column config:', e); - } - return defaultColumns; + if (typeof window === 'undefined') return defaultColumns; + try { + const saved = localStorage.getItem(storageKey); + if (saved) { + const parsed = JSON.parse(saved); + return defaultColumns.map((def) => { + const savedCol = parsed.find((/** @type {{ key: string }} */ p) => p.key === def.key); + return savedCol ? { ...def, visible: savedCol.visible } : def; + }); + } + } catch (e) { + console.error('Failed to load column config:', e); + } + return defaultColumns; } /** @@ -84,12 +84,12 @@ export function loadColumnConfig(storageKey, defaultColumns) { * @param {{ key: string, visible: boolean }[]} columns - Column config */ export function saveColumnConfig(storageKey, columns) { - if (typeof window === 'undefined') return; - try { - localStorage.setItem(storageKey, JSON.stringify(columns)); - } catch (e) { - console.error('Failed to save column config:', e); - } + if (typeof window === 'undefined') return; + try { + localStorage.setItem(storageKey, JSON.stringify(columns)); + } catch (e) { + console.error('Failed to save column config:', e); + } } /** @@ -99,12 +99,12 @@ export function saveColumnConfig(storageKey, columns) { * @returns {{ key: string, visible: boolean, canHide: boolean }[]} */ export function toggleColumnVisibility(columns, key) { - return columns.map((col) => { - if (col.key === key && col.canHide) { - return { ...col, visible: !col.visible }; - } - return col; - }); + return columns.map((col) => { + if (col.key === key && col.canHide) { + return { ...col, visible: !col.visible }; + } + return col; + }); } /** @@ -113,77 +113,237 @@ export function toggleColumnVisibility(columns, key) { * @returns {{ visible: number, total: number }} */ export function getVisibleColumnCount(columns) { - return { - visible: columns.filter((c) => c.visible).length, - total: columns.length - }; + return { + visible: columns.filter((c) => c.visible).length, + total: columns.length + }; } // Common status options for CRM entities // Must match Django LEAD_STATUS: assigned, in process, converted, recycled, closed export const leadStatusOptions = [ - { value: 'ASSIGNED', label: 'Assigned', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }, - { value: 'IN_PROCESS', label: 'In Process', color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' }, - { value: 'CONVERTED', label: 'Converted', color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' }, - { value: 'RECYCLED', label: 'Recycled', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' }, - { value: 'CLOSED', label: 'Closed', color: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' } + { + value: 'ASSIGNED', + label: 'Assigned', + color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + }, + { + value: 'IN_PROCESS', + label: 'In Process', + color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + }, + { + value: 'CONVERTED', + label: 'Converted', + color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' + }, + { + value: 'RECYCLED', + label: 'Recycled', + color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + }, + { + value: 'CLOSED', + label: 'Closed', + color: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' + } ]; export const leadRatingOptions = [ - { value: 'HOT', label: 'Hot', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' }, - { value: 'WARM', label: 'Warm', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' }, - { value: 'COLD', label: 'Cold', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' } + { + value: 'HOT', + label: 'Hot', + color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' + }, + { + value: 'WARM', + label: 'Warm', + color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + }, + { + value: 'COLD', + label: 'Cold', + color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + } ]; export const priorityOptions = [ - { value: 'URGENT', label: 'Urgent', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' }, - { value: 'HIGH', label: 'High', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' }, - { value: 'NORMAL', label: 'Normal', color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' }, - { value: 'MEDIUM', label: 'Medium', color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' }, - { value: 'LOW', label: 'Low', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' } + { + value: 'URGENT', + label: 'Urgent', + color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' + }, + { + value: 'HIGH', + label: 'High', + color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + }, + { + value: 'NORMAL', + label: 'Normal', + color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + }, + { + value: 'MEDIUM', + label: 'Medium', + color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + }, + { + value: 'LOW', + label: 'Low', + color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + } ]; export const taskStatusOptions = [ - { value: 'NOT STARTED', label: 'Not Started', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }, - { value: 'IN PROGRESS', label: 'In Progress', color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' }, - { value: 'PENDING', label: 'Pending', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' }, - { value: 'COMPLETED', label: 'Completed', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' }, - { value: 'DEFERRED', label: 'Deferred', color: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' } + { + value: 'NOT STARTED', + label: 'Not Started', + color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + }, + { + value: 'IN PROGRESS', + label: 'In Progress', + color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + }, + { + value: 'PENDING', + label: 'Pending', + color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + }, + { + value: 'COMPLETED', + label: 'Completed', + color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + }, + { + value: 'DEFERRED', + label: 'Deferred', + color: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' + } ]; export const caseStatusOptions = [ - { value: 'New', label: 'New', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }, - { value: 'Assigned', label: 'Assigned', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }, - { value: 'Pending', label: 'Pending', color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' }, - { value: 'Closed', label: 'Closed', color: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' }, - { value: 'Rejected', label: 'Rejected', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' }, - { value: 'Duplicate', label: 'Duplicate', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' } + { + value: 'New', + label: 'New', + color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + }, + { + value: 'Assigned', + label: 'Assigned', + color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + }, + { + value: 'Pending', + label: 'Pending', + color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + }, + { + value: 'Closed', + label: 'Closed', + color: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' + }, + { + value: 'Rejected', + label: 'Rejected', + color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' + }, + { + value: 'Duplicate', + label: 'Duplicate', + color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + } ]; export const caseTypeOptions = [ - { value: 'Question', label: 'Question', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }, - { value: 'Incident', label: 'Incident', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' }, - { value: 'Problem', label: 'Problem', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' } + { + value: 'Question', + label: 'Question', + color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + }, + { + value: 'Incident', + label: 'Incident', + color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + }, + { + value: 'Problem', + label: 'Problem', + color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' + } ]; export const casePriorityOptions = [ - { value: 'Low', label: 'Low', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' }, - { value: 'Normal', label: 'Normal', color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' }, - { value: 'High', label: 'High', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' }, - { value: 'Urgent', label: 'Urgent', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' } + { + value: 'Low', + label: 'Low', + color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + }, + { + value: 'Normal', + label: 'Normal', + color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + }, + { + value: 'High', + label: 'High', + color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + }, + { + value: 'Urgent', + label: 'Urgent', + color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' + } ]; export const accountStatusOptions = [ - { value: 'ACTIVE', label: 'Active', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' }, - { value: 'INACTIVE', label: 'Inactive', color: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' }, - { value: 'PENDING', label: 'Pending', color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' } + { + value: 'ACTIVE', + label: 'Active', + color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + }, + { + value: 'INACTIVE', + label: 'Inactive', + color: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' + }, + { + value: 'PENDING', + label: 'Pending', + color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + } ]; export const opportunityStageOptions = [ - { value: 'PROSPECTING', label: 'Prospecting', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }, - { value: 'QUALIFICATION', label: 'Qualification', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' }, - { value: 'PROPOSAL', label: 'Proposal', color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' }, - { value: 'NEGOTIATION', label: 'Negotiation', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' }, - { value: 'CLOSED_WON', label: 'Closed Won', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' }, - { value: 'CLOSED_LOST', label: 'Closed Lost', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' } + { + value: 'PROSPECTING', + label: 'Prospecting', + color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + }, + { + value: 'QUALIFICATION', + label: 'Qualification', + color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' + }, + { + value: 'PROPOSAL', + label: 'Proposal', + color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' + }, + { + value: 'NEGOTIATION', + label: 'Negotiation', + color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + }, + { + value: 'CLOSED_WON', + label: 'Closed Won', + color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + }, + { + value: 'CLOSED_LOST', + label: 'Closed Lost', + color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' + } ]; diff --git a/frontend/src/lib/utils/ui-helpers.js b/frontend/src/lib/utils/ui-helpers.js index 62a8f06e..87aab1f1 100644 --- a/frontend/src/lib/utils/ui-helpers.js +++ b/frontend/src/lib/utils/ui-helpers.js @@ -9,19 +9,19 @@ * @returns {string} Tailwind CSS classes */ export function getLeadStatusClass(status) { - /** @type {{ [key: string]: string }} */ - const classes = { - NEW: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', - ASSIGNED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', - IN_PROCESS: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', - PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', - CONTACTED: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', - QUALIFIED: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', - CONVERTED: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', - RECYCLED: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', - CLOSED: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' - }; - return classes[status?.toUpperCase() || ''] || classes.NEW; + /** @type {{ [key: string]: string }} */ + const classes = { + NEW: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + ASSIGNED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + IN_PROCESS: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + CONTACTED: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + QUALIFIED: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', + CONVERTED: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', + RECYCLED: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + CLOSED: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' + }; + return classes[status?.toUpperCase() || ''] || classes.NEW; } /** @@ -30,19 +30,19 @@ export function getLeadStatusClass(status) { * @returns {string} Tailwind CSS classes */ export function getCaseStatusClass(status) { - /** @type {{ [key: string]: string }} */ - const classes = { - NEW: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', - ASSIGNED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', - OPEN: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', - PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', - 'IN PROGRESS': 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', - IN_PROGRESS: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', - CLOSED: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', - REJECTED: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', - DUPLICATE: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' - }; - return classes[status?.toUpperCase() || ''] || classes.NEW; + /** @type {{ [key: string]: string }} */ + const classes = { + NEW: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + ASSIGNED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + OPEN: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + 'IN PROGRESS': 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + IN_PROGRESS: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + CLOSED: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', + REJECTED: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + DUPLICATE: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' + }; + return classes[status?.toUpperCase() || ''] || classes.NEW; } /** @@ -51,13 +51,16 @@ export function getCaseStatusClass(status) { * @returns {string} Tailwind CSS classes */ export function getCaseTypeClass(caseType) { - /** @type {{ [key: string]: string }} */ - const classes = { - QUESTION: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', - INCIDENT: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', - PROBLEM: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' - }; - return classes[caseType?.toUpperCase() || ''] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; + /** @type {{ [key: string]: string }} */ + const classes = { + QUESTION: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + INCIDENT: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + PROBLEM: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' + }; + return ( + classes[caseType?.toUpperCase() || ''] || + 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' + ); } /** @@ -66,15 +69,15 @@ export function getCaseTypeClass(caseType) { * @returns {string} Tailwind CSS classes */ export function getPriorityClass(priority) { - /** @type {{ [key: string]: string }} */ - const classes = { - URGENT: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', - HIGH: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', - NORMAL: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', - MEDIUM: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', - LOW: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' - }; - return classes[priority?.toUpperCase() || ''] || classes.NORMAL; + /** @type {{ [key: string]: string }} */ + const classes = { + URGENT: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + HIGH: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + NORMAL: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + MEDIUM: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + LOW: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + }; + return classes[priority?.toUpperCase() || ''] || classes.NORMAL; } /** @@ -83,13 +86,19 @@ export function getPriorityClass(priority) { * @returns {{ color: string, bgColor: string, dots: number }} */ export function getRatingConfig(rating) { - /** @type {{ [key: string]: { color: string, bgColor: string, dots: number } }} */ - const configs = { - HOT: { color: 'text-red-500', bgColor: 'bg-red-500', dots: 3 }, - WARM: { color: 'text-orange-500', bgColor: 'bg-orange-500', dots: 2 }, - COLD: { color: 'text-blue-500', bgColor: 'bg-blue-500', dots: 1 } - }; - return configs[rating?.toUpperCase() || ''] || { color: 'text-gray-400', bgColor: 'bg-gray-400', dots: 0 }; + /** @type {{ [key: string]: { color: string, bgColor: string, dots: number } }} */ + const configs = { + HOT: { color: 'text-red-500', bgColor: 'bg-red-500', dots: 3 }, + WARM: { color: 'text-orange-500', bgColor: 'bg-orange-500', dots: 2 }, + COLD: { color: 'text-blue-500', bgColor: 'bg-blue-500', dots: 1 } + }; + return ( + configs[rating?.toUpperCase() || ''] || { + color: 'text-gray-400', + bgColor: 'bg-gray-400', + dots: 0 + } + ); } /** @@ -98,17 +107,17 @@ export function getRatingConfig(rating) { * @returns {string} Tailwind CSS classes */ export function getTaskStatusClass(status) { - /** @type {{ [key: string]: string }} */ - const classes = { - NEW: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', - 'NOT STARTED': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', - 'IN PROGRESS': 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', - IN_PROGRESS: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', - PENDING: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', - COMPLETED: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', - DEFERRED: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' - }; - return classes[status?.toUpperCase() || ''] || classes.NEW; + /** @type {{ [key: string]: string }} */ + const classes = { + NEW: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + 'NOT STARTED': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + 'IN PROGRESS': 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + IN_PROGRESS: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + PENDING: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + COMPLETED: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + DEFERRED: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' + }; + return classes[status?.toUpperCase() || ''] || classes.NEW; } /** @@ -117,6 +126,6 @@ export function getTaskStatusClass(status) { * @returns {string} */ export function formatStatusDisplay(status) { - if (!status) return 'Unknown'; - return status.replace(/_/g, ' '); + if (!status) return 'Unknown'; + return status.replace(/_/g, ' '); } diff --git a/frontend/src/routes/(app)/+layout.server.js b/frontend/src/routes/(app)/+layout.server.js index 26641486..6a97de1b 100644 --- a/frontend/src/routes/(app)/+layout.server.js +++ b/frontend/src/routes/(app)/+layout.server.js @@ -1,15 +1,15 @@ /** @type {import('./$types').LayoutServerLoad} */ export async function load({ locals }) { - // console.log("locals", locals.user); - return { - user: locals.user, - org_name: locals.org_name || 'BottleCRM', - org_settings: locals.org_settings || { - default_currency: 'USD', - currency_symbol: '$', - default_country: null - } - }; + // console.log("locals", locals.user); + return { + user: locals.user, + org_name: locals.org_name || 'BottleCRM', + org_settings: locals.org_settings || { + default_currency: 'USD', + currency_symbol: '$', + default_country: null + } + }; } export const ssr = true; diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index 6ce2cad5..f423eead 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -1,23 +1,23 @@ -
    - {@render children()} -
    +
    + {@render children()} +
    diff --git a/frontend/src/routes/(app)/+page.server.js b/frontend/src/routes/(app)/+page.server.js index fe1c67b7..03b1e939 100644 --- a/frontend/src/routes/(app)/+page.server.js +++ b/frontend/src/routes/(app)/+page.server.js @@ -13,141 +13,141 @@ import { apiRequest } from '$lib/api-helpers.js'; /** @type {import('./$types').PageServerLoad} */ export async function load({ locals, cookies }) { - const userId = locals.user?.id; - const org = locals.org; + const userId = locals.user?.id; + const org = locals.org; - if (!userId || !org) { - return { - error: 'User not authenticated' - }; - } + if (!userId || !org) { + return { + error: 'User not authenticated' + }; + } - try { - // Fetch dashboard data from Django API - const dashboardResponse = await apiRequest('/dashboard/', {}, { cookies, org }); + try { + // Fetch dashboard data from Django API + const dashboardResponse = await apiRequest('/dashboard/', {}, { cookies, org }); - // Django returns: - // { - // accounts_count, contacts_count, leads_count, opportunities_count, - // accounts: [], contacts: [], leads: [], opportunities: [] - // } + // Django returns: + // { + // accounts_count, contacts_count, leads_count, opportunities_count, + // accounts: [], contacts: [], leads: [], opportunities: [] + // } - // Transform recent leads - const recentLeads = (dashboardResponse.leads || []).slice(0, 5).map((lead) => ({ - id: lead.id, - firstName: lead.first_name, - lastName: lead.last_name, - company: lead.company?.name || null, - status: lead.status, - createdAt: lead.created_on - })); + // Transform recent leads + const recentLeads = (dashboardResponse.leads || []).slice(0, 5).map((lead) => ({ + id: lead.id, + firstName: lead.first_name, + lastName: lead.last_name, + company: lead.company?.name || null, + status: lead.status, + createdAt: lead.created_on + })); - // Transform recent opportunities - const recentOpportunities = (dashboardResponse.opportunities || []).slice(0, 5).map((opp) => ({ - id: opp.id, - name: opp.name, - amount: opp.amount ? parseFloat(opp.amount) : null, - currency: opp.currency || null, - stage: opp.stage, - probability: opp.probability ? parseFloat(opp.probability) : null, - createdAt: opp.created_on, - closed_on: opp.closed_on, - account: opp.account - ? { - name: opp.account.name - } - : null - })); + // Transform recent opportunities + const recentOpportunities = (dashboardResponse.opportunities || []).slice(0, 5).map((opp) => ({ + id: opp.id, + name: opp.name, + amount: opp.amount ? parseFloat(opp.amount) : null, + currency: opp.currency || null, + stage: opp.stage, + probability: opp.probability ? parseFloat(opp.probability) : null, + createdAt: opp.created_on, + closed_on: opp.closed_on, + account: opp.account + ? { + name: opp.account.name + } + : null + })); - // Calculate opportunity revenue (sum of all opportunity amounts) - const opportunityRevenue = (dashboardResponse.opportunities || []).reduce( - (sum, opp) => sum + (opp.amount ? parseFloat(opp.amount) : 0), - 0 - ); + // Calculate opportunity revenue (sum of all opportunity amounts) + const opportunityRevenue = (dashboardResponse.opportunities || []).reduce( + (sum, opp) => sum + (opp.amount ? parseFloat(opp.amount) : 0), + 0 + ); - // Tasks and activities now come from dashboard response (no separate API calls) - // Transform tasks from dashboard response - const upcomingTasks = (dashboardResponse.tasks || []) - .filter((task) => { - const isAssignedToUser = task.assigned_to && task.assigned_to.some((id) => id === userId); - const isNotCompleted = task.status !== 'Completed'; - return isAssignedToUser && isNotCompleted; - }) - .slice(0, 5) - .map((task) => ({ - id: task.id, - subject: task.title, - status: task.status, - priority: task.priority, - dueDate: task.due_date - })); + // Tasks and activities now come from dashboard response (no separate API calls) + // Transform tasks from dashboard response + const upcomingTasks = (dashboardResponse.tasks || []) + .filter((task) => { + const isAssignedToUser = task.assigned_to && task.assigned_to.some((id) => id === userId); + const isNotCompleted = task.status !== 'Completed'; + return isAssignedToUser && isNotCompleted; + }) + .slice(0, 5) + .map((task) => ({ + id: task.id, + subject: task.title, + status: task.status, + priority: task.priority, + dueDate: task.due_date + })); - // Transform activities from dashboard response - const recentActivities = (dashboardResponse.activities || []).map((activity) => ({ - id: activity.id, - user: { - name: activity.user?.name || activity.user?.email?.split('@')[0] || 'Unknown' - }, - action: activity.action, - entityType: activity.entity_type, - entityId: activity.entity_id, - entityName: activity.entity_name, - description: - activity.description || - `${activity.action_display} ${activity.entity_type}: ${activity.entity_name}`, - timestamp: activity.timestamp, - humanizedTime: activity.humanized_time - })); + // Transform activities from dashboard response + const recentActivities = (dashboardResponse.activities || []).map((activity) => ({ + id: activity.id, + user: { + name: activity.user?.name || activity.user?.email?.split('@')[0] || 'Unknown' + }, + action: activity.action, + entityType: activity.entity_type, + entityId: activity.entity_id, + entityName: activity.entity_name, + description: + activity.description || + `${activity.action_display} ${activity.entity_type}: ${activity.entity_name}`, + timestamp: activity.timestamp, + humanizedTime: activity.humanized_time + })); - // Count pending tasks for the user - const pendingTasks = upcomingTasks.length; + // Count pending tasks for the user + const pendingTasks = upcomingTasks.length; - // Transform hot leads from new API response - const hotLeads = (dashboardResponse.hot_leads || []).map((lead) => ({ - id: lead.id, - first_name: lead.first_name, - last_name: lead.last_name, - company: lead.company, - rating: lead.rating, - next_follow_up: lead.next_follow_up, - last_contacted: lead.last_contacted - })); + // Transform hot leads from new API response + const hotLeads = (dashboardResponse.hot_leads || []).map((lead) => ({ + id: lead.id, + first_name: lead.first_name, + last_name: lead.last_name, + company: lead.company, + rating: lead.rating, + next_follow_up: lead.next_follow_up, + last_contacted: lead.last_contacted + })); - return { - metrics: { - totalLeads: dashboardResponse.leads_count || 0, - totalOpportunities: dashboardResponse.opportunities_count || 0, - totalAccounts: dashboardResponse.accounts_count || 0, - totalContacts: dashboardResponse.contacts_count || 0, - pendingTasks: pendingTasks, - opportunityRevenue: opportunityRevenue - }, - recentData: { - leads: recentLeads, - opportunities: recentOpportunities, - tasks: upcomingTasks, - activities: recentActivities - }, - // NEW: Enhanced dashboard data - urgentCounts: dashboardResponse.urgent_counts || { - overdue_tasks: 0, - tasks_due_today: 0, - followups_today: 0, - hot_leads: 0 - }, - pipelineByStage: dashboardResponse.pipeline_by_stage || {}, - revenueMetrics: dashboardResponse.revenue_metrics || { - pipeline_value: 0, - weighted_pipeline: 0, - won_this_month: 0, - conversion_rate: 0 - }, - hotLeads: hotLeads - }; - } catch (error) { - console.error('Dashboard load error:', error); - return { - error: 'Failed to load dashboard data' - }; - } + return { + metrics: { + totalLeads: dashboardResponse.leads_count || 0, + totalOpportunities: dashboardResponse.opportunities_count || 0, + totalAccounts: dashboardResponse.accounts_count || 0, + totalContacts: dashboardResponse.contacts_count || 0, + pendingTasks: pendingTasks, + opportunityRevenue: opportunityRevenue + }, + recentData: { + leads: recentLeads, + opportunities: recentOpportunities, + tasks: upcomingTasks, + activities: recentActivities + }, + // NEW: Enhanced dashboard data + urgentCounts: dashboardResponse.urgent_counts || { + overdue_tasks: 0, + tasks_due_today: 0, + followups_today: 0, + hot_leads: 0 + }, + pipelineByStage: dashboardResponse.pipeline_by_stage || {}, + revenueMetrics: dashboardResponse.revenue_metrics || { + pipeline_value: 0, + weighted_pipeline: 0, + won_this_month: 0, + conversion_rate: 0 + }, + hotLeads: hotLeads + }; + } catch (error) { + console.error('Dashboard load error:', error); + return { + error: 'Failed to load dashboard data' + }; + } } diff --git a/frontend/src/routes/(app)/+page.svelte b/frontend/src/routes/(app)/+page.svelte index a1b3121d..c1715245 100644 --- a/frontend/src/routes/(app)/+page.svelte +++ b/frontend/src/routes/(app)/+page.svelte @@ -1,143 +1,143 @@ - Dashboard - BottleCRM + Dashboard - BottleCRM
    - -
    -
    -

    Dashboard

    -

    - Welcome back! Here's what's happening with your CRM. -

    -
    -
    + +
    +
    +

    Dashboard

    +

    + Welcome back! Here's what's happening with your CRM. +

    +
    +
    - {#if data.error} -
    - - {data.error} -
    - {:else} - - + {#if data.error} +
    + + {data.error} +
    + {:else} + + - -
    -
    -

    Sales Pipeline

    - {currencyNote} -
    - -
    + +
    +
    +

    Sales Pipeline

    + {currencyNote} +
    + +
    - -
    - -
    - - {#snippet icon()} - - {/snippet} - - - {#snippet icon()} - - {/snippet} - - - {#snippet icon()} - - {/snippet} - - - {#snippet icon()} - - {/snippet} - -
    + +
    + +
    + + {#snippet icon()} + + {/snippet} + + + {#snippet icon()} + + {/snippet} + + + {#snippet icon()} + + {/snippet} + + + {#snippet icon()} + + {/snippet} + +
    - - -
    + + +
    - -
    - - -
    + +
    + + +
    - -
    - - -
    - {/if} + +
    + + +
    + {/if}
    diff --git a/frontend/src/routes/(app)/accounts/+page.server.js b/frontend/src/routes/(app)/accounts/+page.server.js index 0da5bd22..6100c453 100644 --- a/frontend/src/routes/(app)/accounts/+page.server.js +++ b/frontend/src/routes/(app)/accounts/+page.server.js @@ -15,431 +15,431 @@ import { apiRequest, buildQueryParams } from '$lib/api-helpers.js'; /** @type {import('./$types').PageServerLoad} */ export async function load({ locals, url, cookies }) { - const org = locals.org; + const org = locals.org; - if (!org) { - throw error(401, 'Organization context required'); - } + if (!org) { + throw error(401, 'Organization context required'); + } - // Parse filter params from URL - const filters = { - search: url.searchParams.get('search') || '', - industry: url.searchParams.get('industry') || '', - assigned_to: url.searchParams.getAll('assigned_to'), - tags: url.searchParams.getAll('tags'), - created_at_gte: url.searchParams.get('created_at_gte') || '', - created_at_lte: url.searchParams.get('created_at_lte') || '' - }; + // Parse filter params from URL + const filters = { + search: url.searchParams.get('search') || '', + industry: url.searchParams.get('industry') || '', + assigned_to: url.searchParams.getAll('assigned_to'), + tags: url.searchParams.getAll('tags'), + created_at_gte: url.searchParams.get('created_at_gte') || '', + created_at_lte: url.searchParams.get('created_at_lte') || '' + }; - // Extract URL parameters - const page = parseInt(url.searchParams.get('page') || '1'); - const limit = parseInt(url.searchParams.get('limit') || '10'); - const sort = url.searchParams.get('sort') || 'name'; - const order = url.searchParams.get('order') || 'asc'; - const status = url.searchParams.get('status'); // 'open' or 'closed' + // Extract URL parameters + const page = parseInt(url.searchParams.get('page') || '1'); + const limit = parseInt(url.searchParams.get('limit') || '10'); + const sort = url.searchParams.get('sort') || 'name'; + const order = url.searchParams.get('order') || 'asc'; + const status = url.searchParams.get('status'); // 'open' or 'closed' - try { - // Build query parameters for Django - const queryParams = buildQueryParams({ - page, - limit, - sort, - order - }); + try { + // Build query parameters for Django + const queryParams = buildQueryParams({ + page, + limit, + sort, + order + }); - // Add filter params - if (filters.search) queryParams.append('search', filters.search); - if (filters.industry) queryParams.append('industry', filters.industry); - filters.assigned_to.forEach((id) => queryParams.append('assigned_to', id)); - filters.tags.forEach((id) => queryParams.append('tags', id)); - if (filters.created_at_gte) queryParams.append('created_at__gte', filters.created_at_gte); - if (filters.created_at_lte) queryParams.append('created_at__lte', filters.created_at_lte); + // Add filter params + if (filters.search) queryParams.append('search', filters.search); + if (filters.industry) queryParams.append('industry', filters.industry); + filters.assigned_to.forEach((id) => queryParams.append('assigned_to', id)); + filters.tags.forEach((id) => queryParams.append('tags', id)); + if (filters.created_at_gte) queryParams.append('created_at__gte', filters.created_at_gte); + if (filters.created_at_lte) queryParams.append('created_at__lte', filters.created_at_lte); - // Make API request - const response = await apiRequest(`/accounts/?${queryParams.toString()}`, {}, { cookies, org }); + // Make API request + const response = await apiRequest(`/accounts/?${queryParams.toString()}`, {}, { cookies, org }); - // Django returns complex structure with active_accounts and closed_accounts - // We need to extract based on status filter - let accounts = []; - let total = 0; + // Django returns complex structure with active_accounts and closed_accounts + // We need to extract based on status filter + let accounts = []; + let total = 0; - if (response.active_accounts && response.closed_accounts) { - // Django endpoint returns separate lists - const activeAccounts = response.active_accounts.open_accounts || []; - const closedAccounts = response.closed_accounts.close_accounts || []; - const activeCount = response.active_accounts.open_accounts_count || activeAccounts.length; - const closedCount = response.closed_accounts.close_accounts_count || closedAccounts.length; + if (response.active_accounts && response.closed_accounts) { + // Django endpoint returns separate lists + const activeAccounts = response.active_accounts.open_accounts || []; + const closedAccounts = response.closed_accounts.close_accounts || []; + const activeCount = response.active_accounts.open_accounts_count || activeAccounts.length; + const closedCount = response.closed_accounts.close_accounts_count || closedAccounts.length; - if (status === 'open') { - accounts = activeAccounts; - total = activeCount; - } else if (status === 'closed') { - accounts = closedAccounts; - total = closedCount; - } else { - // No filter - show all accounts (both active and closed) - accounts = [...activeAccounts, ...closedAccounts]; - total = activeCount + closedCount; - } - } else if (response.results) { - // Standard Django pagination response - accounts = response.results; - total = response.count || 0; - } + if (status === 'open') { + accounts = activeAccounts; + total = activeCount; + } else if (status === 'closed') { + accounts = closedAccounts; + total = closedCount; + } else { + // No filter - show all accounts (both active and closed) + accounts = [...activeAccounts, ...closedAccounts]; + total = activeCount + closedCount; + } + } else if (response.results) { + // Standard Django pagination response + accounts = response.results; + total = response.count || 0; + } - // Transform Django accounts to match SvelteKit expectations - const transformedAccounts = accounts.map((account) => { - // Calculate counts from related data - const opportunityCount = account.opportunities?.length || 0; - const contactCount = account.contacts?.length || 0; - const taskCount = account.tasks?.length || 0; - const caseCount = account.cases?.length || 0; + // Transform Django accounts to match SvelteKit expectations + const transformedAccounts = accounts.map((account) => { + // Calculate counts from related data + const opportunityCount = account.opportunities?.length || 0; + const contactCount = account.contacts?.length || 0; + const taskCount = account.tasks?.length || 0; + const caseCount = account.cases?.length || 0; - const openOpportunities = - account.opportunities?.filter((opp) => !['CLOSED_WON', 'CLOSED_LOST'].includes(opp.stage)) - .length || 0; + const openOpportunities = + account.opportunities?.filter((opp) => !['CLOSED_WON', 'CLOSED_LOST'].includes(opp.stage)) + .length || 0; - const totalOpportunityValue = - account.opportunities?.reduce((sum, opp) => sum + (parseFloat(opp.amount) || 0), 0) || 0; + const totalOpportunityValue = + account.opportunities?.reduce((sum, opp) => sum + (parseFloat(opp.amount) || 0), 0) || 0; - return { - id: account.id, - name: account.name, - email: account.email, - phone: account.phone, - website: account.website, - industry: account.industry, - description: account.description, - isActive: account.is_active === true, - createdAt: account.created_at, - updatedAt: account.updated_at || account.created_at, + return { + id: account.id, + name: account.name, + email: account.email, + phone: account.phone, + website: account.website, + industry: account.industry, + description: account.description, + isActive: account.is_active === true, + createdAt: account.created_at, + updatedAt: account.updated_at || account.created_at, - // Address fields (matching API field names) - addressLine: account.address_line, - city: account.city, - state: account.state, - postcode: account.postcode, - country: account.country, + // Address fields (matching API field names) + addressLine: account.address_line, + city: account.city, + state: account.state, + postcode: account.postcode, + country: account.country, - // Business fields - annualRevenue: account.annual_revenue ? parseFloat(account.annual_revenue) : null, - currency: account.currency || null, - numberOfEmployees: account.number_of_employees, + // Business fields + annualRevenue: account.annual_revenue ? parseFloat(account.annual_revenue) : null, + currency: account.currency || null, + numberOfEmployees: account.number_of_employees, - // Owner - Django returns assigned_to array - owner: - account.assigned_to && account.assigned_to.length > 0 - ? { - id: account.assigned_to[0].id, - name: account.assigned_to[0].user_details?.email || 'Unknown', - email: account.assigned_to[0].user_details?.email, - profilePhoto: account.assigned_to[0].user_details?.profile_pic - } - : account.created_by - ? { - id: account.created_by.id, - name: account.created_by.email, - email: account.created_by.email, - profilePhoto: account.created_by.profile_pic - } - : null, + // Owner - Django returns assigned_to array + owner: + account.assigned_to && account.assigned_to.length > 0 + ? { + id: account.assigned_to[0].id, + name: account.assigned_to[0].user_details?.email || 'Unknown', + email: account.assigned_to[0].user_details?.email, + profilePhoto: account.assigned_to[0].user_details?.profile_pic + } + : account.created_by + ? { + id: account.created_by.id, + name: account.created_by.email, + email: account.created_by.email, + profilePhoto: account.created_by.profile_pic + } + : null, - // Aggregated data - opportunityCount, - contactCount, - taskCount, - caseCount, - openOpportunities, - totalOpportunityValue, + // Aggregated data + opportunityCount, + contactCount, + taskCount, + caseCount, + openOpportunities, + totalOpportunityValue, - // Top contacts for display - topContacts: - account.contacts?.slice(0, 3).map((contact) => ({ - id: contact.id, - name: `${contact.first_name || ''} ${contact.last_name || ''}`.trim() - })) || [], + // Top contacts for display + topContacts: + account.contacts?.slice(0, 3).map((contact) => ({ + id: contact.id, + name: `${contact.first_name || ''} ${contact.last_name || ''}`.trim() + })) || [], - // M2M field IDs for form editing - assignedTo: (account.assigned_to || []).map((u) => u.id), - contacts: (account.contacts || []).map((c) => c.id), - tags: (account.tags || []).map((t) => t.id), + // M2M field IDs for form editing + assignedTo: (account.assigned_to || []).map((u) => u.id), + contacts: (account.contacts || []).map((c) => c.id), + tags: (account.tags || []).map((t) => t.id), - // Full M2M data for display - assignedToData: account.assigned_to || [], - contactsData: account.contacts || [], - tagsData: account.tags || [], - teams: account.teams || [] - }; - }); + // Full M2M data for display + assignedToData: account.assigned_to || [], + contactsData: account.contacts || [], + tagsData: account.tags || [], + teams: account.teams || [] + }; + }); - // Extract form options from Django response - const users = (response.users || []).map((u) => ({ - id: u.id, - email: u.user__email - })); + // Extract form options from Django response + const users = (response.users || []).map((u) => ({ + id: u.id, + email: u.user__email + })); - const allContacts = (response.contacts || []).map((c) => ({ - id: c.id, - name: c.first_name || 'Unknown' - })); + const allContacts = (response.contacts || []).map((c) => ({ + id: c.id, + name: c.first_name || 'Unknown' + })); - const allTags = (response.tags || []).map((t) => ({ - id: t.id, - name: t.name, - slug: t.slug, - color: t.color || 'blue' - })); + const allTags = (response.tags || []).map((t) => ({ + id: t.id, + name: t.name, + slug: t.slug, + color: t.color || 'blue' + })); - return { - accounts: transformedAccounts, - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit) || 1 - }, - filters, - // Form options for M2M fields - users, - contacts: allContacts, - tags: allTags - }; - } catch (err) { - console.error('Error fetching accounts from API:', err); - throw error(500, `Failed to fetch accounts: ${err.message}`); - } + return { + accounts: transformedAccounts, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit) || 1 + }, + filters, + // Form options for M2M fields + users, + contacts: allContacts, + tags: allTags + }; + } catch (err) { + console.error('Error fetching accounts from API:', err); + throw error(500, `Failed to fetch accounts: ${err.message}`); + } } /** @type {import('./$types').Actions} */ export const actions = { - create: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - const name = form.get('name')?.toString().trim(); - const email = form.get('email')?.toString().trim() || null; - const phone = form.get('phone')?.toString().trim() || null; - const website = form.get('website')?.toString().trim() || null; - const industry = form.get('industry')?.toString().trim() || null; - const description = form.get('description')?.toString().trim() || null; - const address_line = form.get('address_line')?.toString().trim() || null; - const city = form.get('city')?.toString().trim() || null; - const state = form.get('state')?.toString().trim() || null; - const postcode = form.get('postcode')?.toString().trim() || null; - const country = form.get('country')?.toString().trim() || null; - const annual_revenue = form.get('annual_revenue')?.toString().trim() || null; - const currency = form.get('currency')?.toString().trim() || null; - const number_of_employees = form.get('number_of_employees')?.toString().trim() || null; + create: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + const name = form.get('name')?.toString().trim(); + const email = form.get('email')?.toString().trim() || null; + const phone = form.get('phone')?.toString().trim() || null; + const website = form.get('website')?.toString().trim() || null; + const industry = form.get('industry')?.toString().trim() || null; + const description = form.get('description')?.toString().trim() || null; + const address_line = form.get('address_line')?.toString().trim() || null; + const city = form.get('city')?.toString().trim() || null; + const state = form.get('state')?.toString().trim() || null; + const postcode = form.get('postcode')?.toString().trim() || null; + const country = form.get('country')?.toString().trim() || null; + const annual_revenue = form.get('annual_revenue')?.toString().trim() || null; + const currency = form.get('currency')?.toString().trim() || null; + const number_of_employees = form.get('number_of_employees')?.toString().trim() || null; - // M2M fields (JSON arrays) - const assignedToRaw = form.get('assigned_to')?.toString() || '[]'; - const contactsRaw = form.get('contacts')?.toString() || '[]'; - const tagsRaw = form.get('tags')?.toString() || '[]'; + // M2M fields (JSON arrays) + const assignedToRaw = form.get('assigned_to')?.toString() || '[]'; + const contactsRaw = form.get('contacts')?.toString() || '[]'; + const tagsRaw = form.get('tags')?.toString() || '[]'; - /** @type {string[]} */ - let assigned_to = []; - /** @type {string[]} */ - let contacts = []; - /** @type {string[]} */ - let tags = []; + /** @type {string[]} */ + let assigned_to = []; + /** @type {string[]} */ + let contacts = []; + /** @type {string[]} */ + let tags = []; - try { - assigned_to = JSON.parse(assignedToRaw); - contacts = JSON.parse(contactsRaw); - tags = JSON.parse(tagsRaw); - } catch (e) { - // If parsing fails, use empty arrays - } + try { + assigned_to = JSON.parse(assignedToRaw); + contacts = JSON.parse(contactsRaw); + tags = JSON.parse(tagsRaw); + } catch (e) { + // If parsing fails, use empty arrays + } - if (!name) { - return fail(400, { error: 'Account name is required.' }); - } + if (!name) { + return fail(400, { error: 'Account name is required.' }); + } - const accountData = { - name, - email, - phone, - website, - industry, - description, - address_line, - city, - state, - postcode, - country, - annual_revenue: annual_revenue ? parseFloat(annual_revenue) : null, - currency, - number_of_employees: number_of_employees ? parseInt(number_of_employees, 10) : null, - assigned_to, - contacts, - tags - }; + const accountData = { + name, + email, + phone, + website, + industry, + description, + address_line, + city, + state, + postcode, + country, + annual_revenue: annual_revenue ? parseFloat(annual_revenue) : null, + currency, + number_of_employees: number_of_employees ? parseInt(number_of_employees, 10) : null, + assigned_to, + contacts, + tags + }; - await apiRequest( - '/accounts/', - { - method: 'POST', - body: accountData - }, - { cookies, org: locals.org } - ); + await apiRequest( + '/accounts/', + { + method: 'POST', + body: accountData + }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error creating account:', err); - return fail(500, { error: 'Failed to create account' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error creating account:', err); + return fail(500, { error: 'Failed to create account' }); + } + }, - update: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - const accountId = form.get('accountId')?.toString(); - const name = form.get('name')?.toString().trim(); - const email = form.get('email')?.toString().trim() || null; - const phone = form.get('phone')?.toString().trim() || null; - const website = form.get('website')?.toString().trim() || null; - const industry = form.get('industry')?.toString().trim() || null; - const description = form.get('description')?.toString().trim() || null; - const address_line = form.get('address_line')?.toString().trim() || null; - const city = form.get('city')?.toString().trim() || null; - const state = form.get('state')?.toString().trim() || null; - const postcode = form.get('postcode')?.toString().trim() || null; - const country = form.get('country')?.toString().trim() || null; - const annual_revenue = form.get('annual_revenue')?.toString().trim() || null; - const currency = form.get('currency')?.toString().trim() || null; - const number_of_employees = form.get('number_of_employees')?.toString().trim() || null; + update: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + const accountId = form.get('accountId')?.toString(); + const name = form.get('name')?.toString().trim(); + const email = form.get('email')?.toString().trim() || null; + const phone = form.get('phone')?.toString().trim() || null; + const website = form.get('website')?.toString().trim() || null; + const industry = form.get('industry')?.toString().trim() || null; + const description = form.get('description')?.toString().trim() || null; + const address_line = form.get('address_line')?.toString().trim() || null; + const city = form.get('city')?.toString().trim() || null; + const state = form.get('state')?.toString().trim() || null; + const postcode = form.get('postcode')?.toString().trim() || null; + const country = form.get('country')?.toString().trim() || null; + const annual_revenue = form.get('annual_revenue')?.toString().trim() || null; + const currency = form.get('currency')?.toString().trim() || null; + const number_of_employees = form.get('number_of_employees')?.toString().trim() || null; - // M2M fields (JSON arrays) - const assignedToRaw = form.get('assigned_to')?.toString() || '[]'; - const contactsRaw = form.get('contacts')?.toString() || '[]'; - const tagsRaw = form.get('tags')?.toString() || '[]'; + // M2M fields (JSON arrays) + const assignedToRaw = form.get('assigned_to')?.toString() || '[]'; + const contactsRaw = form.get('contacts')?.toString() || '[]'; + const tagsRaw = form.get('tags')?.toString() || '[]'; - /** @type {string[]} */ - let assigned_to = []; - /** @type {string[]} */ - let contacts = []; - /** @type {string[]} */ - let tags = []; + /** @type {string[]} */ + let assigned_to = []; + /** @type {string[]} */ + let contacts = []; + /** @type {string[]} */ + let tags = []; - try { - assigned_to = JSON.parse(assignedToRaw); - contacts = JSON.parse(contactsRaw); - tags = JSON.parse(tagsRaw); - } catch (e) { - // If parsing fails, use empty arrays - } + try { + assigned_to = JSON.parse(assignedToRaw); + contacts = JSON.parse(contactsRaw); + tags = JSON.parse(tagsRaw); + } catch (e) { + // If parsing fails, use empty arrays + } - if (!accountId || !name) { - return fail(400, { error: 'Account ID and name are required.' }); - } + if (!accountId || !name) { + return fail(400, { error: 'Account ID and name are required.' }); + } - const accountData = { - name, - email, - phone, - website, - industry, - description, - address_line, - city, - state, - postcode, - country, - annual_revenue: annual_revenue ? parseFloat(annual_revenue) : null, - currency, - number_of_employees: number_of_employees ? parseInt(number_of_employees, 10) : null, - assigned_to, - contacts, - tags - }; + const accountData = { + name, + email, + phone, + website, + industry, + description, + address_line, + city, + state, + postcode, + country, + annual_revenue: annual_revenue ? parseFloat(annual_revenue) : null, + currency, + number_of_employees: number_of_employees ? parseInt(number_of_employees, 10) : null, + assigned_to, + contacts, + tags + }; - await apiRequest( - `/accounts/${accountId}/`, - { - method: 'PATCH', - body: accountData - }, - { cookies, org: locals.org } - ); + await apiRequest( + `/accounts/${accountId}/`, + { + method: 'PATCH', + body: accountData + }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error updating account:', err); - return fail(500, { error: 'Failed to update account' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error updating account:', err); + return fail(500, { error: 'Failed to update account' }); + } + }, - deactivate: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - const accountId = form.get('accountId')?.toString(); + deactivate: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + const accountId = form.get('accountId')?.toString(); - if (!accountId) { - return fail(400, { error: 'Account ID is required.' }); - } + if (!accountId) { + return fail(400, { error: 'Account ID is required.' }); + } - await apiRequest( - `/accounts/${accountId}/`, - { - method: 'PATCH', - body: { is_active: false } - }, - { cookies, org: locals.org } - ); + await apiRequest( + `/accounts/${accountId}/`, + { + method: 'PATCH', + body: { is_active: false } + }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error deactivating account:', err); - return fail(500, { error: 'Failed to deactivate account' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error deactivating account:', err); + return fail(500, { error: 'Failed to deactivate account' }); + } + }, - activate: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - const accountId = form.get('accountId')?.toString(); + activate: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + const accountId = form.get('accountId')?.toString(); - if (!accountId) { - return fail(400, { error: 'Account ID is required.' }); - } + if (!accountId) { + return fail(400, { error: 'Account ID is required.' }); + } - await apiRequest( - `/accounts/${accountId}/`, - { - method: 'PATCH', - body: { is_active: true } - }, - { cookies, org: locals.org } - ); + await apiRequest( + `/accounts/${accountId}/`, + { + method: 'PATCH', + body: { is_active: true } + }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error activating account:', err); - return fail(500, { error: 'Failed to activate account' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error activating account:', err); + return fail(500, { error: 'Failed to activate account' }); + } + }, - delete: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - const accountId = form.get('accountId')?.toString(); + delete: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + const accountId = form.get('accountId')?.toString(); - if (!accountId) { - return fail(400, { error: 'Account ID is required.' }); - } + if (!accountId) { + return fail(400, { error: 'Account ID is required.' }); + } - await apiRequest( - `/accounts/${accountId}/`, - { - method: 'DELETE' - }, - { cookies, org: locals.org } - ); + await apiRequest( + `/accounts/${accountId}/`, + { + method: 'DELETE' + }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error deleting account:', err); - return fail(500, { error: 'Failed to delete account' }); - } - } + return { success: true }; + } catch (err) { + console.error('Error deleting account:', err); + return fail(500, { error: 'Failed to delete account' }); + } + } }; diff --git a/frontend/src/routes/(app)/accounts/+page.svelte b/frontend/src/routes/(app)/accounts/+page.svelte index 3d52a474..7161998d 100644 --- a/frontend/src/routes/(app)/accounts/+page.svelte +++ b/frontend/src/routes/(app)/accounts/+page.svelte @@ -1,1227 +1,1237 @@ - Accounts - BottleCRM + Accounts - BottleCRM - {#snippet actions()} -
    - -
    - - - -
    + {#snippet actions()} +
    + +
    + + + +
    -
    +
    - - + + - - - - {#snippet child({ props })} - - {/snippet} - - - Toggle columns - - {#each columns as column (column.key)} - toggleColumn(column.key)} - disabled={column.canHide === false} - > - {column.label} - - {/each} - - - -
    - {/snippet} + + + + {#snippet child({ props })} + + {/snippet} + + + Toggle columns + + {#each columns as column (column.key)} + toggleColumn(column.key)} + disabled={column.canHide === false} + > + {column.label} + + {/each} + + + +
    + {/snippet}
    - - - updateFilters({ ...filters, search: value })} - placeholder="Search accounts..." - /> - updateFilters({ ...filters, industry: value })} - /> - - updateFilters({ ...filters, created_at_gte: start, created_at_lte: end })} - /> - updateFilters({ ...filters, tags: ids })} - /> - - - {#if filteredAccounts.length === 0} -
    - -

    No accounts found

    -
    - {:else} - - + + + updateFilters({ ...filters, search: value })} + placeholder="Search accounts..." + /> + updateFilters({ ...filters, industry: value })} + /> + + updateFilters({ ...filters, created_at_gte: start, created_at_lte: end })} + /> + updateFilters({ ...filters, tags: ids })} + /> + + + {#if filteredAccounts.length === 0} +
    + +

    No accounts found

    +
    + {:else} + + - -
    - {#each filteredAccounts as account (account.id)} - - {/each} -
    - {/if} + +
    + {#each filteredAccounts as account (account.id)} + + {/each} +
    + {/if} - - + +
    - {#snippet activitySection()} - - {#if isClosed && drawerMode !== 'create'} -
    -
    - -
    -

    This account is closed

    -

    - Reopen the account to make changes -

    -
    -
    -
    - {/if} + {#snippet activitySection()} + + {#if isClosed && drawerMode !== 'create'} +
    +
    + +
    +

    This account is closed

    +

    + Reopen the account to make changes +

    +
    +
    +
    + {/if} - - {#if drawerMode !== 'create' && selectedAccount} -
    -

    - Related -

    -
    -
    -
    - -
    -

    - {selectedAccount.contactCount || 0} -

    -

    Contacts

    -
    -
    -
    - -
    -

    - {selectedAccount.opportunityCount || 0} -

    -

    Opportunities

    -
    -
    -
    - -
    -

    - {selectedAccount.caseCount || 0} -

    -

    Cases

    -
    -
    -
    - -
    -

    - {selectedAccount.taskCount || 0} -

    -

    Tasks

    -
    -
    -
    + + {#if drawerMode !== 'create' && selectedAccount} +
    +

    + Related +

    +
    +
    +
    + +
    +

    + {selectedAccount.contactCount || 0} +

    +

    Contacts

    +
    +
    +
    + +
    +

    + {selectedAccount.opportunityCount || 0} +

    +

    Opportunities

    +
    +
    +
    + +
    +

    + {selectedAccount.caseCount || 0} +

    +

    Cases

    +
    +
    +
    + +
    +

    + {selectedAccount.taskCount || 0} +

    +

    Tasks

    +
    +
    +
    - - {#if !isClosed} -
    -

    - Quick Actions -

    -
    - - -
    -
    - - -
    -
    - {/if} + + {#if !isClosed} +
    +

    + Quick Actions +

    +
    + + +
    +
    + + +
    +
    + {/if} - -
    -

    - Details -

    -
    -
    -

    Owner

    -

    - {selectedAccount.owner?.name || 'Unassigned'} -

    -
    -
    -

    Created

    -

    - {formatRelativeDate(selectedAccount.createdAt)} -

    -
    -
    -
    - {/if} - {/snippet} + +
    +

    + Details +

    +
    +
    +

    Owner

    +

    + {selectedAccount.owner?.name || 'Unassigned'} +

    +
    +
    +

    Created

    +

    + {formatRelativeDate(selectedAccount.createdAt)} +

    +
    +
    +
    + {/if} + {/snippet} - {#snippet footerActions()} - {#if drawerMode === 'create'} - - - {:else if isClosed} - - - {:else} - - - - {/if} - {/snippet} + {#snippet footerActions()} + {#if drawerMode === 'create'} + + + {:else if isClosed} + + + {:else} + + + + {/if} + {/snippet}
    diff --git a/frontend/src/routes/(app)/cases/+page.server.js b/frontend/src/routes/(app)/cases/+page.server.js index 4b8252fd..24728daa 100644 --- a/frontend/src/routes/(app)/cases/+page.server.js +++ b/frontend/src/routes/(app)/cases/+page.server.js @@ -14,512 +14,523 @@ import { apiRequest, buildQueryParams } from '$lib/api-helpers.js'; /** @type {import('./$types').PageServerLoad} */ export async function load({ url, locals, cookies }) { - const org = locals.org; - const user = locals.user; + const org = locals.org; + const user = locals.user; - if (!org) { - throw error(401, 'Organization context required'); - } + if (!org) { + throw error(401, 'Organization context required'); + } - // Parse view mode (list or kanban) - const viewMode = url.searchParams.get('view') || 'list'; + // Parse view mode (list or kanban) + const viewMode = url.searchParams.get('view') || 'list'; - // Parse pagination params from URL - const page = parseInt(url.searchParams.get('page') || '1'); - const limit = parseInt(url.searchParams.get('limit') || '10'); + // Parse pagination params from URL + const page = parseInt(url.searchParams.get('page') || '1'); + const limit = parseInt(url.searchParams.get('limit') || '10'); - // Parse filter params from URL - const filters = { - search: url.searchParams.get('search') || '', - status: url.searchParams.get('status') || '', - priority: url.searchParams.get('priority') || '', - case_type: url.searchParams.get('case_type') || '', - assigned_to: url.searchParams.getAll('assigned_to'), - tags: url.searchParams.getAll('tags'), - created_at_gte: url.searchParams.get('created_at_gte') || '', - created_at_lte: url.searchParams.get('created_at_lte') || '' - }; + // Parse filter params from URL + const filters = { + search: url.searchParams.get('search') || '', + status: url.searchParams.get('status') || '', + priority: url.searchParams.get('priority') || '', + case_type: url.searchParams.get('case_type') || '', + assigned_to: url.searchParams.getAll('assigned_to'), + tags: url.searchParams.getAll('tags'), + created_at_gte: url.searchParams.get('created_at_gte') || '', + created_at_lte: url.searchParams.get('created_at_lte') || '' + }; - try { - // Build query parameters - const queryParams = buildQueryParams({ - sort: 'created_at', - order: 'desc' - }); - queryParams.append('limit', limit.toString()); - queryParams.append('offset', ((page - 1) * limit).toString()); + try { + // Build query parameters + const queryParams = buildQueryParams({ + sort: 'created_at', + order: 'desc' + }); + queryParams.append('limit', limit.toString()); + queryParams.append('offset', ((page - 1) * limit).toString()); - // Add filter params - if (filters.search) queryParams.append('search', filters.search); - if (filters.status) queryParams.append('status', filters.status); - if (filters.priority) queryParams.append('priority', filters.priority); - if (filters.case_type) queryParams.append('case_type', filters.case_type); - filters.assigned_to.forEach((id) => queryParams.append('assigned_to', id)); - filters.tags.forEach((id) => queryParams.append('tags', id)); - if (filters.created_at_gte) queryParams.append('created_at__gte', filters.created_at_gte); - if (filters.created_at_lte) queryParams.append('created_at__lte', filters.created_at_lte); + // Add filter params + if (filters.search) queryParams.append('search', filters.search); + if (filters.status) queryParams.append('status', filters.status); + if (filters.priority) queryParams.append('priority', filters.priority); + if (filters.case_type) queryParams.append('case_type', filters.case_type); + filters.assigned_to.forEach((id) => queryParams.append('assigned_to', id)); + filters.tags.forEach((id) => queryParams.append('tags', id)); + if (filters.created_at_gte) queryParams.append('created_at__gte', filters.created_at_gte); + if (filters.created_at_lte) queryParams.append('created_at__lte', filters.created_at_lte); - // Build kanban query params (reuse filter params) - const kanbanQueryParams = new URLSearchParams(); - if (filters.search) kanbanQueryParams.append('search', filters.search); - if (filters.priority) kanbanQueryParams.append('priority', filters.priority); - if (filters.case_type) kanbanQueryParams.append('case_type', filters.case_type); - filters.assigned_to.forEach((id) => kanbanQueryParams.append('assigned_to', id)); + // Build kanban query params (reuse filter params) + const kanbanQueryParams = new URLSearchParams(); + if (filters.search) kanbanQueryParams.append('search', filters.search); + if (filters.priority) kanbanQueryParams.append('priority', filters.priority); + if (filters.case_type) kanbanQueryParams.append('case_type', filters.case_type); + filters.assigned_to.forEach((id) => kanbanQueryParams.append('assigned_to', id)); - // Fetch cases, kanban data, and dropdown options in parallel - const [casesResponse, kanbanResponse, accountsRes, usersRes, contactsRes, teamsRes, tagsRes] = await Promise.all([ - apiRequest(`/cases/?${queryParams.toString()}`, {}, { cookies, org }), - viewMode === 'kanban' - ? apiRequest(`/cases/kanban/${kanbanQueryParams.toString() ? '?' + kanbanQueryParams.toString() : ''}`, {}, { cookies, org }) - : Promise.resolve(null), - apiRequest('/accounts/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/users/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/contacts/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/teams/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/tags/', {}, { cookies, org }).catch(() => ({})) - ]); + // Fetch cases, kanban data, and dropdown options in parallel + const [casesResponse, kanbanResponse, accountsRes, usersRes, contactsRes, teamsRes, tagsRes] = + await Promise.all([ + apiRequest(`/cases/?${queryParams.toString()}`, {}, { cookies, org }), + viewMode === 'kanban' + ? apiRequest( + `/cases/kanban/${kanbanQueryParams.toString() ? '?' + kanbanQueryParams.toString() : ''}`, + {}, + { cookies, org } + ) + : Promise.resolve(null), + apiRequest('/accounts/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/users/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/contacts/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/teams/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/tags/', {}, { cookies, org }).catch(() => ({})) + ]); - // Extract cases from response - let cases = []; - if (casesResponse.cases) { - cases = casesResponse.cases; - } else if (Array.isArray(casesResponse)) { - cases = casesResponse; - } else if (casesResponse.results) { - cases = casesResponse.results; - } + // Extract cases from response + let cases = []; + if (casesResponse.cases) { + cases = casesResponse.cases; + } else if (Array.isArray(casesResponse)) { + cases = casesResponse; + } else if (casesResponse.results) { + cases = casesResponse.results; + } - // Transform Django cases to frontend structure - const transformedCases = cases.map((caseItem) => ({ - id: caseItem.id, - subject: caseItem.name, - description: caseItem.description, - status: caseItem.status, - priority: caseItem.priority, - caseType: caseItem.case_type, - closedOn: caseItem.closed_on, - createdAt: caseItem.created_at, - updatedAt: caseItem.updated_at, - isActive: caseItem.is_active, + // Transform Django cases to frontend structure + const transformedCases = cases.map((caseItem) => ({ + id: caseItem.id, + subject: caseItem.name, + description: caseItem.description, + status: caseItem.status, + priority: caseItem.priority, + caseType: caseItem.case_type, + closedOn: caseItem.closed_on, + createdAt: caseItem.created_at, + updatedAt: caseItem.updated_at, + isActive: caseItem.is_active, - // Created by - createdBy: caseItem.created_by - ? { - id: caseItem.created_by.id, - name: - caseItem.created_by.first_name && caseItem.created_by.last_name - ? `${caseItem.created_by.first_name} ${caseItem.created_by.last_name}` - : caseItem.created_by.email - } - : null, + // Created by + createdBy: caseItem.created_by + ? { + id: caseItem.created_by.id, + name: + caseItem.created_by.first_name && caseItem.created_by.last_name + ? `${caseItem.created_by.first_name} ${caseItem.created_by.last_name}` + : caseItem.created_by.email + } + : null, - // Assigned to (multiple profiles) - assignedTo: (caseItem.assigned_to || []).map((profile) => ({ - id: profile.id, - name: - profile.user_details?.first_name && profile.user_details?.last_name - ? `${profile.user_details.first_name} ${profile.user_details.last_name}` - : profile.user_details?.email || profile.email - })), + // Assigned to (multiple profiles) + assignedTo: (caseItem.assigned_to || []).map((profile) => ({ + id: profile.id, + name: + profile.user_details?.first_name && profile.user_details?.last_name + ? `${profile.user_details.first_name} ${profile.user_details.last_name}` + : profile.user_details?.email || profile.email + })), - // Owner (first assigned user for backwards compatibility) - owner: - caseItem.assigned_to && caseItem.assigned_to.length > 0 - ? { - id: caseItem.assigned_to[0].id, - name: - caseItem.assigned_to[0].user_details?.first_name && - caseItem.assigned_to[0].user_details?.last_name - ? `${caseItem.assigned_to[0].user_details.first_name} ${caseItem.assigned_to[0].user_details.last_name}` - : caseItem.assigned_to[0].user_details?.email || caseItem.assigned_to[0].email - } - : null, + // Owner (first assigned user for backwards compatibility) + owner: + caseItem.assigned_to && caseItem.assigned_to.length > 0 + ? { + id: caseItem.assigned_to[0].id, + name: + caseItem.assigned_to[0].user_details?.first_name && + caseItem.assigned_to[0].user_details?.last_name + ? `${caseItem.assigned_to[0].user_details.first_name} ${caseItem.assigned_to[0].user_details.last_name}` + : caseItem.assigned_to[0].user_details?.email || caseItem.assigned_to[0].email + } + : null, - // Account - account: caseItem.account - ? { - id: caseItem.account.id, - name: caseItem.account.name - } - : null, + // Account + account: caseItem.account + ? { + id: caseItem.account.id, + name: caseItem.account.name + } + : null, - // Contacts (M2M) - contacts: (caseItem.contacts || []).map((contact) => ({ - id: contact.id, - name: - contact.first_name && contact.last_name - ? `${contact.first_name} ${contact.last_name}` - : contact.email, - email: contact.email - })), + // Contacts (M2M) + contacts: (caseItem.contacts || []).map((contact) => ({ + id: contact.id, + name: + contact.first_name && contact.last_name + ? `${contact.first_name} ${contact.last_name}` + : contact.email, + email: contact.email + })), - // Teams (M2M) - teams: (caseItem.teams || []).map((team) => ({ - id: team.id, - name: team.name - })), + // Teams (M2M) + teams: (caseItem.teams || []).map((team) => ({ + id: team.id, + name: team.name + })), - // Tags (M2M) - tags: (caseItem.tags || []).map((tag) => ({ - id: tag.id, - name: tag.name - })), + // Tags (M2M) + tags: (caseItem.tags || []).map((tag) => ({ + id: tag.id, + name: tag.name + })), - // Comments (from detail endpoint) - comments: (caseItem.comments || []).map((comment) => ({ - id: comment.id, - body: comment.comment, - createdAt: comment.created_at, - author: comment.commented_by - ? { - id: comment.commented_by.id, - name: comment.commented_by.user_details?.email || comment.commented_by.email - } - : null - })) - })); + // Comments (from detail endpoint) + comments: (caseItem.comments || []).map((comment) => ({ + id: comment.id, + body: comment.comment, + createdAt: comment.created_at, + author: comment.commented_by + ? { + id: comment.commented_by.id, + name: comment.commented_by.user_details?.email || comment.commented_by.email + } + : null + })) + })); - // Status options from Django (matching backend CASE_TYPE choices) - const statusOptions = ['New', 'Assigned', 'Pending', 'Closed', 'Rejected', 'Duplicate']; - const caseTypeOptions = ['Question', 'Incident', 'Problem']; + // Status options from Django (matching backend CASE_TYPE choices) + const statusOptions = ['New', 'Assigned', 'Pending', 'Closed', 'Rejected', 'Duplicate']; + const caseTypeOptions = ['Question', 'Incident', 'Problem']; - // Get total count from response - const total = casesResponse.cases_count || casesResponse.count || transformedCases.length; + // Get total count from response + const total = casesResponse.cases_count || casesResponse.count || transformedCases.length; - // Transform accounts for dropdown/lookup - let accountsList = []; - if (accountsRes.active_accounts?.open_accounts) { - accountsList = accountsRes.active_accounts.open_accounts; - } else if (accountsRes.results) { - accountsList = accountsRes.results; - } else if (Array.isArray(accountsRes)) { - accountsList = accountsRes; - } - const accounts = accountsList.map((a) => ({ id: a.id, name: a.name })); + // Transform accounts for dropdown/lookup + let accountsList = []; + if (accountsRes.active_accounts?.open_accounts) { + accountsList = accountsRes.active_accounts.open_accounts; + } else if (accountsRes.results) { + accountsList = accountsRes.results; + } else if (Array.isArray(accountsRes)) { + accountsList = accountsRes; + } + const accounts = accountsList.map((a) => ({ id: a.id, name: a.name })); - // Transform users - const users = (usersRes.active_users?.active_users || []).map((u) => ({ - id: u.id, - name: u.user_details?.first_name && u.user_details?.last_name - ? `${u.user_details.first_name} ${u.user_details.last_name}` - : u.user_details?.email || u.email - })); + // Transform users + const users = (usersRes.active_users?.active_users || []).map((u) => ({ + id: u.id, + name: + u.user_details?.first_name && u.user_details?.last_name + ? `${u.user_details.first_name} ${u.user_details.last_name}` + : u.user_details?.email || u.email + })); - // Transform contacts - let contactsList = []; - if (contactsRes.contact_obj_list) { - contactsList = contactsRes.contact_obj_list; - } else if (contactsRes.results) { - contactsList = contactsRes.results; - } else if (Array.isArray(contactsRes)) { - contactsList = contactsRes; - } - const contacts = contactsList.map((c) => ({ - id: c.id, - name: c.first_name && c.last_name - ? `${c.first_name} ${c.last_name}` - : c.email, - email: c.email - })); + // Transform contacts + let contactsList = []; + if (contactsRes.contact_obj_list) { + contactsList = contactsRes.contact_obj_list; + } else if (contactsRes.results) { + contactsList = contactsRes.results; + } else if (Array.isArray(contactsRes)) { + contactsList = contactsRes; + } + const contacts = contactsList.map((c) => ({ + id: c.id, + name: c.first_name && c.last_name ? `${c.first_name} ${c.last_name}` : c.email, + email: c.email + })); - // Transform teams - const teams = (teamsRes.teams || teamsRes.results || []).map((t) => ({ id: t.id, name: t.name })); + // Transform teams + const teams = (teamsRes.teams || teamsRes.results || []).map((t) => ({ + id: t.id, + name: t.name + })); - // Transform tags (include color for TagFilter) - const tags = (tagsRes.tags || tagsRes.results || []).map((t) => ({ id: t.id, name: t.name, color: t.color || 'blue' })); + // Transform tags (include color for TagFilter) + const tags = (tagsRes.tags || tagsRes.results || []).map((t) => ({ + id: t.id, + name: t.name, + color: t.color || 'blue' + })); - return { - cases: transformedCases, - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit) || 1 - }, - filters, - viewMode, - kanbanData: kanbanResponse, - statusOptions, - caseTypeOptions, - // Dropdown options loaded server-side - formOptions: { - accounts, - users, - contacts, - teams, - tags - } - }; - } catch (err) { - console.error('Error loading cases from API:', err); - throw error(500, `Failed to load cases: ${err.message}`); - } + return { + cases: transformedCases, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit) || 1 + }, + filters, + viewMode, + kanbanData: kanbanResponse, + statusOptions, + caseTypeOptions, + // Dropdown options loaded server-side + formOptions: { + accounts, + users, + contacts, + teams, + tags + } + }; + } catch (err) { + console.error('Error loading cases from API:', err); + throw error(500, `Failed to load cases: ${err.message}`); + } } /** @type {import('./$types').Actions} */ export const actions = { - create: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - const name = form.get('title')?.toString().trim(); - const description = form.get('description')?.toString().trim(); - const accountId = form.get('accountId')?.toString(); - const dueDateValue = form.get('dueDate'); - const closedOn = dueDateValue ? dueDateValue.toString() : null; - const priority = form.get('priority')?.toString() || 'Normal'; - const caseType = form.get('caseType')?.toString() || ''; - const assignedToJson = form.get('assignedTo')?.toString(); - const contactsJson = form.get('contacts')?.toString(); - const teamsJson = form.get('teams')?.toString(); - const tagsJson = form.get('tags')?.toString(); + create: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + const name = form.get('title')?.toString().trim(); + const description = form.get('description')?.toString().trim(); + const accountId = form.get('accountId')?.toString(); + const dueDateValue = form.get('dueDate'); + const closedOn = dueDateValue ? dueDateValue.toString() : null; + const priority = form.get('priority')?.toString() || 'Normal'; + const caseType = form.get('caseType')?.toString() || ''; + const assignedToJson = form.get('assignedTo')?.toString(); + const contactsJson = form.get('contacts')?.toString(); + const teamsJson = form.get('teams')?.toString(); + const tagsJson = form.get('tags')?.toString(); - // Parse JSON arrays - let assignedTo = []; - let contacts = []; - let teams = []; - let tags = []; + // Parse JSON arrays + let assignedTo = []; + let contacts = []; + let teams = []; + let tags = []; - try { - assignedTo = assignedToJson ? JSON.parse(assignedToJson) : []; - contacts = contactsJson ? JSON.parse(contactsJson) : []; - teams = teamsJson ? JSON.parse(teamsJson) : []; - tags = tagsJson ? JSON.parse(tagsJson) : []; - } catch { - // Fallback for single ID format - const ownerId = form.get('assignedId')?.toString(); - if (ownerId) assignedTo = [ownerId]; - } + try { + assignedTo = assignedToJson ? JSON.parse(assignedToJson) : []; + contacts = contactsJson ? JSON.parse(contactsJson) : []; + teams = teamsJson ? JSON.parse(teamsJson) : []; + tags = tagsJson ? JSON.parse(tagsJson) : []; + } catch { + // Fallback for single ID format + const ownerId = form.get('assignedId')?.toString(); + if (ownerId) assignedTo = [ownerId]; + } - if (!name) { - return fail(400, { error: 'Case name is required.' }); - } + if (!name) { + return fail(400, { error: 'Case name is required.' }); + } - // Create case via API - const caseData = { - name, - description, - account: accountId || null, - closed_on: closedOn, - priority, - case_type: caseType, - assigned_to: assignedTo, - contacts, - teams, - tags, - status: 'New' - }; + // Create case via API + const caseData = { + name, + description, + account: accountId || null, + closed_on: closedOn, + priority, + case_type: caseType, + assigned_to: assignedTo, + contacts, + teams, + tags, + status: 'New' + }; - const newCase = await apiRequest( - '/cases/', - { - method: 'POST', - body: caseData - }, - { cookies, org: locals.org } - ); + const newCase = await apiRequest( + '/cases/', + { + method: 'POST', + body: caseData + }, + { cookies, org: locals.org } + ); - return { success: true, case: newCase }; - } catch (err) { - console.error('Error creating case:', err); - return fail(500, { error: 'Failed to create case' }); - } - }, + return { success: true, case: newCase }; + } catch (err) { + console.error('Error creating case:', err); + return fail(500, { error: 'Failed to create case' }); + } + }, - update: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - const name = form.get('title')?.toString().trim(); - const description = form.get('description')?.toString().trim(); - const dueDateValue = form.get('dueDate'); - const closedOn = dueDateValue ? dueDateValue.toString() : null; - const priority = form.get('priority')?.toString() || 'Normal'; - const status = form.get('status')?.toString() || 'New'; - const caseType = form.get('caseType')?.toString() || ''; - const caseId = form.get('caseId')?.toString(); - const assignedToJson = form.get('assignedTo')?.toString(); - const contactsJson = form.get('contacts')?.toString(); - const teamsJson = form.get('teams')?.toString(); - const tagsJson = form.get('tags')?.toString(); + update: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + const name = form.get('title')?.toString().trim(); + const description = form.get('description')?.toString().trim(); + const dueDateValue = form.get('dueDate'); + const closedOn = dueDateValue ? dueDateValue.toString() : null; + const priority = form.get('priority')?.toString() || 'Normal'; + const status = form.get('status')?.toString() || 'New'; + const caseType = form.get('caseType')?.toString() || ''; + const caseId = form.get('caseId')?.toString(); + const assignedToJson = form.get('assignedTo')?.toString(); + const contactsJson = form.get('contacts')?.toString(); + const teamsJson = form.get('teams')?.toString(); + const tagsJson = form.get('tags')?.toString(); - // Parse JSON arrays - let assignedTo = []; - let contacts = []; - let teams = []; - let tags = []; + // Parse JSON arrays + let assignedTo = []; + let contacts = []; + let teams = []; + let tags = []; - try { - assignedTo = assignedToJson ? JSON.parse(assignedToJson) : []; - contacts = contactsJson ? JSON.parse(contactsJson) : []; - teams = teamsJson ? JSON.parse(teamsJson) : []; - tags = tagsJson ? JSON.parse(tagsJson) : []; - } catch { - // Fallback for single ID format - const ownerId = form.get('assignedId')?.toString(); - if (ownerId) assignedTo = [ownerId]; - } + try { + assignedTo = assignedToJson ? JSON.parse(assignedToJson) : []; + contacts = contactsJson ? JSON.parse(contactsJson) : []; + teams = teamsJson ? JSON.parse(teamsJson) : []; + tags = tagsJson ? JSON.parse(tagsJson) : []; + } catch { + // Fallback for single ID format + const ownerId = form.get('assignedId')?.toString(); + if (ownerId) assignedTo = [ownerId]; + } - if (!name || !caseId) { - return fail(400, { error: 'Case name and ID are required.' }); - } + if (!name || !caseId) { + return fail(400, { error: 'Case name and ID are required.' }); + } - // Update case via API (account is read-only, not included) - const caseData = { - name, - description, - closed_on: closedOn, - priority, - status, - case_type: caseType, - assigned_to: assignedTo, - contacts, - teams, - tags - }; + // Update case via API (account is read-only, not included) + const caseData = { + name, + description, + closed_on: closedOn, + priority, + status, + case_type: caseType, + assigned_to: assignedTo, + contacts, + teams, + tags + }; - await apiRequest( - `/cases/${caseId}/`, - { - method: 'PATCH', - body: caseData - }, - { cookies, org: locals.org } - ); + await apiRequest( + `/cases/${caseId}/`, + { + method: 'PATCH', + body: caseData + }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error updating case:', err); - return fail(500, { error: 'Failed to update case' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error updating case:', err); + return fail(500, { error: 'Failed to update case' }); + } + }, - close: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - const caseId = form.get('caseId')?.toString(); + close: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + const caseId = form.get('caseId')?.toString(); - if (!caseId) { - return fail(400, { error: 'Case ID is required.' }); - } + if (!caseId) { + return fail(400, { error: 'Case ID is required.' }); + } - await apiRequest( - `/cases/${caseId}/`, - { - method: 'PATCH', - body: { status: 'Closed' } - }, - { cookies, org: locals.org } - ); + await apiRequest( + `/cases/${caseId}/`, + { + method: 'PATCH', + body: { status: 'Closed' } + }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error closing case:', err); - return fail(500, { error: 'Failed to close case' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error closing case:', err); + return fail(500, { error: 'Failed to close case' }); + } + }, - reopen: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - const caseId = form.get('caseId')?.toString(); + reopen: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + const caseId = form.get('caseId')?.toString(); - if (!caseId) { - return fail(400, { error: 'Case ID is required.' }); - } + if (!caseId) { + return fail(400, { error: 'Case ID is required.' }); + } - await apiRequest( - `/cases/${caseId}/`, - { - method: 'PATCH', - body: { status: 'Assigned' } - }, - { cookies, org: locals.org } - ); + await apiRequest( + `/cases/${caseId}/`, + { + method: 'PATCH', + body: { status: 'Assigned' } + }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error reopening case:', err); - return fail(500, { error: 'Failed to reopen case' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error reopening case:', err); + return fail(500, { error: 'Failed to reopen case' }); + } + }, - delete: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - const caseId = form.get('caseId')?.toString(); + delete: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + const caseId = form.get('caseId')?.toString(); - if (!caseId) { - return fail(400, { error: 'Case ID is required.' }); - } + if (!caseId) { + return fail(400, { error: 'Case ID is required.' }); + } - // Delete via API - await apiRequest(`/cases/${caseId}/`, { method: 'DELETE' }, { cookies, org: locals.org }); + // Delete via API + await apiRequest(`/cases/${caseId}/`, { method: 'DELETE' }, { cookies, org: locals.org }); - return { success: true }; - } catch (err) { - console.error('Error deleting case:', err); - return fail(500, { error: 'Failed to delete case' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error deleting case:', err); + return fail(500, { error: 'Failed to delete case' }); + } + }, - comment: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - const body = form.get('body')?.toString().trim(); - const caseId = form.get('caseId')?.toString(); + comment: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + const body = form.get('body')?.toString().trim(); + const caseId = form.get('caseId')?.toString(); - if (!body || !caseId) { - return fail(400, { error: 'Comment and case ID are required.' }); - } + if (!body || !caseId) { + return fail(400, { error: 'Comment and case ID are required.' }); + } - // Create comment via API - await apiRequest( - `/cases/comment/${caseId}/`, - { - method: 'POST', - body: { comment: body } - }, - { cookies, org: locals.org } - ); + // Create comment via API + await apiRequest( + `/cases/comment/${caseId}/`, + { + method: 'POST', + body: { comment: body } + }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error creating comment:', err); - return fail(500, { error: 'Failed to create comment' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error creating comment:', err); + return fail(500, { error: 'Failed to create comment' }); + } + }, - moveCase: async ({ request, locals, cookies }) => { - const org = locals.org; + moveCase: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const form = await request.formData(); - const caseId = form.get('caseId')?.toString(); - const status = form.get('status')?.toString(); - const stageId = form.get('stageId')?.toString() || null; - const aboveCaseId = form.get('aboveCaseId')?.toString() || null; - const belowCaseId = form.get('belowCaseId')?.toString() || null; + try { + const form = await request.formData(); + const caseId = form.get('caseId')?.toString(); + const status = form.get('status')?.toString(); + const stageId = form.get('stageId')?.toString() || null; + const aboveCaseId = form.get('aboveCaseId')?.toString() || null; + const belowCaseId = form.get('belowCaseId')?.toString() || null; - if (!caseId) { - return fail(400, { error: 'Case ID is required' }); - } + if (!caseId) { + return fail(400, { error: 'Case ID is required' }); + } - /** @type {Record} */ - const moveData = {}; - if (status) moveData.status = status; - if (stageId) moveData.stage_id = stageId; - if (aboveCaseId) moveData.above_case_id = aboveCaseId; - if (belowCaseId) moveData.below_case_id = belowCaseId; + /** @type {Record} */ + const moveData = {}; + if (status) moveData.status = status; + if (stageId) moveData.stage_id = stageId; + if (aboveCaseId) moveData.above_case_id = aboveCaseId; + if (belowCaseId) moveData.below_case_id = belowCaseId; - await apiRequest( - `/cases/${caseId}/move/`, - { method: 'PATCH', body: moveData }, - { cookies, org } - ); + await apiRequest( + `/cases/${caseId}/move/`, + { method: 'PATCH', body: moveData }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error moving case:', err); - return fail(500, { error: 'Failed to move case' }); - } - } + return { success: true }; + } catch (err) { + console.error('Error moving case:', err); + return fail(500, { error: 'Failed to move case' }); + } + } }; diff --git a/frontend/src/routes/(app)/cases/+page.svelte b/frontend/src/routes/(app)/cases/+page.svelte index 692d8598..fd194e1b 100644 --- a/frontend/src/routes/(app)/cases/+page.svelte +++ b/frontend/src/routes/(app)/cases/+page.svelte @@ -1,1169 +1,1175 @@ - Cases - BottleCRM + Cases - BottleCRM - {#snippet actions()} -
    - -
    - - - -
    + {#snippet actions()} +
    + +
    + + + +
    -
    +
    - -
    - - -
    + +
    + + +
    -
    +
    - - + + - - - {#snippet child({ props })} - - {/snippet} - - - Toggle columns - - {#each columns as column (column.key)} - toggleColumn(column.key)} - disabled={column.canHide === false} - > - {column.label} - - {/each} - - + + + {#snippet child({ props })} + + {/snippet} + + + Toggle columns + + {#each columns as column (column.key)} + toggleColumn(column.key)} + disabled={column.canHide === false} + > + {column.label} + + {/each} + + - -
    - {/snippet} + +
    + {/snippet}
    - - - updateFilters({ ...filters, search: value })} - placeholder="Search cases..." - /> - updateFilters({ ...filters, priority: value })} - /> - updateFilters({ ...filters, case_type: value })} - /> - - updateFilters({ ...filters, created_at_gte: start, created_at_lte: end })} - /> - updateFilters({ ...filters, tags: ids })} - /> - + + + updateFilters({ ...filters, search: value })} + placeholder="Search cases..." + /> + updateFilters({ ...filters, priority: value })} + /> + updateFilters({ ...filters, case_type: value })} + /> + + updateFilters({ ...filters, created_at_gte: start, created_at_lte: end })} + /> + updateFilters({ ...filters, tags: ids })} + /> + - {#if viewMode === 'list'} - drawer.openDetail(row)} - > - {#snippet emptyState()} -
    - -

    No cases found

    -
    - {/snippet} -
    + {#if viewMode === 'list'} + drawer.openDetail(row)} + > + {#snippet emptyState()} +
    + +

    No cases found

    +
    + {/snippet} +
    - - - {:else} - - {/if} + + + {:else} + + {/if}
    { - if (!open) { - drawer.closeAll(); - if (accountFromUrl) { - clearUrlParams(); - } - } - }} - data={drawerFormData} - columns={drawerColumns} - titleKey="subject" - titlePlaceholder="Case title" - headerLabel={drawer.mode === 'create' ? 'New Case' : 'Case'} - onFieldChange={handleDrawerFieldChange} - onDelete={handleDelete} - onClose={() => drawer.closeAll()} - loading={drawer.loading} - mode={drawer.mode === 'create' ? 'create' : 'view'} + bind:open={drawer.detailOpen} + onOpenChange={(open) => { + if (!open) { + drawer.closeAll(); + if (accountFromUrl) { + clearUrlParams(); + } + } + }} + data={drawerFormData} + columns={drawerColumns} + titleKey="subject" + titlePlaceholder="Case title" + headerLabel={drawer.mode === 'create' ? 'New Case' : 'Case'} + onFieldChange={handleDrawerFieldChange} + onDelete={handleDelete} + onClose={() => drawer.closeAll()} + loading={drawer.loading} + mode={drawer.mode === 'create' ? 'create' : 'view'} > - {#snippet activitySection()} - {#if drawer.mode !== 'create' && drawer.selected?.comments?.length > 0} -
    -
    - -

    - Activity -

    -
    - {#each drawer.selected.comments.slice(0, 5) as comment (comment.id)} -
    -
    - -
    -
    -

    - {comment.author?.name || 'Unknown'} - {' '}added a note -

    -

    - {new Date(comment.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - })} -

    -

    {comment.body}

    -
    -
    - {/each} -
    - {:else if drawer.mode !== 'create'} -
    - -

    No activity yet

    -
    - {/if} - {/snippet} + {#snippet activitySection()} + {#if drawer.mode !== 'create' && drawer.selected?.comments?.length > 0} +
    +
    + +

    + Activity +

    +
    + {#each drawer.selected.comments.slice(0, 5) as comment (comment.id)} +
    +
    + +
    +
    +

    + {comment.author?.name || 'Unknown'} + {' '}added a note +

    +

    + {new Date(comment.createdAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + })} +

    +

    {comment.body}

    +
    +
    + {/each} +
    + {:else if drawer.mode !== 'create'} +
    + +

    No activity yet

    +
    + {/if} + {/snippet} - {#snippet footerActions()} - {#if drawer.mode !== 'create' && drawer.selected} - {#if drawerFormData.status === 'Closed'} - - {:else} - - {/if} - {/if} - - {#if isDrawerDirty || drawer.mode === 'create'} - - {/if} - {/snippet} + {#snippet footerActions()} + {#if drawer.mode !== 'create' && drawer.selected} + {#if drawerFormData.status === 'Closed'} + + {:else} + + {/if} + {/if} + + {#if isDrawerDirty || drawer.mode === 'create'} + + {/if} + {/snippet}
    diff --git a/frontend/src/routes/(app)/contacts/+page.server.js b/frontend/src/routes/(app)/contacts/+page.server.js index da77a683..6bcc980e 100644 --- a/frontend/src/routes/(app)/contacts/+page.server.js +++ b/frontend/src/routes/(app)/contacts/+page.server.js @@ -14,339 +14,339 @@ import { apiRequest, buildQueryParams } from '$lib/api-helpers.js'; /** @type {import('./$types').PageServerLoad} */ export async function load({ url, locals, cookies }) { - const org = locals.org; + const org = locals.org; - if (!org) { - throw error(401, 'Organization context required'); - } + if (!org) { + throw error(401, 'Organization context required'); + } - // Parse filter params from URL - const filters = { - search: url.searchParams.get('search') || '', - assigned_to: url.searchParams.getAll('assigned_to'), - tags: url.searchParams.getAll('tags'), - created_at_gte: url.searchParams.get('created_at_gte') || '', - created_at_lte: url.searchParams.get('created_at_lte') || '' - }; + // Parse filter params from URL + const filters = { + search: url.searchParams.get('search') || '', + assigned_to: url.searchParams.getAll('assigned_to'), + tags: url.searchParams.getAll('tags'), + created_at_gte: url.searchParams.get('created_at_gte') || '', + created_at_lte: url.searchParams.get('created_at_lte') || '' + }; - try { - const page = parseInt(url.searchParams.get('page') || '1'); - const limit = parseInt(url.searchParams.get('limit') || '10'); + try { + const page = parseInt(url.searchParams.get('page') || '1'); + const limit = parseInt(url.searchParams.get('limit') || '10'); - // Build query parameters for Django - const queryParams = buildQueryParams({ - page, - limit, - sort: 'created_at', - order: 'desc' - }); + // Build query parameters for Django + const queryParams = buildQueryParams({ + page, + limit, + sort: 'created_at', + order: 'desc' + }); - // Add filter params - if (filters.search) queryParams.append('search', filters.search); - filters.assigned_to.forEach((id) => queryParams.append('assigned_to', id)); - filters.tags.forEach((id) => queryParams.append('tags', id)); - if (filters.created_at_gte) queryParams.append('created_at__gte', filters.created_at_gte); - if (filters.created_at_lte) queryParams.append('created_at__lte', filters.created_at_lte); + // Add filter params + if (filters.search) queryParams.append('search', filters.search); + filters.assigned_to.forEach((id) => queryParams.append('assigned_to', id)); + filters.tags.forEach((id) => queryParams.append('tags', id)); + if (filters.created_at_gte) queryParams.append('created_at__gte', filters.created_at_gte); + if (filters.created_at_lte) queryParams.append('created_at__lte', filters.created_at_lte); - // Fetch contacts, accounts, and tags in parallel - const [contactsResponse, accountsRes, tagsResponse] = await Promise.all([ - apiRequest(`/contacts/?${queryParams.toString()}`, {}, { cookies, org }), - apiRequest('/accounts/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/tags/', {}, { cookies, org }).catch(() => ({ tags: [] })) - ]); + // Fetch contacts, accounts, and tags in parallel + const [contactsResponse, accountsRes, tagsResponse] = await Promise.all([ + apiRequest(`/contacts/?${queryParams.toString()}`, {}, { cookies, org }), + apiRequest('/accounts/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/tags/', {}, { cookies, org }).catch(() => ({ tags: [] })) + ]); - // Handle Django response format - let contacts = []; - let totalCount = 0; + // Handle Django response format + let contacts = []; + let totalCount = 0; - if (contactsResponse.contact_obj_list) { - // Django custom response format - contacts = contactsResponse.contact_obj_list; - totalCount = contactsResponse.contacts_count || contacts.length; - } else if (contactsResponse.results) { - // Standard Django pagination - contacts = contactsResponse.results; - totalCount = contactsResponse.count || 0; - } else if (Array.isArray(contactsResponse)) { - // Direct array response - contacts = contactsResponse; - totalCount = contacts.length; - } + if (contactsResponse.contact_obj_list) { + // Django custom response format + contacts = contactsResponse.contact_obj_list; + totalCount = contactsResponse.contacts_count || contacts.length; + } else if (contactsResponse.results) { + // Standard Django pagination + contacts = contactsResponse.results; + totalCount = contactsResponse.count || 0; + } else if (Array.isArray(contactsResponse)) { + // Direct array response + contacts = contactsResponse; + totalCount = contacts.length; + } - // Transform Django contacts to match frontend structure - const transformedContacts = contacts.map((contact) => ({ - id: contact.id, - // Core fields - firstName: contact.first_name, - lastName: contact.last_name, - email: contact.email, - phone: contact.phone, - // Professional info - organization: contact.organization, - title: contact.title, - department: contact.department, - // Communication preferences - doNotCall: contact.do_not_call || false, - linkedInUrl: contact.linkedin_url, - // Address fields - addressLine: contact.address_line, - city: contact.city, - state: contact.state, - postcode: contact.postcode, - country: contact.country, - // Notes - description: contact.description, - // Timestamps - createdAt: contact.created_at, - updatedAt: contact.updated_at, + // Transform Django contacts to match frontend structure + const transformedContacts = contacts.map((contact) => ({ + id: contact.id, + // Core fields + firstName: contact.first_name, + lastName: contact.last_name, + email: contact.email, + phone: contact.phone, + // Professional info + organization: contact.organization, + title: contact.title, + department: contact.department, + // Communication preferences + doNotCall: contact.do_not_call || false, + linkedInUrl: contact.linkedin_url, + // Address fields + addressLine: contact.address_line, + city: contact.city, + state: contact.state, + postcode: contact.postcode, + country: contact.country, + // Notes + description: contact.description, + // Timestamps + createdAt: contact.created_at, + updatedAt: contact.updated_at, - // Owner info - Django returns assigned_to array - owner: - contact.assigned_to && contact.assigned_to.length > 0 - ? { - id: contact.assigned_to[0].id, - name: contact.assigned_to[0].user_details?.email || 'Unknown', - email: contact.assigned_to[0].user_details?.email - } - : contact.created_by - ? { - id: contact.created_by.id, - name: contact.created_by.email, - email: contact.created_by.email - } - : null, + // Owner info - Django returns assigned_to array + owner: + contact.assigned_to && contact.assigned_to.length > 0 + ? { + id: contact.assigned_to[0].id, + name: contact.assigned_to[0].user_details?.email || 'Unknown', + email: contact.assigned_to[0].user_details?.email + } + : contact.created_by + ? { + id: contact.created_by.id, + name: contact.created_by.email, + email: contact.created_by.email + } + : null, - // Teams - teams: - contact.teams?.map((team) => ({ - id: team.id, - name: team.name - })) || [], + // Teams + teams: + contact.teams?.map((team) => ({ + id: team.id, + name: team.name + })) || [], - // Tags - tags: - contact.tags?.map((tag) => ({ - id: tag.id, - name: tag.name - })) || [], + // Tags + tags: + contact.tags?.map((tag) => ({ + id: tag.id, + name: tag.name + })) || [], - // Related accounts - relatedAccounts: - contact.accounts?.map((account) => ({ - account: { - id: account.id, - name: account.name - } - })) || [], + // Related accounts + relatedAccounts: + contact.accounts?.map((account) => ({ + account: { + id: account.id, + name: account.name + } + })) || [], - // Counts - Django may not provide _count, use 0 as default - _count: { - tasks: contact.task_count || 0, - events: contact.event_count || 0, - opportunities: contact.opportunity_count || 0, - cases: contact.case_count || 0 - } - })); + // Counts - Django may not provide _count, use 0 as default + _count: { + tasks: contact.task_count || 0, + events: contact.event_count || 0, + opportunities: contact.opportunity_count || 0, + cases: contact.case_count || 0 + } + })); - // Transform accounts for dropdown/lookup - let accountsList = []; - if (accountsRes.active_accounts?.open_accounts) { - accountsList = accountsRes.active_accounts.open_accounts; - } else if (accountsRes.results) { - accountsList = accountsRes.results; - } else if (Array.isArray(accountsRes)) { - accountsList = accountsRes; - } - const accounts = accountsList.map((a) => ({ id: a.id, name: a.name })); + // Transform accounts for dropdown/lookup + let accountsList = []; + if (accountsRes.active_accounts?.open_accounts) { + accountsList = accountsRes.active_accounts.open_accounts; + } else if (accountsRes.results) { + accountsList = accountsRes.results; + } else if (Array.isArray(accountsRes)) { + accountsList = accountsRes; + } + const accounts = accountsList.map((a) => ({ id: a.id, name: a.name })); - // Transform tags for filter - const allTags = (tagsResponse.tags || []).map((/** @type {any} */ tag) => ({ - id: tag.id, - name: tag.name, - color: tag.color || 'blue' - })); + // Transform tags for filter + const allTags = (tagsResponse.tags || []).map((/** @type {any} */ tag) => ({ + id: tag.id, + name: tag.name, + color: tag.color || 'blue' + })); - return { - contacts: transformedContacts, - pagination: { - page, - limit, - total: totalCount, - totalPages: Math.ceil(totalCount / limit) || 1 - }, - filters, - // Dropdown options are now lazy-loaded on client when drawer opens - owners: [], - allTags, - // Accounts for quick action prefill lookup - accounts - }; - } catch (err) { - console.error('Error loading contacts from API:', err); - throw error(500, `Failed to load contacts: ${err.message}`); - } + return { + contacts: transformedContacts, + pagination: { + page, + limit, + total: totalCount, + totalPages: Math.ceil(totalCount / limit) || 1 + }, + filters, + // Dropdown options are now lazy-loaded on client when drawer opens + owners: [], + allTags, + // Accounts for quick action prefill lookup + accounts + }; + } catch (err) { + console.error('Error loading contacts from API:', err); + throw error(500, `Failed to load contacts: ${err.message}`); + } } /** @type {import('./$types').Actions} */ export const actions = { - create: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - // Account link (from quick action) - const accountId = form.get('accountId')?.toString().trim() || null; - // Core fields - const firstName = form.get('firstName')?.toString().trim(); - const lastName = form.get('lastName')?.toString().trim(); - const email = form.get('email')?.toString().trim() || ''; - const phone = form.get('phone')?.toString().trim() || ''; - // Professional info - const organization = form.get('organization')?.toString().trim() || ''; - const title = form.get('title')?.toString().trim() || ''; - const department = form.get('department')?.toString().trim() || ''; - // Communication preferences - const doNotCall = form.get('doNotCall') === 'true'; - const linkedInUrl = form.get('linkedInUrl')?.toString().trim() || ''; - // Address fields - const addressLine = form.get('addressLine')?.toString().trim() || ''; - const city = form.get('city')?.toString().trim() || ''; - const state = form.get('state')?.toString().trim() || ''; - const postcode = form.get('postcode')?.toString().trim() || ''; - const country = form.get('country')?.toString().trim() || ''; - // Notes - const description = form.get('description')?.toString().trim() || ''; - // Tags - const tagsJson = form.get('tags')?.toString() || '[]'; - const tags = JSON.parse(tagsJson); + create: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + // Account link (from quick action) + const accountId = form.get('accountId')?.toString().trim() || null; + // Core fields + const firstName = form.get('firstName')?.toString().trim(); + const lastName = form.get('lastName')?.toString().trim(); + const email = form.get('email')?.toString().trim() || ''; + const phone = form.get('phone')?.toString().trim() || ''; + // Professional info + const organization = form.get('organization')?.toString().trim() || ''; + const title = form.get('title')?.toString().trim() || ''; + const department = form.get('department')?.toString().trim() || ''; + // Communication preferences + const doNotCall = form.get('doNotCall') === 'true'; + const linkedInUrl = form.get('linkedInUrl')?.toString().trim() || ''; + // Address fields + const addressLine = form.get('addressLine')?.toString().trim() || ''; + const city = form.get('city')?.toString().trim() || ''; + const state = form.get('state')?.toString().trim() || ''; + const postcode = form.get('postcode')?.toString().trim() || ''; + const country = form.get('country')?.toString().trim() || ''; + // Notes + const description = form.get('description')?.toString().trim() || ''; + // Tags + const tagsJson = form.get('tags')?.toString() || '[]'; + const tags = JSON.parse(tagsJson); - if (!firstName || !lastName) { - return fail(400, { error: 'First name and last name are required.' }); - } + if (!firstName || !lastName) { + return fail(400, { error: 'First name and last name are required.' }); + } - const contactData = { - first_name: firstName, - last_name: lastName, - email: email || null, - phone: phone || null, - organization: organization || null, - title: title || null, - department: department || null, - do_not_call: doNotCall, - linkedin_url: linkedInUrl || null, - address_line: addressLine || null, - city: city || null, - state: state || null, - postcode: postcode || null, - country: country || null, - description: description || null, - tags: tags, - account: accountId - }; + const contactData = { + first_name: firstName, + last_name: lastName, + email: email || null, + phone: phone || null, + organization: organization || null, + title: title || null, + department: department || null, + do_not_call: doNotCall, + linkedin_url: linkedInUrl || null, + address_line: addressLine || null, + city: city || null, + state: state || null, + postcode: postcode || null, + country: country || null, + description: description || null, + tags: tags, + account: accountId + }; - await apiRequest( - '/contacts/', - { - method: 'POST', - body: contactData - }, - { cookies, org: locals.org } - ); + await apiRequest( + '/contacts/', + { + method: 'POST', + body: contactData + }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error creating contact:', err); - return fail(400, { error: err.message || 'Failed to create contact' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error creating contact:', err); + return fail(400, { error: err.message || 'Failed to create contact' }); + } + }, - update: async ({ request, locals, cookies }) => { - try { - const form = await request.formData(); - const contactId = form.get('contactId')?.toString(); - // Core fields - const firstName = form.get('firstName')?.toString().trim(); - const lastName = form.get('lastName')?.toString().trim(); - const email = form.get('email')?.toString().trim() || ''; - const phone = form.get('phone')?.toString().trim() || ''; - // Professional info - const organization = form.get('organization')?.toString().trim() || ''; - const title = form.get('title')?.toString().trim() || ''; - const department = form.get('department')?.toString().trim() || ''; - // Communication preferences - const doNotCall = form.get('doNotCall') === 'true'; - const linkedInUrl = form.get('linkedInUrl')?.toString().trim() || ''; - // Address fields - const addressLine = form.get('addressLine')?.toString().trim() || ''; - const city = form.get('city')?.toString().trim() || ''; - const state = form.get('state')?.toString().trim() || ''; - const postcode = form.get('postcode')?.toString().trim() || ''; - const country = form.get('country')?.toString().trim() || ''; - // Notes - const description = form.get('description')?.toString().trim() || ''; - // Tags - const tagsJson = form.get('tags')?.toString() || '[]'; - const tags = JSON.parse(tagsJson); + update: async ({ request, locals, cookies }) => { + try { + const form = await request.formData(); + const contactId = form.get('contactId')?.toString(); + // Core fields + const firstName = form.get('firstName')?.toString().trim(); + const lastName = form.get('lastName')?.toString().trim(); + const email = form.get('email')?.toString().trim() || ''; + const phone = form.get('phone')?.toString().trim() || ''; + // Professional info + const organization = form.get('organization')?.toString().trim() || ''; + const title = form.get('title')?.toString().trim() || ''; + const department = form.get('department')?.toString().trim() || ''; + // Communication preferences + const doNotCall = form.get('doNotCall') === 'true'; + const linkedInUrl = form.get('linkedInUrl')?.toString().trim() || ''; + // Address fields + const addressLine = form.get('addressLine')?.toString().trim() || ''; + const city = form.get('city')?.toString().trim() || ''; + const state = form.get('state')?.toString().trim() || ''; + const postcode = form.get('postcode')?.toString().trim() || ''; + const country = form.get('country')?.toString().trim() || ''; + // Notes + const description = form.get('description')?.toString().trim() || ''; + // Tags + const tagsJson = form.get('tags')?.toString() || '[]'; + const tags = JSON.parse(tagsJson); - if (!contactId || !firstName || !lastName) { - return fail(400, { error: 'Contact ID, first name, and last name are required.' }); - } + if (!contactId || !firstName || !lastName) { + return fail(400, { error: 'Contact ID, first name, and last name are required.' }); + } - const contactData = { - first_name: firstName, - last_name: lastName, - email: email || null, - phone: phone || null, - organization: organization || null, - title: title || null, - department: department || null, - do_not_call: doNotCall, - linkedin_url: linkedInUrl || null, - address_line: addressLine || null, - city: city || null, - state: state || null, - postcode: postcode || null, - country: country || null, - description: description || null, - tags: tags - }; + const contactData = { + first_name: firstName, + last_name: lastName, + email: email || null, + phone: phone || null, + organization: organization || null, + title: title || null, + department: department || null, + do_not_call: doNotCall, + linkedin_url: linkedInUrl || null, + address_line: addressLine || null, + city: city || null, + state: state || null, + postcode: postcode || null, + country: country || null, + description: description || null, + tags: tags + }; - await apiRequest( - `/contacts/${contactId}/`, - { - method: 'PATCH', - body: contactData - }, - { cookies, org: locals.org } - ); + await apiRequest( + `/contacts/${contactId}/`, + { + method: 'PATCH', + body: contactData + }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error updating contact:', err); - // Extract the actual error message from the API response - const errorMessage = err.message || 'Failed to update contact'; - return fail(400, { error: errorMessage }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error updating contact:', err); + // Extract the actual error message from the API response + const errorMessage = err.message || 'Failed to update contact'; + return fail(400, { error: errorMessage }); + } + }, - delete: async ({ request, locals, cookies }) => { - try { - const data = await request.formData(); - const contactId = data.get('contactId')?.toString(); + delete: async ({ request, locals, cookies }) => { + try { + const data = await request.formData(); + const contactId = data.get('contactId')?.toString(); - if (!contactId) { - return fail(400, { error: 'Contact ID is required' }); - } + if (!contactId) { + return fail(400, { error: 'Contact ID is required' }); + } - // Delete via API - await apiRequest( - `/contacts/${contactId}/`, - { method: 'DELETE' }, - { cookies, org: locals.org } - ); + // Delete via API + await apiRequest( + `/contacts/${contactId}/`, + { method: 'DELETE' }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error deleting contact:', err); - return fail(400, { error: err.message || 'Failed to delete contact' }); - } - } + return { success: true }; + } catch (err) { + console.error('Error deleting contact:', err); + return fail(400, { error: err.message || 'Failed to delete contact' }); + } + } }; diff --git a/frontend/src/routes/(app)/contacts/+page.svelte b/frontend/src/routes/(app)/contacts/+page.svelte index e8a32d38..a132753a 100644 --- a/frontend/src/routes/(app)/contacts/+page.svelte +++ b/frontend/src/routes/(app)/contacts/+page.svelte @@ -1,973 +1,973 @@ - Contacts - BottleCRM + Contacts - BottleCRM - {#snippet actions()} -
    - - + {#snippet actions()} +
    + + - - - - {#snippet child({ props })} - - {/snippet} - - - Toggle columns - - {#each columns as column (column.key)} - toggleColumn(column.key)} - disabled={column.canHide === false} - > - {column.label} - - {/each} - - + + + + {#snippet child({ props })} + + {/snippet} + + + Toggle columns + + {#each columns as column (column.key)} + toggleColumn(column.key)} + disabled={column.canHide === false} + > + {column.label} + + {/each} + + - -
    - {/snippet} + +
    + {/snippet}
    - - - updateFilters({ ...filters, search: value })} - class="w-64" - /> - - updateFilters({ ...filters, created_at_gte: start, created_at_lte: end })} - class="w-56" - /> - updateFilters({ ...filters, tags: ids })} - /> - + + + updateFilters({ ...filters, search: value })} + class="w-64" + /> + + updateFilters({ ...filters, created_at_gte: start, created_at_lte: end })} + class="w-56" + /> + updateFilters({ ...filters, tags: ids })} + /> + - - {#if filteredContacts.length === 0} -
    - -

    No contacts found

    -
    - {:else} - openContact(row)} - > - {#snippet emptyState()} -
    - -

    No contacts found

    -
    - {/snippet} -
    - {/if} + + {#if filteredContacts.length === 0} +
    + +

    No contacts found

    +
    + {:else} + openContact(row)} + > + {#snippet emptyState()} +
    + +

    No contacts found

    +
    + {/snippet} +
    + {/if} - - + +
    - {#snippet activitySection()} - - {#if drawerMode !== 'create' && selectedContact} -
    -

    - Details -

    -
    -
    -

    Owner

    -

    - {selectedContact.owner?.name || 'Unassigned'} -

    -
    -
    -

    Created

    -

    - {formatRelativeDate(selectedContact.createdAt)} -

    -
    -
    -
    - {/if} - {/snippet} + {#snippet activitySection()} + + {#if drawerMode !== 'create' && selectedContact} +
    +

    + Details +

    +
    +
    +

    Owner

    +

    + {selectedContact.owner?.name || 'Unassigned'} +

    +
    +
    +

    Created

    +

    + {formatRelativeDate(selectedContact.createdAt)} +

    +
    +
    +
    + {/if} + {/snippet} - {#snippet footerActions()} - {#if drawerMode === 'create'} - - - {:else} - - - {/if} - {/snippet} + {#snippet footerActions()} + {#if drawerMode === 'create'} + + + {:else} + + + {/if} + {/snippet}
    diff --git a/frontend/src/routes/(app)/invoices/+page.server.js b/frontend/src/routes/(app)/invoices/+page.server.js index c91c6360..3de19ea7 100644 --- a/frontend/src/routes/(app)/invoices/+page.server.js +++ b/frontend/src/routes/(app)/invoices/+page.server.js @@ -10,105 +10,109 @@ import { apiRequest, buildQueryParams } from '$lib/api-helpers.js'; /** @type {import('./$types').PageServerLoad} */ export async function load({ url, locals, cookies }) { - const org = locals.org; + const org = locals.org; - if (!org) { - throw error(401, 'Organization context required'); - } + if (!org) { + throw error(401, 'Organization context required'); + } - // Parse filter params from URL - const filters = { - search: url.searchParams.get('search') || '', - status: url.searchParams.get('status') || '', - account: url.searchParams.get('account') || '', - contact: url.searchParams.get('contact') || '', - assigned_to: url.searchParams.getAll('assigned_to'), - issue_date_gte: url.searchParams.get('issue_date_gte') || '', - issue_date_lte: url.searchParams.get('issue_date_lte') || '', - due_date_gte: url.searchParams.get('due_date_gte') || '', - due_date_lte: url.searchParams.get('due_date_lte') || '' - }; + // Parse filter params from URL + const filters = { + search: url.searchParams.get('search') || '', + status: url.searchParams.get('status') || '', + account: url.searchParams.get('account') || '', + contact: url.searchParams.get('contact') || '', + assigned_to: url.searchParams.getAll('assigned_to'), + issue_date_gte: url.searchParams.get('issue_date_gte') || '', + issue_date_lte: url.searchParams.get('issue_date_lte') || '', + due_date_gte: url.searchParams.get('due_date_gte') || '', + due_date_lte: url.searchParams.get('due_date_lte') || '' + }; - try { - const page = parseInt(url.searchParams.get('page') || '1'); - const limit = parseInt(url.searchParams.get('limit') || '10'); - const sort = url.searchParams.get('sort') || '-created_at'; + try { + const page = parseInt(url.searchParams.get('page') || '1'); + const limit = parseInt(url.searchParams.get('limit') || '10'); + const sort = url.searchParams.get('sort') || '-created_at'; - // Build query parameters for Django - const queryParams = buildQueryParams({ - page, - limit, - sort: sort.startsWith('-') ? sort.substring(1) : sort, - order: sort.startsWith('-') ? 'desc' : 'asc' - }); + // Build query parameters for Django + const queryParams = buildQueryParams({ + page, + limit, + sort: sort.startsWith('-') ? sort.substring(1) : sort, + order: sort.startsWith('-') ? 'desc' : 'asc' + }); - // Add filter params - if (filters.search) queryParams.append('search', filters.search); - if (filters.status) queryParams.append('status', filters.status); - if (filters.account) queryParams.append('account', filters.account); - if (filters.contact) queryParams.append('contact', filters.contact); - filters.assigned_to.forEach((id) => queryParams.append('assigned_to', id)); - if (filters.issue_date_gte) queryParams.append('issue_date_gte', filters.issue_date_gte); - if (filters.issue_date_lte) queryParams.append('issue_date_lte', filters.issue_date_lte); - if (filters.due_date_gte) queryParams.append('due_date_gte', filters.due_date_gte); - if (filters.due_date_lte) queryParams.append('due_date_lte', filters.due_date_lte); + // Add filter params + if (filters.search) queryParams.append('search', filters.search); + if (filters.status) queryParams.append('status', filters.status); + if (filters.account) queryParams.append('account', filters.account); + if (filters.contact) queryParams.append('contact', filters.contact); + filters.assigned_to.forEach((id) => queryParams.append('assigned_to', id)); + if (filters.issue_date_gte) queryParams.append('issue_date_gte', filters.issue_date_gte); + if (filters.issue_date_lte) queryParams.append('issue_date_lte', filters.issue_date_lte); + if (filters.due_date_gte) queryParams.append('due_date_gte', filters.due_date_gte); + if (filters.due_date_lte) queryParams.append('due_date_lte', filters.due_date_lte); - // Fetch invoices - const invoicesResponse = await apiRequest(`/invoices/?${queryParams.toString()}`, {}, { cookies, org }); + // Fetch invoices + const invoicesResponse = await apiRequest( + `/invoices/?${queryParams.toString()}`, + {}, + { cookies, org } + ); - // Handle Django response format - let invoices = []; - let totalCount = 0; + // Handle Django response format + let invoices = []; + let totalCount = 0; - if (invoicesResponse.results) { - invoices = invoicesResponse.results; - totalCount = invoicesResponse.count || 0; - } else if (Array.isArray(invoicesResponse)) { - invoices = invoicesResponse; - totalCount = invoices.length; - } + if (invoicesResponse.results) { + invoices = invoicesResponse.results; + totalCount = invoicesResponse.count || 0; + } else if (Array.isArray(invoicesResponse)) { + invoices = invoicesResponse; + totalCount = invoices.length; + } - // Transform Django invoices to frontend structure - const transformedInvoices = invoices.map((invoice) => ({ - id: invoice.id, - invoiceNumber: invoice.invoice_number, - invoiceTitle: invoice.invoice_title || '', - status: invoice.status, - clientName: invoice.client_name || '', - clientEmail: invoice.client_email || '', - issueDate: invoice.issue_date, - dueDate: invoice.due_date, - totalAmount: invoice.total_amount || '0.00', - amountDue: invoice.amount_due || '0.00', - amountPaid: invoice.amount_paid || '0.00', - currency: invoice.currency || 'USD', - account: invoice.account - ? { - id: invoice.account.id || invoice.account, - name: invoice.account_name || invoice.account?.name || '' - } - : null, - contact: invoice.contact - ? { - id: invoice.contact.id || invoice.contact, - name: invoice.contact_name || invoice.contact?.name || '' - } - : null, - createdAt: invoice.created_at - })); + // Transform Django invoices to frontend structure + const transformedInvoices = invoices.map((invoice) => ({ + id: invoice.id, + invoiceNumber: invoice.invoice_number, + invoiceTitle: invoice.invoice_title || '', + status: invoice.status, + clientName: invoice.client_name || '', + clientEmail: invoice.client_email || '', + issueDate: invoice.issue_date, + dueDate: invoice.due_date, + totalAmount: invoice.total_amount || '0.00', + amountDue: invoice.amount_due || '0.00', + amountPaid: invoice.amount_paid || '0.00', + currency: invoice.currency || 'USD', + account: invoice.account + ? { + id: invoice.account.id || invoice.account, + name: invoice.account_name || invoice.account?.name || '' + } + : null, + contact: invoice.contact + ? { + id: invoice.contact.id || invoice.contact, + name: invoice.contact_name || invoice.contact?.name || '' + } + : null, + createdAt: invoice.created_at + })); - return { - invoices: transformedInvoices, - pagination: { - page, - limit, - total: totalCount, - totalPages: Math.ceil(totalCount / limit) || 1 - }, - filters - }; - } catch (err) { - console.error('Error loading invoices from API:', err); - throw error(500, `Failed to load invoices: ${err.message}`); - } + return { + invoices: transformedInvoices, + pagination: { + page, + limit, + total: totalCount, + totalPages: Math.ceil(totalCount / limit) || 1 + }, + filters + }; + } catch (err) { + console.error('Error loading invoices from API:', err); + throw error(500, `Failed to load invoices: ${err.message}`); + } } diff --git a/frontend/src/routes/(app)/invoices/+page.svelte b/frontend/src/routes/(app)/invoices/+page.svelte index 82e94aa2..ca6de5eb 100644 --- a/frontend/src/routes/(app)/invoices/+page.svelte +++ b/frontend/src/routes/(app)/invoices/+page.svelte @@ -1,466 +1,466 @@ - Invoices | BottleCRM + Invoices | BottleCRM
    - - - {#snippet actions()} -
    - -
    - {#each STATUS_CHIPS as chip} - - {/each} -
    + + + {#snippet actions()} +
    + +
    + {#each STATUS_CHIPS as chip} + + {/each} +
    -
    +
    - - + + - - - - {#snippet child({ props })} - - {/snippet} - - - {#each columns as column} - toggleColumn(column.key)} - > - {column.label} - - {/each} - - + + + + {#snippet child({ props })} + + {/snippet} + + + {#each columns as column} + toggleColumn(column.key)} + > + {column.label} + + {/each} + + - - -
    - {/snippet} -
    + + +
    + {/snippet} +
    - - - updateFilters({ ...filters, search: value })} - /> + + + updateFilters({ ...filters, search: value })} + /> - updateFilters({ ...filters, status: value })} - /> + updateFilters({ ...filters, status: value })} + /> - - updateFilters({ ...filters, issue_date_gte: start, issue_date_lte: end })} - /> + + updateFilters({ ...filters, issue_date_gte: start, issue_date_lte: end })} + /> - - updateFilters({ ...filters, due_date_gte: start, due_date_lte: end })} - /> - + + updateFilters({ ...filters, due_date_gte: start, due_date_lte: end })} + /> + - - - {#snippet emptyState()} -
    - 📄 -

    No invoices yet

    -

    Create your first invoice to get started

    - -
    - {/snippet} - {#snippet cellContent(row, column)} - {#if column.key === 'status'} - - {row.status.replace('_', ' ')} - - {:else if column.key === 'issueDate' || column.key === 'dueDate'} - {row[column.key] ? formatDate(row[column.key]) : '-'} - {:else if column.key === 'totalAmount' || column.key === 'amountDue'} - 0 - ? 'font-medium text-orange-600 dark:text-orange-400' - : ''} - > - {formatCurrency(row[column.key], row.currency)} - - {:else if column.key === 'account' || column.key === 'contact'} - {row[column.key]?.name || '-'} - {:else} - {row[column.key] || '-'} - {/if} - {/snippet} -
    + + + {#snippet emptyState()} +
    + 📄 +

    No invoices yet

    +

    Create your first invoice to get started

    + +
    + {/snippet} + {#snippet cellContent(row, column)} + {#if column.key === 'status'} + + {row.status.replace('_', ' ')} + + {:else if column.key === 'issueDate' || column.key === 'dueDate'} + {row[column.key] ? formatDate(row[column.key]) : '-'} + {:else if column.key === 'totalAmount' || column.key === 'amountDue'} + 0 + ? 'font-medium text-orange-600 dark:text-orange-400' + : ''} + > + {formatCurrency(row[column.key], row.currency)} + + {:else if column.key === 'account' || column.key === 'contact'} + {row[column.key]?.name || '-'} + {:else} + {row[column.key] || '-'} + {/if} + {/snippet} +
    - - {#if invoices.length > 0} - - {/if} + + {#if invoices.length > 0} + + {/if}
    diff --git a/frontend/src/routes/(app)/invoices/[id]/+page.server.js b/frontend/src/routes/(app)/invoices/[id]/+page.server.js index d1e94d9f..1308e377 100644 --- a/frontend/src/routes/(app)/invoices/[id]/+page.server.js +++ b/frontend/src/routes/(app)/invoices/[id]/+page.server.js @@ -8,348 +8,352 @@ import { env } from '$env/dynamic/public'; /** @type {import('./$types').PageServerLoad} */ export async function load({ params, locals, cookies }) { - const org = locals.org; + const org = locals.org; - if (!org) { - throw error(401, 'Organization context required'); - } + if (!org) { + throw error(401, 'Organization context required'); + } - try { - // Fetch invoice, accounts, contacts, org settings, and templates in parallel - const [invoiceRes, accountsRes, contactsRes, orgSettingsRes, templatesRes] = await Promise.all([ - apiRequest(`/invoices/${params.id}/`, {}, { cookies, org }), - apiRequest('/accounts/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/contacts/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/org/settings/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/invoices/templates/', {}, { cookies, org }).catch(() => ({})) - ]); + try { + // Fetch invoice, accounts, contacts, org settings, and templates in parallel + const [invoiceRes, accountsRes, contactsRes, orgSettingsRes, templatesRes] = await Promise.all([ + apiRequest(`/invoices/${params.id}/`, {}, { cookies, org }), + apiRequest('/accounts/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/contacts/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/org/settings/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/invoices/templates/', {}, { cookies, org }).catch(() => ({})) + ]); - // API returns { invoice: {...}, attachments: [...], comments: [...], history: [...] } - const invoiceData = invoiceRes.invoice || invoiceRes; + // API returns { invoice: {...}, attachments: [...], comments: [...], history: [...] } + const invoiceData = invoiceRes.invoice || invoiceRes; - // Transform invoice - const invoice = { - id: invoiceData.id, - invoiceNumber: invoiceData.invoice_number, - invoiceTitle: invoiceData.invoice_title || '', - status: invoiceData.status, - // CRM relationships - accountId: invoiceData.account?.id || '', - contactId: invoiceData.contact?.id || '', - opportunityId: invoiceData.opportunity?.id || '', - // Client details - clientName: invoiceData.client_name || '', - clientEmail: invoiceData.client_email || '', - clientPhone: invoiceData.client_phone || '', - clientAddressLine: invoiceData.client_address_line || '', - clientCity: invoiceData.client_city || '', - clientState: invoiceData.client_state || '', - clientPostcode: invoiceData.client_postcode || '', - clientCountry: invoiceData.client_country || '', - // Financial - subtotal: invoiceData.subtotal || '0.00', - discountType: invoiceData.discount_type || '', - discountValue: invoiceData.discount_value || '0', - discountAmount: invoiceData.discount_amount || '0.00', - taxRate: invoiceData.tax_rate || '0', - taxAmount: invoiceData.tax_amount || '0.00', - shippingAmount: invoiceData.shipping_amount || '0.00', - totalAmount: invoiceData.total_amount || '0.00', - amountPaid: invoiceData.amount_paid || '0.00', - amountDue: invoiceData.amount_due || '0.00', - currency: invoiceData.currency || 'USD', - // Dates - issueDate: invoiceData.issue_date, - dueDate: invoiceData.due_date, - paymentTerms: invoiceData.payment_terms || 'NET_30', - sentAt: invoiceData.sent_at, - viewedAt: invoiceData.viewed_at, - paidAt: invoiceData.paid_at, - // Notes - notes: invoiceData.notes || '', - terms: invoiceData.terms || '', - // Additional metadata - billingPeriod: invoiceData.billing_period || '', - poNumber: invoiceData.po_number || '', - // Line items - lineItems: (invoiceData.line_items || []).map((item) => ({ - id: item.id, - name: item.name || '', - description: item.description || '', - quantity: parseFloat(item.quantity) || 1, - rate: parseFloat(item.unit_price) || 0, - amount: parseFloat(item.total) || 0 - })), - // Meta - createdAt: invoiceData.created_at, - createdBy: invoiceData.created_by - }; + // Transform invoice + const invoice = { + id: invoiceData.id, + invoiceNumber: invoiceData.invoice_number, + invoiceTitle: invoiceData.invoice_title || '', + status: invoiceData.status, + // CRM relationships + accountId: invoiceData.account?.id || '', + contactId: invoiceData.contact?.id || '', + opportunityId: invoiceData.opportunity?.id || '', + // Client details + clientName: invoiceData.client_name || '', + clientEmail: invoiceData.client_email || '', + clientPhone: invoiceData.client_phone || '', + clientAddressLine: invoiceData.client_address_line || '', + clientCity: invoiceData.client_city || '', + clientState: invoiceData.client_state || '', + clientPostcode: invoiceData.client_postcode || '', + clientCountry: invoiceData.client_country || '', + // Financial + subtotal: invoiceData.subtotal || '0.00', + discountType: invoiceData.discount_type || '', + discountValue: invoiceData.discount_value || '0', + discountAmount: invoiceData.discount_amount || '0.00', + taxRate: invoiceData.tax_rate || '0', + taxAmount: invoiceData.tax_amount || '0.00', + shippingAmount: invoiceData.shipping_amount || '0.00', + totalAmount: invoiceData.total_amount || '0.00', + amountPaid: invoiceData.amount_paid || '0.00', + amountDue: invoiceData.amount_due || '0.00', + currency: invoiceData.currency || 'USD', + // Dates + issueDate: invoiceData.issue_date, + dueDate: invoiceData.due_date, + paymentTerms: invoiceData.payment_terms || 'NET_30', + sentAt: invoiceData.sent_at, + viewedAt: invoiceData.viewed_at, + paidAt: invoiceData.paid_at, + // Notes + notes: invoiceData.notes || '', + terms: invoiceData.terms || '', + // Additional metadata + billingPeriod: invoiceData.billing_period || '', + poNumber: invoiceData.po_number || '', + // Line items + lineItems: (invoiceData.line_items || []).map((item) => ({ + id: item.id, + name: item.name || '', + description: item.description || '', + quantity: parseFloat(item.quantity) || 1, + rate: parseFloat(item.unit_price) || 0, + amount: parseFloat(item.total) || 0 + })), + // Meta + createdAt: invoiceData.created_at, + createdBy: invoiceData.created_by + }; - // Transform accounts - let accountsList = []; - if (accountsRes.active_accounts?.open_accounts) { - accountsList = accountsRes.active_accounts.open_accounts; - } else if (accountsRes.results) { - accountsList = accountsRes.results; - } else if (Array.isArray(accountsRes)) { - accountsList = accountsRes; - } - const accounts = accountsList.map((a) => ({ id: a.id, name: a.name })); + // Transform accounts + let accountsList = []; + if (accountsRes.active_accounts?.open_accounts) { + accountsList = accountsRes.active_accounts.open_accounts; + } else if (accountsRes.results) { + accountsList = accountsRes.results; + } else if (Array.isArray(accountsRes)) { + accountsList = accountsRes; + } + const accounts = accountsList.map((a) => ({ id: a.id, name: a.name })); - // Transform contacts - let contactsList = []; - if (contactsRes.contact_obj_list) { - contactsList = contactsRes.contact_obj_list; - } else if (contactsRes.results) { - contactsList = contactsRes.results; - } else if (Array.isArray(contactsRes)) { - contactsList = contactsRes; - } - const contacts = contactsList.map((c) => ({ - id: c.id, - name: `${c.first_name || ''} ${c.last_name || ''}`.trim() || c.email, - email: c.email || '', - phone: c.mobile_number || c.phone || '' - })); + // Transform contacts + let contactsList = []; + if (contactsRes.contact_obj_list) { + contactsList = contactsRes.contact_obj_list; + } else if (contactsRes.results) { + contactsList = contactsRes.results; + } else if (Array.isArray(contactsRes)) { + contactsList = contactsRes; + } + const contacts = contactsList.map((c) => ({ + id: c.id, + name: `${c.first_name || ''} ${c.last_name || ''}`.trim() || c.email, + email: c.email || '', + phone: c.mobile_number || c.phone || '' + })); - // Transform org settings for company profile - const company = { - name: orgSettingsRes.name || '', - companyName: orgSettingsRes.company_name || orgSettingsRes.name || '', - addressLine: orgSettingsRes.address_line || '', - city: orgSettingsRes.city || '', - state: orgSettingsRes.state || '', - postcode: orgSettingsRes.postcode || '', - country: orgSettingsRes.country || '', - phone: orgSettingsRes.phone || '', - email: orgSettingsRes.email || '', - website: orgSettingsRes.website || '', - taxId: orgSettingsRes.tax_id || '', - logoUrl: orgSettingsRes.logo_url || null - }; + // Transform org settings for company profile + const company = { + name: orgSettingsRes.name || '', + companyName: orgSettingsRes.company_name || orgSettingsRes.name || '', + addressLine: orgSettingsRes.address_line || '', + city: orgSettingsRes.city || '', + state: orgSettingsRes.state || '', + postcode: orgSettingsRes.postcode || '', + country: orgSettingsRes.country || '', + phone: orgSettingsRes.phone || '', + email: orgSettingsRes.email || '', + website: orgSettingsRes.website || '', + taxId: orgSettingsRes.tax_id || '', + logoUrl: orgSettingsRes.logo_url || null + }; - // Find the default template - let templatesList = []; - if (templatesRes.results) { - templatesList = templatesRes.results; - } else if (Array.isArray(templatesRes)) { - templatesList = templatesRes; - } - const defaultTemplate = templatesList.find((t) => t.is_default); - const template = defaultTemplate - ? { - id: defaultTemplate.id, - name: defaultTemplate.name, - primaryColor: defaultTemplate.primary_color || '#3B82F6', - secondaryColor: defaultTemplate.secondary_color || '#1E40AF', - footerText: defaultTemplate.footer_text || '', - defaultNotes: defaultTemplate.default_notes || '', - defaultTerms: defaultTemplate.default_terms || '' - } - : { - primaryColor: '#3B82F6', - secondaryColor: '#1E40AF', - footerText: '', - defaultNotes: '', - defaultTerms: '' - }; + // Find the default template + let templatesList = []; + if (templatesRes.results) { + templatesList = templatesRes.results; + } else if (Array.isArray(templatesRes)) { + templatesList = templatesRes; + } + const defaultTemplate = templatesList.find((t) => t.is_default); + const template = defaultTemplate + ? { + id: defaultTemplate.id, + name: defaultTemplate.name, + primaryColor: defaultTemplate.primary_color || '#3B82F6', + secondaryColor: defaultTemplate.secondary_color || '#1E40AF', + footerText: defaultTemplate.footer_text || '', + defaultNotes: defaultTemplate.default_notes || '', + defaultTerms: defaultTemplate.default_terms || '' + } + : { + primaryColor: '#3B82F6', + secondaryColor: '#1E40AF', + footerText: '', + defaultNotes: '', + defaultTerms: '' + }; - return { - invoice, - accounts, - contacts, - company, - template - }; - } catch (err) { - console.error('Error loading invoice:', err); - throw error(500, `Failed to load invoice: ${err.message}`); - } + return { + invoice, + accounts, + contacts, + company, + template + }; + } catch (err) { + console.error('Error loading invoice:', err); + throw error(500, `Failed to load invoice: ${err.message}`); + } } /** @type {import('./$types').Actions} */ export const actions = { - update: async ({ params, request, locals, cookies }) => { - try { - const form = await request.formData(); + update: async ({ params, request, locals, cookies }) => { + try { + const form = await request.formData(); - // Get CRM fields - const accountId = form.get('accountId')?.toString().trim(); - const contactId = form.get('contactId')?.toString().trim(); + // Get CRM fields + const accountId = form.get('accountId')?.toString().trim(); + const contactId = form.get('contactId')?.toString().trim(); - // Parse line items - const lineItemsJson = form.get('lineItems')?.toString() || '[]'; - let lineItems = []; - try { - lineItems = JSON.parse(lineItemsJson); - } catch (e) { - console.error('Failed to parse line items:', e); - } + // Parse line items + const lineItemsJson = form.get('lineItems')?.toString() || '[]'; + let lineItems = []; + try { + lineItems = JSON.parse(lineItemsJson); + } catch (e) { + console.error('Failed to parse line items:', e); + } - // Build invoice data - const invoiceData = { - invoice_title: form.get('invoiceTitle')?.toString().trim() || '', - status: form.get('status')?.toString() || 'Draft', - // Client info - client_name: form.get('clientName')?.toString().trim() || '', - client_email: form.get('clientEmail')?.toString().trim() || '', - client_phone: form.get('clientPhone')?.toString().trim() || '', - // Client address - client_address_line: form.get('clientAddressLine')?.toString() || '', - client_city: form.get('clientCity')?.toString() || '', - client_state: form.get('clientState')?.toString() || '', - client_postcode: form.get('clientPostcode')?.toString() || '', - client_country: form.get('clientCountry')?.toString() || '', - // Dates - issue_date: form.get('issueDate')?.toString() || null, - due_date: form.get('dueDate')?.toString() || null, - payment_terms: form.get('paymentTerms')?.toString() || 'NET_30', - // Financial - currency: form.get('currency')?.toString() || 'USD', - tax_rate: Math.max(0, Math.min(100, parseFloat(form.get('taxRate')?.toString() || '0') || 0)), - discount_type: '', - discount_value: '0', - // Notes - notes: form.get('notes')?.toString() || '', - terms: form.get('terms')?.toString() || '', - // Additional metadata - billing_period: form.get('billingPeriod')?.toString() || '', - po_number: form.get('poNumber')?.toString() || '', - // Reminder settings - reminder_enabled: true, - reminder_days_before: 3, - reminder_days_after: 7, - reminder_frequency: 'ONCE' - }; + // Build invoice data + const invoiceData = { + invoice_title: form.get('invoiceTitle')?.toString().trim() || '', + status: form.get('status')?.toString() || 'Draft', + // Client info + client_name: form.get('clientName')?.toString().trim() || '', + client_email: form.get('clientEmail')?.toString().trim() || '', + client_phone: form.get('clientPhone')?.toString().trim() || '', + // Client address + client_address_line: form.get('clientAddressLine')?.toString() || '', + client_city: form.get('clientCity')?.toString() || '', + client_state: form.get('clientState')?.toString() || '', + client_postcode: form.get('clientPostcode')?.toString() || '', + client_country: form.get('clientCountry')?.toString() || '', + // Dates + issue_date: form.get('issueDate')?.toString() || null, + due_date: form.get('dueDate')?.toString() || null, + payment_terms: form.get('paymentTerms')?.toString() || 'NET_30', + // Financial + currency: form.get('currency')?.toString() || 'USD', + tax_rate: Math.max( + 0, + Math.min(100, parseFloat(form.get('taxRate')?.toString() || '0') || 0) + ), + discount_type: '', + discount_value: '0', + // Notes + notes: form.get('notes')?.toString() || '', + terms: form.get('terms')?.toString() || '', + // Additional metadata + billing_period: form.get('billingPeriod')?.toString() || '', + po_number: form.get('poNumber')?.toString() || '', + // Reminder settings + reminder_enabled: true, + reminder_days_before: 3, + reminder_days_after: 7, + reminder_frequency: 'ONCE' + }; - // Add CRM relationships if provided - if (accountId) invoiceData.account_id = accountId; - if (contactId) invoiceData.contact_id = contactId; + // Add CRM relationships if provided + if (accountId) invoiceData.account_id = accountId; + if (contactId) invoiceData.contact_id = contactId; - // Add line items if any - if (lineItems.length > 0) { - invoiceData.line_items = lineItems.map((item, idx) => ({ - name: item.name || '', - description: item.description || '', - quantity: item.quantity || 1, - unit_price: item.rate || 0, - order: idx - })); - } + // Add line items if any + if (lineItems.length > 0) { + invoiceData.line_items = lineItems.map((item, idx) => ({ + name: item.name || '', + description: item.description || '', + quantity: item.quantity || 1, + unit_price: item.rate || 0, + order: idx + })); + } - await apiRequest( - `/invoices/${params.id}/`, - { - method: 'PUT', - body: invoiceData - }, - { cookies, org: locals.org } - ); + await apiRequest( + `/invoices/${params.id}/`, + { + method: 'PUT', + body: invoiceData + }, + { cookies, org: locals.org } + ); - return { success: true }; - } catch (err) { - console.error('Error updating invoice:', err); - return fail(400, { error: err.message || 'Failed to update invoice' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error updating invoice:', err); + return fail(400, { error: err.message || 'Failed to update invoice' }); + } + }, - cancel: async ({ params, locals, cookies }) => { - try { - await apiRequest( - `/invoices/${params.id}/cancel/`, - { method: 'POST' }, - { cookies, org: locals.org } - ); - throw redirect(303, '/invoices'); - } catch (err) { - if (err.status === 303) throw err; // Re-throw redirect - console.error('Error cancelling invoice:', err); - return fail(400, { error: err.message || 'Failed to cancel invoice' }); - } - }, + cancel: async ({ params, locals, cookies }) => { + try { + await apiRequest( + `/invoices/${params.id}/cancel/`, + { method: 'POST' }, + { cookies, org: locals.org } + ); + throw redirect(303, '/invoices'); + } catch (err) { + if (err.status === 303) throw err; // Re-throw redirect + console.error('Error cancelling invoice:', err); + return fail(400, { error: err.message || 'Failed to cancel invoice' }); + } + }, - send: async ({ params, locals, cookies }) => { - try { - await apiRequest( - `/invoices/${params.id}/send/`, - { method: 'POST' }, - { cookies, org: locals.org } - ); - return { success: true, message: 'Invoice sent successfully' }; - } catch (err) { - console.error('Error sending invoice:', err); - return fail(400, { error: err.message || 'Failed to send invoice' }); - } - }, + send: async ({ params, locals, cookies }) => { + try { + await apiRequest( + `/invoices/${params.id}/send/`, + { method: 'POST' }, + { cookies, org: locals.org } + ); + return { success: true, message: 'Invoice sent successfully' }; + } catch (err) { + console.error('Error sending invoice:', err); + return fail(400, { error: err.message || 'Failed to send invoice' }); + } + }, - markPaid: async ({ params, request, locals, cookies }) => { - try { - const form = await request.formData(); - const amount = form.get('amount')?.toString(); - const paymentMethod = form.get('paymentMethod')?.toString() || 'OTHER'; - const paymentDate = form.get('paymentDate')?.toString() || new Date().toISOString().split('T')[0]; + markPaid: async ({ params, request, locals, cookies }) => { + try { + const form = await request.formData(); + const amount = form.get('amount')?.toString(); + const paymentMethod = form.get('paymentMethod')?.toString() || 'OTHER'; + const paymentDate = + form.get('paymentDate')?.toString() || new Date().toISOString().split('T')[0]; - await apiRequest( - `/invoices/${params.id}/mark-paid/`, - { - method: 'POST', - body: { - amount, - payment_method: paymentMethod, - payment_date: paymentDate - } - }, - { cookies, org: locals.org } - ); + await apiRequest( + `/invoices/${params.id}/mark-paid/`, + { + method: 'POST', + body: { + amount, + payment_method: paymentMethod, + payment_date: paymentDate + } + }, + { cookies, org: locals.org } + ); - return { success: true, message: 'Payment recorded' }; - } catch (err) { - console.error('Error marking paid:', err); - return fail(400, { error: err.message || 'Failed to record payment' }); - } - }, + return { success: true, message: 'Payment recorded' }; + } catch (err) { + console.error('Error marking paid:', err); + return fail(400, { error: err.message || 'Failed to record payment' }); + } + }, - downloadPdf: async ({ params, locals, cookies }) => { - try { - const org = locals.org; - if (!org) { - return fail(401, { error: 'Organization context required' }); - } + downloadPdf: async ({ params, locals, cookies }) => { + try { + const org = locals.org; + if (!org) { + return fail(401, { error: 'Organization context required' }); + } - // Get the JWT token from cookies - const accessToken = cookies.get('jwt_access'); - if (!accessToken) { - return fail(401, { error: 'Authentication required' }); - } + // Get the JWT token from cookies + const accessToken = cookies.get('jwt_access'); + if (!accessToken) { + return fail(401, { error: 'Authentication required' }); + } - // Make request to Django backend for PDF - const apiUrl = env.PUBLIC_DJANGO_API_URL || 'http://localhost:8000'; - const response = await fetch(`${apiUrl}/api/invoices/${params.id}/pdf/`, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - 'X-Org-ID': org.id - } - }); + // Make request to Django backend for PDF + const apiUrl = env.PUBLIC_DJANGO_API_URL || 'http://localhost:8000'; + const response = await fetch(`${apiUrl}/api/invoices/${params.id}/pdf/`, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'X-Org-ID': org.id + } + }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || errorData.error || 'Failed to generate PDF'); - } + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || errorData.error || 'Failed to generate PDF'); + } - // Get the PDF blob - const pdfBuffer = await response.arrayBuffer(); - const base64Pdf = Buffer.from(pdfBuffer).toString('base64'); + // Get the PDF blob + const pdfBuffer = await response.arrayBuffer(); + const base64Pdf = Buffer.from(pdfBuffer).toString('base64'); - return { - success: true, - pdf: base64Pdf, - filename: `invoice-${params.id}.pdf` - }; - } catch (err) { - console.error('Error downloading PDF:', err); - return fail(400, { error: err.message || 'Failed to download PDF' }); - } - } + return { + success: true, + pdf: base64Pdf, + filename: `invoice-${params.id}.pdf` + }; + } catch (err) { + console.error('Error downloading PDF:', err); + return fail(400, { error: err.message || 'Failed to download PDF' }); + } + } }; diff --git a/frontend/src/routes/(app)/invoices/[id]/+page.svelte b/frontend/src/routes/(app)/invoices/[id]/+page.svelte index 7b606eff..28379a7b 100644 --- a/frontend/src/routes/(app)/invoices/[id]/+page.svelte +++ b/frontend/src/routes/(app)/invoices/[id]/+page.svelte @@ -1,739 +1,811 @@ - Invoice {invoice.invoiceNumber} | BottleCRM + Invoice {invoice.invoiceNumber} | BottleCRM -
    - -
    -
    -
    - -
    -
    -

    {invoice.invoiceNumber || 'Invoice'}

    - - - {(invoice.status || 'Draft').replace('_', ' ')} - - - {#each INVOICE_STATUS_OPTIONS as option} - - - - {option.label} - - - {/each} - - -
    -

    {invoice.clientName || 'No client name'}

    -
    -
    -
    - {#if isEditing} - - - {:else} - - - {#if invoice.status === 'Draft'} - - {/if} - - {#if invoice.status !== 'Cancelled' && invoice.status !== 'Paid'} - - - {#snippet child({ props })} - - {/snippet} - - - - Cancel Invoice? - - This will cancel invoice {invoice.invoiceNumber}. Cancelled invoices cannot be sent or edited. - - - - Keep Invoice - - - - - {/if} - {/if} -
    -
    -
    +
    + +
    +
    +
    + +
    +
    +

    {invoice.invoiceNumber || 'Invoice'}

    + + + {(invoice.status || 'Draft').replace('_', ' ')} + + + {#each INVOICE_STATUS_OPTIONS as option} + + + + {option.label} + + + {/each} + + +
    +

    {invoice.clientName || 'No client name'}

    +
    +
    +
    + {#if isEditing} + + + {:else} + + + {#if invoice.status === 'Draft'} + + {/if} + + {#if invoice.status !== 'Cancelled' && invoice.status !== 'Paid'} + + + {#snippet child({ props })} + + {/snippet} + + + + Cancel Invoice? + + This will cancel invoice {invoice.invoiceNumber}. Cancelled invoices cannot be + sent or edited. + + + + Keep Invoice + + + + + {/if} + {/if} +
    +
    +
    - -
    - {#if currentTab === 'view'} - - - {:else} - -
    - -
    -
    -
    -
    - - INVOICE -
    -
    -
    - - {invoice.invoiceNumber} -
    - {#if isEditing} -
    - - -
    -
    - - -
    -
    - - -
    - {:else if invoice.invoiceTitle || invoice.poNumber || invoice.billingPeriod} - {#if invoice.invoiceTitle} -
    - - {invoice.invoiceTitle} -
    - {/if} - {#if invoice.poNumber} -
    - - {invoice.poNumber} -
    - {/if} - {#if invoice.billingPeriod} -
    - - {invoice.billingPeriod} -
    - {/if} - {/if} -
    -
    + +
    + {#if currentTab === 'view'} + + + {:else} + +
    + +
    +
    +
    +
    + + INVOICE +
    +
    +
    + + {invoice.invoiceNumber} +
    + {#if isEditing} +
    + + +
    +
    + + +
    +
    + + +
    + {:else if invoice.invoiceTitle || invoice.poNumber || invoice.billingPeriod} + {#if invoice.invoiceTitle} +
    + + {invoice.invoiceTitle} +
    + {/if} + {#if invoice.poNumber} +
    + + {invoice.poNumber} +
    + {/if} + {#if invoice.billingPeriod} +
    + + {invoice.billingPeriod} +
    + {/if} + {/if} +
    +
    - -
    - {#if isEditing} -
    - - (invoice.currency = v)}> - - {currencyOptions.find((c) => c.value === invoice.currency)?.label || 'Select'} - - - {#each currencyOptions as option} - {option.label} - {/each} - - -
    -
    - - -
    -
    - - -
    - {:else} -
    - Issue Date: - {formatDate(invoice.issueDate)} -
    -
    - Due Date: - {formatDate(invoice.dueDate)} -
    -
    - Currency: - {invoice.currency} -
    - {/if} -
    -
    -
    + +
    + {#if isEditing} +
    + + (invoice.currency = v)} + > + + {currencyOptions.find((c) => c.value === invoice.currency)?.label || 'Select'} + + + {#each currencyOptions as option} + {option.label} + {/each} + + +
    +
    + + +
    +
    + + +
    + {:else} +
    + Issue Date: + {formatDate(invoice.issueDate)} +
    +
    + Due Date: + {formatDate(invoice.dueDate)} +
    +
    + Currency: + {invoice.currency} +
    + {/if} +
    +
    +
    - -
    - -
    -
    - - Bill From -
    - {#if isEditing} - (invoice.accountId = v)}> - - {accounts.find((a) => a.id === invoice.accountId)?.name || 'Select Account'} - - - {#each accounts as account} - {account.name} - {/each} - - - {:else} -

    {accounts.find((a) => a.id === invoice.accountId)?.name || 'No account'}

    - {/if} -
    + +
    + +
    +
    + + Bill From +
    + {#if isEditing} + (invoice.accountId = v)} + > + + {accounts.find((a) => a.id === invoice.accountId)?.name || 'Select Account'} + + + {#each accounts as account} + {account.name} + {/each} + + + {:else} +

    + {accounts.find((a) => a.id === invoice.accountId)?.name || 'No account'} +

    + {/if} +
    - -
    -
    - - Bill To -
    - {#if isEditing} - - - {contacts.find((c) => c.id === invoice.contactId)?.name || 'Select Contact'} - - - {#each contacts as contact} - {contact.name} - {/each} - - -
    - - - -
    - {:else} -
    -

    {invoice.clientName || 'No name'}

    - {#if invoice.clientEmail} -

    {invoice.clientEmail}

    - {/if} - {#if invoice.clientPhone} -

    {invoice.clientPhone}

    - {/if} -
    - {/if} -
    -
    + +
    +
    + + Bill To +
    + {#if isEditing} + + + {contacts.find((c) => c.id === invoice.contactId)?.name || 'Select Contact'} + + + {#each contacts as contact} + {contact.name} + {/each} + + +
    + + + +
    + {:else} +
    +

    {invoice.clientName || 'No name'}

    + {#if invoice.clientEmail} +

    {invoice.clientEmail}

    + {/if} + {#if invoice.clientPhone} +

    {invoice.clientPhone}

    + {/if} +
    + {/if} +
    +
    - -
    -

    Line Items

    -
    -
    -
    Description
    -
    Quantity
    -
    Rate
    -
    Amount
    - {#if isEditing}
    {/if} -
    + +
    +

    Line Items

    +
    +
    +
    Description
    +
    Quantity
    +
    Rate
    +
    Amount
    + {#if isEditing}
    {/if} +
    - {#each lineItems as item, index (item.id)} -
    -
    - {#if isEditing} -
    - - -
    - {:else} -
    - {#if item.name} - {item.name} - {#if item.description} - {item.description} - {/if} - {:else} - {item.description || '-'} - {/if} -
    - {/if} -
    -
    - {#if isEditing} - updateLineItemAmount(index)} - /> - {:else} - {item.quantity} - {/if} -
    -
    - {#if isEditing} - updateLineItemAmount(index)} - /> - {:else} - {formatCurrency(item.rate, invoice.currency)} - {/if} -
    -
    - {formatCurrency(item.quantity * item.rate, invoice.currency)} -
    - {#if isEditing} -
    - -
    - {/if} -
    - {/each} + {#each lineItems as item, index (item.id)} +
    +
    + {#if isEditing} +
    + + +
    + {:else} +
    + {#if item.name} + {item.name} + {#if item.description} + {item.description} + {/if} + {:else} + {item.description || '-'} + {/if} +
    + {/if} +
    +
    + {#if isEditing} + updateLineItemAmount(index)} + /> + {:else} + {item.quantity} + {/if} +
    +
    + {#if isEditing} + updateLineItemAmount(index)} + /> + {:else} + {formatCurrency(item.rate, invoice.currency)} + {/if} +
    +
    + {formatCurrency(item.quantity * item.rate, invoice.currency)} +
    + {#if isEditing} +
    + +
    + {/if} +
    + {/each} - {#if isEditing} -
    - -
    - {/if} -
    -
    + {#if isEditing} +
    + +
    + {/if} +
    +
    - -
    -
    -
    - Subtotal - {formatCurrency(subtotal, invoice.currency)} -
    -
    -
    - Tax - {#if isEditing} - - % - {:else} - ({taxRate}%) - {/if} -
    - {formatCurrency(taxAmount, invoice.currency)} -
    - -
    - Total - {formatCurrency(total, invoice.currency)} -
    - {#if Number(invoice.amountPaid) > 0} -
    - Amount Paid - -{formatCurrency(Number(invoice.amountPaid), invoice.currency)} -
    -
    - Amount Due - {formatCurrency(Number(invoice.amountDue), invoice.currency)} -
    - {/if} -
    -
    + +
    +
    +
    + Subtotal + {formatCurrency(subtotal, invoice.currency)} +
    +
    +
    + Tax + {#if isEditing} + + % + {:else} + ({taxRate}%) + {/if} +
    + {formatCurrency(taxAmount, invoice.currency)} +
    + +
    + Total + {formatCurrency(total, invoice.currency)} +
    + {#if Number(invoice.amountPaid) > 0} +
    + Amount Paid + -{formatCurrency(Number(invoice.amountPaid), invoice.currency)} +
    +
    + Amount Due + {formatCurrency(Number(invoice.amountDue), invoice.currency)} +
    + {/if} +
    +
    - -
    -
    - - -
    - - +
    { + isLoading = true; + return async ({ update }) => { + await update(); + isLoading = false; + }; + }} + > + + + + + + Organization Details + + Basic information about your organization + + +
    +
    + + +
    +
    + +
    + + +
    +
    +
    +
    + + +
    +
    +
    - - - - - - Locale Settings - - - Configure default currency and country for your organization - - - -
    -
    - -
    - - -
    -

    - This will be the default currency for new opportunities, leads, and invoices. -

    -
    + + + + + + Locale Settings + + + Configure default currency and country for your organization + + + +
    +
    + +
    + + +
    +

    + This will be the default currency for new opportunities, leads, and invoices. +

    +
    -
    - -
    - - -
    -

    - Default country for addresses and locale formatting. -

    -
    -
    +
    + +
    + + +
    +

    + Default country for addresses and locale formatting. +

    +
    +
    - - {#if formCurrency} -
    -

    Preview

    -

    - {new Intl.NumberFormat('en-US', { - style: 'currency', - currency: formCurrency - }).format(12345.67)} -

    -
    - {/if} -
    -
    + + {#if formCurrency} +
    +

    Preview

    +

    + {new Intl.NumberFormat('en-US', { + style: 'currency', + currency: formCurrency + }).format(12345.67)} +

    +
    + {/if} + + - -
    - -
    -
    + +
    + +
    +
    diff --git a/frontend/src/routes/(app)/settings/tags/+page.server.js b/frontend/src/routes/(app)/settings/tags/+page.server.js index 0675fe03..457ce18c 100644 --- a/frontend/src/routes/(app)/settings/tags/+page.server.js +++ b/frontend/src/routes/(app)/settings/tags/+page.server.js @@ -3,94 +3,94 @@ import { apiRequest } from '$lib/api-helpers.js'; /** @type {import('./$types').PageServerLoad} */ export async function load({ cookies, locals }) { - // Check if user is admin - const profile = locals.profile; - if (profile?.role !== 'ADMIN' && !profile?.is_organization_admin) { - throw error(403, 'Only admins can manage tags'); - } + // Check if user is admin + const profile = locals.profile; + if (profile?.role !== 'ADMIN' && !profile?.is_organization_admin) { + throw error(403, 'Only admins can manage tags'); + } - try { - // Get all tags including archived for admin view - const response = await apiRequest('/tags/?include_archived=true', {}, { cookies }); - return { - tags: response.tags || [] - }; - } catch (err) { - console.error('Failed to load tags:', err); - throw error(500, 'Failed to load tags'); - } + try { + // Get all tags including archived for admin view + const response = await apiRequest('/tags/?include_archived=true', {}, { cookies }); + return { + tags: response.tags || [] + }; + } catch (err) { + console.error('Failed to load tags:', err); + throw error(500, 'Failed to load tags'); + } } /** @type {import('./$types').Actions} */ export const actions = { - create: async ({ request, cookies }) => { - const formData = await request.formData(); - const name = /** @type {string | null} */ (formData.get('name')); - const data = { - name, - color: formData.get('color') || 'blue', - description: formData.get('description') || '' - }; + create: async ({ request, cookies }) => { + const formData = await request.formData(); + const name = /** @type {string | null} */ (formData.get('name')); + const data = { + name, + color: formData.get('color') || 'blue', + description: formData.get('description') || '' + }; - if (!name?.trim()) { - return fail(400, { error: 'Tag name is required' }); - } + if (!name?.trim()) { + return fail(400, { error: 'Tag name is required' }); + } - try { - await apiRequest('/tags/', { method: 'POST', body: data }, { cookies }); - return { success: true, action: 'create' }; - } catch (err) { - console.error('Failed to create tag:', err); - return fail(400, { error: err?.message || 'Failed to create tag' }); - } - }, + try { + await apiRequest('/tags/', { method: 'POST', body: data }, { cookies }); + return { success: true, action: 'create' }; + } catch (err) { + console.error('Failed to create tag:', err); + return fail(400, { error: err?.message || 'Failed to create tag' }); + } + }, - update: async ({ request, cookies }) => { - const formData = await request.formData(); - const tagId = formData.get('tagId'); - const name = /** @type {string | null} */ (formData.get('name')); - const data = { - name, - color: formData.get('color'), - description: formData.get('description') || '' - }; + update: async ({ request, cookies }) => { + const formData = await request.formData(); + const tagId = formData.get('tagId'); + const name = /** @type {string | null} */ (formData.get('name')); + const data = { + name, + color: formData.get('color'), + description: formData.get('description') || '' + }; - if (!name?.trim()) { - return fail(400, { error: 'Tag name is required' }); - } + if (!name?.trim()) { + return fail(400, { error: 'Tag name is required' }); + } - try { - await apiRequest(`/tags/${tagId}/`, { method: 'PUT', body: data }, { cookies }); - return { success: true, action: 'update' }; - } catch (err) { - console.error('Failed to update tag:', err); - return fail(400, { error: err?.message || 'Failed to update tag' }); - } - }, + try { + await apiRequest(`/tags/${tagId}/`, { method: 'PUT', body: data }, { cookies }); + return { success: true, action: 'update' }; + } catch (err) { + console.error('Failed to update tag:', err); + return fail(400, { error: err?.message || 'Failed to update tag' }); + } + }, - archive: async ({ request, cookies }) => { - const formData = await request.formData(); - const tagId = formData.get('tagId'); + archive: async ({ request, cookies }) => { + const formData = await request.formData(); + const tagId = formData.get('tagId'); - try { - await apiRequest(`/tags/${tagId}/`, { method: 'DELETE' }, { cookies }); - return { success: true, action: 'archive' }; - } catch (err) { - console.error('Failed to archive tag:', err); - return fail(400, { error: err?.message || 'Failed to archive tag' }); - } - }, + try { + await apiRequest(`/tags/${tagId}/`, { method: 'DELETE' }, { cookies }); + return { success: true, action: 'archive' }; + } catch (err) { + console.error('Failed to archive tag:', err); + return fail(400, { error: err?.message || 'Failed to archive tag' }); + } + }, - restore: async ({ request, cookies }) => { - const formData = await request.formData(); - const tagId = formData.get('tagId'); + restore: async ({ request, cookies }) => { + const formData = await request.formData(); + const tagId = formData.get('tagId'); - try { - await apiRequest(`/tags/${tagId}/restore/`, { method: 'POST' }, { cookies }); - return { success: true, action: 'restore' }; - } catch (err) { - console.error('Failed to restore tag:', err); - return fail(400, { error: err?.message || 'Failed to restore tag' }); - } - } + try { + await apiRequest(`/tags/${tagId}/restore/`, { method: 'POST' }, { cookies }); + return { success: true, action: 'restore' }; + } catch (err) { + console.error('Failed to restore tag:', err); + return fail(400, { error: err?.message || 'Failed to restore tag' }); + } + } }; diff --git a/frontend/src/routes/(app)/settings/tags/+page.svelte b/frontend/src/routes/(app)/settings/tags/+page.svelte index f8eb1c34..7ea3a9c1 100644 --- a/frontend/src/routes/(app)/settings/tags/+page.svelte +++ b/frontend/src/routes/(app)/settings/tags/+page.svelte @@ -1,273 +1,299 @@ - Tag Management - BottleCRM + Tag Management - BottleCRM - {#snippet actions()} - - {/snippet} + {#snippet actions()} + + {/snippet}
    - - - - - - Active Tags ({activeTags.length}) - - Tags available for use across all entities - - - {#if activeTags.length === 0} -

    No tags created yet. Click "New Tag" to create your first tag.

    - {:else} -
    - {#each activeTags as tag (tag.id)} -
    -
    - {tag.name} - {#if tag.description} - - {tag.description} - - {/if} -
    -
    - -
    - - -
    -
    -
    - {/each} -
    - {/if} -
    -
    + + + + + + Active Tags ({activeTags.length}) + + Tags available for use across all entities + + + {#if activeTags.length === 0} +

    + No tags created yet. Click "New Tag" to create your first tag. +

    + {:else} +
    + {#each activeTags as tag (tag.id)} +
    +
    + {tag.name} + {#if tag.description} + + {tag.description} + + {/if} +
    +
    + +
    + + +
    +
    +
    + {/each} +
    + {/if} +
    +
    - - {#if archivedTags.length > 0} - - - - - Archived Tags ({archivedTags.length}) - - Hidden from selection but preserved in existing associations - - -
    - {#each archivedTags as tag (tag.id)} -
    - {tag.name} -
    - - -
    -
    - {/each} -
    -
    -
    - {/if} + + {#if archivedTags.length > 0} + + + + + Archived Tags ({archivedTags.length}) + + Hidden from selection but preserved in existing associations + + +
    + {#each archivedTags as tag (tag.id)} +
    + {tag.name} +
    + + +
    +
    + {/each} +
    +
    +
    + {/if}
    - - - Create New Tag - Add a new tag to organize your CRM entities - -
    { - isLoading = true; - return async ({ update }) => { - await update(); - isLoading = false; - }; - }} - > -
    -
    - - -
    -
    - -
    - {#each TAG_COLORS as color} - - {/each} -
    - -
    -
    - - -
    -
    - - - - -
    -
    + + + Create New Tag + Add a new tag to organize your CRM entities + +
    { + isLoading = true; + return async ({ update }) => { + await update(); + isLoading = false; + }; + }} + > +
    +
    + + +
    +
    + +
    + {#each TAG_COLORS as color} + + {/each} +
    + +
    +
    + + +
    +
    + + + + +
    +
    - - - Edit Tag - Update tag details - -
    { - isLoading = true; - return async ({ update }) => { - await update(); - isLoading = false; - }; - }} - > - -
    -
    - - -
    -
    - -
    - {#each TAG_COLORS as color} - - {/each} -
    - -
    -
    - - -
    -
    - - - - -
    -
    + + + Edit Tag + Update tag details + +
    { + isLoading = true; + return async ({ update }) => { + await update(); + isLoading = false; + }; + }} + > + +
    +
    + + +
    +
    + +
    + {#each TAG_COLORS as color} + + {/each} +
    + +
    +
    + + +
    +
    + + + + +
    +
    diff --git a/frontend/src/routes/(app)/support/+page.svelte b/frontend/src/routes/(app)/support/+page.svelte index d6e79edf..c0ffebba 100644 --- a/frontend/src/routes/(app)/support/+page.svelte +++ b/frontend/src/routes/(app)/support/+page.svelte @@ -1,251 +1,251 @@ - Support - BottleCRM + Support - BottleCRM
    -
    - - - -
    -
    - -
    -
    -

    Our Mission

    -

    - BottleCRM addresses the high subscription costs of commercial CRM alternatives by - providing a completely free, open-source, and highly customizable solution. Clone it, - self-host it, and make it yours - forever free. -

    -
    -
    -
    -
    +
    + + + +
    +
    + +
    +
    +

    Our Mission

    +

    + BottleCRM addresses the high subscription costs of commercial CRM alternatives by + providing a completely free, open-source, and highly customizable solution. Clone it, + self-host it, and make it yours - forever free. +

    +
    +
    +
    +
    - -
    - - - -
    -
    - -
    - Community Support -
    -
    - -

    - Join our open-source community for free support, discussions, and collaboration. -

    - -
    -
    + +
    + + + +
    +
    + +
    + Community Support +
    +
    + +

    + Join our open-source community for free support, discussions, and collaboration. +

    + +
    +
    - - - -
    -
    - -
    - Professional Support -
    -
    - -

    - Get priority support, hosting assistance, and custom development services. -

    - -
    -
    -
    + + + +
    +
    + +
    + Professional Support +
    +
    + +

    + Get priority support, hosting assistance, and custom development services. +

    + +
    +
    +
    - - - -
    -
    - -
    -
    - Feature Requests & Ideas - Have an idea to make BottleCRM better? -
    -
    -
    - -

    - We'd love to hear from you! Share your feature requests and help shape the future of - open-source CRM. -

    - -
    -
    + + + +
    +
    + +
    +
    + Feature Requests & Ideas + Have an idea to make BottleCRM better? +
    +
    +
    + +

    + We'd love to hear from you! Share your feature requests and help shape the future of + open-source CRM. +

    + +
    +
    - - - -
    -
    - -
    -
    - Bug Reports - Found a bug? Help us improve BottleCRM -
    -
    -
    - -

    - Your feedback helps make the platform more stable for everyone. -

    - -
    -
    + + + +
    +
    + +
    +
    + Bug Reports + Found a bug? Help us improve BottleCRM +
    +
    +
    + +

    + Your feedback helps make the platform more stable for everyone. +

    + +
    +
    - - - -
    -
    - -
    -
    - Security Issues - Report security vulnerabilities privately -
    -
    -
    - -

    - Security is our priority. If you discover any security vulnerabilities, please - report them privately. Do not create public GitHub issues for security concerns. -

    - -
    -
    + + + +
    +
    + +
    +
    + Security Issues + Report security vulnerabilities privately +
    +
    +
    + +

    + Security is our priority. If you discover any security vulnerabilities, please + report them privately. Do not create public GitHub issues for security concerns. +

    + +
    +
    - - - -
    -
    - -
    -
    - Custom CRM Development - Tailored to your specific business needs -
    -
    -
    - -

    - Need BottleCRM tailored to your specific business needs? We offer professional - customization services including hosting, custom features, integrations, and ongoing - support. -

    - -
    -
    + + + +
    +
    + +
    +
    + Custom CRM Development + Tailored to your specific business needs +
    +
    +
    + +

    + Need BottleCRM tailored to your specific business needs? We offer professional + customization services including hosting, custom features, integrations, and ongoing + support. +

    + +
    +
    - -
    -

    BottleCRM v1.0.0

    -
    -
    + +
    +

    BottleCRM v1.0.0

    +
    +
    diff --git a/frontend/src/routes/(app)/tasks/+page.server.js b/frontend/src/routes/(app)/tasks/+page.server.js index 4c0f67a5..8a64a970 100644 --- a/frontend/src/routes/(app)/tasks/+page.server.js +++ b/frontend/src/routes/(app)/tasks/+page.server.js @@ -15,479 +15,507 @@ import { apiRequest, buildQueryParams } from '$lib/api-helpers.js'; /** @type {import('./$types').PageServerLoad} */ export async function load({ locals, cookies, url }) { - const user = locals.user; - const org = locals.org; + const user = locals.user; + const org = locals.org; - if (!org) { - throw error(401, 'Organization context required'); - } + if (!org) { + throw error(401, 'Organization context required'); + } - // Parse view mode from URL - const viewMode = url.searchParams.get('view') || 'list'; + // Parse view mode from URL + const viewMode = url.searchParams.get('view') || 'list'; - // Parse pagination params from URL - const page = parseInt(url.searchParams.get('page') || '1'); - const limit = parseInt(url.searchParams.get('limit') || '10'); + // Parse pagination params from URL + const page = parseInt(url.searchParams.get('page') || '1'); + const limit = parseInt(url.searchParams.get('limit') || '10'); - // Parse filter params from URL - const filters = { - search: url.searchParams.get('search') || '', - status: url.searchParams.get('status') || '', - priority: url.searchParams.get('priority') || '', - assigned_to: url.searchParams.getAll('assigned_to'), - tags: url.searchParams.getAll('tags'), - due_date_gte: url.searchParams.get('due_date_gte') || '', - due_date_lte: url.searchParams.get('due_date_lte') || '' - }; + // Parse filter params from URL + const filters = { + search: url.searchParams.get('search') || '', + status: url.searchParams.get('status') || '', + priority: url.searchParams.get('priority') || '', + assigned_to: url.searchParams.getAll('assigned_to'), + tags: url.searchParams.getAll('tags'), + due_date_gte: url.searchParams.get('due_date_gte') || '', + due_date_lte: url.searchParams.get('due_date_lte') || '' + }; - try { - // Build query parameters for tasks - const queryParams = buildQueryParams({ - sort: 'created_at', - order: 'desc' - }); - queryParams.append('limit', limit.toString()); - queryParams.append('offset', ((page - 1) * limit).toString()); + try { + // Build query parameters for tasks + const queryParams = buildQueryParams({ + sort: 'created_at', + order: 'desc' + }); + queryParams.append('limit', limit.toString()); + queryParams.append('offset', ((page - 1) * limit).toString()); - // Add filter params - if (filters.search) queryParams.append('search', filters.search); - if (filters.status) queryParams.append('status', filters.status); - if (filters.priority) queryParams.append('priority', filters.priority); - filters.assigned_to.forEach((id) => queryParams.append('assigned_to', id)); - filters.tags.forEach((id) => queryParams.append('tags', id)); - if (filters.due_date_gte) queryParams.append('due_date__gte', filters.due_date_gte); - if (filters.due_date_lte) queryParams.append('due_date__lte', filters.due_date_lte); + // Add filter params + if (filters.search) queryParams.append('search', filters.search); + if (filters.status) queryParams.append('status', filters.status); + if (filters.priority) queryParams.append('priority', filters.priority); + filters.assigned_to.forEach((id) => queryParams.append('assigned_to', id)); + filters.tags.forEach((id) => queryParams.append('tags', id)); + if (filters.due_date_gte) queryParams.append('due_date__gte', filters.due_date_gte); + if (filters.due_date_lte) queryParams.append('due_date__lte', filters.due_date_lte); - // Build kanban query params (similar filters) - const kanbanQueryParams = new URLSearchParams(); - if (filters.search) kanbanQueryParams.append('search', filters.search); - if (filters.priority) kanbanQueryParams.append('priority', filters.priority); - filters.assigned_to.forEach((id) => kanbanQueryParams.append('assigned_to', id)); - if (filters.due_date_gte) kanbanQueryParams.append('due_date__gte', filters.due_date_gte); - if (filters.due_date_lte) kanbanQueryParams.append('due_date__lte', filters.due_date_lte); + // Build kanban query params (similar filters) + const kanbanQueryParams = new URLSearchParams(); + if (filters.search) kanbanQueryParams.append('search', filters.search); + if (filters.priority) kanbanQueryParams.append('priority', filters.priority); + filters.assigned_to.forEach((id) => kanbanQueryParams.append('assigned_to', id)); + if (filters.due_date_gte) kanbanQueryParams.append('due_date__gte', filters.due_date_gte); + if (filters.due_date_lte) kanbanQueryParams.append('due_date__lte', filters.due_date_lte); - // Fetch tasks (or kanban data) and dropdown options in parallel - const kanbanQueryString = kanbanQueryParams.toString(); - const [tasksResponse, kanbanResponse, usersRes, accountsRes, contactsRes, teamsRes, opportunitiesRes, casesRes, leadsRes, tagsRes] = await Promise.all([ - apiRequest(`/tasks/?${queryParams.toString()}`, {}, { cookies, org }), - viewMode === 'kanban' - ? apiRequest(`/tasks/kanban/${kanbanQueryString ? '?' + kanbanQueryString : ''}`, {}, { cookies, org }) - : Promise.resolve(null), - apiRequest('/users/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/accounts/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/contacts/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/teams/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/opportunities/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/cases/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/leads/', {}, { cookies, org }).catch(() => ({})), - apiRequest('/tags/', {}, { cookies, org }).catch(() => ({})) - ]); + // Fetch tasks (or kanban data) and dropdown options in parallel + const kanbanQueryString = kanbanQueryParams.toString(); + const [ + tasksResponse, + kanbanResponse, + usersRes, + accountsRes, + contactsRes, + teamsRes, + opportunitiesRes, + casesRes, + leadsRes, + tagsRes + ] = await Promise.all([ + apiRequest(`/tasks/?${queryParams.toString()}`, {}, { cookies, org }), + viewMode === 'kanban' + ? apiRequest( + `/tasks/kanban/${kanbanQueryString ? '?' + kanbanQueryString : ''}`, + {}, + { cookies, org } + ) + : Promise.resolve(null), + apiRequest('/users/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/accounts/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/contacts/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/teams/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/opportunities/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/cases/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/leads/', {}, { cookies, org }).catch(() => ({})), + apiRequest('/tags/', {}, { cookies, org }).catch(() => ({})) + ]); - // Handle Django response structure - // Django TaskListView returns { tasks: [...], tasks_count: ..., ... } - let tasks = []; - if (tasksResponse.tasks) { - tasks = tasksResponse.tasks; - } else if (Array.isArray(tasksResponse)) { - tasks = tasksResponse; - } else if (tasksResponse.results) { - tasks = tasksResponse.results; - } + // Handle Django response structure + // Django TaskListView returns { tasks: [...], tasks_count: ..., ... } + let tasks = []; + if (tasksResponse.tasks) { + tasks = tasksResponse.tasks; + } else if (Array.isArray(tasksResponse)) { + tasks = tasksResponse; + } else if (tasksResponse.results) { + tasks = tasksResponse.results; + } - // Transform accounts first (needed for task account name lookup) - let accountsList = []; - if (accountsRes.active_accounts?.open_accounts) { - accountsList = accountsRes.active_accounts.open_accounts; - } else if (accountsRes.results) { - accountsList = accountsRes.results; - } else if (Array.isArray(accountsRes)) { - accountsList = accountsRes; - } - const accounts = accountsList.map((a) => ({ id: a.id, name: a.name })); + // Transform accounts first (needed for task account name lookup) + let accountsList = []; + if (accountsRes.active_accounts?.open_accounts) { + accountsList = accountsRes.active_accounts.open_accounts; + } else if (accountsRes.results) { + accountsList = accountsRes.results; + } else if (Array.isArray(accountsRes)) { + accountsList = accountsRes; + } + const accounts = accountsList.map((a) => ({ id: a.id, name: a.name })); - // Transform cases, opportunities, leads early (needed for task field name lookups) - const cases = (casesRes.cases || casesRes.results || []).map((c) => ({ id: c.id, name: c.name })); - const opportunities = (opportunitiesRes.opportunities || opportunitiesRes.results || []).map((o) => ({ id: o.id, name: o.name })); - const leads = (leadsRes.leads || leadsRes.results || []).map((l) => ({ - id: l.id, - name: l.first_name || l.last_name ? `${l.first_name || ''} ${l.last_name || ''}`.trim() : l.title || 'Lead' - })); + // Transform cases, opportunities, leads early (needed for task field name lookups) + const cases = (casesRes.cases || casesRes.results || []).map((c) => ({ + id: c.id, + name: c.name + })); + const opportunities = (opportunitiesRes.opportunities || opportunitiesRes.results || []).map( + (o) => ({ id: o.id, name: o.name }) + ); + const leads = (leadsRes.leads || leadsRes.results || []).map((l) => ({ + id: l.id, + name: + l.first_name || l.last_name + ? `${l.first_name || ''} ${l.last_name || ''}`.trim() + : l.title || 'Lead' + })); - // Helper to look up related entity name from list - const getRelatedName = (data, list, fallback) => { - if (!data) return null; - // If it's an object with name, use it - if (typeof data === 'object' && data.name) { - return { id: data.id, name: data.name }; - } - // If it's just an ID (string), look up from list - const id = typeof data === 'object' ? data.id : data; - const found = list.find((item) => item.id === id); - return { id, name: found?.name || fallback }; - }; + // Helper to look up related entity name from list + const getRelatedName = (data, list, fallback) => { + if (!data) return null; + // If it's an object with name, use it + if (typeof data === 'object' && data.name) { + return { id: data.id, name: data.name }; + } + // If it's just an ID (string), look up from list + const id = typeof data === 'object' ? data.id : data; + const found = list.find((item) => item.id === id); + return { id, name: found?.name || fallback }; + }; - // Transform Django tasks to frontend structure - const transformedTasks = tasks.map((task) => ({ - id: task.id, - subject: task.title, - description: task.description, - status: task.status, - priority: task.priority, - dueDate: task.due_date, - createdAt: task.created_at, - updatedAt: task.updated_at, + // Transform Django tasks to frontend structure + const transformedTasks = tasks.map((task) => ({ + id: task.id, + subject: task.title, + description: task.description, + status: task.status, + priority: task.priority, + dueDate: task.due_date, + createdAt: task.created_at, + updatedAt: task.updated_at, - // All assigned users (M2M) - assignedTo: (task.assigned_to || []).map((u) => ({ - id: u.id, - name: u.user_details?.email || u.user?.email || u.email || 'Unknown' - })), + // All assigned users (M2M) + assignedTo: (task.assigned_to || []).map((u) => ({ + id: u.id, + name: u.user_details?.email || u.user?.email || u.email || 'Unknown' + })), - // Contacts (M2M) - contacts: (task.contacts || []).map((c) => ({ - id: c.id, - name: c.first_name - ? `${c.first_name} ${c.last_name || ''}`.trim() - : c.email || 'Unknown' - })), + // Contacts (M2M) + contacts: (task.contacts || []).map((c) => ({ + id: c.id, + name: c.first_name ? `${c.first_name} ${c.last_name || ''}`.trim() : c.email || 'Unknown' + })), - // Teams (M2M) - teams: (task.teams || []).map((t) => ({ - id: t.id, - name: t.name - })), + // Teams (M2M) + teams: (task.teams || []).map((t) => ({ + id: t.id, + name: t.name + })), - // Tags (M2M) - tags: (task.tags || []).map((t) => ({ - id: t.id, - name: t.name - })), + // Tags (M2M) + tags: (task.tags || []).map((t) => ({ + id: t.id, + name: t.name + })), - // Account (FK) - lookup name from accounts list if needed - account: getRelatedName(task.account, accounts, 'Unknown Account'), + // Account (FK) - lookup name from accounts list if needed + account: getRelatedName(task.account, accounts, 'Unknown Account'), - // Opportunity (FK) - lookup name from opportunities list if needed - opportunity: getRelatedName(task.opportunity, opportunities, 'Unknown Opportunity'), + // Opportunity (FK) - lookup name from opportunities list if needed + opportunity: getRelatedName(task.opportunity, opportunities, 'Unknown Opportunity'), - // Case (FK) - lookup name from cases list if needed - case_: getRelatedName(task.case, cases, 'Unknown Case'), + // Case (FK) - lookup name from cases list if needed + case_: getRelatedName(task.case, cases, 'Unknown Case'), - // Lead (FK) - lookup name from leads list if needed - lead: getRelatedName(task.lead, leads, 'Unknown Lead'), + // Lead (FK) - lookup name from leads list if needed + lead: getRelatedName(task.lead, leads, 'Unknown Lead'), - // Created by - createdBy: task.created_by - ? { - id: task.created_by.id, - name: task.created_by.email - } - : null - })); + // Created by + createdBy: task.created_by + ? { + id: task.created_by.id, + name: task.created_by.email + } + : null + })); - // Get total count from response - const total = tasksResponse.tasks_count || tasksResponse.count || transformedTasks.length; + // Get total count from response + const total = tasksResponse.tasks_count || tasksResponse.count || transformedTasks.length; - // Transform dropdown options - const users = (usersRes.active_users?.active_users || []).map((u) => ({ - id: u.id, - name: u.user_details?.email || u.email - })); + // Transform dropdown options + const users = (usersRes.active_users?.active_users || []).map((u) => ({ + id: u.id, + name: u.user_details?.email || u.email + })); - // accounts already transformed above for task account name lookup + // accounts already transformed above for task account name lookup - let contactsList = []; - if (contactsRes.contact_obj_list) { - contactsList = contactsRes.contact_obj_list; - } else if (contactsRes.results) { - contactsList = contactsRes.results; - } else if (Array.isArray(contactsRes)) { - contactsList = contactsRes; - } - const contacts = contactsList.map((c) => ({ - id: c.id, - name: c.first_name ? `${c.first_name} ${c.last_name || ''}`.trim() : c.email || 'Unknown' - })); + let contactsList = []; + if (contactsRes.contact_obj_list) { + contactsList = contactsRes.contact_obj_list; + } else if (contactsRes.results) { + contactsList = contactsRes.results; + } else if (Array.isArray(contactsRes)) { + contactsList = contactsRes; + } + const contacts = contactsList.map((c) => ({ + id: c.id, + name: c.first_name ? `${c.first_name} ${c.last_name || ''}`.trim() : c.email || 'Unknown' + })); - const teams = (teamsRes.teams || teamsRes.results || []).map((t) => ({ id: t.id, name: t.name })); + const teams = (teamsRes.teams || teamsRes.results || []).map((t) => ({ + id: t.id, + name: t.name + })); - // opportunities, cases, leads already transformed above for task field name lookups + // opportunities, cases, leads already transformed above for task field name lookups - // Transform tags (include color for TagFilter) - const tags = (tagsRes.tags || tagsRes.results || []).map((t) => ({ id: t.id, name: t.name, color: t.color || 'blue' })); + // Transform tags (include color for TagFilter) + const tags = (tagsRes.tags || tagsRes.results || []).map((t) => ({ + id: t.id, + name: t.name, + color: t.color || 'blue' + })); - return { - tasks: transformedTasks, - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit) || 1 - }, - filters, - viewMode, - kanbanData: kanbanResponse, - // Dropdown options for drawer form - formOptions: { - users, - accounts, - contacts, - teams, - opportunities, - cases, - leads, - tags - } - }; - } catch (err) { - console.error('Error loading tasks from API:', err); - throw error(500, `Failed to load tasks: ${err.message}`); - } + return { + tasks: transformedTasks, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit) || 1 + }, + filters, + viewMode, + kanbanData: kanbanResponse, + // Dropdown options for drawer form + formOptions: { + users, + accounts, + contacts, + teams, + opportunities, + cases, + leads, + tags + } + }; + } catch (err) { + console.error('Error loading tasks from API:', err); + throw error(500, `Failed to load tasks: ${err.message}`); + } } /** @type {import('./$types').Actions} */ export const actions = { - create: async ({ request, locals, cookies }) => { - const org = locals.org; + create: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const form = await request.formData(); - const subject = form.get('subject')?.toString().trim(); - const description = form.get('description')?.toString().trim(); - const status = form.get('status')?.toString() || 'New'; - const priority = form.get('priority')?.toString() || 'Medium'; - const dueDate = form.get('dueDate')?.toString() || null; - const accountId = form.get('accountId')?.toString() || null; - const opportunityId = form.get('opportunityId')?.toString() || null; - const caseId = form.get('caseId')?.toString() || null; - const leadId = form.get('leadId')?.toString() || null; + try { + const form = await request.formData(); + const subject = form.get('subject')?.toString().trim(); + const description = form.get('description')?.toString().trim(); + const status = form.get('status')?.toString() || 'New'; + const priority = form.get('priority')?.toString() || 'Medium'; + const dueDate = form.get('dueDate')?.toString() || null; + const accountId = form.get('accountId')?.toString() || null; + const opportunityId = form.get('opportunityId')?.toString() || null; + const caseId = form.get('caseId')?.toString() || null; + const leadId = form.get('leadId')?.toString() || null; - // Parse array fields (sent as JSON strings) - const assignedToJson = form.get('assignedTo')?.toString() || '[]'; - const contactsJson = form.get('contacts')?.toString() || '[]'; - const teamsJson = form.get('teams')?.toString() || '[]'; - const tagsJson = form.get('tags')?.toString() || '[]'; + // Parse array fields (sent as JSON strings) + const assignedToJson = form.get('assignedTo')?.toString() || '[]'; + const contactsJson = form.get('contacts')?.toString() || '[]'; + const teamsJson = form.get('teams')?.toString() || '[]'; + const tagsJson = form.get('tags')?.toString() || '[]'; - const assignedTo = JSON.parse(assignedToJson); - const contacts = JSON.parse(contactsJson); - const teams = JSON.parse(teamsJson); - const tags = JSON.parse(tagsJson); + const assignedTo = JSON.parse(assignedToJson); + const contacts = JSON.parse(contactsJson); + const teams = JSON.parse(teamsJson); + const tags = JSON.parse(tagsJson); - if (!subject) { - return { success: false, error: 'Subject is required' }; - } + if (!subject) { + return { success: false, error: 'Subject is required' }; + } - // Transform to Django field names - const djangoData = { - title: subject, - description: description || null, - status: status, - priority: priority, - due_date: dueDate, - assigned_to: assignedTo, - contacts: contacts, - teams: teams, - tags: tags, - account: accountId || null, - opportunity: opportunityId || null, - case: caseId || null, - lead: leadId || null - }; + // Transform to Django field names + const djangoData = { + title: subject, + description: description || null, + status: status, + priority: priority, + due_date: dueDate, + assigned_to: assignedTo, + contacts: contacts, + teams: teams, + tags: tags, + account: accountId || null, + opportunity: opportunityId || null, + case: caseId || null, + lead: leadId || null + }; - await apiRequest( - '/tasks/', - { - method: 'POST', - body: djangoData - }, - { cookies, org } - ); + await apiRequest( + '/tasks/', + { + method: 'POST', + body: djangoData + }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error creating task:', err); - return { success: false, error: 'Failed to create task' }; - } - }, + return { success: true }; + } catch (err) { + console.error('Error creating task:', err); + return { success: false, error: 'Failed to create task' }; + } + }, - update: async ({ request, locals, cookies }) => { - const org = locals.org; + update: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const form = await request.formData(); - const taskId = form.get('taskId')?.toString(); - const subject = form.get('subject')?.toString().trim(); - const description = form.get('description')?.toString().trim(); - const status = form.get('status')?.toString() || 'New'; - const priority = form.get('priority')?.toString() || 'Medium'; - const dueDate = form.get('dueDate')?.toString() || null; - const accountId = form.get('accountId')?.toString() || null; - const opportunityId = form.get('opportunityId')?.toString() || null; - const caseId = form.get('caseId')?.toString() || null; - const leadId = form.get('leadId')?.toString() || null; + try { + const form = await request.formData(); + const taskId = form.get('taskId')?.toString(); + const subject = form.get('subject')?.toString().trim(); + const description = form.get('description')?.toString().trim(); + const status = form.get('status')?.toString() || 'New'; + const priority = form.get('priority')?.toString() || 'Medium'; + const dueDate = form.get('dueDate')?.toString() || null; + const accountId = form.get('accountId')?.toString() || null; + const opportunityId = form.get('opportunityId')?.toString() || null; + const caseId = form.get('caseId')?.toString() || null; + const leadId = form.get('leadId')?.toString() || null; - // Parse array fields (sent as JSON strings) - const assignedToJson = form.get('assignedTo')?.toString() || '[]'; - const contactsJson = form.get('contacts')?.toString() || '[]'; - const teamsJson = form.get('teams')?.toString() || '[]'; - const tagsJson = form.get('tags')?.toString() || '[]'; + // Parse array fields (sent as JSON strings) + const assignedToJson = form.get('assignedTo')?.toString() || '[]'; + const contactsJson = form.get('contacts')?.toString() || '[]'; + const teamsJson = form.get('teams')?.toString() || '[]'; + const tagsJson = form.get('tags')?.toString() || '[]'; - const assignedTo = JSON.parse(assignedToJson); - const contacts = JSON.parse(contactsJson); - const teams = JSON.parse(teamsJson); - const tags = JSON.parse(tagsJson); + const assignedTo = JSON.parse(assignedToJson); + const contacts = JSON.parse(contactsJson); + const teams = JSON.parse(teamsJson); + const tags = JSON.parse(tagsJson); - if (!taskId || !subject) { - return { success: false, error: 'Task ID and subject are required' }; - } + if (!taskId || !subject) { + return { success: false, error: 'Task ID and subject are required' }; + } - // Transform to Django field names - const djangoData = { - title: subject, - description: description || null, - status: status, - priority: priority, - due_date: dueDate, - assigned_to: assignedTo, - contacts: contacts, - teams: teams, - tags: tags, - account: accountId || null, - opportunity: opportunityId || null, - case: caseId || null, - lead: leadId || null - }; + // Transform to Django field names + const djangoData = { + title: subject, + description: description || null, + status: status, + priority: priority, + due_date: dueDate, + assigned_to: assignedTo, + contacts: contacts, + teams: teams, + tags: tags, + account: accountId || null, + opportunity: opportunityId || null, + case: caseId || null, + lead: leadId || null + }; - await apiRequest( - `/tasks/${taskId}/`, - { - method: 'PATCH', - body: djangoData - }, - { cookies, org } - ); + await apiRequest( + `/tasks/${taskId}/`, + { + method: 'PATCH', + body: djangoData + }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error updating task:', err); - return { success: false, error: 'Failed to update task' }; - } - }, + return { success: true }; + } catch (err) { + console.error('Error updating task:', err); + return { success: false, error: 'Failed to update task' }; + } + }, - complete: async ({ request, locals, cookies }) => { - const org = locals.org; + complete: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const form = await request.formData(); - const taskId = form.get('taskId')?.toString(); + try { + const form = await request.formData(); + const taskId = form.get('taskId')?.toString(); - if (!taskId) { - return { success: false, error: 'Task ID is required' }; - } + if (!taskId) { + return { success: false, error: 'Task ID is required' }; + } - // Update with PATCH, changing only status to Completed - await apiRequest( - `/tasks/${taskId}/`, - { - method: 'PATCH', - body: { status: 'Completed' } - }, - { cookies, org } - ); + // Update with PATCH, changing only status to Completed + await apiRequest( + `/tasks/${taskId}/`, + { + method: 'PATCH', + body: { status: 'Completed' } + }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error completing task:', err); - return { success: false, error: 'Failed to complete task' }; - } - }, + return { success: true }; + } catch (err) { + console.error('Error completing task:', err); + return { success: false, error: 'Failed to complete task' }; + } + }, - reopen: async ({ request, locals, cookies }) => { - const org = locals.org; + reopen: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const form = await request.formData(); - const taskId = form.get('taskId')?.toString(); + try { + const form = await request.formData(); + const taskId = form.get('taskId')?.toString(); - if (!taskId) { - return { success: false, error: 'Task ID is required' }; - } + if (!taskId) { + return { success: false, error: 'Task ID is required' }; + } - // Update with PATCH, changing only status to New - await apiRequest( - `/tasks/${taskId}/`, - { - method: 'PATCH', - body: { status: 'New' } - }, - { cookies, org } - ); + // Update with PATCH, changing only status to New + await apiRequest( + `/tasks/${taskId}/`, + { + method: 'PATCH', + body: { status: 'New' } + }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error reopening task:', err); - return { success: false, error: 'Failed to reopen task' }; - } - }, + return { success: true }; + } catch (err) { + console.error('Error reopening task:', err); + return { success: false, error: 'Failed to reopen task' }; + } + }, - delete: async ({ request, locals, cookies }) => { - const org = locals.org; + delete: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const form = await request.formData(); - const taskId = form.get('taskId')?.toString(); + try { + const form = await request.formData(); + const taskId = form.get('taskId')?.toString(); - if (!taskId) { - return { success: false, error: 'Task ID is required' }; - } + if (!taskId) { + return { success: false, error: 'Task ID is required' }; + } - await apiRequest(`/tasks/${taskId}/`, { method: 'DELETE' }, { cookies, org }); + await apiRequest(`/tasks/${taskId}/`, { method: 'DELETE' }, { cookies, org }); - return { success: true }; - } catch (err) { - console.error('Error deleting task:', err); - return { success: false, error: 'Failed to delete task' }; - } - }, + return { success: true }; + } catch (err) { + console.error('Error deleting task:', err); + return { success: false, error: 'Failed to delete task' }; + } + }, - moveTask: async ({ request, locals, cookies }) => { - const org = locals.org; + moveTask: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const form = await request.formData(); - const taskId = form.get('taskId')?.toString(); - const status = form.get('status')?.toString(); - const stageId = form.get('stageId')?.toString() || null; - const aboveTaskId = form.get('aboveTaskId')?.toString() || null; - const belowTaskId = form.get('belowTaskId')?.toString() || null; + try { + const form = await request.formData(); + const taskId = form.get('taskId')?.toString(); + const status = form.get('status')?.toString(); + const stageId = form.get('stageId')?.toString() || null; + const aboveTaskId = form.get('aboveTaskId')?.toString() || null; + const belowTaskId = form.get('belowTaskId')?.toString() || null; - if (!taskId) { - return { success: false, error: 'Task ID is required' }; - } + if (!taskId) { + return { success: false, error: 'Task ID is required' }; + } - if (!status && !stageId) { - return { success: false, error: 'Either status or stageId is required' }; - } + if (!status && !stageId) { + return { success: false, error: 'Either status or stageId is required' }; + } - /** @type {Record} */ - const moveData = {}; - if (status) moveData.status = status; - if (stageId) moveData.stage_id = stageId; - if (aboveTaskId) moveData.above_task_id = aboveTaskId; - if (belowTaskId) moveData.below_task_id = belowTaskId; + /** @type {Record} */ + const moveData = {}; + if (status) moveData.status = status; + if (stageId) moveData.stage_id = stageId; + if (aboveTaskId) moveData.above_task_id = aboveTaskId; + if (belowTaskId) moveData.below_task_id = belowTaskId; - await apiRequest( - `/tasks/${taskId}/move/`, - { - method: 'PATCH', - body: moveData - }, - { cookies, org } - ); + await apiRequest( + `/tasks/${taskId}/move/`, + { + method: 'PATCH', + body: moveData + }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error moving task:', err); - return { success: false, error: 'Failed to move task' }; - } - } + return { success: true }; + } catch (err) { + console.error('Error moving task:', err); + return { success: false, error: 'Failed to move task' }; + } + } }; diff --git a/frontend/src/routes/(app)/tasks/+page.svelte b/frontend/src/routes/(app)/tasks/+page.svelte index b15ae279..c32eb7b5 100644 --- a/frontend/src/routes/(app)/tasks/+page.svelte +++ b/frontend/src/routes/(app)/tasks/+page.svelte @@ -1,1636 +1,1642 @@ - Tasks - BottleCRM + Tasks - BottleCRM - {#snippet actions()} -
    - -
    - - - -
    + {#snippet actions()} +
    + +
    + + + +
    -
    +
    - -
    - - - -
    + +
    + + + +
    - - + + - {#if viewMode === 'list'} - - - {#snippet child({ props })} - - {/snippet} - - - Toggle columns - - {#each taskColumns as column (column.key)} - toggleColumn(column.key)} - disabled={column.canHide === false} - > - {column.label} - - {/each} - - - {/if} + {#if viewMode === 'list'} + + + {#snippet child({ props })} + + {/snippet} + + + Toggle columns + + {#each taskColumns as column (column.key)} + toggleColumn(column.key)} + disabled={column.canHide === false} + > + {column.label} + + {/each} + + + {/if} - -
    - {/snippet} + +
    + {/snippet}
    - - - updateFilters({ ...filters, search: value })} - placeholder="Search tasks..." - /> - updateFilters({ ...filters, priority: value })} - /> - - updateFilters({ ...filters, due_date_gte: start, due_date_lte: end })} - /> - updateFilters({ ...filters, tags: ids })} - /> - - {#if viewMode === 'list'} - - {#snippet emptyState()} -
    - -

    No tasks found

    -
    - {/snippet} -
    - {:else if viewMode === 'kanban'} - - {:else} - -
    - -
    - - - -
    - -

    - {monthNames[calendarMonth]} - {calendarYear} -

    - -
    - -
    + + + updateFilters({ ...filters, search: value })} + placeholder="Search tasks..." + /> + updateFilters({ ...filters, priority: value })} + /> + + updateFilters({ ...filters, due_date_gte: start, due_date_lte: end })} + /> + updateFilters({ ...filters, tags: ids })} + /> + + {#if viewMode === 'list'} + + {#snippet emptyState()} +
    + +

    No tasks found

    +
    + {/snippet} +
    + {:else if viewMode === 'kanban'} + + {:else} + +
    + +
    + + + +
    + +

    + {monthNames[calendarMonth]} + {calendarYear} +

    + +
    + +
    - - -
    - {#each ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as day} -
    - {day} -
    - {/each} -
    + + +
    + {#each ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as day} +
    + {day} +
    + {/each} +
    - -
    - {#each calendar as date, i} - {#if date} - - {:else} -
    - {/if} - {/each} -
    -
    -
    -
    + +
    + {#each calendar as date, i} + {#if date} + + {:else} +
    + {/if} + {/each} +
    + + +
    - -
    - - - - Tasks for {new Date(selectedDate + 'T00:00:00').toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric' - })} - -

    - {selectedTasks.length} task{selectedTasks.length !== 1 ? 's' : ''} -

    -
    + +
    + + + + Tasks for {new Date(selectedDate + 'T00:00:00').toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric' + })} + +

    + {selectedTasks.length} task{selectedTasks.length !== 1 ? 's' : ''} +

    +
    - - {#if selectedTasks.length > 0} -
    - {#each selectedTasks as task} - - {/each} -
    - {:else} -
    - -

    No tasks scheduled for this date

    - -
    - {/if} -
    -
    +
    + {#if task.priority} + + + {task.priority} + + {/if} + + {task.status} + +
    + + {/each} +
    + {:else} +
    + +

    No tasks scheduled for this date

    + +
    + {/if} + +
    - - - - This Month - - -
    - Total Tasks - {totalMonthlyTasks} -
    -
    - Days with Tasks - {monthlyTaskDates.length} -
    -
    -
    -
    -
    - {/if} + + + + This Month + + +
    + Total Tasks + {totalMonthlyTasks} +
    +
    + Days with Tasks + {monthlyTaskDates.length} +
    +
    +
    +
    +
    + {/if} - - + +
    !open && closeTaskSheet()} - data={drawerFormData} - columns={drawerColumns} - titleKey="subject" - titlePlaceholder="Task title" - headerLabel={isCreateMode ? 'New Task' : 'Task'} - mode={isCreateMode ? 'create' : 'view'} - onFieldChange={handleDrawerFieldChange} - onDelete={deleteSelectedTask} - onClose={closeTaskSheet} + bind:open={sheetOpen} + onOpenChange={(open) => !open && closeTaskSheet()} + data={drawerFormData} + columns={drawerColumns} + titleKey="subject" + titlePlaceholder="Task title" + headerLabel={isCreateMode ? 'New Task' : 'Task'} + mode={isCreateMode ? 'create' : 'view'} + onFieldChange={handleDrawerFieldChange} + onDelete={deleteSelectedTask} + onClose={closeTaskSheet} > - {#snippet activitySection()} - - {#if selectedTask && !isCreateMode} -
    -
    Details
    - {#if selectedTask.createdBy} -
    - - Created by {selectedTask.createdBy.name} -
    - {/if} - {#if selectedTask.createdAt} -
    - - Created {formatRelativeDate(selectedTask.createdAt)} -
    - {/if} -
    - {/if} - {/snippet} + {#snippet activitySection()} + + {#if selectedTask && !isCreateMode} +
    +
    Details
    + {#if selectedTask.createdBy} +
    + + Created by {selectedTask.createdBy.name} +
    + {/if} + {#if selectedTask.createdAt} +
    + + Created {formatRelativeDate(selectedTask.createdAt)} +
    + {/if} +
    + {/if} + {/snippet} - {#snippet footerActions()} - {#if isCreateMode} - - - {:else if selectedTask} - - {#if isCompleted} - - {:else} - - {/if} - - {/if} - {/snippet} + {#snippet footerActions()} + {#if isCreateMode} + + + {:else if selectedTask} + + {#if isCompleted} + + {:else} + + {/if} + + {/if} + {/snippet}
    diff --git a/frontend/src/routes/(app)/tasks/board/[boardId]/+page.server.js b/frontend/src/routes/(app)/tasks/board/[boardId]/+page.server.js index bb7337ca..09a33219 100644 --- a/frontend/src/routes/(app)/tasks/board/[boardId]/+page.server.js +++ b/frontend/src/routes/(app)/tasks/board/[boardId]/+page.server.js @@ -17,240 +17,240 @@ import { apiRequest } from '$lib/api-helpers.js'; /** @type {import('./$types').PageServerLoad} */ export async function load({ params, locals, cookies }) { - const { boardId } = params; - const user = locals.user; - const org = locals.org; + const { boardId } = params; + const user = locals.user; + const org = locals.org; - try { - // Fetch board details with columns - const board = await apiRequest(`/boards/${boardId}/`, {}, { cookies, org }); + try { + // Fetch board details with columns + const board = await apiRequest(`/boards/${boardId}/`, {}, { cookies, org }); - // Fetch columns with tasks - const columns = await apiRequest(`/boards/${boardId}/columns/`, {}, { cookies, org }); + // Fetch columns with tasks + const columns = await apiRequest(`/boards/${boardId}/columns/`, {}, { cookies, org }); - return { - board: { - id: board.id, - name: board.name, - description: board.description, - isArchived: board.is_archived, - owner: board.owner, - createdAt: new Date(board.created_at), - updatedAt: new Date(board.updated_at) - }, - columns: columns.map((col) => ({ - id: col.id, - name: col.name, - order: col.order, - color: col.color, - limit: col.limit, - taskCount: col.task_count, - tasks: (col.tasks || []).map((task) => ({ - id: task.id, - title: task.title, - description: task.description, - order: task.order, - priority: task.priority, - dueDate: task.due_date ? new Date(task.due_date) : null, - completedAt: task.completed_at ? new Date(task.completed_at) : null, - isCompleted: task.is_completed, - isOverdue: task.is_overdue, - assignedTo: task.assigned_to || [], - accountId: task.account, - contactId: task.contact, - opportunityId: task.opportunity - })) - })), - user - }; - } catch (err) { - console.error('Error loading board:', err); + return { + board: { + id: board.id, + name: board.name, + description: board.description, + isArchived: board.is_archived, + owner: board.owner, + createdAt: new Date(board.created_at), + updatedAt: new Date(board.updated_at) + }, + columns: columns.map((col) => ({ + id: col.id, + name: col.name, + order: col.order, + color: col.color, + limit: col.limit, + taskCount: col.task_count, + tasks: (col.tasks || []).map((task) => ({ + id: task.id, + title: task.title, + description: task.description, + order: task.order, + priority: task.priority, + dueDate: task.due_date ? new Date(task.due_date) : null, + completedAt: task.completed_at ? new Date(task.completed_at) : null, + isCompleted: task.is_completed, + isOverdue: task.is_overdue, + assignedTo: task.assigned_to || [], + accountId: task.account, + contactId: task.contact, + opportunityId: task.opportunity + })) + })), + user + }; + } catch (err) { + console.error('Error loading board:', err); - if (err.message.includes('404') || err.message.includes('not found')) { - throw error(404, 'Board not found'); - } + if (err.message.includes('404') || err.message.includes('not found')) { + throw error(404, 'Board not found'); + } - throw error(500, err.message || 'Failed to load board'); - } + throw error(500, err.message || 'Failed to load board'); + } } /** @type {import('./$types').Actions} */ export const actions = { - /** - * Create a new column - */ - createColumn: async ({ params, request, locals, cookies }) => { - const { boardId } = params; - const org = locals.org; + /** + * Create a new column + */ + createColumn: async ({ params, request, locals, cookies }) => { + const { boardId } = params; + const org = locals.org; - try { - const formData = await request.formData(); - const name = formData.get('name')?.toString(); - const color = formData.get('color')?.toString() || '#6B7280'; + try { + const formData = await request.formData(); + const name = formData.get('name')?.toString(); + const color = formData.get('color')?.toString() || '#6B7280'; - if (!name) { - return fail(400, { error: 'Column name is required' }); - } + if (!name) { + return fail(400, { error: 'Column name is required' }); + } - await apiRequest( - `/boards/${boardId}/columns/`, - { - method: 'POST', - body: { name, color } - }, - { cookies, org } - ); + await apiRequest( + `/boards/${boardId}/columns/`, + { + method: 'POST', + body: { name, color } + }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error creating column:', err); - return fail(500, { error: err.message || 'Failed to create column' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error creating column:', err); + return fail(500, { error: err.message || 'Failed to create column' }); + } + }, - /** - * Create a new task in a column - */ - createTask: async ({ params, request, locals, cookies }) => { - const { boardId } = params; - const org = locals.org; + /** + * Create a new task in a column + */ + createTask: async ({ params, request, locals, cookies }) => { + const { boardId } = params; + const org = locals.org; - try { - const formData = await request.formData(); - const columnId = formData.get('columnId')?.toString(); - const title = formData.get('title')?.toString(); - const description = formData.get('description')?.toString() || ''; - const priority = formData.get('priority')?.toString() || 'medium'; + try { + const formData = await request.formData(); + const columnId = formData.get('columnId')?.toString(); + const title = formData.get('title')?.toString(); + const description = formData.get('description')?.toString() || ''; + const priority = formData.get('priority')?.toString() || 'medium'; - if (!columnId || !title) { - return fail(400, { error: 'Column ID and title are required' }); - } + if (!columnId || !title) { + return fail(400, { error: 'Column ID and title are required' }); + } - await apiRequest( - `/boards/columns/${columnId}/tasks/`, - { - method: 'POST', - body: { - title, - description, - priority, - column: columnId - } - }, - { cookies, org } - ); + await apiRequest( + `/boards/columns/${columnId}/tasks/`, + { + method: 'POST', + body: { + title, + description, + priority, + column: columnId + } + }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error creating task:', err); - return fail(500, { error: err.message || 'Failed to create task' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error creating task:', err); + return fail(500, { error: err.message || 'Failed to create task' }); + } + }, - /** - * Update a task (move to different column, update details) - */ - updateTask: async ({ request, locals, cookies }) => { - const org = locals.org; + /** + * Update a task (move to different column, update details) + */ + updateTask: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const formData = await request.formData(); - const taskId = formData.get('taskId')?.toString(); - const title = formData.get('title')?.toString(); - const description = formData.get('description')?.toString(); - const columnId = formData.get('columnId')?.toString(); - const order = formData.get('order')?.toString(); - const priority = formData.get('priority')?.toString(); + try { + const formData = await request.formData(); + const taskId = formData.get('taskId')?.toString(); + const title = formData.get('title')?.toString(); + const description = formData.get('description')?.toString(); + const columnId = formData.get('columnId')?.toString(); + const order = formData.get('order')?.toString(); + const priority = formData.get('priority')?.toString(); - if (!taskId) { - return fail(400, { error: 'Task ID is required' }); - } + if (!taskId) { + return fail(400, { error: 'Task ID is required' }); + } - /** @type {Record} */ - const updateData = {}; - if (title) updateData.title = title; - if (description !== undefined) updateData.description = description; - if (columnId) updateData.column = columnId; - if (order) updateData.order = parseInt(order); - if (priority) updateData.priority = priority; + /** @type {Record} */ + const updateData = {}; + if (title) updateData.title = title; + if (description !== undefined) updateData.description = description; + if (columnId) updateData.column = columnId; + if (order) updateData.order = parseInt(order); + if (priority) updateData.priority = priority; - await apiRequest( - `/boards/tasks/${taskId}/`, - { - method: 'PATCH', - body: updateData - }, - { cookies, org } - ); + await apiRequest( + `/boards/tasks/${taskId}/`, + { + method: 'PATCH', + body: updateData + }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error updating task:', err); - return fail(500, { error: err.message || 'Failed to update task' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error updating task:', err); + return fail(500, { error: err.message || 'Failed to update task' }); + } + }, - /** - * Delete a task - */ - deleteTask: async ({ request, locals, cookies }) => { - const org = locals.org; + /** + * Delete a task + */ + deleteTask: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const formData = await request.formData(); - const taskId = formData.get('taskId')?.toString(); + try { + const formData = await request.formData(); + const taskId = formData.get('taskId')?.toString(); - if (!taskId) { - return fail(400, { error: 'Task ID is required' }); - } + if (!taskId) { + return fail(400, { error: 'Task ID is required' }); + } - await apiRequest( - `/boards/tasks/${taskId}/`, - { - method: 'DELETE' - }, - { cookies, org } - ); + await apiRequest( + `/boards/tasks/${taskId}/`, + { + method: 'DELETE' + }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error deleting task:', err); - return fail(500, { error: err.message || 'Failed to delete task' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error deleting task:', err); + return fail(500, { error: err.message || 'Failed to delete task' }); + } + }, - /** - * Move task to a different column (drag and drop) - */ - moveTask: async ({ request, locals, cookies }) => { - const org = locals.org; + /** + * Move task to a different column (drag and drop) + */ + moveTask: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const formData = await request.formData(); - const taskId = formData.get('taskId')?.toString(); - const columnId = formData.get('columnId')?.toString(); - const order = parseInt(formData.get('order')?.toString() || '0'); + try { + const formData = await request.formData(); + const taskId = formData.get('taskId')?.toString(); + const columnId = formData.get('columnId')?.toString(); + const order = parseInt(formData.get('order')?.toString() || '0'); - if (!taskId || !columnId) { - return fail(400, { error: 'Task ID and column ID are required' }); - } + if (!taskId || !columnId) { + return fail(400, { error: 'Task ID and column ID are required' }); + } - await apiRequest( - `/boards/tasks/${taskId}/`, - { - method: 'PATCH', - body: { - column: columnId, - order - } - }, - { cookies, org } - ); + await apiRequest( + `/boards/tasks/${taskId}/`, + { + method: 'PATCH', + body: { + column: columnId, + order + } + }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error moving task:', err); - return fail(500, { error: err.message || 'Failed to move task' }); - } - } + return { success: true }; + } catch (err) { + console.error('Error moving task:', err); + return fail(500, { error: err.message || 'Failed to move task' }); + } + } }; diff --git a/frontend/src/routes/(app)/tasks/board/[boardId]/+page.svelte b/frontend/src/routes/(app)/tasks/board/[boardId]/+page.svelte index 438c3c8b..fc70d140 100644 --- a/frontend/src/routes/(app)/tasks/board/[boardId]/+page.svelte +++ b/frontend/src/routes/(app)/tasks/board/[boardId]/+page.svelte @@ -1,549 +1,549 @@
    -
    - Back to Boards -

    {data.board.name}

    - {#if data.board.description} -

    {data.board.description}

    - {/if} -
    +
    + Back to Boards +

    {data.board.name}

    + {#if data.board.description} +

    {data.board.description}

    + {/if} +
    -
    - {#each data.columns as column (column.id)} -
    handleDragOver(e, column.id)} - on:dragleave={handleDragLeave} - on:drop={(e) => handleDrop(e, column.id)} - > -
    -

    {column.name}

    - {column.tasks.length} -
    +
    + {#each data.columns as column (column.id)} +
    handleDragOver(e, column.id)} + on:dragleave={handleDragLeave} + on:drop={(e) => handleDrop(e, column.id)} + > +
    +

    {column.name}

    + {column.tasks.length} +
    -
    - {#each column.tasks as task (task.id)} -
    handleDragStart(e, task, column.id)} - on:dragend={handleDragEnd} - > -
    {task.title}
    - {#if task.description} -
    {task.description}
    - {/if} -
    - - {task.priority} - - {#if task.dueDate} - - {formatDate(task.dueDate)} - - {/if} -
    - {#if task.assignedTo && task.assignedTo.length > 0} -
    - {#each task.assignedTo.slice(0, 3) as assignee} - - {assignee.user?.email?.charAt(0).toUpperCase() || '?'} - - {/each} - {#if task.assignedTo.length > 3} - +{task.assignedTo.length - 3} - {/if} -
    - {/if} -
    - {/each} +
    + {#each column.tasks as task (task.id)} +
    handleDragStart(e, task, column.id)} + on:dragend={handleDragEnd} + > +
    {task.title}
    + {#if task.description} +
    {task.description}
    + {/if} +
    + + {task.priority} + + {#if task.dueDate} + + {formatDate(task.dueDate)} + + {/if} +
    + {#if task.assignedTo && task.assignedTo.length > 0} +
    + {#each task.assignedTo.slice(0, 3) as assignee} + + {assignee.user?.email?.charAt(0).toUpperCase() || '?'} + + {/each} + {#if task.assignedTo.length > 3} + +{task.assignedTo.length - 3} + {/if} +
    + {/if} +
    + {/each} - {#if addingTaskToColumn === column.id} -
    { - return async ({ result }) => { - if (result.type === 'success') { - cancelAddingTask(); - await invalidateAll(); - } - }; - }} - > - - - - -
    - - -
    -
    - {:else} - - {/if} -
    -
    - {/each} + {#if addingTaskToColumn === column.id} +
    { + return async ({ result }) => { + if (result.type === 'success') { + cancelAddingTask(); + await invalidateAll(); + } + }; + }} + > + + + + +
    + + +
    +
    + {:else} + + {/if} +
    +
    + {/each} - -
    - {#if showAddColumn} -
    { - return async ({ result }) => { - if (result.type === 'success') { - showAddColumn = false; - newColumnName = ''; - await invalidateAll(); - } - }; - }} - > - - -
    - - -
    -
    - {:else} - - {/if} -
    -
    + +
    + {#if showAddColumn} +
    { + return async ({ result }) => { + if (result.type === 'success') { + showAddColumn = false; + newColumnName = ''; + await invalidateAll(); + } + }; + }} + > + + +
    + + +
    +
    + {:else} + + {/if} +
    +
    diff --git a/frontend/src/routes/(app)/tasks/calendar/+page.server.js b/frontend/src/routes/(app)/tasks/calendar/+page.server.js index 22bd8a0e..5b45530f 100644 --- a/frontend/src/routes/(app)/tasks/calendar/+page.server.js +++ b/frontend/src/routes/(app)/tasks/calendar/+page.server.js @@ -9,6 +9,6 @@ import { redirect } from '@sveltejs/kit'; /** @type {import('./$types').PageServerLoad} */ export async function load() { - // Redirect to tasks list page - calendar is now integrated there - throw redirect(302, '/tasks'); + // Redirect to tasks list page - calendar is now integrated there + throw redirect(302, '/tasks'); } diff --git a/frontend/src/routes/(app)/tasks/calendar/+page.svelte b/frontend/src/routes/(app)/tasks/calendar/+page.svelte index 290f4b0b..e643c6c6 100644 --- a/frontend/src/routes/(app)/tasks/calendar/+page.svelte +++ b/frontend/src/routes/(app)/tasks/calendar/+page.svelte @@ -1,6 +1,6 @@

    Redirecting to tasks...

    diff --git a/frontend/src/routes/(app)/users/+page.server.js b/frontend/src/routes/(app)/users/+page.server.js index eea21599..958d3252 100644 --- a/frontend/src/routes/(app)/users/+page.server.js +++ b/frontend/src/routes/(app)/users/+page.server.js @@ -35,394 +35,394 @@ const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api`; * @returns {Promise} */ async function apiRequest(endpoint, options = {}, context) { - const { cookies, org } = context; - const accessToken = cookies.get('jwt_access') || cookies.get('access_token'); + const { cookies, org } = context; + const accessToken = cookies.get('jwt_access') || cookies.get('access_token'); - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, - org: org.id, - ...options.headers - } - }); + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + org: org.id, + ...options.headers + } + }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: response.statusText })); - throw new Error(errorData.errors || errorData.error || response.statusText); - } + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(errorData.errors || errorData.error || response.statusText); + } - return await response.json(); + return await response.json(); } /** @type {import('./$types').PageServerLoad} */ export async function load({ locals, cookies }) { - const org = locals.org; - const user = locals.user; + const org = locals.org; + const user = locals.user; - try { - // Fetch users and teams in parallel - const [usersData, teamsData] = await Promise.all([ - apiRequest('/users/', {}, { cookies, org }), - apiRequest('/teams/', {}, { cookies, org }).catch(() => ({ teams: [] })) - ]); + try { + // Fetch users and teams in parallel + const [usersData, teamsData] = await Promise.all([ + apiRequest('/users/', {}, { cookies, org }), + apiRequest('/teams/', {}, { cookies, org }).catch(() => ({ teams: [] })) + ]); - // Django returns: { active_users: {...}, inactive_users: {...}, roles: [...] } - const activeUsers = usersData.active_users?.active_users || []; - const inactiveUsers = usersData.inactive_users?.inactive_users || []; + // Django returns: { active_users: {...}, inactive_users: {...}, roles: [...] } + const activeUsers = usersData.active_users?.active_users || []; + const inactiveUsers = usersData.inactive_users?.inactive_users || []; - // Check if current user is admin - // Django returns user_details with id and email - const currentUserProfile = activeUsers.find( - (p) => p.user_details?.id === user.id || p.user_details?.email === user.email - ); - const isAdmin = - currentUserProfile?.role === 'ADMIN' || currentUserProfile?.is_organization_admin; + // Check if current user is admin + // Django returns user_details with id and email + const currentUserProfile = activeUsers.find( + (p) => p.user_details?.id === user.id || p.user_details?.email === user.email + ); + const isAdmin = + currentUserProfile?.role === 'ADMIN' || currentUserProfile?.is_organization_admin; - if (!isAdmin) { - return { - error: { - name: 'You do not have permission to access this page' - } - }; - } + if (!isAdmin) { + return { + error: { + name: 'You do not have permission to access this page' + } + }; + } - // Combine active and inactive users, transform to match expected format - const allUsers = [ - ...activeUsers.map((profile) => ({ - odId: profile.user_details?.id || profile.id, - organizationId: org.id, - role: profile.role, - user: { - id: profile.user_details?.id || profile.id, - email: profile.user_details?.email || 'N/A', - name: profile.user_details?.email?.split('@')[0] || 'N/A' - }, - isActive: true, - profile - })), - ...inactiveUsers.map((profile) => ({ - odId: profile.user_details?.id || profile.id, - organizationId: org.id, - role: profile.role, - user: { - id: profile.user_details?.id || profile.id, - email: profile.user_details?.email || 'N/A', - name: profile.user_details?.email?.split('@')[0] || 'N/A' - }, - isActive: false, - profile - })) - ]; + // Combine active and inactive users, transform to match expected format + const allUsers = [ + ...activeUsers.map((profile) => ({ + odId: profile.user_details?.id || profile.id, + organizationId: org.id, + role: profile.role, + user: { + id: profile.user_details?.id || profile.id, + email: profile.user_details?.email || 'N/A', + name: profile.user_details?.email?.split('@')[0] || 'N/A' + }, + isActive: true, + profile + })), + ...inactiveUsers.map((profile) => ({ + odId: profile.user_details?.id || profile.id, + organizationId: org.id, + role: profile.role, + user: { + id: profile.user_details?.id || profile.id, + email: profile.user_details?.email || 'N/A', + name: profile.user_details?.email?.split('@')[0] || 'N/A' + }, + isActive: false, + profile + })) + ]; - // Transform teams - extract user IDs for form pre-population - const teams = (teamsData.teams || []).map((team) => ({ - ...team, - userIds: (team.users || []).map((u) => u.id) - })); + // Transform teams - extract user IDs for form pre-population + const teams = (teamsData.teams || []).map((team) => ({ + ...team, + userIds: (team.users || []).map((u) => u.id) + })); - return { - organization: { - id: org.id, - name: org.name, - domain: org.domain || '', - description: org.description || '' - }, - users: allUsers, - teams, - user: { id: user.id } - }; - } catch (err) { - console.error('Error loading users:', err); - return { - error: { - name: err.message || 'Failed to load users' - } - }; - } + return { + organization: { + id: org.id, + name: org.name, + domain: org.domain || '', + description: org.description || '' + }, + users: allUsers, + teams, + user: { id: user.id } + }; + } catch (err) { + console.error('Error loading users:', err); + return { + error: { + name: err.message || 'Failed to load users' + } + }; + } } /** @type {import('./$types').Actions} */ export const actions = { - /** - * Add user to organization by email - */ - add_user: async ({ request, locals, cookies }) => { - const org = locals.org; + /** + * Add user to organization by email + */ + add_user: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const formData = await request.formData(); - const email = formData.get('email')?.toString().trim().toLowerCase(); - const role = formData.get('role')?.toString(); + try { + const formData = await request.formData(); + const email = formData.get('email')?.toString().trim().toLowerCase(); + const role = formData.get('role')?.toString(); - if (!email || !role) { - return fail(400, { error: 'Email and role are required' }); - } + if (!email || !role) { + return fail(400, { error: 'Email and role are required' }); + } - // Create user via Django API - // Django endpoint: POST /api/users/ - const userData = { - email, - role, - // Django requires these fields for user creation - username: email.split('@')[0], - password: Math.random().toString(36).slice(-12) // Random password - }; + // Create user via Django API + // Django endpoint: POST /api/users/ + const userData = { + email, + role, + // Django requires these fields for user creation + username: email.split('@')[0], + password: Math.random().toString(36).slice(-12) // Random password + }; - await apiRequest( - '/users/', - { - method: 'POST', - body: JSON.stringify(userData) - }, - { cookies, org } - ); + await apiRequest( + '/users/', + { + method: 'POST', + body: JSON.stringify(userData) + }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error adding user:', err); - // Check for specific error messages - if ( - err.message.includes('already exists') || - err.message.includes('already in organization') - ) { - return fail(400, { error: 'User already in organization' }); - } - if (err.message.includes('not found')) { - return fail(404, { error: 'No user found with that email' }); - } - return fail(500, { error: err.message || 'Failed to add user' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error adding user:', err); + // Check for specific error messages + if ( + err.message.includes('already exists') || + err.message.includes('already in organization') + ) { + return fail(400, { error: 'User already in organization' }); + } + if (err.message.includes('not found')) { + return fail(404, { error: 'No user found with that email' }); + } + return fail(500, { error: err.message || 'Failed to add user' }); + } + }, - /** - * Edit user role - */ - edit_role: async ({ request, locals, cookies }) => { - const org = locals.org; - const user = locals.user; + /** + * Edit user role + */ + edit_role: async ({ request, locals, cookies }) => { + const org = locals.org; + const user = locals.user; - try { - const formData = await request.formData(); - const user_id = formData.get('user_id')?.toString(); - const role = formData.get('role')?.toString(); + try { + const formData = await request.formData(); + const user_id = formData.get('user_id')?.toString(); + const role = formData.get('role')?.toString(); - if (!user_id || !role) { - return fail(400, { error: 'User and role are required' }); - } + if (!user_id || !role) { + return fail(400, { error: 'User and role are required' }); + } - // Don't allow editing own role - if (user_id === user.id) { - return fail(400, { error: 'You cannot change your own role' }); - } + // Don't allow editing own role + if (user_id === user.id) { + return fail(400, { error: 'You cannot change your own role' }); + } - // Update user role via Django API - // Django endpoint: PATCH /api/user/{id}/ (partial update) - await apiRequest( - `/user/${user_id}/`, - { - method: 'PATCH', - body: JSON.stringify({ role }) - }, - { cookies, org } - ); + // Update user role via Django API + // Django endpoint: PATCH /api/user/{id}/ (partial update) + await apiRequest( + `/user/${user_id}/`, + { + method: 'PATCH', + body: JSON.stringify({ role }) + }, + { cookies, org } + ); - return { success: true }; - } catch (err) { - console.error('Error editing role:', err); - if (err.message.includes('at least one admin')) { - return fail(400, { error: 'Organization must have at least one admin' }); - } - return fail(500, { error: err.message || 'Failed to edit role' }); - } - }, + return { success: true }; + } catch (err) { + console.error('Error editing role:', err); + if (err.message.includes('at least one admin')) { + return fail(400, { error: 'Organization must have at least one admin' }); + } + return fail(500, { error: err.message || 'Failed to edit role' }); + } + }, - /** - * Remove user from organization - */ - remove_user: async ({ request, locals, cookies }) => { - const org = locals.org; - const user = locals.user; + /** + * Remove user from organization + */ + remove_user: async ({ request, locals, cookies }) => { + const org = locals.org; + const user = locals.user; - try { - const formData = await request.formData(); - const user_id = formData.get('user_id')?.toString(); + try { + const formData = await request.formData(); + const user_id = formData.get('user_id')?.toString(); - if (!user_id) { - return fail(400, { error: 'User is required' }); - } + if (!user_id) { + return fail(400, { error: 'User is required' }); + } - // Don't allow removing self - if (user_id === user.id) { - return fail(400, { error: 'You cannot remove yourself' }); - } + // Don't allow removing self + if (user_id === user.id) { + return fail(400, { error: 'You cannot remove yourself' }); + } - // Remove user via Django API (soft delete - set is_active=False) - // Django endpoint: POST /api/user/{id}/status/ - await apiRequest( - `/user/${user_id}/status/`, - { - method: 'POST', - body: JSON.stringify({ status: 'Inactive' }) - }, - { cookies, org } - ); + // Remove user via Django API (soft delete - set is_active=False) + // Django endpoint: POST /api/user/{id}/status/ + await apiRequest( + `/user/${user_id}/status/`, + { + method: 'POST', + body: JSON.stringify({ status: 'Inactive' }) + }, + { cookies, org } + ); - return { success: true, action: 'remove_user' }; - } catch (err) { - console.error('Error removing user:', err); - if (err.message.includes('at least one admin')) { - return fail(400, { error: 'Organization must have at least one admin' }); - } - return fail(500, { error: err.message || 'Failed to remove user' }); - } - }, + return { success: true, action: 'remove_user' }; + } catch (err) { + console.error('Error removing user:', err); + if (err.message.includes('at least one admin')) { + return fail(400, { error: 'Organization must have at least one admin' }); + } + return fail(500, { error: err.message || 'Failed to remove user' }); + } + }, - /** - * Activate user (restore inactive user) - */ - activate_user: async ({ request, locals, cookies }) => { - const org = locals.org; + /** + * Activate user (restore inactive user) + */ + activate_user: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const formData = await request.formData(); - const user_id = formData.get('user_id')?.toString(); + try { + const formData = await request.formData(); + const user_id = formData.get('user_id')?.toString(); - if (!user_id) { - return fail(400, { error: 'User is required' }); - } + if (!user_id) { + return fail(400, { error: 'User is required' }); + } - // Activate user via Django API (set is_active=True) - await apiRequest( - `/user/${user_id}/status/`, - { - method: 'POST', - body: JSON.stringify({ status: 'Active' }) - }, - { cookies, org } - ); + // Activate user via Django API (set is_active=True) + await apiRequest( + `/user/${user_id}/status/`, + { + method: 'POST', + body: JSON.stringify({ status: 'Active' }) + }, + { cookies, org } + ); - return { success: true, action: 'activate_user' }; - } catch (err) { - console.error('Error activating user:', err); - return fail(500, { error: err.message || 'Failed to activate user' }); - } - }, + return { success: true, action: 'activate_user' }; + } catch (err) { + console.error('Error activating user:', err); + return fail(500, { error: err.message || 'Failed to activate user' }); + } + }, - /** - * Create a new team - */ - create_team: async ({ request, locals, cookies }) => { - const org = locals.org; + /** + * Create a new team + */ + create_team: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const formData = await request.formData(); - const name = formData.get('name')?.toString().trim(); - const description = formData.get('description')?.toString().trim() || ''; - const users = formData.getAll('users').map((u) => u.toString()); + try { + const formData = await request.formData(); + const name = formData.get('name')?.toString().trim(); + const description = formData.get('description')?.toString().trim() || ''; + const users = formData.getAll('users').map((u) => u.toString()); - if (!name) { - return fail(400, { error: 'Team name is required' }); - } + if (!name) { + return fail(400, { error: 'Team name is required' }); + } - // Create team via Django API - await apiRequest( - '/teams/', - { - method: 'POST', - body: JSON.stringify({ - name, - description, - assign_users: true, - users - }) - }, - { cookies, org } - ); + // Create team via Django API + await apiRequest( + '/teams/', + { + method: 'POST', + body: JSON.stringify({ + name, + description, + assign_users: true, + users + }) + }, + { cookies, org } + ); - return { success: true, action: 'create_team' }; - } catch (err) { - console.error('Error creating team:', err); - if (err.message.includes('already exists')) { - return fail(400, { error: 'A team with this name already exists' }); - } - return fail(500, { error: err.message || 'Failed to create team' }); - } - }, + return { success: true, action: 'create_team' }; + } catch (err) { + console.error('Error creating team:', err); + if (err.message.includes('already exists')) { + return fail(400, { error: 'A team with this name already exists' }); + } + return fail(500, { error: err.message || 'Failed to create team' }); + } + }, - /** - * Update an existing team - */ - update_team: async ({ request, locals, cookies }) => { - const org = locals.org; + /** + * Update an existing team + */ + update_team: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const formData = await request.formData(); - const teamId = formData.get('team_id')?.toString(); - const name = formData.get('name')?.toString().trim(); - const description = formData.get('description')?.toString().trim() || ''; - const users = formData.getAll('users').map((u) => u.toString()); + try { + const formData = await request.formData(); + const teamId = formData.get('team_id')?.toString(); + const name = formData.get('name')?.toString().trim(); + const description = formData.get('description')?.toString().trim() || ''; + const users = formData.getAll('users').map((u) => u.toString()); - if (!teamId) { - return fail(400, { error: 'Team ID is required' }); - } + if (!teamId) { + return fail(400, { error: 'Team ID is required' }); + } - if (!name) { - return fail(400, { error: 'Team name is required' }); - } + if (!name) { + return fail(400, { error: 'Team name is required' }); + } - // Update team via Django API - await apiRequest( - `/teams/${teamId}/`, - { - method: 'PUT', - body: JSON.stringify({ - name, - description, - assign_users: users - }) - }, - { cookies, org } - ); + // Update team via Django API + await apiRequest( + `/teams/${teamId}/`, + { + method: 'PUT', + body: JSON.stringify({ + name, + description, + assign_users: users + }) + }, + { cookies, org } + ); - return { success: true, action: 'update_team' }; - } catch (err) { - console.error('Error updating team:', err); - if (err.message.includes('already exists')) { - return fail(400, { error: 'A team with this name already exists' }); - } - return fail(500, { error: err.message || 'Failed to update team' }); - } - }, + return { success: true, action: 'update_team' }; + } catch (err) { + console.error('Error updating team:', err); + if (err.message.includes('already exists')) { + return fail(400, { error: 'A team with this name already exists' }); + } + return fail(500, { error: err.message || 'Failed to update team' }); + } + }, - /** - * Delete a team - */ - delete_team: async ({ request, locals, cookies }) => { - const org = locals.org; + /** + * Delete a team + */ + delete_team: async ({ request, locals, cookies }) => { + const org = locals.org; - try { - const formData = await request.formData(); - const teamId = formData.get('team_id')?.toString(); + try { + const formData = await request.formData(); + const teamId = formData.get('team_id')?.toString(); - if (!teamId) { - return fail(400, { error: 'Team ID is required' }); - } + if (!teamId) { + return fail(400, { error: 'Team ID is required' }); + } - // Delete team via Django API - await apiRequest( - `/teams/${teamId}/`, - { - method: 'DELETE' - }, - { cookies, org } - ); + // Delete team via Django API + await apiRequest( + `/teams/${teamId}/`, + { + method: 'DELETE' + }, + { cookies, org } + ); - return { success: true, action: 'delete_team' }; - } catch (err) { - console.error('Error deleting team:', err); - return fail(500, { error: err.message || 'Failed to delete team' }); - } - } + return { success: true, action: 'delete_team' }; + } catch (err) { + console.error('Error deleting team:', err); + return fail(500, { error: err.message || 'Failed to delete team' }); + } + } }; diff --git a/frontend/src/routes/(app)/users/+page.svelte b/frontend/src/routes/(app)/users/+page.svelte index a7c6ebb4..a8735fb9 100644 --- a/frontend/src/routes/(app)/users/+page.svelte +++ b/frontend/src/routes/(app)/users/+page.svelte @@ -1,543 +1,550 @@ - Users & Teams - BottleCRM + Users & Teams - BottleCRM
    - - {#if data.error} - - -
    - -
    -

    - {data.error.name} -

    -
    -
    - {:else} -
    - - - - - Users - - - - Teams - - + + {#if data.error} + + +
    + +
    +

    + {data.error.name} +

    +
    +
    + {:else} +
    + + + + + Users + + + + Teams + + - - - - - -
    -
    - -
    -
    - Add New Member - Invite a user to join your organization -
    -
    -
    - -
    -
    - - -
    -
    - - -
    - -
    -
    -
    + + + + + +
    +
    + +
    +
    + Add New Member + Invite a user to join your organization +
    +
    +
    + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    - - - -
    -
    - -
    -
    - Team Members - {users.length} member{users.length !== 1 ? 's' : ''} in your organization -
    -
    -
    - -
    - - - - Member - Role - Joined - Actions - - - - {#each users as user (user.id)} - - -
    - - {#if user.avatar} - - {/if} - - {getInitials(user.name)} - - -
    -
    - {user.name} - {#if user.isSelf} - You - {/if} - {#if !user.isActive} - Inactive - {/if} -
    - {user.email} -
    -
    -
    - - {#if user.isSelf || editingRoleId !== user.id} - - {#if user.role === 'ADMIN'} - - {:else} - - {/if} - {user.role} - - {#if !user.isSelf} - - {/if} - {:else} -
    - - - - -
    - {/if} -
    - - {formatDate(user.joined)} - - - {#if user.isSelf} - - - {:else if !user.isActive} - - - {:else} - - - - - - - - Deactivate Team Member - - Are you sure you want to deactivate {user.name}? - They will no longer be able to access the organization. - - - - Cancel - - - - - {/if} - -
    - {/each} + + + +
    +
    + +
    +
    + Team Members + {users.length} member{users.length !== 1 ? 's' : ''} in your organization +
    +
    +
    + +
    + + + + Member + Role + Joined + Actions + + + + {#each users as user (user.id)} + + +
    + + {#if user.avatar} + + {/if} + + {getInitials(user.name)} + + +
    +
    + {user.name} + {#if user.isSelf} + You + {/if} + {#if !user.isActive} + Inactive + {/if} +
    + {user.email} +
    +
    +
    + + {#if user.isSelf || editingRoleId !== user.id} + + {#if user.role === 'ADMIN'} + + {:else} + + {/if} + {user.role} + + {#if !user.isSelf} + + {/if} + {:else} +
    + + + + +
    + {/if} +
    + + {formatDate(user.joined)} + + + {#if user.isSelf} + - + {:else if !user.isActive} + + + {:else} + + + + + + + + Deactivate Team Member + + Are you sure you want to deactivate {user.name}? They will no longer be able to access the organization. + + + + Cancel + + + + + {/if} + +
    + {/each} - {#if users.length === 0} - - - -

    No team members found

    -
    -
    - {/if} -
    -
    -
    -
    -
    - + {#if users.length === 0} + + + +

    No team members found

    +
    +
    + {/if} +
    +
    +
    +
    +
    +
    - - - -
    -
    -

    Teams

    -

    - Create teams to group users for assignments and access control. -

    -
    - -
    + + + +
    +
    +

    Teams

    +

    + Create teams to group users for assignments and access control. +

    +
    + +
    - - {#if teams.length > 0} -
    - {#each teams as team (team.id)} - - {/each} -
    - {:else} - - - -
    - -
    -

    No teams yet

    -

    - Teams help you organize users and manage access to records. Create your first - team to get started. -

    - -
    -
    - {/if} -
    -
    -
    - {/if} + + {#if teams.length > 0} +
    + {#each teams as team (team.id)} + + {/each} +
    + {:else} + + + +
    + +
    +

    No teams yet

    +

    + Teams help you organize users and manage access to records. Create your first team + to get started. +

    + +
    +
    + {/if} + +
    +
    + {/if}
    { - teamDialogOpen = false; - editingTeam = null; - }} - onSubmit={handleTeamSubmit} - isLoading={isTeamLoading} + bind:open={teamDialogOpen} + team={editingTeam} + users={availableUsers} + onClose={() => { + teamDialogOpen = false; + editingTeam = null; + }} + onSubmit={handleTeamSubmit} + isLoading={isTeamLoading} /> diff --git a/frontend/src/routes/(no-layout)/bounce/+page.svelte b/frontend/src/routes/(no-layout)/bounce/+page.svelte index 731e6d36..b94e869a 100644 --- a/frontend/src/routes/(no-layout)/bounce/+page.svelte +++ b/frontend/src/routes/(no-layout)/bounce/+page.svelte @@ -1,9 +1,9 @@ diff --git a/frontend/src/routes/(no-layout)/login/+page.server.js b/frontend/src/routes/(no-layout)/login/+page.server.js index 74e19cbf..54ff276d 100644 --- a/frontend/src/routes/(no-layout)/login/+page.server.js +++ b/frontend/src/routes/(no-layout)/login/+page.server.js @@ -21,9 +21,9 @@ const GOOGLE_SCOPES = ['openid', 'email', 'profile'].join(' '); // Cookie configuration const COOKIE_OPTIONS = { - path: '/', - httpOnly: true, - sameSite: 'lax' + path: '/', + httpOnly: true, + sameSite: 'lax' }; /** @@ -32,42 +32,42 @@ const COOKIE_OPTIONS = { * @returns {object} Cookie options with secure flag for production */ function getCookieOptions(maxAge) { - return { - ...COOKIE_OPTIONS, - secure: env.NODE_ENV === 'production', - maxAge - }; + return { + ...COOKIE_OPTIONS, + secure: env.NODE_ENV === 'production', + maxAge + }; } /** @type {import('@sveltejs/kit').ServerLoad} */ export async function load({ url, cookies }) { - const code = url.searchParams.get('code'); - const returnedState = url.searchParams.get('state'); - const error = url.searchParams.get('error'); - const errorDescription = url.searchParams.get('error_description'); + const code = url.searchParams.get('code'); + const returnedState = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); - // Handle OAuth error returned from Google - if (error) { - console.error('Google OAuth error:', error, errorDescription); - return { - google_url: null, - error: errorDescription || `OAuth error: ${error}` - }; - } + // Handle OAuth error returned from Google + if (error) { + console.error('Google OAuth error:', error, errorDescription); + return { + google_url: null, + error: errorDescription || `OAuth error: ${error}` + }; + } - // Handle OAuth callback with authorization code - if (code) { - return handleOAuthCallback(code, returnedState, cookies); - } + // Handle OAuth callback with authorization code + if (code) { + return handleOAuthCallback(code, returnedState, cookies); + } - // Check if user is already authenticated - const jwtAccess = cookies.get('jwt_access'); - if (jwtAccess) { - throw redirect(307, '/org'); - } + // Check if user is already authenticated + const jwtAccess = cookies.get('jwt_access'); + if (jwtAccess) { + throw redirect(307, '/org'); + } - // Generate OAuth parameters and return login URL - return await generateOAuthUrl(cookies); + // Generate OAuth parameters and return login URL + return await generateOAuthUrl(cookies); } /** @@ -77,74 +77,74 @@ export async function load({ url, cookies }) { * @param {import('@sveltejs/kit').Cookies} cookies - SvelteKit cookies */ async function handleOAuthCallback(code, returnedState, cookies) { - // Retrieve and immediately clear OAuth cookies (one-time use) - const savedState = cookies.get('oauth_state'); - const codeVerifier = cookies.get('oauth_code_verifier'); + // Retrieve and immediately clear OAuth cookies (one-time use) + const savedState = cookies.get('oauth_state'); + const codeVerifier = cookies.get('oauth_code_verifier'); - // Clear OAuth cookies regardless of outcome - cookies.delete('oauth_state', { path: '/' }); - cookies.delete('oauth_code_verifier', { path: '/' }); + // Clear OAuth cookies regardless of outcome + cookies.delete('oauth_state', { path: '/' }); + cookies.delete('oauth_code_verifier', { path: '/' }); - // Validate state parameter (CSRF protection) - if (!savedState || savedState !== returnedState) { - console.error('OAuth state mismatch - possible CSRF attack'); - console.error('Expected:', savedState?.substring(0, 10) + '...'); - console.error('Received:', returnedState?.substring(0, 10) + '...'); - throw redirect(307, '/login?error=state_mismatch'); - } + // Validate state parameter (CSRF protection) + if (!savedState || savedState !== returnedState) { + console.error('OAuth state mismatch - possible CSRF attack'); + console.error('Expected:', savedState?.substring(0, 10) + '...'); + console.error('Received:', returnedState?.substring(0, 10) + '...'); + throw redirect(307, '/login?error=state_mismatch'); + } - // Validate code verifier exists - if (!codeVerifier) { - console.error('Missing PKCE code verifier - session may have expired'); - throw redirect(307, '/login?error=session_expired'); - } + // Validate code verifier exists + if (!codeVerifier) { + console.error('Missing PKCE code verifier - session may have expired'); + throw redirect(307, '/login?error=session_expired'); + } - const redirect_uri = env.GOOGLE_LOGIN_DOMAIN + '/login'; + const redirect_uri = env.GOOGLE_LOGIN_DOMAIN + '/login'; - try { - // Exchange code for tokens via Django backend - // The backend handles the actual token exchange with Google using the client secret - const apiUrl = publicEnv.PUBLIC_DJANGO_API_URL; - console.log('Using API URL:', apiUrl); - const response = await axios.post( - `${apiUrl}/api/auth/google/callback/`, - { - code, - code_verifier: codeVerifier, - redirect_uri - }, - { - headers: { 'Content-Type': 'application/json' }, - timeout: 30000 - } - ); + try { + // Exchange code for tokens via Django backend + // The backend handles the actual token exchange with Google using the client secret + const apiUrl = publicEnv.PUBLIC_DJANGO_API_URL; + console.log('Using API URL:', apiUrl); + const response = await axios.post( + `${apiUrl}/api/auth/google/callback/`, + { + code, + code_verifier: codeVerifier, + redirect_uri + }, + { + headers: { 'Content-Type': 'application/json' }, + timeout: 30000 + } + ); - const { access_token, refresh_token, user } = response.data; + const { access_token, refresh_token, user } = response.data; - // Store JWT tokens in secure httpOnly cookies - cookies.set('jwt_access', access_token, getCookieOptions(60 * 60 * 24)); // 1 day - cookies.set('jwt_refresh', refresh_token, getCookieOptions(60 * 60 * 24 * 365)); // 1 year - } catch (error) { - console.error('OAuth token exchange failed - Full error:', error); - console.error('Response data:', error.response?.data); - console.error('Response status:', error.response?.status); - const errorMessage = error.response?.data?.error || error.message || 'Unknown error'; + // Store JWT tokens in secure httpOnly cookies + cookies.set('jwt_access', access_token, getCookieOptions(60 * 60 * 24)); // 1 day + cookies.set('jwt_refresh', refresh_token, getCookieOptions(60 * 60 * 24 * 365)); // 1 year + } catch (error) { + console.error('OAuth token exchange failed - Full error:', error); + console.error('Response data:', error.response?.data); + console.error('Response status:', error.response?.status); + const errorMessage = error.response?.data?.error || error.message || 'Unknown error'; - // Provide user-friendly error messages - let userError = 'auth_failed'; - const errStr = String(errorMessage); - if (errStr.includes('code_verifier')) { - userError = 'pkce_failed'; - } else if (errStr.includes('expired')) { - userError = 'code_expired'; - } - console.error('Final error message:', errorMessage); + // Provide user-friendly error messages + let userError = 'auth_failed'; + const errStr = String(errorMessage); + if (errStr.includes('code_verifier')) { + userError = 'pkce_failed'; + } else if (errStr.includes('expired')) { + userError = 'code_expired'; + } + console.error('Final error message:', errorMessage); - throw redirect(307, `/login?error=${userError}`); - } + throw redirect(307, `/login?error=${userError}`); + } - // Success - redirect to organization selection - throw redirect(307, '/org'); + // Success - redirect to organization selection + throw redirect(307, '/org'); } /** @@ -153,36 +153,36 @@ async function handleOAuthCallback(code, returnedState, cookies) { * @returns {Promise} Object containing the Google OAuth URL */ async function generateOAuthUrl(cookies) { - // Generate PKCE parameters - const codeVerifier = generateCodeVerifier(); - const codeChallenge = await generateCodeChallenge(codeVerifier); + // Generate PKCE parameters + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); - // Generate cryptographically random state for CSRF protection - const state = generateState(); + // Generate cryptographically random state for CSRF protection + const state = generateState(); - // Store PKCE verifier and state in secure httpOnly cookies - // These expire in 10 minutes - should be plenty for OAuth flow - const oauthCookieMaxAge = 60 * 10; // 10 minutes + // Store PKCE verifier and state in secure httpOnly cookies + // These expire in 10 minutes - should be plenty for OAuth flow + const oauthCookieMaxAge = 60 * 10; // 10 minutes - cookies.set('oauth_code_verifier', codeVerifier, getCookieOptions(oauthCookieMaxAge)); - cookies.set('oauth_state', state, getCookieOptions(oauthCookieMaxAge)); + cookies.set('oauth_code_verifier', codeVerifier, getCookieOptions(oauthCookieMaxAge)); + cookies.set('oauth_state', state, getCookieOptions(oauthCookieMaxAge)); - // Build Google OAuth URL with all required parameters - const redirect_uri = env.GOOGLE_LOGIN_DOMAIN + '/login'; + // Build Google OAuth URL with all required parameters + const redirect_uri = env.GOOGLE_LOGIN_DOMAIN + '/login'; - const params = new URLSearchParams({ - client_id: env.GOOGLE_CLIENT_ID, - redirect_uri, - response_type: 'code', - scope: GOOGLE_SCOPES, - state, - code_challenge: codeChallenge, - code_challenge_method: 'S256', - access_type: 'offline', // Request refresh token - prompt: 'consent' // Always show consent screen (required for refresh token) - }); + const params = new URLSearchParams({ + client_id: env.GOOGLE_CLIENT_ID, + redirect_uri, + response_type: 'code', + scope: GOOGLE_SCOPES, + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + access_type: 'offline', // Request refresh token + prompt: 'consent' // Always show consent screen (required for refresh token) + }); - const google_login_url = `${GOOGLE_AUTH_URL}?${params.toString()}`; + const google_login_url = `${GOOGLE_AUTH_URL}?${params.toString()}`; - return { google_url: google_login_url }; + return { google_url: google_login_url }; } diff --git a/frontend/src/routes/(no-layout)/login/+page.svelte b/frontend/src/routes/(no-layout)/login/+page.svelte index 7699565e..982d923b 100644 --- a/frontend/src/routes/(no-layout)/login/+page.svelte +++ b/frontend/src/routes/(no-layout)/login/+page.svelte @@ -1,237 +1,264 @@ - Sign In | BottleCRM - Modern CRM for Growing Teams - + Sign In | BottleCRM - Modern CRM for Growing Teams +
    - - - -
    - -
    -
    - BottleCRM - BottleCRM -
    -
    + +
    + +
    +
    + BottleCRM + BottleCRM +
    +
    - -
    -
    - -
    -
    - -
    -

    Welcome back

    -

    Sign in to continue to your dashboard

    -
    + +
    +
    + +
    +
    + +
    +

    Welcome back

    +

    Sign in to continue to your dashboard

    +
    - - + + - -
    -
    - Secure login -
    -
    + +
    +
    + Secure login +
    +
    - -
    -
    -
    - -
    -
    -

    Enterprise-grade security

    -

    Your data is encrypted and secure

    -
    -
    + +
    +
    +
    + +
    +
    +

    Enterprise-grade security

    +

    Your data is encrypted and secure

    +
    +
    -
    -
    - -
    -
    -

    Privacy first

    -

    Self-host for complete data ownership

    -
    -
    -
    +
    +
    + +
    +
    +

    Privacy first

    +

    Self-host for complete data ownership

    +
    +
    +
    - -
    -

    What you get

    -
    - {#each ['Unlimited contacts', 'Sales pipeline', 'Task management', 'Team collaboration'] as item} -
    - - {item} -
    - {/each} -
    -
    -
    -
    + +
    +

    + What you get +

    +
    + {#each ['Unlimited contacts', 'Sales pipeline', 'Task management', 'Team collaboration'] as item} +
    + + {item} +
    + {/each} +
    +
    +
    +
    - -
    -
    -

    - Don't have an account? - Sign in with Google to get started -

    -
    - - GitHub - - · - Docs - · - Privacy -
    -
    -
    -
    + +
    +
    +

    + Don't have an account? + Sign in with Google to get started +

    +
    + + GitHub + + · + Docs + · + Privacy +
    +
    +
    +
    diff --git a/frontend/src/routes/(no-layout)/logout/+page.server.js b/frontend/src/routes/(no-layout)/logout/+page.server.js index dd46cb45..e67adae7 100644 --- a/frontend/src/routes/(no-layout)/logout/+page.server.js +++ b/frontend/src/routes/(no-layout)/logout/+page.server.js @@ -13,23 +13,23 @@ import { redirect } from '@sveltejs/kit'; /** @type {import('./$types').PageServerLoad} */ export async function load({ locals, cookies }) { - // Clear all authentication-related cookies - const cookiesToClear = ['jwt_access', 'jwt_refresh', 'org', 'oauth_state', 'oauth_code_verifier']; + // Clear all authentication-related cookies + const cookiesToClear = ['jwt_access', 'jwt_refresh', 'org', 'oauth_state', 'oauth_code_verifier']; - for (const cookieName of cookiesToClear) { - if (cookies.get(cookieName)) { - await cookies.delete(cookieName, { path: '/' }); - } - } + for (const cookieName of cookiesToClear) { + if (cookies.get(cookieName)) { + await cookies.delete(cookieName, { path: '/' }); + } + } - // Clear locals - if (locals.user) { - delete locals.user; - } - if (locals.org) { - delete locals.org; - } + // Clear locals + if (locals.user) { + delete locals.user; + } + if (locals.org) { + delete locals.org; + } - // Redirect to login page - throw redirect(303, '/login'); + // Redirect to login page + throw redirect(303, '/login'); } diff --git a/frontend/src/routes/(no-layout)/org/+page.server.js b/frontend/src/routes/(no-layout)/org/+page.server.js index ed5c6878..16bc7476 100644 --- a/frontend/src/routes/(no-layout)/org/+page.server.js +++ b/frontend/src/routes/(no-layout)/org/+page.server.js @@ -15,114 +15,114 @@ import axios from 'axios'; /** @type {import('./$types').PageServerLoad} */ export async function load({ cookies, locals }) { - const user = locals.user; + const user = locals.user; - if (!user) { - return { orgs: [] }; - } + if (!user) { + return { orgs: [] }; + } - try { - const jwtAccess = cookies.get('jwt_access'); - if (!jwtAccess) { - return { orgs: [] }; - } + try { + const jwtAccess = cookies.get('jwt_access'); + if (!jwtAccess) { + return { orgs: [] }; + } - const apiUrl = publicEnv.PUBLIC_DJANGO_API_URL; + const apiUrl = publicEnv.PUBLIC_DJANGO_API_URL; - // Fetch current user with organization memberships - // The /api/auth/me/ endpoint returns user data with organizations array - const response = await axios.get(`${apiUrl}/api/auth/me/`, { - headers: { - Authorization: `Bearer ${jwtAccess}` - } - }); + // Fetch current user with organization memberships + // The /api/auth/me/ endpoint returns user data with organizations array + const response = await axios.get(`${apiUrl}/api/auth/me/`, { + headers: { + Authorization: `Bearer ${jwtAccess}` + } + }); - // Extract organizations from the user data - // Django's MeView returns user with organizations array - let orgs = []; + // Extract organizations from the user data + // Django's MeView returns user with organizations array + let orgs = []; - if (response.data.organizations && Array.isArray(response.data.organizations)) { - orgs = response.data.organizations.map((org) => ({ - id: org.id, - name: org.name, - logo: org.logo || null, - role: org.role || 'USER' - })); - } + if (response.data.organizations && Array.isArray(response.data.organizations)) { + orgs = response.data.organizations.map((org) => ({ + id: org.id, + name: org.name, + logo: org.logo || null, + role: org.role || 'USER' + })); + } - return { orgs }; - } catch (error) { - console.error('Error fetching organizations:', error); - // Return empty array so user can create a new organization - return { orgs: [] }; - } + return { orgs }; + } catch (error) { + console.error('Error fetching organizations:', error); + // Return empty array so user can create a new organization + return { orgs: [] }; + } } /** @type {import('./$types').Actions} */ export const actions = { - selectOrg: async ({ request, cookies }) => { - const formData = await request.formData(); - const orgId = formData.get('org_id')?.toString(); - const orgName = formData.get('org_name')?.toString(); + selectOrg: async ({ request, cookies }) => { + const formData = await request.formData(); + const orgId = formData.get('org_id')?.toString(); + const orgName = formData.get('org_name')?.toString(); - if (!orgId) { - return fail(400, { error: 'Organization ID is required' }); - } + if (!orgId) { + return fail(400, { error: 'Organization ID is required' }); + } - const jwtAccess = cookies.get('jwt_access'); - if (!jwtAccess) { - throw redirect(307, '/login'); - } + const jwtAccess = cookies.get('jwt_access'); + if (!jwtAccess) { + throw redirect(307, '/login'); + } - const apiUrl = publicEnv.PUBLIC_DJANGO_API_URL; + const apiUrl = publicEnv.PUBLIC_DJANGO_API_URL; - try { - // Call switch-org endpoint to get new tokens with org context - const response = await axios.post( - `${apiUrl}/api/auth/switch-org/`, - { org_id: orgId }, - { - headers: { - Authorization: `Bearer ${jwtAccess}`, - 'Content-Type': 'application/json' - } - } - ); + try { + // Call switch-org endpoint to get new tokens with org context + const response = await axios.post( + `${apiUrl}/api/auth/switch-org/`, + { org_id: orgId }, + { + headers: { + Authorization: `Bearer ${jwtAccess}`, + 'Content-Type': 'application/json' + } + } + ); - const { access_token, refresh_token, current_org } = response.data; + const { access_token, refresh_token, current_org } = response.data; - // Update cookies with new tokens that have org context embedded - cookies.set('jwt_access', access_token, { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - maxAge: 60 * 60 * 24 // 1 day - }); + // Update cookies with new tokens that have org context embedded + cookies.set('jwt_access', access_token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 // 1 day + }); - cookies.set('jwt_refresh', refresh_token, { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - maxAge: 60 * 60 * 24 * 365 // 1 year - }); + cookies.set('jwt_refresh', refresh_token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 365 // 1 year + }); - // Set org cookie for reference - cookies.set('org', orgId, { - path: '/', - sameSite: 'strict', - maxAge: 60 * 60 * 24 * 365 - }); + // Set org cookie for reference + cookies.set('org', orgId, { + path: '/', + sameSite: 'strict', + maxAge: 60 * 60 * 24 * 365 + }); - // Redirect to app - throw redirect(303, '/'); - } catch (error) { - if (error.status === 303) { - throw error; // Re-throw redirect - } - console.error('Org switch failed:', error); - return fail(500, { error: 'Failed to switch organization' }); - } - } + // Redirect to app + throw redirect(303, '/'); + } catch (error) { + if (error.status === 303) { + throw error; // Re-throw redirect + } + console.error('Org switch failed:', error); + return fail(500, { error: 'Failed to switch organization' }); + } + } }; diff --git a/frontend/src/routes/(no-layout)/org/+page.svelte b/frontend/src/routes/(no-layout)/org/+page.svelte index d1370068..3c427c6a 100644 --- a/frontend/src/routes/(no-layout)/org/+page.svelte +++ b/frontend/src/routes/(no-layout)/org/+page.svelte @@ -1,135 +1,145 @@ - Select Organization | BottleCRM + Select Organization | BottleCRM
    - -
    -
    -
    - BottleCRM - BottleCRM -
    - - - - -
    -
    + +
    +
    +
    + BottleCRM + BottleCRM +
    + + + + +
    +
    - -
    -
    - -
    -

    Select an organization

    -

    Choose which organization you'd like to work in

    -
    + +
    +
    + +
    +

    Select an organization

    +

    Choose which organization you'd like to work in

    +
    - - {#if orgs.length > 0} -
    - {#each orgs as org} -
    { - loading = true; - selectedOrgId = org.id; - return async ({ update }) => { - await update(); - loading = false; - selectedOrgId = null; - }; - }} - > - - - -
    - {/each} -
    + +
    + {#if loading && selectedOrgId === org.id} +
    + {:else} + + {/if} +
    +
    + + + {/each} +
    - - - {:else} - -
    -
    - -
    -

    No organizations yet

    -

    Create your first organization to get started with BottleCRM

    - - - Create organization - -
    - {/if} + + + {:else} + +
    +
    + +
    +

    No organizations yet

    +

    + Create your first organization to get started with BottleCRM +

    + + + Create organization + +
    + {/if} - -
    - - Your data stays private and secure -
    -
    - + +
    + + Your data stays private and secure +
    + + diff --git a/frontend/src/routes/(no-layout)/org/new/+page.server.js b/frontend/src/routes/(no-layout)/org/new/+page.server.js index 2b19afb3..caddc525 100644 --- a/frontend/src/routes/(no-layout)/org/new/+page.server.js +++ b/frontend/src/routes/(no-layout)/org/new/+page.server.js @@ -15,99 +15,99 @@ import axios from 'axios'; /** @type {import('./$types').PageServerLoad} */ export async function load({ cookies }) { - // No data needed for load + // No data needed for load } /** @satisfies {import('./$types').Actions} */ export const actions = { - default: async ({ request, cookies, locals }) => { - // Get the user from locals - const user = locals.user; + default: async ({ request, cookies, locals }) => { + // Get the user from locals + const user = locals.user; - if (!user) { - return { - error: { - name: 'You must be logged in to create an organization' - } - }; - } + if (!user) { + return { + error: { + name: 'You must be logged in to create an organization' + } + }; + } - // Get the submitted form data - const formData = await request.formData(); - const orgName = formData.get('org_name')?.toString(); + // Get the submitted form data + const formData = await request.formData(); + const orgName = formData.get('org_name')?.toString(); - if (!orgName || orgName.trim().length === 0) { - return { - error: { - name: 'Organization name is required' - } - }; - } + if (!orgName || orgName.trim().length === 0) { + return { + error: { + name: 'Organization name is required' + } + }; + } - try { - const jwtAccess = cookies.get('jwt_access'); - if (!jwtAccess) { - return { - error: { - name: 'Authentication required' - } - }; - } + try { + const jwtAccess = cookies.get('jwt_access'); + if (!jwtAccess) { + return { + error: { + name: 'Authentication required' + } + }; + } - const apiUrl = publicEnv.PUBLIC_DJANGO_API_URL; + const apiUrl = publicEnv.PUBLIC_DJANGO_API_URL; - // Create organization and profile via Django API - // Django's OrgProfileCreateView creates both org and profile - const response = await axios.post( - `${apiUrl}/api/org/`, - { - name: orgName.trim() - }, - { - headers: { - Authorization: `Bearer ${jwtAccess}`, - 'Content-Type': 'application/json' - } - } - ); + // Create organization and profile via Django API + // Django's OrgProfileCreateView creates both org and profile + const response = await axios.post( + `${apiUrl}/api/org/`, + { + name: orgName.trim() + }, + { + headers: { + Authorization: `Bearer ${jwtAccess}`, + 'Content-Type': 'application/json' + } + } + ); - // Response should contain the created org - const newOrg = response.data.org || response.data; + // Response should contain the created org + const newOrg = response.data.org || response.data; - // Set org cookie for the newly created org - await cookies.set('org', newOrg.id, { - path: '/', - httpOnly: true, - sameSite: 'strict', - secure: env.NODE_ENV === 'production' - }); + // Set org cookie for the newly created org + await cookies.set('org', newOrg.id, { + path: '/', + httpOnly: true, + sameSite: 'strict', + secure: env.NODE_ENV === 'production' + }); - // Return success - return { - data: { - name: orgName - } - }; - } catch (err) { - console.error('Error creating organization:', err); + // Return success + return { + data: { + name: orgName + } + }; + } catch (err) { + console.error('Error creating organization:', err); - // Check if it's a duplicate name error - if (err.response?.status === 400) { - return { - error: { - name: - err.response.data?.name?.[0] || - err.response.data?.error || - 'Organization with this name may already exist' - } - }; - } + // Check if it's a duplicate name error + if (err.response?.status === 400) { + return { + error: { + name: + err.response.data?.name?.[0] || + err.response.data?.error || + 'Organization with this name may already exist' + } + }; + } - return { - error: { - name: 'An unexpected error occurred while creating the organization.' - } - }; - } - } + return { + error: { + name: 'An unexpected error occurred while creating the organization.' + } + }; + } + } }; diff --git a/frontend/src/routes/(no-layout)/org/new/+page.svelte b/frontend/src/routes/(no-layout)/org/new/+page.svelte index 564cafc2..f690bcbe 100644 --- a/frontend/src/routes/(no-layout)/org/new/+page.svelte +++ b/frontend/src/routes/(no-layout)/org/new/+page.svelte @@ -1,136 +1,140 @@ - Create Organization | BottleCRM + Create Organization | BottleCRM
    - -
    -
    -
    - BottleCRM - BottleCRM -
    - - - Back - -
    -
    + +
    +
    +
    + BottleCRM + BottleCRM +
    + + + Back + +
    +
    - -
    -
    - -
    -
    - -
    -

    Create organization

    -

    Set up a new workspace for your team

    -
    + +
    +
    + +
    +
    + +
    +

    Create organization

    +

    Set up a new workspace for your team

    +
    - -
    -
    { - isSubmitting = true; - return async ({ update }) => { - await update(); - isSubmitting = false; - }; - }} - class="space-y-5" - > - -
    - - -

    This will be your workspace name in BottleCRM

    -
    + +
    + { + isSubmitting = true; + return async ({ update }) => { + await update(); + isSubmitting = false; + }; + }} + class="space-y-5" + > + +
    + + +

    This will be your workspace name in BottleCRM

    +
    - - {#if form?.error} -
    - -
    -

    Unable to create organization

    -

    {form.error.name || 'Please try again'}

    -
    -
    - {/if} + + {#if form?.error} +
    + +
    +

    Unable to create organization

    +

    {form.error.name || 'Please try again'}

    +
    +
    + {/if} - - {#if form?.data} -
    - -
    -

    Organization created!

    -

    Redirecting to your organizations...

    -
    -
    - {/if} + + {#if form?.data} +
    + +
    +

    Organization created!

    +

    Redirecting to your organizations...

    +
    +
    + {/if} - - - -
    + + + +
    - -

    - You can invite team members after creating your organization -

    -
    -
    + +

    + You can invite team members after creating your organization +

    +
    +
    diff --git a/frontend/src/routes/(no-layout)/portal/estimate/[token]/+page.server.js b/frontend/src/routes/(no-layout)/portal/estimate/[token]/+page.server.js index c67563e7..73fa8af1 100644 --- a/frontend/src/routes/(no-layout)/portal/estimate/[token]/+page.server.js +++ b/frontend/src/routes/(no-layout)/portal/estimate/[token]/+page.server.js @@ -9,111 +9,111 @@ import { error, fail } from '@sveltejs/kit'; /** @type {import('./$types').PageServerLoad} */ export async function load({ params, fetch }) { - const { token } = params; + const { token } = params; - if (!token) { - throw error(400, 'Estimate token is required'); - } + if (!token) { + throw error(400, 'Estimate token is required'); + } - try { - // Fetch estimate from public API (no auth) - const response = await fetch(`/api/public/estimate/${token}/`); + try { + // Fetch estimate from public API (no auth) + const response = await fetch(`/api/public/estimate/${token}/`); - if (!response.ok) { - if (response.status === 404) { - throw error(404, 'Estimate not found or link has expired'); - } - throw error(response.status, 'Failed to load estimate'); - } + if (!response.ok) { + if (response.status === 404) { + throw error(404, 'Estimate not found or link has expired'); + } + throw error(response.status, 'Failed to load estimate'); + } - const estimate = await response.json(); + const estimate = await response.json(); - return { - estimate: { - id: estimate.id, - estimateNumber: estimate.estimate_number, - title: estimate.title, - status: estimate.status, - clientName: estimate.client_name, - clientEmail: estimate.client_email, - issueDate: estimate.issue_date, - expiryDate: estimate.expiry_date, - subtotal: estimate.subtotal, - discountAmount: estimate.discount_amount, - taxAmount: estimate.tax_amount, - totalAmount: estimate.total_amount, - currency: estimate.currency, - notes: estimate.notes, - terms: estimate.terms, - clientAddress: estimate.client_address, - lineItems: estimate.line_items || [], - org: estimate.org - }, - template: estimate.template - ? { - primaryColor: estimate.template.primary_color || '#3B82F6', - secondaryColor: estimate.template.secondary_color || '#1E40AF', - footerText: estimate.template.footer_text || '' - } - : { - primaryColor: '#3B82F6', - secondaryColor: '#1E40AF', - footerText: '' - }, - token - }; - } catch (err) { - if (err.status) throw err; - console.error('Error loading public estimate:', err); - throw error(500, 'Failed to load estimate'); - } + return { + estimate: { + id: estimate.id, + estimateNumber: estimate.estimate_number, + title: estimate.title, + status: estimate.status, + clientName: estimate.client_name, + clientEmail: estimate.client_email, + issueDate: estimate.issue_date, + expiryDate: estimate.expiry_date, + subtotal: estimate.subtotal, + discountAmount: estimate.discount_amount, + taxAmount: estimate.tax_amount, + totalAmount: estimate.total_amount, + currency: estimate.currency, + notes: estimate.notes, + terms: estimate.terms, + clientAddress: estimate.client_address, + lineItems: estimate.line_items || [], + org: estimate.org + }, + template: estimate.template + ? { + primaryColor: estimate.template.primary_color || '#3B82F6', + secondaryColor: estimate.template.secondary_color || '#1E40AF', + footerText: estimate.template.footer_text || '' + } + : { + primaryColor: '#3B82F6', + secondaryColor: '#1E40AF', + footerText: '' + }, + token + }; + } catch (err) { + if (err.status) throw err; + console.error('Error loading public estimate:', err); + throw error(500, 'Failed to load estimate'); + } } /** @type {import('./$types').Actions} */ export const actions = { - accept: async ({ params, fetch }) => { - const { token } = params; + accept: async ({ params, fetch }) => { + const { token } = params; - try { - const response = await fetch(`/api/public/estimate/${token}/accept/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); + try { + const response = await fetch(`/api/public/estimate/${token}/accept/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); - if (!response.ok) { - const data = await response.json(); - return fail(response.status, { error: data.message || 'Failed to accept estimate' }); - } + if (!response.ok) { + const data = await response.json(); + return fail(response.status, { error: data.message || 'Failed to accept estimate' }); + } - return { success: true, action: 'accepted' }; - } catch (err) { - console.error('Error accepting estimate:', err); - return fail(500, { error: 'Failed to accept estimate' }); - } - }, + return { success: true, action: 'accepted' }; + } catch (err) { + console.error('Error accepting estimate:', err); + return fail(500, { error: 'Failed to accept estimate' }); + } + }, - decline: async ({ params, fetch }) => { - const { token } = params; + decline: async ({ params, fetch }) => { + const { token } = params; - try { - const response = await fetch(`/api/public/estimate/${token}/decline/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); + try { + const response = await fetch(`/api/public/estimate/${token}/decline/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); - if (!response.ok) { - const data = await response.json(); - return fail(response.status, { error: data.message || 'Failed to decline estimate' }); - } + if (!response.ok) { + const data = await response.json(); + return fail(response.status, { error: data.message || 'Failed to decline estimate' }); + } - return { success: true, action: 'declined' }; - } catch (err) { - console.error('Error declining estimate:', err); - return fail(500, { error: 'Failed to decline estimate' }); - } - } + return { success: true, action: 'declined' }; + } catch (err) { + console.error('Error declining estimate:', err); + return fail(500, { error: 'Failed to decline estimate' }); + } + } }; diff --git a/frontend/src/routes/(no-layout)/portal/estimate/[token]/+page.svelte b/frontend/src/routes/(no-layout)/portal/estimate/[token]/+page.svelte index f0edb744..948fceb6 100644 --- a/frontend/src/routes/(no-layout)/portal/estimate/[token]/+page.svelte +++ b/frontend/src/routes/(no-layout)/portal/estimate/[token]/+page.svelte @@ -1,349 +1,347 @@ - Estimate #{estimate.estimateNumber} | {estimate.org?.name || 'Estimate'} + Estimate #{estimate.estimateNumber} | {estimate.org?.name || 'Estimate'} -
    - -
    -
    -
    -
    -

    {estimate.org?.name || 'Estimate'}

    -

    Estimate #{estimate.estimateNumber}

    -
    - -
    -
    -
    +
    + +
    +
    +
    +
    +

    + {estimate.org?.name || 'Estimate'} +

    +

    Estimate #{estimate.estimateNumber}

    +
    + +
    +
    +
    - - {#if form?.success} -
    -
    -

    - {#if form.action === 'accepted'} - Thank you! You have accepted this estimate. We will be in touch shortly. - {:else if form.action === 'declined'} - You have declined this estimate. Thank you for your response. - {/if} -

    -
    -
    - {/if} + + {#if form?.success} +
    +
    +

    + {#if form.action === 'accepted'} + Thank you! You have accepted this estimate. We will be in touch shortly. + {:else if form.action === 'declined'} + You have declined this estimate. Thank you for your response. + {/if} +

    +
    +
    + {/if} - {#if form?.error} -
    -
    -

    {form.error}

    -
    -
    - {/if} + {#if form?.error} +
    +
    +

    {form.error}

    +
    +
    + {/if} - -
    -
    - -
    -
    -
    -

    Prepared For:

    -

    {estimate.clientName}

    - {#if estimate.clientEmail} -

    {estimate.clientEmail}

    - {/if} - {#if estimate.clientAddress?.line} -

    - {estimate.clientAddress.line}
    - {#if estimate.clientAddress.city} - {estimate.clientAddress.city}, - {/if} - {estimate.clientAddress.state} - {estimate.clientAddress.postcode}
    - {estimate.clientAddress.country} -

    - {/if} -
    -
    -
    - - {estimate.status} - -
    - Issue Date: - {formatDate(estimate.issueDate)} -
    - {#if estimate.expiryDate} -
    - Valid Until: - - {formatDate(estimate.expiryDate)} - {#if isExpired()} - (Expired) - {/if} - -
    - {/if} -
    -
    -
    -
    + +
    +
    + +
    +
    +
    +

    Prepared For:

    +

    {estimate.clientName}

    + {#if estimate.clientEmail} +

    {estimate.clientEmail}

    + {/if} + {#if estimate.clientAddress?.line} +

    + {estimate.clientAddress.line}
    + {#if estimate.clientAddress.city} + {estimate.clientAddress.city}, + {/if} + {estimate.clientAddress.state} + {estimate.clientAddress.postcode}
    + {estimate.clientAddress.country} +

    + {/if} +
    +
    +
    + + {estimate.status} + +
    + Issue Date: + {formatDate(estimate.issueDate)} +
    + {#if estimate.expiryDate} +
    + Valid Until: + + {formatDate(estimate.expiryDate)} + {#if isExpired()} + (Expired) + {/if} + +
    + {/if} +
    +
    +
    +
    - -
    - {#if estimate.title} -

    {estimate.title}

    - {/if} + +
    + {#if estimate.title} +

    {estimate.title}

    + {/if} -
    - - - - - - - - - - - {#each estimate.lineItems as item} - - - - - - - {/each} - -
    DescriptionQtyUnit PriceTotal
    {item.description}{item.quantity} - {formatCurrency(Number(item.unit_price), estimate.currency)} - - {formatCurrency(Number(item.total), estimate.currency)} -
    -
    +
    + + + + + + + + + + + {#each estimate.lineItems as item} + + + + + + + {/each} + +
    DescriptionQtyUnit PriceTotal
    {item.description}{item.quantity} + {formatCurrency(Number(item.unit_price), estimate.currency)} + + {formatCurrency(Number(item.total), estimate.currency)} +
    +
    - -
    -
    -
    - Subtotal: - {formatCurrency(Number(estimate.subtotal), estimate.currency)} -
    - {#if Number(estimate.discountAmount) > 0} -
    - Discount: - -{formatCurrency(Number(estimate.discountAmount), estimate.currency)} -
    - {/if} - {#if Number(estimate.taxAmount) > 0} -
    - Tax: - {formatCurrency(Number(estimate.taxAmount), estimate.currency)} -
    - {/if} -
    - Total: - {formatCurrency(Number(estimate.totalAmount), estimate.currency)} -
    -
    -
    -
    + +
    +
    +
    + Subtotal: + {formatCurrency(Number(estimate.subtotal), estimate.currency)} +
    + {#if Number(estimate.discountAmount) > 0} +
    + Discount: + -{formatCurrency(Number(estimate.discountAmount), estimate.currency)} +
    + {/if} + {#if Number(estimate.taxAmount) > 0} +
    + Tax: + {formatCurrency(Number(estimate.taxAmount), estimate.currency)} +
    + {/if} +
    + Total: + {formatCurrency(Number(estimate.totalAmount), estimate.currency)} +
    +
    +
    +
    - - {#if canRespond()} -
    -

    Your Response

    -

    - Please review this estimate and let us know if you'd like to proceed. -

    -
    -
    { - isSubmitting = true; - return async ({ update }) => { - isSubmitting = false; - await update(); - invalidateAll(); - }; - }} - > - -
    -
    { - isSubmitting = true; - return async ({ update }) => { - isSubmitting = false; - await update(); - invalidateAll(); - }; - }} - > - -
    -
    -
    - {:else if estimate.status === 'Accepted'} -
    -
    - - - - -

    - You have accepted this estimate. Thank you! -

    -
    -
    - {:else if estimate.status === 'Declined'} -
    -

    This estimate was declined.

    -
    - {:else if isExpired()} -
    -

    - This estimate has expired. Please contact us if you'd like an updated quote. -

    -
    - {/if} + + {#if canRespond()} +
    +

    Your Response

    +

    + Please review this estimate and let us know if you'd like to proceed. +

    +
    +
    { + isSubmitting = true; + return async ({ update }) => { + isSubmitting = false; + await update(); + invalidateAll(); + }; + }} + > + +
    +
    { + isSubmitting = true; + return async ({ update }) => { + isSubmitting = false; + await update(); + invalidateAll(); + }; + }} + > + +
    +
    +
    + {:else if estimate.status === 'Accepted'} +
    +
    + + + + +

    You have accepted this estimate. Thank you!

    +
    +
    + {:else if estimate.status === 'Declined'} +
    +

    This estimate was declined.

    +
    + {:else if isExpired()} +
    +

    + This estimate has expired. Please contact us if you'd like an updated quote. +

    +
    + {/if} - - {#if estimate.notes || estimate.terms} -
    - {#if estimate.notes} -
    -

    Notes

    -

    {estimate.notes}

    -
    - {/if} - {#if estimate.terms} -
    -

    Terms & Conditions

    -

    {estimate.terms}

    -
    - {/if} -
    - {/if} -
    + + {#if estimate.notes || estimate.terms} +
    + {#if estimate.notes} +
    +

    Notes

    +

    {estimate.notes}

    +
    + {/if} + {#if estimate.terms} +
    +

    Terms & Conditions

    +

    {estimate.terms}

    +
    + {/if} +
    + {/if} +
    - -
    - {#if footerText} -

    {footerText}

    - {/if} -

    Thank you for considering our services!

    -

    - If you have any questions about this estimate, please contact us. -

    -
    -
    + +
    + {#if footerText} +

    {footerText}

    + {/if} +

    Thank you for considering our services!

    +

    If you have any questions about this estimate, please contact us.

    +
    +
    diff --git a/frontend/src/routes/(no-layout)/portal/invoice/[token]/+page.server.js b/frontend/src/routes/(no-layout)/portal/invoice/[token]/+page.server.js index 682f4510..2be79ec7 100644 --- a/frontend/src/routes/(no-layout)/portal/invoice/[token]/+page.server.js +++ b/frontend/src/routes/(no-layout)/portal/invoice/[token]/+page.server.js @@ -9,65 +9,65 @@ import { error } from '@sveltejs/kit'; /** @type {import('./$types').PageServerLoad} */ export async function load({ params, fetch }) { - const { token } = params; + const { token } = params; - if (!token) { - throw error(400, 'Invoice token is required'); - } + if (!token) { + throw error(400, 'Invoice token is required'); + } - try { - // Fetch invoice from public API (no auth) - const response = await fetch(`/api/public/invoice/${token}/`); + try { + // Fetch invoice from public API (no auth) + const response = await fetch(`/api/public/invoice/${token}/`); - if (!response.ok) { - if (response.status === 404) { - throw error(404, 'Invoice not found or link has expired'); - } - throw error(response.status, 'Failed to load invoice'); - } + if (!response.ok) { + if (response.status === 404) { + throw error(404, 'Invoice not found or link has expired'); + } + throw error(response.status, 'Failed to load invoice'); + } - const invoice = await response.json(); + const invoice = await response.json(); - return { - invoice: { - id: invoice.id, - invoiceNumber: invoice.invoice_number, - invoiceTitle: invoice.invoice_title, - status: invoice.status, - clientName: invoice.client_name, - clientEmail: invoice.client_email, - issueDate: invoice.issue_date, - dueDate: invoice.due_date, - subtotal: invoice.subtotal, - discountAmount: invoice.discount_amount, - taxAmount: invoice.tax_amount, - totalAmount: invoice.total_amount, - amountPaid: invoice.amount_paid, - amountDue: invoice.amount_due, - currency: invoice.currency, - notes: invoice.notes, - terms: invoice.terms, - billingAddress: invoice.billing_address, - lineItems: invoice.line_items || [], - payments: invoice.payments || [], - org: invoice.org - }, - template: invoice.template - ? { - primaryColor: invoice.template.primary_color || '#3B82F6', - secondaryColor: invoice.template.secondary_color || '#1E40AF', - footerText: invoice.template.footer_text || '' - } - : { - primaryColor: '#3B82F6', - secondaryColor: '#1E40AF', - footerText: '' - }, - token - }; - } catch (err) { - if (err.status) throw err; - console.error('Error loading public invoice:', err); - throw error(500, 'Failed to load invoice'); - } + return { + invoice: { + id: invoice.id, + invoiceNumber: invoice.invoice_number, + invoiceTitle: invoice.invoice_title, + status: invoice.status, + clientName: invoice.client_name, + clientEmail: invoice.client_email, + issueDate: invoice.issue_date, + dueDate: invoice.due_date, + subtotal: invoice.subtotal, + discountAmount: invoice.discount_amount, + taxAmount: invoice.tax_amount, + totalAmount: invoice.total_amount, + amountPaid: invoice.amount_paid, + amountDue: invoice.amount_due, + currency: invoice.currency, + notes: invoice.notes, + terms: invoice.terms, + billingAddress: invoice.billing_address, + lineItems: invoice.line_items || [], + payments: invoice.payments || [], + org: invoice.org + }, + template: invoice.template + ? { + primaryColor: invoice.template.primary_color || '#3B82F6', + secondaryColor: invoice.template.secondary_color || '#1E40AF', + footerText: invoice.template.footer_text || '' + } + : { + primaryColor: '#3B82F6', + secondaryColor: '#1E40AF', + footerText: '' + }, + token + }; + } catch (err) { + if (err.status) throw err; + console.error('Error loading public invoice:', err); + throw error(500, 'Failed to load invoice'); + } } diff --git a/frontend/src/routes/(no-layout)/portal/invoice/[token]/+page.svelte b/frontend/src/routes/(no-layout)/portal/invoice/[token]/+page.svelte index bb207a50..45d82606 100644 --- a/frontend/src/routes/(no-layout)/portal/invoice/[token]/+page.svelte +++ b/frontend/src/routes/(no-layout)/portal/invoice/[token]/+page.svelte @@ -1,258 +1,261 @@ - Invoice #{invoice.invoiceNumber} | {invoice.org?.name || 'Invoice'} + Invoice #{invoice.invoiceNumber} | {invoice.org?.name || 'Invoice'} -
    - -
    -
    -
    -
    -

    {invoice.org?.name || 'Invoice'}

    -

    Invoice #{invoice.invoiceNumber}

    -
    - -
    -
    -
    +
    + +
    +
    +
    +
    +

    + {invoice.org?.name || 'Invoice'} +

    +

    Invoice #{invoice.invoiceNumber}

    +
    + +
    +
    +
    - -
    -
    - -
    -
    -
    -

    Bill To:

    -

    {invoice.clientName}

    - {#if invoice.clientEmail} -

    {invoice.clientEmail}

    - {/if} - {#if invoice.billingAddress?.line} -

    - {invoice.billingAddress.line}
    - {#if invoice.billingAddress.city} - {invoice.billingAddress.city}, - {/if} - {invoice.billingAddress.state} - {invoice.billingAddress.postcode}
    - {invoice.billingAddress.country} -

    - {/if} -
    -
    -
    - - {invoice.status.replace('_', ' ')} - -
    - Issue Date: - {formatDate(invoice.issueDate)} -
    -
    - Due Date: - - {formatDate(invoice.dueDate)} - -
    -
    -
    -
    -
    + +
    +
    + +
    +
    +
    +

    Bill To:

    +

    {invoice.clientName}

    + {#if invoice.clientEmail} +

    {invoice.clientEmail}

    + {/if} + {#if invoice.billingAddress?.line} +

    + {invoice.billingAddress.line}
    + {#if invoice.billingAddress.city} + {invoice.billingAddress.city}, + {/if} + {invoice.billingAddress.state} + {invoice.billingAddress.postcode}
    + {invoice.billingAddress.country} +

    + {/if} +
    +
    +
    + + {invoice.status.replace('_', ' ')} + +
    + Issue Date: + {formatDate(invoice.issueDate)} +
    +
    + Due Date: + + {formatDate(invoice.dueDate)} + +
    +
    +
    +
    +
    - -
    - {#if invoice.invoiceTitle} -

    {invoice.invoiceTitle}

    - {/if} + +
    + {#if invoice.invoiceTitle} +

    {invoice.invoiceTitle}

    + {/if} -
    - - - - - - - - - - - {#each invoice.lineItems as item} - - - - - - - {/each} - -
    DescriptionQtyUnit PriceTotal
    {item.description}{item.quantity} - {formatCurrency(Number(item.unit_price), invoice.currency)} - - {formatCurrency(Number(item.total), invoice.currency)} -
    -
    +
    + + + + + + + + + + + {#each invoice.lineItems as item} + + + + + + + {/each} + +
    DescriptionQtyUnit PriceTotal
    {item.description}{item.quantity} + {formatCurrency(Number(item.unit_price), invoice.currency)} + + {formatCurrency(Number(item.total), invoice.currency)} +
    +
    - -
    -
    -
    - Subtotal: - {formatCurrency(Number(invoice.subtotal), invoice.currency)} -
    - {#if Number(invoice.discountAmount) > 0} -
    - Discount: - -{formatCurrency(Number(invoice.discountAmount), invoice.currency)} -
    - {/if} - {#if Number(invoice.taxAmount) > 0} -
    - Tax: - {formatCurrency(Number(invoice.taxAmount), invoice.currency)} -
    - {/if} -
    - Total: - {formatCurrency(Number(invoice.totalAmount), invoice.currency)} -
    - {#if Number(invoice.amountPaid) > 0} -
    - Amount Paid: - {formatCurrency(Number(invoice.amountPaid), invoice.currency)} -
    - {/if} - {#if Number(invoice.amountDue) > 0} -
    - Amount Due: - {formatCurrency(Number(invoice.amountDue), invoice.currency)} -
    - {/if} -
    -
    -
    + +
    +
    +
    + Subtotal: + {formatCurrency(Number(invoice.subtotal), invoice.currency)} +
    + {#if Number(invoice.discountAmount) > 0} +
    + Discount: + -{formatCurrency(Number(invoice.discountAmount), invoice.currency)} +
    + {/if} + {#if Number(invoice.taxAmount) > 0} +
    + Tax: + {formatCurrency(Number(invoice.taxAmount), invoice.currency)} +
    + {/if} +
    + Total: + {formatCurrency(Number(invoice.totalAmount), invoice.currency)} +
    + {#if Number(invoice.amountPaid) > 0} +
    + Amount Paid: + {formatCurrency(Number(invoice.amountPaid), invoice.currency)} +
    + {/if} + {#if Number(invoice.amountDue) > 0} +
    + Amount Due: + {formatCurrency(Number(invoice.amountDue), invoice.currency)} +
    + {/if} +
    +
    +
    - - {#if invoice.payments && invoice.payments.length > 0} -
    -

    Payment History

    -
    - {#each invoice.payments as payment} -
    -
    - {formatDate(payment.payment_date)} - via {payment.payment_method} -
    - - {formatCurrency(Number(payment.amount), invoice.currency)} - -
    - {/each} -
    -
    - {/if} + + {#if invoice.payments && invoice.payments.length > 0} +
    +

    Payment History

    +
    + {#each invoice.payments as payment} +
    +
    + {formatDate(payment.payment_date)} + via {payment.payment_method} +
    + + {formatCurrency(Number(payment.amount), invoice.currency)} + +
    + {/each} +
    +
    + {/if} - - {#if invoice.notes || invoice.terms} -
    - {#if invoice.notes} -
    -

    Notes

    -

    {invoice.notes}

    -
    - {/if} - {#if invoice.terms} -
    -

    Terms & Conditions

    -

    {invoice.terms}

    -
    - {/if} -
    - {/if} -
    + + {#if invoice.notes || invoice.terms} +
    + {#if invoice.notes} +
    +

    Notes

    +

    {invoice.notes}

    +
    + {/if} + {#if invoice.terms} +
    +

    Terms & Conditions

    +

    {invoice.terms}

    +
    + {/if} +
    + {/if} +
    - -
    - {#if footerText} -

    {footerText}

    - {/if} -

    Thank you for your business!

    -

    - If you have any questions about this invoice, please contact us. -

    -
    -
    + +
    + {#if footerText} +

    {footerText}

    + {/if} +

    Thank you for your business!

    +

    If you have any questions about this invoice, please contact us.

    +
    +
    diff --git a/frontend/static/funding.json b/frontend/static/funding.json index d7c2e9e3..e49f0630 100644 --- a/frontend/static/funding.json +++ b/frontend/static/funding.json @@ -1,122 +1,122 @@ { - "version": "v1.0.0", + "version": "v1.0.0", - "entity": { - "type": "organisation", - "role": "owner", - "name": "MicroPyramid Informatics Private Limited", - "email": "hello@micropyramid.com", - "phone": "+91-9959166266", - "description": "MicroPyramid is a technology company specializing in web development, mobile applications, and SaaS solutions. We build modern, scalable software solutions for businesses of all sizes, with expertise in Python, Django, React, and other cutting-edge technologies.", - "webpageUrl": { - "url": "https://micropyramid.com", - "wellKnown": "" - } - }, + "entity": { + "type": "organisation", + "role": "owner", + "name": "MicroPyramid Informatics Private Limited", + "email": "hello@micropyramid.com", + "phone": "+91-9959166266", + "description": "MicroPyramid is a technology company specializing in web development, mobile applications, and SaaS solutions. We build modern, scalable software solutions for businesses of all sizes, with expertise in Python, Django, React, and other cutting-edge technologies.", + "webpageUrl": { + "url": "https://micropyramid.com", + "wellKnown": "" + } + }, - "projects": [ - { - "guid": "startup-crm", - "name": "BottleCRM", - "description": "BottleCRM is a dynamic, SaaS CRM platform designed to streamline the entire CRM needs of startups and enterprises. Built with modern web technologies including SvelteKit, Svelte 5, and Prisma, it offers a seamless experience for users through robust role-based access control (RBAC). Each user role is equipped with tailored functionalities to enhance efficiency, engagement, and management, ensuring a streamlined and secure business process.", - "webpageUrl": { - "url": "https://bottlecrm.io", - "wellKnown": "" - }, - "repositoryUrl": { - "url": "https://github.com/MicroPyramid/Django-CRM", - "wellKnown": "" - }, - "licenses": ["spdx:MIT"], - "tags": [ - "crm", - "saas", - "business-tools", - "customer-management", - "sales", - "svelte", - "typescript", - "web-application", - "enterprise", - "startup" - ] - } - ], + "projects": [ + { + "guid": "startup-crm", + "name": "BottleCRM", + "description": "BottleCRM is a dynamic, SaaS CRM platform designed to streamline the entire CRM needs of startups and enterprises. Built with modern web technologies including SvelteKit, Svelte 5, and Prisma, it offers a seamless experience for users through robust role-based access control (RBAC). Each user role is equipped with tailored functionalities to enhance efficiency, engagement, and management, ensuring a streamlined and secure business process.", + "webpageUrl": { + "url": "https://bottlecrm.io", + "wellKnown": "" + }, + "repositoryUrl": { + "url": "https://github.com/MicroPyramid/Django-CRM", + "wellKnown": "" + }, + "licenses": ["spdx:MIT"], + "tags": [ + "crm", + "saas", + "business-tools", + "customer-management", + "sales", + "svelte", + "typescript", + "web-application", + "enterprise", + "startup" + ] + } + ], - "funding": { - "channels": [ - { - "guid": "paypal", - "type": "payment-provider", - "address": "paypal@micropyramid.com", - "description": "PayPal payments for international transactions" - }, - { - "guid": "bank-transfer", - "type": "bank", - "address": "Contact hello@micropyramid.com for bank details", - "description": "Direct bank transfers for larger contributions" - }, - { - "guid": "github-sponsors", - "type": "payment-provider", - "address": "https://github.com/sponsors/micropyramid", - "description": "GitHub Sponsors for recurring monthly support" - } - ], + "funding": { + "channels": [ + { + "guid": "paypal", + "type": "payment-provider", + "address": "paypal@micropyramid.com", + "description": "PayPal payments for international transactions" + }, + { + "guid": "bank-transfer", + "type": "bank", + "address": "Contact hello@micropyramid.com for bank details", + "description": "Direct bank transfers for larger contributions" + }, + { + "guid": "github-sponsors", + "type": "payment-provider", + "address": "https://github.com/sponsors/micropyramid", + "description": "GitHub Sponsors for recurring monthly support" + } + ], - "plans": [ - { - "guid": "monthly-supporter", - "status": "active", - "name": "Monthly Supporter", - "description": "Monthly support to help maintain and improve BottleCRM with regular updates, bug fixes, and new features.", - "amount": 25, - "currency": "USD", - "frequency": "monthly", - "channels": ["paypal", "github-sponsors"] - }, - { - "guid": "yearly-backer", - "status": "active", - "name": "Yearly Backer", - "description": "Annual support for sustained development and maintenance of BottleCRM platform.", - "amount": 250, - "currency": "USD", - "frequency": "yearly", - "channels": ["paypal", "bank-transfer", "github-sponsors"] - }, - { - "guid": "enterprise-sponsor", - "status": "active", - "name": "Enterprise Sponsor", - "description": "Enterprise-level sponsorship for priority support, custom features, and dedicated development resources.", - "amount": 1000, - "currency": "USD", - "frequency": "monthly", - "channels": ["bank-transfer", "paypal"] - }, - { - "guid": "one-time-donation", - "status": "active", - "name": "One-time Contribution", - "description": "One-time donation to support BottleCRM development. Any amount is appreciated.", - "amount": 0, - "currency": "USD", - "frequency": "one-time", - "channels": ["paypal", "github-sponsors"] - } - ], + "plans": [ + { + "guid": "monthly-supporter", + "status": "active", + "name": "Monthly Supporter", + "description": "Monthly support to help maintain and improve BottleCRM with regular updates, bug fixes, and new features.", + "amount": 25, + "currency": "USD", + "frequency": "monthly", + "channels": ["paypal", "github-sponsors"] + }, + { + "guid": "yearly-backer", + "status": "active", + "name": "Yearly Backer", + "description": "Annual support for sustained development and maintenance of BottleCRM platform.", + "amount": 250, + "currency": "USD", + "frequency": "yearly", + "channels": ["paypal", "bank-transfer", "github-sponsors"] + }, + { + "guid": "enterprise-sponsor", + "status": "active", + "name": "Enterprise Sponsor", + "description": "Enterprise-level sponsorship for priority support, custom features, and dedicated development resources.", + "amount": 1000, + "currency": "USD", + "frequency": "monthly", + "channels": ["bank-transfer", "paypal"] + }, + { + "guid": "one-time-donation", + "status": "active", + "name": "One-time Contribution", + "description": "One-time donation to support BottleCRM development. Any amount is appreciated.", + "amount": 0, + "currency": "USD", + "frequency": "one-time", + "channels": ["paypal", "github-sponsors"] + } + ], - "history": [ - { - "year": 2025, - "income": 0, - "expenses": 5000, - "taxes": 0, - "currency": "USD", - "description": "Initial development phase with investment in infrastructure, development tools, and hosting." - } - ] - } + "history": [ + { + "year": 2025, + "income": 0, + "expenses": 5000, + "taxes": 0, + "currency": "USD", + "description": "Initial development phase with investment in infrastructure, development tools, and hosting." + } + ] + } } diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index e6059ffa..74cb778b 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -1,5 +1,5 @@ import type { Config } from 'tailwindcss'; export default { - darkMode: 'class' + darkMode: 'class' } satisfies Config; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 2d35c4f5..50f72580 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -3,5 +3,5 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [tailwindcss(), sveltekit()] + plugins: [tailwindcss(), sveltekit()] });