feat: implement orders application and Salesforce import functionality.
This commit is contained in:
parent
66774700b3
commit
12d9e9ef41
18 changed files with 2707 additions and 1 deletions
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -70,6 +70,9 @@ ORG_SCOPED_TABLES = [
|
|||
"recurring_invoice_line_item",
|
||||
# Products
|
||||
"product",
|
||||
# Orders
|
||||
"orders",
|
||||
"order_line_item",
|
||||
# Security & Audit
|
||||
"security_audit_log",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
|||
"opportunity",
|
||||
"tasks",
|
||||
"invoices",
|
||||
"orders",
|
||||
# "teams", # Merged into common app
|
||||
]
|
||||
|
||||
|
|
|
|||
0
backend/orders/__init__.py
Normal file
0
backend/orders/__init__.py
Normal file
149
backend/orders/admin.py
Normal file
149
backend/orders/admin.py
Normal 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
6
backend/orders/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OrdersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.AutoField"
|
||||
name = "orders"
|
||||
924
backend/orders/migrations/0001_initial.py
Normal file
924
backend/orders/migrations/0001_initial.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
0
backend/orders/migrations/__init__.py
Normal file
0
backend/orders/migrations/__init__.py
Normal file
182
backend/orders/models.py
Normal file
182
backend/orders/models.py
Normal 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)
|
||||
0
backend/orders/tests/__init__.py
Normal file
0
backend/orders/tests/__init__.py
Normal file
335
backend/orders/tests/test_models.py
Normal file
335
backend/orders/tests/test_models.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
318
frontend/src/routes/(app)/settings/salesforce/+page.svelte
Normal file
318
frontend/src/routes/(app)/settings/salesforce/+page.svelte
Normal 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> → <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 → 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>
|
||||
|
|
@ -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.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
23
frontend/src/routes/api/salesforce-import-poll/+server.js
Normal file
23
frontend/src/routes/api/salesforce-import-poll/+server.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue