feat: implement orders application and Salesforce import functionality.

This commit is contained in:
Ashwin 2026-02-20 10:27:10 +05:30
parent 66774700b3
commit 12d9e9ef41
18 changed files with 2707 additions and 1 deletions

View file

@ -0,0 +1,88 @@
# Enable RLS on orders tables
# Note: salesforce_imports tables were moved to enterprise edition
from django.db import migrations
from common.rls import (
get_check_table_exists_sql,
get_disable_policy_sql,
get_enable_policy_sql,
)
TABLES = [
"orders",
"order_line_item",
]
def check_column_exists(cursor, table, column):
"""Check if a column exists in a table."""
cursor.execute(
"""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = %s
AND column_name = %s
)
""",
[table, column],
)
return cursor.fetchone()[0]
def enable_rls(apps, schema_editor):
"""Enable RLS on orders tables."""
if schema_editor.connection.vendor != "postgresql":
print("RLS is only supported on PostgreSQL. Skipping.")
return
with schema_editor.connection.cursor() as cursor:
for table in TABLES:
# Check if table exists
cursor.execute(get_check_table_exists_sql(), [table])
if not cursor.fetchone()[0]:
print(f" Skipping {table} (table does not exist)")
continue
# Check if org_id column exists
if not check_column_exists(cursor, table, "org_id"):
print(f" Skipping {table} (no org_id column)")
continue
# Enable RLS
cursor.execute(get_enable_policy_sql(table))
print(f" Enabled RLS on {table}")
def disable_rls(apps, schema_editor):
"""Disable RLS on orders tables (rollback)."""
if schema_editor.connection.vendor != "postgresql":
return
with schema_editor.connection.cursor() as cursor:
for table in TABLES:
cursor.execute(get_check_table_exists_sql(), [table])
if not cursor.fetchone()[0]:
continue
if not check_column_exists(cursor, table, "org_id"):
continue
cursor.execute(get_disable_policy_sql(table))
print(f" Disabled RLS on {table}")
class Migration(migrations.Migration):
"""
Enable Row-Level Security (RLS) on orders tables.
"""
dependencies = [
("common", "0012_add_tag_color_description_isactive"),
("orders", "0001_initial"),
]
operations = [
migrations.RunPython(enable_rls, disable_rls),
]

View file

@ -70,6 +70,9 @@ ORG_SCOPED_TABLES = [
"recurring_invoice_line_item",
# Products
"product",
# Orders
"orders",
"order_line_item",
# Security & Audit
"security_audit_log",
]

View file

@ -42,6 +42,7 @@ INSTALLED_APPS = [
"opportunity",
"tasks",
"invoices",
"orders",
# "teams", # Merged into common app
]

View file

149
backend/orders/admin.py Normal file
View file

@ -0,0 +1,149 @@
from django.contrib import admin
from orders.models import Order, OrderLineItem
class OrderLineItemInline(admin.TabularInline):
model = OrderLineItem
extra = 1
fields = (
"product",
"name",
"description",
"quantity",
"unit_price",
"discount_amount",
"total",
"sort_order",
)
readonly_fields = ("total",)
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = (
"name",
"order_number",
"account",
"status",
"total_amount",
"order_date",
"created_at",
)
list_filter = ("status", "created_at", "order_date", "org")
search_fields = (
"name",
"order_number",
"account__name",
)
raw_id_fields = (
"created_by",
"org",
"account",
"contact",
"opportunity",
)
inlines = [OrderLineItemInline]
date_hierarchy = "created_at"
ordering = ("-created_at",)
fieldsets = (
(
"Order Info",
{
"fields": (
"name",
"order_number",
"status",
)
},
),
(
"CRM Integration",
{
"fields": (
"account",
"contact",
"opportunity",
)
},
),
(
"Financial",
{
"fields": (
"currency",
"subtotal",
"discount_amount",
"tax_amount",
"total_amount",
)
},
),
(
"Dates",
{
"fields": (
"order_date",
"activated_date",
"shipped_date",
)
},
),
(
"Billing Address",
{
"fields": (
"billing_address_line",
"billing_city",
"billing_state",
"billing_postcode",
"billing_country",
),
"classes": ("collapse",),
},
),
(
"Shipping Address",
{
"fields": (
"shipping_address_line",
"shipping_city",
"shipping_state",
"shipping_postcode",
"shipping_country",
),
"classes": ("collapse",),
},
),
(
"Notes",
{
"fields": ("description",)
},
),
(
"Organization",
{
"fields": ("org",)
},
),
)
@admin.register(OrderLineItem)
class OrderLineItemAdmin(admin.ModelAdmin):
list_display = (
"order",
"name",
"product",
"quantity",
"unit_price",
"discount_amount",
"total",
"sort_order",
)
list_filter = ("order__status", "created_at")
search_fields = ("name", "order__name", "product__name")
raw_id_fields = ("order", "product", "org")
ordering = ("order", "sort_order")

6
backend/orders/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class OrdersConfig(AppConfig):
default_auto_field = "django.db.models.AutoField"
name = "orders"

View file

@ -0,0 +1,924 @@
# Generated by Django 5.2.9 on 2026-02-19 02:29
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("accounts", "0006_add_enterprise_constraints"),
("common", "0012_add_tag_color_description_isactive"),
("contacts", "0010_add_enterprise_constraints"),
("invoices", "0007_add_cancelled_at_field"),
("opportunity", "0009_salesgoal"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Order",
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="Order Name")),
(
"order_number",
models.CharField(
blank=True,
max_length=100,
null=True,
verbose_name="Order Number",
),
),
(
"status",
models.CharField(
choices=[
("DRAFT", "Draft"),
("ACTIVATED", "Activated"),
("COMPLETED", "Completed"),
("CANCELLED", "Cancelled"),
],
default="DRAFT",
max_length=20,
verbose_name="Status",
),
),
(
"currency",
models.CharField(
blank=True, max_length=3, null=True, verbose_name="Currency"
),
),
(
"subtotal",
models.DecimalField(
decimal_places=2,
default=0,
max_digits=15,
verbose_name="Subtotal",
),
),
(
"discount_amount",
models.DecimalField(
decimal_places=2,
default=0,
max_digits=15,
verbose_name="Discount Amount",
),
),
(
"tax_amount",
models.DecimalField(
decimal_places=2,
default=0,
max_digits=15,
verbose_name="Tax Amount",
),
),
(
"total_amount",
models.DecimalField(
decimal_places=2,
default=0,
max_digits=15,
verbose_name="Total Amount",
),
),
(
"order_date",
models.DateField(blank=True, null=True, verbose_name="Order Date"),
),
(
"activated_date",
models.DateField(
blank=True, null=True, verbose_name="Activated Date"
),
),
(
"shipped_date",
models.DateField(
blank=True, null=True, verbose_name="Shipped Date"
),
),
(
"billing_address_line",
models.CharField(
blank=True,
max_length=255,
null=True,
verbose_name="Billing Address",
),
),
(
"billing_city",
models.CharField(
blank=True,
max_length=100,
null=True,
verbose_name="Billing City",
),
),
(
"billing_state",
models.CharField(
blank=True,
max_length=100,
null=True,
verbose_name="Billing State",
),
),
(
"billing_postcode",
models.CharField(
blank=True,
max_length=20,
null=True,
verbose_name="Billing Postal Code",
),
),
(
"billing_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,
null=True,
verbose_name="Billing Country",
),
),
(
"shipping_address_line",
models.CharField(
blank=True,
max_length=255,
null=True,
verbose_name="Shipping Address",
),
),
(
"shipping_city",
models.CharField(
blank=True,
max_length=100,
null=True,
verbose_name="Shipping City",
),
),
(
"shipping_state",
models.CharField(
blank=True,
max_length=100,
null=True,
verbose_name="Shipping State",
),
),
(
"shipping_postcode",
models.CharField(
blank=True,
max_length=20,
null=True,
verbose_name="Shipping Postal Code",
),
),
(
"shipping_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,
null=True,
verbose_name="Shipping Country",
),
),
(
"description",
models.TextField(blank=True, null=True, verbose_name="Description"),
),
(
"account",
models.ForeignKey(
help_text="The account this order belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="orders",
to="accounts.account",
),
),
(
"contact",
models.ForeignKey(
blank=True,
help_text="The contact associated with this order",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="orders",
to="contacts.contact",
),
),
(
"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",
),
),
(
"opportunity",
models.ForeignKey(
blank=True,
help_text="The opportunity this order originated from",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="orders",
to="opportunity.opportunity",
),
),
(
"org",
models.ForeignKey(
help_text="Organization this record belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
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": "Order",
"verbose_name_plural": "Orders",
"db_table": "orders",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="OrderLineItem",
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="Item Name")),
(
"description",
models.TextField(blank=True, null=True, 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=15,
verbose_name="Unit Price",
),
),
(
"discount_amount",
models.DecimalField(
decimal_places=2,
default=0,
max_digits=15,
verbose_name="Discount Amount",
),
),
(
"total",
models.DecimalField(
decimal_places=2, default=0, max_digits=15, verbose_name="Total"
),
),
(
"sort_order",
models.IntegerField(default=0, verbose_name="Sort Order"),
),
(
"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",
),
),
(
"order",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="line_items",
to="orders.order",
),
),
(
"org",
models.ForeignKey(
help_text="Organization this record belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="common.org",
),
),
(
"product",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="order_line_items",
to="invoices.product",
),
),
(
"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": "Order Line Item",
"verbose_name_plural": "Order Line Items",
"db_table": "order_line_item",
"ordering": ("sort_order",),
},
),
migrations.AddIndex(
model_name="order",
index=models.Index(
fields=["org", "-created_at"], name="orders_org_id_9026a7_idx"
),
),
migrations.AddIndex(
model_name="orderlineitem",
index=models.Index(
fields=["org", "-created_at"], name="order_line__org_id_444fcc_idx"
),
),
]

View file

182
backend/orders/models.py Normal file
View file

@ -0,0 +1,182 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from common.base import BaseOrgModel
from common.utils import COUNTRIES
# =============================================================================
# CONSTANTS
# =============================================================================
ORDER_STATUS = (
("DRAFT", "Draft"),
("ACTIVATED", "Activated"),
("COMPLETED", "Completed"),
("CANCELLED", "Cancelled"),
)
# =============================================================================
# ORDER
# =============================================================================
class Order(BaseOrgModel):
"""
Order model for CRM - tracks sales orders linked to accounts,
contacts, and opportunities.
"""
# Core Order Information
name = models.CharField(_("Order Name"), max_length=255)
order_number = models.CharField(
_("Order Number"), max_length=100, blank=True, null=True
)
status = models.CharField(
_("Status"), max_length=20, choices=ORDER_STATUS, default="DRAFT"
)
# CRM Integration
account = models.ForeignKey(
"accounts.Account",
on_delete=models.CASCADE,
related_name="orders",
help_text="The account this order belongs to",
)
contact = models.ForeignKey(
"contacts.Contact",
on_delete=models.SET_NULL,
related_name="orders",
null=True,
blank=True,
help_text="The contact associated with this order",
)
opportunity = models.ForeignKey(
"opportunity.Opportunity",
on_delete=models.SET_NULL,
related_name="orders",
null=True,
blank=True,
help_text="The opportunity this order originated from",
)
# Financial
currency = models.CharField(_("Currency"), max_length=3, blank=True, null=True)
subtotal = models.DecimalField(
_("Subtotal"), max_digits=15, decimal_places=2, default=0
)
discount_amount = models.DecimalField(
_("Discount Amount"), max_digits=15, decimal_places=2, default=0
)
tax_amount = models.DecimalField(
_("Tax Amount"), max_digits=15, decimal_places=2, default=0
)
total_amount = models.DecimalField(
_("Total Amount"), max_digits=15, decimal_places=2, default=0
)
# Dates
order_date = models.DateField(_("Order Date"), null=True, blank=True)
activated_date = models.DateField(_("Activated Date"), null=True, blank=True)
shipped_date = models.DateField(_("Shipped Date"), null=True, blank=True)
# Billing Address
billing_address_line = models.CharField(
_("Billing Address"), max_length=255, blank=True, null=True
)
billing_city = models.CharField(
_("Billing City"), max_length=100, blank=True, null=True
)
billing_state = models.CharField(
_("Billing State"), max_length=100, blank=True, null=True
)
billing_postcode = models.CharField(
_("Billing Postal Code"), max_length=20, blank=True, null=True
)
billing_country = models.CharField(
_("Billing Country"), max_length=3, choices=COUNTRIES, blank=True, null=True
)
# Shipping Address
shipping_address_line = models.CharField(
_("Shipping Address"), max_length=255, blank=True, null=True
)
shipping_city = models.CharField(
_("Shipping City"), max_length=100, blank=True, null=True
)
shipping_state = models.CharField(
_("Shipping State"), max_length=100, blank=True, null=True
)
shipping_postcode = models.CharField(
_("Shipping Postal Code"), max_length=20, blank=True, null=True
)
shipping_country = models.CharField(
_("Shipping Country"), max_length=3, choices=COUNTRIES, blank=True, null=True
)
# Notes
description = models.TextField(_("Description"), blank=True, null=True)
class Meta:
verbose_name = "Order"
verbose_name_plural = "Orders"
db_table = "orders"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["org", "-created_at"]),
]
def __str__(self):
return self.name
# =============================================================================
# ORDER LINE ITEM
# =============================================================================
class OrderLineItem(BaseOrgModel):
"""Line item for Orders - individual products/services within an order."""
order = models.ForeignKey(
Order, on_delete=models.CASCADE, related_name="line_items"
)
product = models.ForeignKey(
"invoices.Product",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="order_line_items",
)
name = models.CharField(_("Item Name"), max_length=255)
description = models.TextField(_("Description"), blank=True, null=True)
quantity = models.DecimalField(
_("Quantity"), max_digits=10, decimal_places=2, default=1
)
unit_price = models.DecimalField(
_("Unit Price"), max_digits=15, decimal_places=2, default=0
)
discount_amount = models.DecimalField(
_("Discount Amount"), max_digits=15, decimal_places=2, default=0
)
total = models.DecimalField(_("Total"), max_digits=15, decimal_places=2, default=0)
sort_order = models.IntegerField(_("Sort Order"), default=0)
class Meta:
verbose_name = "Order Line Item"
verbose_name_plural = "Order Line Items"
db_table = "order_line_item"
ordering = ("sort_order",)
indexes = [
models.Index(fields=["org", "-created_at"]),
]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
# Inherit org from order if not set
if not self.org_id and self.order_id:
self.org_id = self.order.org_id
super().save(*args, **kwargs)

View file

View file

@ -0,0 +1,335 @@
"""
Tests for orders/models.py - Order and OrderLineItem models.
Covers:
- Order creation with required fields
- Order __str__ representation
- Order status choices and default
- Order foreign key relationships (account, contact, opportunity)
- Order Meta options (db_table, ordering)
- OrderLineItem creation
- OrderLineItem __str__ representation
- OrderLineItem auto-populate org from parent order
- OrderLineItem Meta options (db_table, ordering)
Run with: pytest orders/tests/test_models.py -v
"""
from decimal import Decimal
import pytest
from accounts.models import Account
from contacts.models import Contact
from opportunity.models import Opportunity
from orders.models import ORDER_STATUS, Order, OrderLineItem
# ---------------------------------------------------------------------------
# Order model tests
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestOrderModel:
"""Test Order model creation, fields, and behavior."""
def test_create_order_minimal(self, org_a):
"""Order can be created with just required fields (name, account, org)."""
account = Account.objects.create(name="Test Account", org=org_a)
order = Order.objects.create(
name="Test Order",
account=account,
org=org_a,
)
assert order.pk is not None
assert order.name == "Test Order"
assert order.account == account
assert order.org == org_a
def test_str_returns_name(self, org_a):
"""Order __str__ should return the order name."""
account = Account.objects.create(name="Test Account", org=org_a)
order = Order.objects.create(
name="My Sales Order",
account=account,
org=org_a,
)
assert str(order) == "My Sales Order"
def test_default_status_is_draft(self, org_a):
"""Order status should default to DRAFT."""
account = Account.objects.create(name="Test Account", org=org_a)
order = Order.objects.create(
name="Test Order",
account=account,
org=org_a,
)
assert order.status == "DRAFT"
def test_status_choices(self):
"""ORDER_STATUS should contain expected choices."""
status_values = [s[0] for s in ORDER_STATUS]
assert "DRAFT" in status_values
assert "ACTIVATED" in status_values
assert "COMPLETED" in status_values
assert "CANCELLED" in status_values
def test_create_order_all_fields(self, org_a):
"""Order can be created with all fields populated."""
account = Account.objects.create(name="Full Account", org=org_a)
contact = Contact.objects.create(
first_name="Jane", last_name="Doe", org=org_a
)
opportunity = Opportunity.objects.create(
name="Big Deal",
account=account,
org=org_a,
)
order = Order.objects.create(
name="Full Order",
order_number="ORD-001",
status="ACTIVATED",
account=account,
contact=contact,
opportunity=opportunity,
currency="USD",
subtotal=Decimal("1000.00"),
discount_amount=Decimal("100.00"),
tax_amount=Decimal("90.00"),
total_amount=Decimal("990.00"),
order_date="2026-01-15",
activated_date="2026-01-16",
shipped_date="2026-01-20",
billing_address_line="123 Main St",
billing_city="Austin",
billing_state="TX",
billing_postcode="78701",
billing_country="US",
shipping_address_line="456 Oak Ave",
shipping_city="Dallas",
shipping_state="TX",
shipping_postcode="75201",
shipping_country="US",
description="Full order with all fields",
org=org_a,
)
assert order.pk is not None
assert order.order_number == "ORD-001"
assert order.status == "ACTIVATED"
assert order.contact == contact
assert order.opportunity == opportunity
assert order.total_amount == Decimal("990.00")
assert order.billing_city == "Austin"
assert order.shipping_city == "Dallas"
def test_order_account_cascade_delete(self, org_a):
"""Deleting an account should cascade-delete its orders."""
account = Account.objects.create(name="To Delete", org=org_a)
Order.objects.create(name="Order to Delete", account=account, org=org_a)
assert Order.objects.count() == 1
account.delete()
assert Order.objects.count() == 0
def test_order_contact_set_null_on_delete(self, org_a):
"""Deleting a contact should set order.contact to NULL."""
account = Account.objects.create(name="Test Account", org=org_a)
contact = Contact.objects.create(
first_name="John", last_name="Doe", org=org_a
)
order = Order.objects.create(
name="Contact Order",
account=account,
contact=contact,
org=org_a,
)
contact.delete()
order.refresh_from_db()
assert order.contact is None
def test_order_opportunity_set_null_on_delete(self, org_a):
"""Deleting an opportunity should set order.opportunity to NULL."""
account = Account.objects.create(name="Test Account", org=org_a)
opportunity = Opportunity.objects.create(
name="Opp", account=account, org=org_a
)
order = Order.objects.create(
name="Opp Order",
account=account,
opportunity=opportunity,
org=org_a,
)
opportunity.delete()
order.refresh_from_db()
assert order.opportunity is None
def test_order_meta_db_table(self):
"""Order Meta db_table should be 'orders'."""
assert Order._meta.db_table == "orders"
def test_order_meta_ordering(self):
"""Order Meta ordering should be ('-created_at',)."""
assert Order._meta.ordering == ("-created_at",)
def test_order_decimal_defaults(self, org_a):
"""Financial fields should default to 0."""
account = Account.objects.create(name="Default Account", org=org_a)
order = Order.objects.create(name="Default Order", account=account, org=org_a)
assert order.subtotal == Decimal("0")
assert order.discount_amount == Decimal("0")
assert order.tax_amount == Decimal("0")
assert order.total_amount == Decimal("0")
def test_order_has_uuid_pk(self, org_a):
"""Order primary key should be a UUID (inherited from BaseOrgModel)."""
import uuid
account = Account.objects.create(name="UUID Account", org=org_a)
order = Order.objects.create(name="UUID Order", account=account, org=org_a)
assert isinstance(order.pk, uuid.UUID)
def test_order_has_timestamps(self, org_a):
"""Order should have created_at and updated_at timestamps."""
account = Account.objects.create(name="Timestamp Account", org=org_a)
order = Order.objects.create(
name="Timestamp Order", account=account, org=org_a
)
assert order.created_at is not None
assert order.updated_at is not None
# ---------------------------------------------------------------------------
# OrderLineItem model tests
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestOrderLineItemModel:
"""Test OrderLineItem model creation, fields, and behavior."""
def _make_order(self, org):
account = Account.objects.create(name="Line Item Account", org=org)
return Order.objects.create(name="Line Item Order", account=account, org=org)
def test_create_line_item_minimal(self, org_a):
"""OrderLineItem can be created with required fields."""
order = self._make_order(org_a)
item = OrderLineItem.objects.create(
order=order,
name="Widget",
org=org_a,
)
assert item.pk is not None
assert item.name == "Widget"
assert item.order == order
def test_str_returns_name(self, org_a):
"""OrderLineItem __str__ should return the item name."""
order = self._make_order(org_a)
item = OrderLineItem.objects.create(
order=order,
name="Premium Widget",
org=org_a,
)
assert str(item) == "Premium Widget"
def test_auto_populate_org_from_order(self, org_a):
"""OrderLineItem should inherit org from parent order if not set."""
order = self._make_order(org_a)
item = OrderLineItem(order=order, name="Auto Org Item")
item.save()
assert item.org_id == org_a.pk
def test_explicit_org_not_overridden(self, org_a, org_b):
"""OrderLineItem with explicitly set org should keep it."""
order = self._make_order(org_a)
item = OrderLineItem(order=order, name="Explicit Org Item", org=org_b)
item.save()
assert item.org_id == org_b.pk
def test_line_item_all_fields(self, org_a):
"""OrderLineItem can be created with all fields populated."""
order = self._make_order(org_a)
item = OrderLineItem.objects.create(
order=order,
name="Full Item",
description="A fully specified line item",
quantity=Decimal("5.00"),
unit_price=Decimal("25.50"),
discount_amount=Decimal("10.00"),
total=Decimal("117.50"),
sort_order=1,
org=org_a,
)
assert item.quantity == Decimal("5.00")
assert item.unit_price == Decimal("25.50")
assert item.discount_amount == Decimal("10.00")
assert item.total == Decimal("117.50")
assert item.sort_order == 1
def test_line_item_cascade_delete_with_order(self, org_a):
"""Deleting an order should cascade-delete its line items."""
order = self._make_order(org_a)
OrderLineItem.objects.create(order=order, name="Item 1", org=org_a)
OrderLineItem.objects.create(order=order, name="Item 2", org=org_a)
assert OrderLineItem.objects.count() == 2
order.delete()
assert OrderLineItem.objects.count() == 0
def test_line_item_product_set_null_on_delete(self, org_a):
"""Deleting a product should set line_item.product to NULL."""
from invoices.models import Product
order = self._make_order(org_a)
product = Product.objects.create(name="Test Product", price=10, org=org_a)
item = OrderLineItem.objects.create(
order=order,
name="Product Item",
product=product,
org=org_a,
)
product.delete()
item.refresh_from_db()
assert item.product is None
def test_line_item_meta_db_table(self):
"""OrderLineItem Meta db_table should be 'order_line_item'."""
assert OrderLineItem._meta.db_table == "order_line_item"
def test_line_item_meta_ordering(self):
"""OrderLineItem Meta ordering should be ('sort_order',)."""
assert OrderLineItem._meta.ordering == ("sort_order",)
def test_line_item_decimal_defaults(self, org_a):
"""Financial fields should default to expected values."""
order = self._make_order(org_a)
item = OrderLineItem.objects.create(
order=order, name="Default Item", org=org_a
)
assert item.quantity == Decimal("1")
assert item.unit_price == Decimal("0")
assert item.discount_amount == Decimal("0")
assert item.total == Decimal("0")
assert item.sort_order == 0
def test_line_item_ordering_by_sort_order(self, org_a):
"""Line items should be ordered by sort_order."""
order = self._make_order(org_a)
item_c = OrderLineItem.objects.create(
order=order, name="C", sort_order=3, org=org_a
)
item_a = OrderLineItem.objects.create(
order=order, name="A", sort_order=1, org=org_a
)
item_b = OrderLineItem.objects.create(
order=order, name="B", sort_order=2, org=org_a
)
items = list(OrderLineItem.objects.all())
assert items == [item_a, item_b, item_c]
def test_order_line_items_related_name(self, org_a):
"""Order.line_items should return associated OrderLineItems."""
order = self._make_order(org_a)
OrderLineItem.objects.create(order=order, name="Item 1", org=org_a)
OrderLineItem.objects.create(order=order, name="Item 2", org=org_a)
assert order.line_items.count() == 2

View file

@ -458,6 +458,39 @@ export const tasks = createCrudApi('tasks');
export const events = createCrudApi('events');
export const invoices = createCrudApi('invoices');
/**
* Salesforce Integration API
*/
export const salesforce = {
async status() {
return apiRequest('/salesforce/status/');
},
async connect() {
return apiRequest('/salesforce/connect/', { method: 'POST' });
},
async callback(code) {
return apiRequest('/salesforce/callback/', {
method: 'POST',
body: { code }
});
},
async disconnect() {
return apiRequest('/salesforce/disconnect/', { method: 'DELETE' });
},
async startImport(objectTypes) {
return apiRequest('/salesforce/import/', {
method: 'POST',
body: { object_types: objectTypes }
});
},
async getImportJob(jobId) {
return apiRequest(`/salesforce/import/${jobId}/`);
},
async importHistory() {
return apiRequest('/salesforce/import/history/');
}
};
/**
* Export all as default
*/
@ -471,6 +504,7 @@ export default {
tasks,
events,
invoices,
salesforce,
apiRequest,
getAccessToken,
getRefreshToken,

View file

@ -36,7 +36,8 @@
Tag,
Sparkles,
Home,
Trophy
Trophy,
Cloud
} from '@lucide/svelte';
/**
@ -599,6 +600,14 @@
<Settings class="size-4" />
<span>Settings</span>
</DropdownMenu.Item>
<DropdownMenu.Item
inset={false}
onclick={() => navigateTo('/settings/salesforce')}
class="gap-2.5"
>
<Cloud class="size-4" />
<span>Salesforce</span>
</DropdownMenu.Item>
<DropdownMenu.Item
inset={false}
onclick={() => navigateTo('/settings/tags')}

View file

@ -0,0 +1,55 @@
import { apiRequest } from '$lib/api-helpers.js';
import { fail } from '@sveltejs/kit';
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
try {
const status = await apiRequest('/salesforce/status/', {}, { cookies });
return { sfStatus: status };
} catch {
return { sfStatus: { connected: false, has_credentials: false } };
}
}
/** @type {import('./$types').Actions} */
export const actions = {
saveCredentials: async ({ request, cookies }) => {
const formData = await request.formData();
const client_id = formData.get('client_id')?.toString().trim();
const client_secret = formData.get('client_secret')?.toString().trim();
const login_url = formData.get('login_url')?.toString().trim() || 'https://login.salesforce.com';
if (!client_id || !client_secret) {
return fail(400, { credentialError: 'Both Client ID and Client Secret are required.' });
}
// Step 1: Save credentials
try {
await apiRequest(
'/salesforce/credentials/',
{ method: 'POST', body: { client_id, client_secret, login_url } },
{ cookies }
);
} catch (err) {
return fail(400, { credentialError: err?.message || 'Failed to save credentials.' });
}
// Step 2: Immediately connect using Client Credentials flow
try {
await apiRequest('/salesforce/connect/', { method: 'POST' }, { cookies });
return { connected: true };
} catch (err) {
return fail(400, {
credentialError: err?.message || 'Credentials saved but failed to connect to Salesforce. Check your Connected App settings.'
});
}
},
disconnect: async ({ cookies }) => {
try {
await apiRequest('/salesforce/disconnect/', { method: 'DELETE' }, { cookies });
return { disconnected: true };
} catch (err) {
return fail(400, { credentialError: err?.message || 'Failed to disconnect.' });
}
}
};

View file

@ -0,0 +1,318 @@
<script>
import { enhance } from '$app/forms';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { PageHeader } from '$lib/components/layout';
import { Cloud, ExternalLink, Trash2, Download, Key } from '@lucide/svelte';
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
let sfStatus = $derived(data.sfStatus || { connected: false });
let isConnecting = $state(false);
let isDisconnecting = $state(false);
let disconnectDialogOpen = $state(false);
// Show toast for form results
$effect(() => {
if (form?.connected) toast.success('Salesforce connected successfully');
if (form?.disconnected) toast.success('Salesforce disconnected');
if (form?.credentialError) toast.error(form.credentialError);
});
/**
* Format a date string for display
* @param {string} dateStr
*/
function formatDate(dateStr) {
if (!dateStr) return 'Unknown';
try {
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateStr;
}
}
</script>
<svelte:head>
<title>Salesforce Integration - BottleCRM</title>
</svelte:head>
<PageHeader title="Salesforce Integration" subtitle="Connect and import data from Salesforce" />
<div class="mx-auto max-w-3xl space-y-6 p-6 md:p-8">
{#if sfStatus.connected}
<!-- ============================================================ -->
<!-- CONNECTED -->
<!-- ============================================================ -->
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex size-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-950"
>
<Cloud class="size-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<Card.Title>Salesforce Connection</Card.Title>
<Card.Description>Your Salesforce org is connected</Card.Description>
</div>
</div>
<Badge
class="border-emerald-200 bg-emerald-100 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-400"
>
Connected
</Badge>
</div>
</Card.Header>
<Card.Content>
<div class="space-y-4">
<div class="bg-muted/50 rounded-lg p-4">
<dl class="grid gap-3 sm:grid-cols-2">
{#if sfStatus.connection?.instance_url}
<div>
<dt class="text-muted-foreground text-sm font-medium">Instance URL</dt>
<dd class="text-foreground mt-1 text-sm">
<a
href={sfStatus.connection.instance_url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-blue-600 hover:underline dark:text-blue-400"
>
{sfStatus.connection.instance_url}
<ExternalLink class="size-3" />
</a>
</dd>
</div>
{/if}
{#if sfStatus.connection?.connected_by_email}
<div>
<dt class="text-muted-foreground text-sm font-medium">Connected By</dt>
<dd class="text-foreground mt-1 text-sm">{sfStatus.connection.connected_by_email}</dd>
</div>
{/if}
{#if sfStatus.connection?.created_at}
<div>
<dt class="text-muted-foreground text-sm font-medium">Connected On</dt>
<dd class="text-foreground mt-1 text-sm">{formatDate(sfStatus.connection.created_at)}</dd>
</div>
{/if}
</dl>
</div>
<Separator />
<div class="flex items-center justify-between">
<Button variant="outline" href="/settings/salesforce/import" class="gap-2">
<Download class="size-4" />
Import Data
</Button>
<Button
variant="outline"
class="text-destructive hover:bg-destructive/10 gap-2"
onclick={() => (disconnectDialogOpen = true)}
type="button"
>
<Trash2 class="size-4" />
Disconnect
</Button>
</div>
</div>
</Card.Content>
</Card.Root>
{:else}
<!-- ============================================================ -->
<!-- NOT CONNECTED - Setup + Credential Form -->
<!-- ============================================================ -->
<Card.Root>
<Card.Header class="text-center">
<div
class="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950"
>
<Key class="size-8 text-blue-600 dark:text-blue-400" />
</div>
<Card.Title class="text-xl">Connect to Salesforce</Card.Title>
<Card.Description class="mx-auto max-w-md">
Create a Connected App in your Salesforce org and enter the credentials below to connect.
</Card.Description>
</Card.Header>
<Card.Content>
<div class="space-y-6">
<!-- Setup instructions -->
<div class="bg-muted/50 rounded-lg p-4">
<h4 class="text-foreground mb-3 text-sm font-medium">How to create a Connected App:</h4>
<ol class="text-muted-foreground space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-foreground shrink-0 font-medium">1.</span>
<span>Log in to <strong class="text-foreground">Salesforce Setup</strong></span>
</li>
<li class="flex gap-2">
<span class="text-foreground shrink-0 font-medium">2.</span>
<span>Go to <strong class="text-foreground">App Manager</strong> &rarr; <strong class="text-foreground">New Connected App</strong></span>
</li>
<li class="flex gap-2">
<span class="text-foreground shrink-0 font-medium">3.</span>
<span>Enable <strong class="text-foreground">OAuth Settings</strong> and set Callback URL to <code class="bg-muted rounded px-1 text-xs">https://login.salesforce.com/services/oauth2/success</code></span>
</li>
<li class="flex gap-2">
<span class="text-foreground shrink-0 font-medium">4.</span>
<span>Add OAuth scopes: <code class="bg-muted rounded px-1 text-xs">api</code>, <code class="bg-muted rounded px-1 text-xs">refresh_token</code></span>
</li>
<li class="flex gap-2">
<span class="text-foreground shrink-0 font-medium">5.</span>
<span>Check <strong class="text-foreground">Enable Client Credentials Flow</strong></span>
</li>
<li class="flex gap-2">
<span class="text-foreground shrink-0 font-medium">6.</span>
<span>Assign a <strong class="text-foreground">Run As</strong> user to the Connected App (required for Client Credentials)</span>
</li>
<li class="flex gap-2">
<span class="text-foreground shrink-0 font-medium">7.</span>
<span>Copy the <strong class="text-foreground">Consumer Key</strong> (Client ID) and <strong class="text-foreground">Consumer Secret</strong> (Client Secret) below</span>
</li>
</ol>
</div>
<Separator />
<!-- Credential form - saves + connects in one step -->
<form method="POST" action="?/saveCredentials" use:enhance={() => {
isConnecting = true;
return async ({ update }) => {
isConnecting = false;
await update();
};
}}>
<div class="space-y-4">
<div class="space-y-2">
<Label for="login_url">Salesforce My Domain URL</Label>
<Input
id="login_url"
name="login_url"
type="url"
placeholder="https://yourcompany.my.salesforce.com"
required
autocomplete="off"
/>
<p class="text-muted-foreground text-xs">
Find this in Salesforce Setup &rarr; My Domain. Use <code class="bg-muted rounded px-1">https://yourcompany.my.salesforce.com</code>
</p>
</div>
<div class="space-y-2">
<Label for="client_id">Client ID (Consumer Key)</Label>
<Input
id="client_id"
name="client_id"
type="text"
placeholder="3MVG9..."
required
autocomplete="off"
/>
</div>
<div class="space-y-2">
<Label for="client_secret">Client Secret (Consumer Secret)</Label>
<Input
id="client_secret"
name="client_secret"
type="password"
placeholder="Enter your client secret"
required
autocomplete="off"
/>
</div>
<Button
type="submit"
disabled={isConnecting}
class="w-full gap-2 border-0 bg-[var(--color-primary-default)] text-white hover:bg-[var(--color-primary-hover)]"
>
<Cloud class="size-4" />
{isConnecting ? 'Connecting to Salesforce...' : 'Connect to Salesforce'}
</Button>
</div>
</form>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">What gets imported?</Card.Title>
</Card.Header>
<Card.Content>
<ul class="text-muted-foreground space-y-2 text-sm">
<li class="flex items-start gap-2">
<span class="mt-1.5 size-1.5 shrink-0 rounded-full bg-blue-500"></span>
<span
><strong class="text-foreground">Accounts</strong> - Companies and organizations</span
>
</li>
<li class="flex items-start gap-2">
<span class="mt-1.5 size-1.5 shrink-0 rounded-full bg-blue-500"></span>
<span
><strong class="text-foreground">Contacts</strong> - People associated with accounts</span
>
</li>
<li class="flex items-start gap-2">
<span class="mt-1.5 size-1.5 shrink-0 rounded-full bg-blue-500"></span>
<span
><strong class="text-foreground">Opportunities</strong> - Sales deals and pipeline</span
>
</li>
<li class="flex items-start gap-2">
<span class="mt-1.5 size-1.5 shrink-0 rounded-full bg-blue-500"></span>
<span><strong class="text-foreground">Products</strong> - Product catalog</span>
</li>
<li class="flex items-start gap-2">
<span class="mt-1.5 size-1.5 shrink-0 rounded-full bg-blue-500"></span>
<span><strong class="text-foreground">Orders</strong> - Sales orders</span>
</li>
<li class="flex items-start gap-2">
<span class="mt-1.5 size-1.5 shrink-0 rounded-full bg-blue-500"></span>
<span><strong class="text-foreground">Quotes</strong> - Price quotes and estimates</span
>
</li>
</ul>
</Card.Content>
</Card.Root>
{/if}
</div>
<!-- Disconnect Confirmation Dialog -->
<Dialog.Root bind:open={disconnectDialogOpen}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Disconnect Salesforce</Dialog.Title>
<Dialog.Description>
Are you sure you want to disconnect from Salesforce? Previously imported data will not be deleted.
</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/disconnect" use:enhance={() => {
isDisconnecting = true;
return async ({ update }) => {
isDisconnecting = false;
disconnectDialogOpen = false;
await update();
};
}}>
<div class="flex justify-end gap-2 pt-4">
<Button variant="outline" type="button" onclick={() => (disconnectDialogOpen = false)}>Cancel</Button>
<Button variant="destructive" type="submit" disabled={isDisconnecting}>
{isDisconnecting ? 'Disconnecting...' : 'Disconnect'}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,108 @@
import { apiRequest } from '$lib/api-helpers.js';
import { redirect, fail } from '@sveltejs/kit';
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies, url }) {
let status;
try {
status = await apiRequest('/salesforce/status/', {}, { cookies });
} catch {
redirect(302, '/settings/salesforce');
}
if (!status?.connected) {
redirect(302, '/settings/salesforce');
}
let history = { jobs: [] };
try {
history = await apiRequest('/salesforce/import/history/', {}, { cookies });
} catch {
/* ignore history fetch failure */
}
const jobs = history.jobs || [];
// Determine active job: explicit ?job= param, or auto-detect from history
const jobIdParam = url.searchParams.get('job');
let activeJob = null;
if (jobIdParam) {
try {
const result = await apiRequest(`/salesforce/import/${jobIdParam}/`, {}, { cookies });
activeJob = result.job || result;
} catch {
/* job may have been deleted or is inaccessible */
}
}
// Auto-detect: if no explicit job param, find any running job from history
// Skip jobs that have been running for over 60 minutes (likely crashed workers)
if (!activeJob) {
const STALE_MS = 60 * 60 * 1000;
const now = Date.now();
const runningJob = jobs.find((/** @type {any} */ j) => {
if (j.status !== 'PENDING' && j.status !== 'IN_PROGRESS') return false;
const startedAt = j.started_at || j.created_at;
if (startedAt && now - new Date(startedAt).getTime() > STALE_MS) return false;
return true;
});
if (runningJob) {
activeJob = runningJob;
}
}
return {
sfStatus: status,
importHistory: jobs,
activeJob
};
}
/** @type {import('./$types').Actions} */
export const actions = {
startImport: async ({ request, cookies }) => {
const formData = await request.formData();
const objectTypesRaw = formData.get('object_types')?.toString();
if (!objectTypesRaw) {
return fail(400, { importError: 'Please select at least one object type.' });
}
const objectTypes = objectTypesRaw.split(',').filter(Boolean);
if (objectTypes.length === 0) {
return fail(400, { importError: 'Please select at least one object type.' });
}
try {
const result = await apiRequest(
'/salesforce/import/',
{ method: 'POST', body: { object_types: objectTypes } },
{ cookies }
);
const jobId = result.job?.id || result.id;
return { importStarted: true, jobId };
} catch (err) {
return fail(400, { importError: err?.message || 'Failed to start import.' });
}
},
cancelImport: async ({ request, cookies }) => {
const formData = await request.formData();
const jobId = formData.get('job_id')?.toString();
if (!jobId) {
return fail(400, { importError: 'No job ID provided.' });
}
try {
await apiRequest(
`/salesforce/import/${jobId}/cancel/`,
{ method: 'POST' },
{ cookies }
);
return { importCancelled: true };
} catch (err) {
return fail(400, { importError: err?.message || 'Failed to cancel import.' });
}
}
};

View file

@ -0,0 +1,471 @@
<script>
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Progress from '$lib/components/ui/progress/index.js';
import { PageHeader } from '$lib/components/layout';
import { Download, CheckCircle, XCircle, Clock, Loader2, ArrowLeft, ChevronDown, ChevronRight, AlertTriangle } from '@lucide/svelte';
import { formatRelativeDate } from '$lib/utils/formatting.js';
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
// Server data (reactive to load function)
let serverJob = $derived(data.activeJob);
let serverHistory = $derived(data.importHistory || []);
// Poll overrides (null = use server data)
/** @type {any} */
let polledJob = $state(null);
/** @type {any[] | null} */
let polledHistory = $state(null);
// Effective values: poll data wins when present
let activeJob = $derived(polledJob ?? serverJob);
let importHistory = $derived(polledHistory ?? serverHistory);
const OBJECT_TYPES = [
{ value: 'Account', label: 'Accounts', description: 'Companies and organizations', depends: [] },
{ value: 'Contact', label: 'Contacts', description: 'People associated with accounts', depends: ['Account'] },
{ value: 'Opportunity', label: 'Opportunities', description: 'Sales deals and pipeline', depends: ['Account'] },
{ value: 'Product2', label: 'Products', description: 'Product catalog', depends: [] },
{ value: 'Order', label: 'Orders', description: 'Sales orders', depends: ['Account'] },
{ value: 'Quote', label: 'Quotes', description: 'Price quotes and estimates', depends: ['Account', 'Contact'] }
];
/** @type {Set<string>} */
let selectedTypes = $state(new Set());
let isStarting = $state(false);
let isCancelling = $state(false);
/** @type {ReturnType<typeof setInterval> | null} */
let pollTimer = null;
let isImportActive = $derived(
activeJob && (activeJob.status === 'IN_PROGRESS' || activeJob.status === 'PENDING')
);
let overallProgress = $derived.by(() => {
if (!activeJob) return 0;
const total = activeJob.total_records || 0;
const done = (activeJob.imported_count || 0) + (activeJob.skipped_count || 0) + (activeJob.error_count || 0);
return total > 0 ? Math.round((done / total) * 100) : 0;
});
/** @type {Set<string>} */
let expandedJobs = $state(new Set());
function toggleJobExpand(/** @type {string} */ jobId) {
const s = new Set(expandedJobs);
s.has(jobId) ? s.delete(jobId) : s.add(jobId);
expandedJobs = s;
}
function getObjectLabel(/** @type {string} */ value) {
return OBJECT_TYPES.find((t) => t.value === value)?.label ?? value;
}
$effect(() => {
if (form?.importError) toast.error(form.importError);
if (form?.importCancelled) toast.success('Import cancelled');
});
// ── Polling ──────────────────────────────────────────────────────────
let pollInFlight = false;
async function pollJobStatus() {
if (pollInFlight) return;
pollInFlight = true;
const jobId = activeJob?.id;
if (!jobId) { pollInFlight = false; return; }
try {
const res = await fetch(`/api/salesforce-import-poll?job=${jobId}&_=${Date.now()}`);
if (!res.ok) return;
const result = await res.json();
if (result.job) {
polledJob = result.job;
}
if (result.jobs?.length) {
polledHistory = result.jobs;
}
const st = result.job?.status;
if (st && st !== 'IN_PROGRESS' && st !== 'PENDING') {
stopPolling();
}
} catch {
// ignore poll errors
} finally {
pollInFlight = false;
}
}
function startPolling() {
stopPolling();
pollJobStatus();
pollTimer = setInterval(pollJobStatus, 3000);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
$effect(() => {
if (isImportActive) {
startPolling();
} else {
stopPolling();
}
return () => stopPolling();
});
// ── Helpers ──────────────────────────────────────────────────────────
function toggleType(/** @type {string} */ value) {
const s = new Set(selectedTypes);
s.has(value) ? s.delete(value) : s.add(value);
selectedTypes = s;
}
function isSelected(/** @type {string} */ value) {
return selectedTypes.has(value);
}
function getStatusBadgeClass(/** @type {string} */ s) {
const map = /** @type {Record<string, string>} */ ({
COMPLETED: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400 border-emerald-200 dark:border-emerald-800',
FAILED: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400 border-red-200 dark:border-red-800',
CANCELLED: 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400 border-amber-200 dark:border-amber-800',
IN_PROGRESS: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400 border-blue-200 dark:border-blue-800',
PENDING: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400 border-gray-200 dark:border-gray-700',
});
return map[s] ?? map['PENDING'];
}
function getStatusIcon(/** @type {string} */ s) {
return /** @type {Record<string, any>} */ ({ COMPLETED: CheckCircle, FAILED: XCircle, IN_PROGRESS: Loader2, PENDING: Clock })[s] ?? Clock;
}
</script>
<svelte:head>
<title>Salesforce Import - BottleCRM</title>
</svelte:head>
<PageHeader title="Import from Salesforce" subtitle="Select data to import">
{#snippet actions()}
<Button variant="outline" href="/settings/salesforce" class="gap-2">
<ArrowLeft class="size-4" />
Back to Connection
</Button>
{/snippet}
</PageHeader>
<div class="mx-auto max-w-4xl space-y-6 p-6 md:p-8">
<!-- Active Import Progress -->
{#if activeJob && isImportActive}
<Card.Root class="border-blue-200 dark:border-blue-800">
<Card.Header>
<div class="flex items-center gap-3">
<Loader2 class="size-5 animate-spin text-blue-600 dark:text-blue-400" />
<div>
<Card.Title>Import In Progress</Card.Title>
<Card.Description>Your Salesforce data is being imported...</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content>
<div class="space-y-4">
<div>
<div class="mb-2 flex items-center justify-between text-sm">
<span class="text-muted-foreground">Overall Progress</span>
<span class="text-foreground font-medium">{overallProgress}%</span>
</div>
<Progress.Root value={overallProgress} max={100} class="h-2" />
</div>
{#if activeJob.progress_detail && Object.keys(activeJob.progress_detail).length > 0}
<div class="space-y-3">
{#each Object.entries(activeJob.progress_detail) as [objType, detail]}
{@const imported = detail.imported || 0}
{@const skipped = detail.skipped || 0}
{@const errors = detail.errors || 0}
{@const total = imported + skipped + errors}
{@const pct = total > 0 ? 100 : 0}
{@const rawStatus = detail.status || 'importing'}
{@const objStatus = rawStatus === 'done' ? 'COMPLETED' : rawStatus === 'importing' ? 'IN_PROGRESS' : rawStatus === 'error' ? 'FAILED' : 'PENDING'}
{@const StatusIcon = getStatusIcon(objStatus)}
<div class="bg-muted/50 rounded-lg p-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<StatusIcon
class="size-4 {objStatus === 'IN_PROGRESS'
? 'animate-spin text-blue-600 dark:text-blue-400'
: objStatus === 'COMPLETED'
? 'text-emerald-600 dark:text-emerald-400'
: objStatus === 'FAILED'
? 'text-red-600 dark:text-red-400'
: 'text-muted-foreground'}"
/>
<span class="text-foreground text-sm font-medium">{getObjectLabel(objType)}</span>
<Badge class={getStatusBadgeClass(objStatus)}>
{objStatus}
</Badge>
</div>
</div>
{#if total > 0}
<div class="mt-2">
<Progress.Root value={pct} max={100} class="h-1" />
</div>
<div class="mt-1 flex gap-4 text-xs">
<span class="text-emerald-600 dark:text-emerald-400">{imported} imported</span>
<span class="text-amber-600 dark:text-amber-400">{skipped} skipped</span>
<span class="text-red-600 dark:text-red-400">{errors} errors</span>
</div>
{/if}
</div>
{/each}
</div>
{/if}
<Separator />
<form method="POST" action="?/cancelImport" use:enhance={() => {
isCancelling = true;
return async ({ update }) => {
isCancelling = false;
await update();
};
}}>
<input type="hidden" name="job_id" value={activeJob.id} />
<Button type="submit" variant="outline" disabled={isCancelling} class="text-destructive hover:bg-destructive/10 gap-2">
<XCircle class="size-4" />
{isCancelling ? 'Cancelling...' : 'Cancel Import'}
</Button>
<span class="text-muted-foreground ml-2 text-xs">Already-imported data will be kept.</span>
</form>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Completion Summary -->
{#if activeJob && !isImportActive}
<Card.Root class={activeJob.status === 'COMPLETED' ? 'border-emerald-200 dark:border-emerald-800' : 'border-red-200 dark:border-red-800'}>
<Card.Header>
<div class="flex items-center gap-3">
{#if activeJob.status === 'COMPLETED'}
<CheckCircle class="size-5 text-emerald-600 dark:text-emerald-400" />
{:else}
<XCircle class="size-5 text-red-600 dark:text-red-400" />
{/if}
<div>
<Card.Title>Import {activeJob.status === 'COMPLETED' ? 'Completed' : 'Failed'}</Card.Title>
<Card.Description>
{#if activeJob.status === 'COMPLETED'}
Your Salesforce data has been imported successfully.
{:else}
The import encountered errors. Check the details below.
{/if}
</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content>
<div class="flex items-center justify-between text-sm">
<span class="text-foreground font-medium">Total</span>
<div class="flex gap-4 text-xs">
<span class="text-emerald-600 dark:text-emerald-400">{activeJob.imported_count || 0} imported</span>
<span class="text-amber-600 dark:text-amber-400">{activeJob.skipped_count || 0} skipped</span>
<span class="text-red-600 dark:text-red-400">{activeJob.error_count || 0} errors</span>
</div>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Object Type Selection -->
{#if !isImportActive}
<Card.Root>
<Card.Header>
<Card.Title>Select Data to Import</Card.Title>
<Card.Description>Choose which Salesforce objects you want to import. Dependencies will be resolved automatically.</Card.Description>
</Card.Header>
<Card.Content>
<div class="space-y-3">
{#each OBJECT_TYPES as objType}
<button
type="button"
onclick={() => toggleType(objType.value)}
class="border-border hover:bg-muted/50 flex w-full items-start gap-3 rounded-lg border p-4 text-left transition-colors
{isSelected(objType.value) ? 'border-blue-300 bg-blue-50/50 dark:border-blue-700 dark:bg-blue-950/30' : ''}"
>
<div class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border transition-colors
{isSelected(objType.value) ? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500' : 'border-muted-foreground/30'}">
{#if isSelected(objType.value)}
<svg class="size-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{/if}
</div>
<div class="flex-1">
<div class="text-foreground text-sm font-medium">{objType.label}</div>
<div class="text-muted-foreground text-xs">{objType.description}</div>
{#if objType.depends.length > 0}
<div class="text-muted-foreground/70 mt-1 text-xs">Depends on: {objType.depends.join(', ')}</div>
{/if}
</div>
</button>
{/each}
</div>
<div class="mt-6 flex items-center justify-between">
<p class="text-muted-foreground text-sm">
{selectedTypes.size} object{selectedTypes.size !== 1 ? 's' : ''} selected
</p>
<form method="POST" action="?/startImport" use:enhance={() => {
isStarting = true;
return async ({ result, update }) => {
isStarting = false;
if (result.type === 'success' && result.data?.jobId) {
polledJob = null;
polledHistory = null;
toast.success('Import started');
await goto(`?job=${result.data.jobId}`, { invalidateAll: true });
} else {
await update();
}
};
}}>
<input type="hidden" name="object_types" value={Array.from(selectedTypes).join(',')} />
<Button type="submit" disabled={selectedTypes.size === 0 || isStarting}
class="gap-2 border-0 bg-[var(--color-primary-default)] text-white hover:bg-[var(--color-primary-dark)]">
<Download class="size-4" />
{isStarting ? 'Starting...' : 'Start Import'}
</Button>
</form>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Import History -->
{#if importHistory.length > 0}
<Card.Root>
<Card.Header>
<Card.Title>Import History</Card.Title>
<Card.Description>Click a row to see details</Card.Description>
</Card.Header>
<Card.Content class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-8"></Table.Head>
<Table.Head>Date</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Objects</Table.Head>
<Table.Head class="text-right">Imported</Table.Head>
<Table.Head class="text-right">Skipped</Table.Head>
<Table.Head class="text-right">Errors</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each importHistory as job}
{@const isExpanded = expandedJobs.has(job.id)}
<Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => toggleJobExpand(job.id)}>
<Table.Cell class="w-8 pr-0">
{#if isExpanded}
<ChevronDown class="text-muted-foreground size-4" />
{:else}
<ChevronRight class="text-muted-foreground size-4" />
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{job.created_at ? formatRelativeDate(job.created_at) : 'Unknown'}
</Table.Cell>
<Table.Cell><Badge class={getStatusBadgeClass(job.status)}>{job.status}</Badge></Table.Cell>
<Table.Cell class="text-muted-foreground max-w-[200px] truncate text-sm">
{(job.object_types || []).join(', ') || 'None'}
</Table.Cell>
<Table.Cell class="text-right text-sm text-emerald-600 dark:text-emerald-400">{job.imported_count || 0}</Table.Cell>
<Table.Cell class="text-right text-sm text-amber-600 dark:text-amber-400">{job.skipped_count || 0}</Table.Cell>
<Table.Cell class="text-right text-sm text-red-600 dark:text-red-400">{job.error_count || 0}</Table.Cell>
</Table.Row>
{#if isExpanded}
{@const errors = (job.error_log || []).filter((/** @type {any} */ e) => e.level !== 'skip')}
{@const skips = (job.error_log || []).filter((/** @type {any} */ e) => e.level === 'skip')}
<Table.Row class="hover:bg-transparent">
<Table.Cell colspan={7} class="bg-muted/30 p-0">
<div class="space-y-4 p-4">
{#if errors.length > 0}
<div>
<h4 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-red-600 dark:text-red-400">
<AlertTriangle class="size-3.5" />
{errors.length} {errors.length === 1 ? 'Error' : 'Errors'}
</h4>
<div class="max-h-60 space-y-1 overflow-y-auto">
{#each errors as err}
<div class="border-border flex items-start gap-2 rounded border bg-white px-3 py-2 dark:bg-gray-900/50">
<XCircle class="mt-0.5 size-3.5 shrink-0 text-red-500" />
<div class="min-w-0 flex-1 text-xs">
<span class="text-muted-foreground">{getObjectLabel(err.object_type)}</span>
{#if err.salesforce_id}
<span class="text-muted-foreground/60 ml-1">({err.salesforce_id})</span>
{/if}
<p class="text-foreground mt-0.5 break-words">{err.error}</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if skips.length > 0}
<div>
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
{skips.length} {skips.length === 1 ? 'Record' : 'Records'} Skipped
</h4>
<div class="max-h-40 space-y-1 overflow-y-auto">
{#each skips as skip}
<div class="border-border flex items-start gap-2 rounded border bg-white px-3 py-2 dark:bg-gray-900/50">
<Clock class="mt-0.5 size-3.5 shrink-0 text-amber-500" />
<div class="min-w-0 flex-1 text-xs">
<span class="text-muted-foreground">{getObjectLabel(skip.object_type)}</span>
{#if skip.salesforce_id}
<span class="text-muted-foreground/60 ml-1">({skip.salesforce_id})</span>
{/if}
<span class="text-foreground ml-1">{skip.error}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if errors.length === 0 && skips.length === 0}
<div class="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400">
<CheckCircle class="size-4" />
All records imported successfully.
</div>
{/if}
</div>
</Table.Cell>
</Table.Row>
{/if}
{/each}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
{/if}
</div>

View file

@ -0,0 +1,23 @@
import { apiRequest } from '$lib/api-helpers.js';
import { json } from '@sveltejs/kit';
/** @type {import('./$types').RequestHandler} */
export async function GET({ url, cookies }) {
const jobId = url.searchParams.get('job');
try {
const [jobResult, historyResult] = await Promise.all([
jobId
? apiRequest(`/salesforce/import/${jobId}/`, {}, { cookies })
: Promise.resolve(null),
apiRequest('/salesforce/import/history/', {}, { cookies })
]);
return json({
job: jobResult?.job || jobResult || null,
jobs: historyResult?.jobs || []
});
} catch {
return json({ job: null, jobs: [] }, { status: 500 });
}
}