Merge pull request #672 from MicroPyramid/sf_import

Sf import
This commit is contained in:
Ashwin Kumar 2026-03-18 20:36:37 +05:30 committed by GitHub
commit 24ccf66546
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1398 additions and 590 deletions

View file

@ -332,8 +332,8 @@ class CreateProfileSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["alternate_phone"].required = False
self.fields["phone"].required = False
self.fields["role"].required = True
self.fields["phone"].required = True
class UserSerializer(serializers.ModelSerializer):
@ -570,14 +570,20 @@ class UserCreateSwaggerSerializer(serializers.Serializer):
email = serializers.CharField(max_length=1000, required=True)
role = serializers.ChoiceField(choices=ROLE_CHOICES, required=True)
phone = serializers.CharField(max_length=12)
alternate_phone = serializers.CharField(max_length=12)
address_line = serializers.CharField(max_length=10000, required=True)
street = serializers.CharField(max_length=1000)
city = serializers.CharField(max_length=1000)
state = serializers.CharField(max_length=1000)
pincode = serializers.CharField(max_length=1000)
country = serializers.CharField(max_length=1000)
phone = serializers.CharField(
max_length=12, required=False, allow_blank=True, allow_null=True
)
alternate_phone = serializers.CharField(
max_length=12, required=False, allow_blank=True, allow_null=True
)
address_line = serializers.CharField(
max_length=10000, required=False, allow_blank=True
)
street = serializers.CharField(max_length=1000, required=False, allow_blank=True)
city = serializers.CharField(max_length=1000, required=False, allow_blank=True)
state = serializers.CharField(max_length=1000, required=False, allow_blank=True)
pincode = serializers.CharField(max_length=1000, required=False, allow_blank=True)
country = serializers.CharField(max_length=1000, required=False, allow_blank=True)
class UserUpdateStatusSwaggerSerializer(serializers.Serializer):

View file

@ -125,6 +125,21 @@ class TestUsersListView:
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_create_user_minimal_payload(self, admin_client, org_a):
"""Admin can create a user with just email and role."""
response = admin_client.post(
self.url,
{
"email": "new-member@test.com",
"role": "USER",
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert Profile.objects.filter(
user__email="new-member@test.com", org=org_a
).exists()
@pytest.mark.django_db
class TestUserDetailView:

View file

@ -1,3 +1,5 @@
from decimal import Decimal
from rest_framework import serializers
from accounts.models import Account
@ -209,6 +211,42 @@ class InvoiceLineItemCreateSerializer(serializers.ModelSerializer):
)
class EstimateLineItemCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating/updating Estimate Line Items"""
class Meta:
model = EstimateLineItem
fields = (
"product",
"name",
"description",
"quantity",
"unit_price",
"discount_type",
"discount_value",
"tax_rate",
"order",
)
class RecurringInvoiceLineItemCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating/updating Recurring Invoice Line Items"""
class Meta:
model = RecurringInvoiceLineItem
fields = (
"product",
"name",
"description",
"quantity",
"unit_price",
"discount_type",
"discount_value",
"tax_rate",
"order",
)
# =============================================================================
# PAYMENT SERIALIZERS
# =============================================================================
@ -681,6 +719,7 @@ class EstimateCreateSerializer(serializers.ModelSerializer):
opportunity_id = serializers.UUIDField(
write_only=True, required=False, allow_null=True
)
line_items = EstimateLineItemCreateSerializer(many=True, required=False)
class Meta:
model = Estimate
@ -707,6 +746,7 @@ class EstimateCreateSerializer(serializers.ModelSerializer):
"public_link_enabled",
"notes",
"terms",
"line_items",
)
def __init__(self, *args, **kwargs):
@ -748,9 +788,56 @@ class EstimateCreateSerializer(serializers.ModelSerializer):
)
return value
def validate(self, attrs):
"""Cross-field validation"""
account_id = attrs.get("account_id")
contact_id = attrs.get("contact_id")
if account_id and contact_id and self.org:
contact = Contact.objects.filter(id=contact_id, org=self.org).first()
if contact and contact.account_id and contact.account_id != account_id:
raise serializers.ValidationError(
{"contact_id": "Contact does not belong to the selected account"}
)
return attrs
def create(self, validated_data):
line_items_data = validated_data.pop("line_items", [])
validated_data["org"] = self.org
return super().create(validated_data)
estimate = Estimate.objects.create(**validated_data)
for idx, item_data in enumerate(line_items_data):
EstimateLineItem.objects.create(
estimate=estimate,
org=self.org,
order=item_data.get("order", idx),
**{k: v for k, v in item_data.items() if k != "order"},
)
estimate.recalculate_totals()
estimate.save()
return estimate
def update(self, instance, validated_data):
line_items_data = validated_data.pop("line_items", None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
if line_items_data is not None:
instance.line_items.all().delete()
for idx, item_data in enumerate(line_items_data):
EstimateLineItem.objects.create(
estimate=instance,
org=self.org or instance.org,
order=item_data.get("order", idx),
**{k: v for k, v in item_data.items() if k != "order"},
)
instance.recalculate_totals()
instance.save()
return instance
# =============================================================================
@ -831,6 +918,7 @@ class RecurringInvoiceCreateSerializer(serializers.ModelSerializer):
opportunity_id = serializers.UUIDField(
write_only=True, required=False, allow_null=True
)
line_items = RecurringInvoiceLineItemCreateSerializer(many=True, required=False)
class Meta:
model = RecurringInvoice
@ -855,6 +943,7 @@ class RecurringInvoiceCreateSerializer(serializers.ModelSerializer):
"tax_rate",
"notes",
"terms",
"line_items",
)
def __init__(self, *args, **kwargs):
@ -896,9 +985,73 @@ class RecurringInvoiceCreateSerializer(serializers.ModelSerializer):
)
return value
def validate(self, attrs):
"""Cross-field validation"""
account_id = attrs.get("account_id")
contact_id = attrs.get("contact_id")
if account_id and contact_id and self.org:
contact = Contact.objects.filter(id=contact_id, org=self.org).first()
if contact and contact.account_id and contact.account_id != account_id:
raise serializers.ValidationError(
{"contact_id": "Contact does not belong to the selected account"}
)
return attrs
def _recalculate_totals(self, instance):
subtotal = sum(
(item.quantity * item.unit_price for item in instance.line_items.all()),
Decimal("0"),
)
instance.subtotal = subtotal
if instance.discount_type == "PERCENTAGE":
discount_amount = subtotal * (instance.discount_value / Decimal("100"))
else:
discount_amount = instance.discount_value
taxable = subtotal - discount_amount
instance.total_amount = taxable + (
taxable * (instance.tax_rate / Decimal("100"))
)
def create(self, validated_data):
line_items_data = validated_data.pop("line_items", [])
validated_data["org"] = self.org
return super().create(validated_data)
recurring = RecurringInvoice.objects.create(**validated_data)
for idx, item_data in enumerate(line_items_data):
RecurringInvoiceLineItem.objects.create(
recurring_invoice=recurring,
org=self.org,
order=item_data.get("order", idx),
**{k: v for k, v in item_data.items() if k != "order"},
)
self._recalculate_totals(recurring)
recurring.save()
return recurring
def update(self, instance, validated_data):
line_items_data = validated_data.pop("line_items", None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
if line_items_data is not None:
instance.line_items.all().delete()
for idx, item_data in enumerate(line_items_data):
RecurringInvoiceLineItem.objects.create(
recurring_invoice=instance,
org=self.org or instance.org,
order=item_data.get("order", idx),
**{k: v for k, v in item_data.items() if k != "order"},
)
self._recalculate_totals(instance)
instance.save()
return instance
# =============================================================================

View file

@ -1145,6 +1145,37 @@ class TestEstimateListView:
assert data["error"] is False
assert data["estimate"]["title"] == "New Estimate"
def test_create_estimate_with_line_items(
self, admin_client, account_for_invoice, contact_for_invoice
):
response = admin_client.post(
"/api/invoices/estimates/",
{
"title": "Estimate With Items",
"account_id": str(account_for_invoice.id),
"contact_id": str(contact_for_invoice.id),
"client_name": "New Client",
"client_email": "new@example.com",
"currency": "USD",
"tax_rate": "10.00",
"line_items": [
{
"name": "Implementation",
"quantity": "2.00",
"unit_price": "100.00",
"order": 0,
}
],
},
format="json",
)
assert response.status_code == 201
data = response.json()
created = Estimate.objects.get(id=data["estimate"]["id"])
assert created.line_items.count() == 1
assert created.subtotal == Decimal("200.00")
assert created.total_amount == Decimal("220.00")
def test_create_estimate_invalid_account(
self, admin_client, contact_for_invoice
):
@ -1417,6 +1448,40 @@ class TestRecurringInvoiceListView:
assert data["error"] is False
assert data["recurring_invoice"]["title"] == "New Recurring"
def test_create_recurring_with_line_items(
self, admin_client, account_for_invoice, contact_for_invoice
):
response = admin_client.post(
"/api/invoices/recurring/",
{
"title": "Recurring With Items",
"account_id": str(account_for_invoice.id),
"contact_id": str(contact_for_invoice.id),
"client_name": "Client",
"client_email": "client@example.com",
"frequency": "WEEKLY",
"start_date": "2026-03-01",
"next_generation_date": "2026-03-01",
"currency": "USD",
"tax_rate": "10.00",
"line_items": [
{
"name": "Hosting",
"quantity": "2.00",
"unit_price": "50.00",
"order": 0,
}
],
},
format="json",
)
assert response.status_code == 201
data = response.json()
created = RecurringInvoice.objects.get(id=data["recurring_invoice"]["id"])
assert created.line_items.count() == 1
assert created.subtotal == Decimal("100.00")
assert created.total_amount == Decimal("110.00")
def test_create_recurring_invalid_account(
self, admin_client, contact_for_invoice
):

View file

@ -14,28 +14,28 @@
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^2.0.2",
"@eslint/compat": "^2.0.3",
"@eslint/js": "^10.0.1",
"@internationalized/date": "^3.11.0",
"@lucide/svelte": "^0.575.0",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^0.577.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@sveltejs/kit": "^2.55.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^25.3.3",
"bits-ui": "^2.16.2",
"eslint": "^10.0.2",
"@types/node": "^25.5.0",
"bits-ui": "^2.16.3",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.15.0",
"eslint-plugin-svelte": "^3.15.2",
"globals": "^17.4.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.0",
"prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.53.6",
"svelte-check": "^4.4.4",
"svelte-sonner": "^1.0.7",
"svelte": "^5.54.0",
"svelte-check": "^4.4.5",
"svelte-sonner": "^1.1.0",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
@ -49,10 +49,10 @@
}
},
"dependencies": {
"@sentry/sveltekit": "^10.40.0",
"@sentry/sveltekit": "^10.44.0",
"axios": "^1.13.6",
"clsx": "^2.1.1",
"libphonenumber-js": "^1.12.38",
"libphonenumber-js": "^1.12.40",
"svelte-dnd-action": "^0.9.69",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",

File diff suppressed because it is too large Load diff

View file

@ -6,8 +6,10 @@
/**
* @typedef {Object} SelectOption
* @property {string} id
* @property {string} name
* @property {string} [id]
* @property {string} [value]
* @property {string} [name]
* @property {string} [label]
* @property {string} [email]
*/
@ -56,10 +58,30 @@
onchange?.(newValue);
}
/**
* Normalize option data so shared drawers can pass either `{ id, name }`
* or `{ value, label }` without crashing keyed each blocks.
* @returns {Array<SelectOption & { id: string, name: string }>}
*/
const normalizedOptions = $derived(
options
.map((opt) => {
const id = opt?.id ?? opt?.value;
if (!id) return null;
return {
...opt,
id,
name: opt?.name ?? opt?.label ?? opt?.email ?? id
};
})
.filter(Boolean)
);
/**
* Get selected options
*/
const selectedOptions = $derived(options.filter((opt) => value.includes(opt.id)));
const selectedOptions = $derived(normalizedOptions.filter((opt) => value.includes(opt.id)));
/**
* Get display options (limited to maxDisplay)
@ -117,11 +139,11 @@
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content class="max-h-64 w-64 overflow-y-auto" align="start">
{#if options.length === 0}
{#if normalizedOptions.length === 0}
<div class="text-muted-foreground px-2 py-4 text-center text-sm">No options available</div>
{:else}
<DropdownMenu.CheckboxGroup value={value}>
{#each options as opt (opt.id)}
{#each normalizedOptions as opt (opt.id)}
<DropdownMenu.CheckboxItem
checked={value.includes(opt.id)}
onCheckedChange={() => toggleOption(opt.id)}

View file

@ -269,9 +269,9 @@ export const actions = {
client_postcode: form.get('clientPostcode')?.toString() || '',
client_country: form.get('clientCountry')?.toString() || '',
// Relationships
account: form.get('accountId')?.toString() || null,
contact: form.get('contactId')?.toString() || null,
opportunity: form.get('opportunityId')?.toString() || null
account_id: form.get('accountId')?.toString() || null,
contact_id: form.get('contactId')?.toString() || null,
opportunity_id: form.get('opportunityId')?.toString() || null
};
// Parse line items JSON
@ -341,11 +341,25 @@ export const actions = {
client_postcode: form.get('clientPostcode')?.toString() || '',
client_country: form.get('clientCountry')?.toString() || '',
// Relationships
account: form.get('accountId')?.toString() || null,
contact: form.get('contactId')?.toString() || null,
opportunity: form.get('opportunityId')?.toString() || null
account_id: form.get('accountId')?.toString() || null,
contact_id: form.get('contactId')?.toString() || null,
opportunity_id: form.get('opportunityId')?.toString() || null
};
// Parse line items JSON
const lineItemsJson = form.get('lineItems')?.toString() || '[]';
const lineItems = JSON.parse(lineItemsJson);
estimateData.line_items = lineItems.map((item, idx) => ({
name: item.name || item.description || '',
description: item.description || '',
quantity: item.quantity || 1,
unit_price: item.unitPrice || '0',
discount_type: item.discountType || 'fixed',
discount_value: item.discountValue || '0',
tax_rate: item.taxRate || '0',
order: item.order || idx
}));
await apiRequest(
`/invoices/estimates/${estimateId}/`,
{

View file

@ -1,4 +1,5 @@
<script>
import { Plus, Trash2 } from '@lucide/svelte';
import { page } from '$app/stores';
import { goto, invalidateAll } from '$app/navigation';
import { enhance } from '$app/forms';
@ -246,8 +247,19 @@
expiryDate: '',
accountId: '',
contactId: '',
opportunityId: '',
currency: 'USD',
discountType: 'fixed',
discountValue: '0',
taxRate: '0',
clientAddressLine: '',
clientCity: '',
clientState: '',
clientPostcode: '',
clientCountry: '',
notes: '',
terms: ''
terms: '',
lineItems: '[]'
});
// Derived values
@ -255,6 +267,12 @@
const pagination = $derived(data.pagination);
const allEstimates = $derived(data.estimates);
const template = $derived(data.template);
const lineItemsSubtotal = $derived.by(() =>
(drawerFormData.lineItems || []).reduce(
(sum, item) => sum + getLineItemAmount(item),
0
)
);
// Filter estimates by chip selection (client-side filtering)
const estimates = $derived.by(() => {
@ -358,10 +376,19 @@
clientPhone: '',
issueDate: new Date().toISOString().split('T')[0],
expiryDate: '',
currency: 'USD',
discountType: 'fixed',
discountValue: '0',
taxRate: '0',
clientAddressLine: '',
clientCity: '',
clientState: '',
clientPostcode: '',
clientCountry: '',
// Pre-populate notes/terms from default template
notes: template?.defaultNotes || '',
terms: template?.defaultTerms || '',
lineItems: []
lineItems: [createEstimateLineItem()]
};
drawerOpen = true;
}
@ -379,7 +406,13 @@
if (drawerMode === 'create') {
// Already set in openCreateDrawer
} else if (selectedEstimate) {
drawerFormData = { ...selectedEstimate };
drawerFormData = {
...selectedEstimate,
lineItems:
selectedEstimate.lineItems?.length > 0
? selectedEstimate.lineItems.map((item) => ({ ...item }))
: [createEstimateLineItem()]
};
}
}
});
@ -389,6 +422,39 @@
drawerFormData[field] = value;
}
function createEstimateLineItem() {
return {
id: crypto.randomUUID(),
name: '',
description: '',
quantity: 1,
unitPrice: '0',
discountType: 'fixed',
discountValue: '0',
taxRate: '0'
};
}
function getLineItemAmount(item) {
const quantity = Number(item?.quantity || 0);
const unitPrice = Number(item?.unitPrice || 0);
return quantity * unitPrice;
}
function addLineItem() {
drawerFormData.lineItems = [...(drawerFormData.lineItems || []), createEstimateLineItem()];
}
function removeLineItem(index) {
const existingItems = drawerFormData.lineItems || [];
if (existingItems.length === 1) {
drawerFormData.lineItems = [createEstimateLineItem()];
return;
}
drawerFormData.lineItems = existingItems.filter((_, itemIndex) => itemIndex !== index);
}
// Save handler
async function handleDrawerSave() {
if (drawerMode === 'create') {
@ -400,8 +466,25 @@
formState.clientPhone = drawerFormData.clientPhone;
formState.issueDate = drawerFormData.issueDate;
formState.expiryDate = drawerFormData.expiryDate;
formState.accountId = drawerFormData.account?.id?.toString() || '';
formState.contactId = drawerFormData.contact?.id?.toString() || '';
formState.opportunityId = drawerFormData.opportunity?.id?.toString() || '';
formState.currency = drawerFormData.currency || 'USD';
formState.discountType = drawerFormData.discountType || 'fixed';
formState.discountValue = drawerFormData.discountValue?.toString() || '0';
formState.taxRate = drawerFormData.taxRate?.toString() || '0';
formState.clientAddressLine = drawerFormData.clientAddressLine || '';
formState.clientCity = drawerFormData.clientCity || '';
formState.clientState = drawerFormData.clientState || '';
formState.clientPostcode = drawerFormData.clientPostcode || '';
formState.clientCountry = drawerFormData.clientCountry || '';
formState.notes = drawerFormData.notes;
formState.terms = drawerFormData.terms;
formState.lineItems = JSON.stringify(
(drawerFormData.lineItems || []).filter(
(item) => item.name || item.description || Number(item.unitPrice) > 0
)
);
await tick();
createForm.requestSubmit();
@ -415,8 +498,25 @@
formState.clientPhone = drawerFormData.clientPhone;
formState.issueDate = drawerFormData.issueDate;
formState.expiryDate = drawerFormData.expiryDate;
formState.accountId = drawerFormData.account?.id?.toString() || '';
formState.contactId = drawerFormData.contact?.id?.toString() || '';
formState.opportunityId = drawerFormData.opportunity?.id?.toString() || '';
formState.currency = drawerFormData.currency || 'USD';
formState.discountType = drawerFormData.discountType || 'fixed';
formState.discountValue = drawerFormData.discountValue?.toString() || '0';
formState.taxRate = drawerFormData.taxRate?.toString() || '0';
formState.clientAddressLine = drawerFormData.clientAddressLine || '';
formState.clientCity = drawerFormData.clientCity || '';
formState.clientState = drawerFormData.clientState || '';
formState.clientPostcode = drawerFormData.clientPostcode || '';
formState.clientCountry = drawerFormData.clientCountry || '';
formState.notes = drawerFormData.notes;
formState.terms = drawerFormData.terms;
formState.lineItems = JSON.stringify(
(drawerFormData.lineItems || []).filter(
(item) => item.name || item.description || Number(item.unitPrice) > 0
)
);
await tick();
updateForm.requestSubmit();
@ -555,10 +655,21 @@
<input type="hidden" name="clientPhone" value={formState.clientPhone} />
<input type="hidden" name="issueDate" value={formState.issueDate} />
<input type="hidden" name="expiryDate" value={formState.expiryDate} />
<input type="hidden" name="currency" value={formState.currency} />
<input type="hidden" name="discountType" value={formState.discountType} />
<input type="hidden" name="discountValue" value={formState.discountValue} />
<input type="hidden" name="taxRate" value={formState.taxRate} />
<input type="hidden" name="clientAddressLine" value={formState.clientAddressLine} />
<input type="hidden" name="clientCity" value={formState.clientCity} />
<input type="hidden" name="clientState" value={formState.clientState} />
<input type="hidden" name="clientPostcode" value={formState.clientPostcode} />
<input type="hidden" name="clientCountry" value={formState.clientCountry} />
<input type="hidden" name="notes" value={formState.notes} />
<input type="hidden" name="terms" value={formState.terms} />
<input type="hidden" name="accountId" value={formState.accountId} />
<input type="hidden" name="contactId" value={formState.contactId} />
<input type="hidden" name="opportunityId" value={formState.opportunityId} />
<input type="hidden" name="lineItems" value={formState.lineItems} />
</form>
<form
@ -586,10 +697,21 @@
<input type="hidden" name="clientPhone" value={formState.clientPhone} />
<input type="hidden" name="issueDate" value={formState.issueDate} />
<input type="hidden" name="expiryDate" value={formState.expiryDate} />
<input type="hidden" name="currency" value={formState.currency} />
<input type="hidden" name="discountType" value={formState.discountType} />
<input type="hidden" name="discountValue" value={formState.discountValue} />
<input type="hidden" name="taxRate" value={formState.taxRate} />
<input type="hidden" name="clientAddressLine" value={formState.clientAddressLine} />
<input type="hidden" name="clientCity" value={formState.clientCity} />
<input type="hidden" name="clientState" value={formState.clientState} />
<input type="hidden" name="clientPostcode" value={formState.clientPostcode} />
<input type="hidden" name="clientCountry" value={formState.clientCountry} />
<input type="hidden" name="notes" value={formState.notes} />
<input type="hidden" name="terms" value={formState.terms} />
<input type="hidden" name="accountId" value={formState.accountId} />
<input type="hidden" name="contactId" value={formState.contactId} />
<input type="hidden" name="opportunityId" value={formState.opportunityId} />
<input type="hidden" name="lineItems" value={formState.lineItems} />
</form>
<form
@ -1032,51 +1154,127 @@
{/snippet}
{#snippet activitySection()}
{#if selectedEstimate}
<div class="space-y-6">
<div class="border-t pt-4">
<h4 class="text-muted-foreground mb-2 text-sm font-medium">Activity</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Created</span>
<span>{formatDate(selectedEstimate.createdAt)}</span>
<div class="mb-3 flex items-center justify-between">
<h4 class="text-muted-foreground text-sm font-medium">Line Items</h4>
<Button variant="ghost" size="sm" class="text-primary" onclick={addLineItem}>
<Plus class="mr-2 h-4 w-4" />
Add Line Item
</Button>
</div>
<div class="overflow-hidden rounded-md border">
<div
class="bg-muted/50 text-muted-foreground grid grid-cols-12 gap-3 border-b px-4 py-3 text-xs font-medium uppercase tracking-wide"
>
<div class="col-span-6">Description</div>
<div class="col-span-2 text-right">Qty</div>
<div class="col-span-2 text-right">Rate</div>
<div class="col-span-1 text-right">Total</div>
<div class="col-span-1"></div>
</div>
{#if selectedEstimate.sentAt}
<div class="flex justify-between">
<span class="text-muted-foreground">Sent</span>
<span>{formatDate(selectedEstimate.sentAt)}</span>
{#each drawerFormData.lineItems || [] as item, index (item.id || `${index}`)}
<div class="grid grid-cols-12 items-start gap-3 border-b px-4 py-3 last:border-b-0">
<div class="col-span-6 space-y-2">
<input
bind:value={item.name}
class="bg-background w-full rounded-md border px-3 py-2 text-sm"
placeholder="Item name"
/>
<input
bind:value={item.description}
class="bg-background w-full rounded-md border px-3 py-2 text-sm"
placeholder="Description"
/>
</div>
<div class="col-span-2">
<input
type="number"
bind:value={item.quantity}
min="0"
step="1"
class="bg-background w-full rounded-md border px-3 py-2 text-right text-sm"
/>
</div>
<div class="col-span-2">
<input
type="number"
bind:value={item.unitPrice}
min="0"
step="0.01"
class="bg-background w-full rounded-md border px-3 py-2 text-right text-sm"
/>
</div>
<div class="col-span-1 pt-2 text-right text-sm font-medium">
{formatCurrency(getLineItemAmount(item), drawerFormData.currency || 'USD')}
</div>
<div class="col-span-1 flex justify-end">
<Button
variant="ghost"
size="icon"
class="text-muted-foreground hover:text-destructive h-8 w-8"
onclick={() => removeLineItem(index)}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
{/if}
{#if selectedEstimate.viewedAt}
<div class="flex justify-between">
<span class="text-muted-foreground">Viewed</span>
<span>{formatDate(selectedEstimate.viewedAt)}</span>
</div>
{/if}
{#if selectedEstimate.acceptedAt}
<div class="flex justify-between">
<span class="text-muted-foreground">Accepted</span>
<span>{formatDate(selectedEstimate.acceptedAt)}</span>
</div>
{/if}
{#if selectedEstimate.declinedAt}
<div class="flex justify-between">
<span class="text-muted-foreground">Declined</span>
<span>{formatDate(selectedEstimate.declinedAt)}</span>
</div>
{/if}
{#if selectedEstimate.convertedToInvoice}
<div class="flex justify-between">
<span class="text-muted-foreground">Converted to Invoice</span>
<a
href="/invoices?selected={selectedEstimate.convertedToInvoice.id}"
class="text-primary hover:underline"
>
{selectedEstimate.convertedToInvoice.invoiceNumber || 'View Invoice'}
</a>
</div>
{/if}
{/each}
<div class="bg-muted/20 flex items-center justify-between px-4 py-3 text-sm">
<span class="text-muted-foreground">Subtotal</span>
<span class="font-medium">
{formatCurrency(lineItemsSubtotal, drawerFormData.currency || 'USD')}
</span>
</div>
</div>
</div>
{/if}
{#if selectedEstimate}
<div class="border-t pt-4">
<h4 class="text-muted-foreground mb-2 text-sm font-medium">Activity</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Created</span>
<span>{formatDate(selectedEstimate.createdAt)}</span>
</div>
{#if selectedEstimate.sentAt}
<div class="flex justify-between">
<span class="text-muted-foreground">Sent</span>
<span>{formatDate(selectedEstimate.sentAt)}</span>
</div>
{/if}
{#if selectedEstimate.viewedAt}
<div class="flex justify-between">
<span class="text-muted-foreground">Viewed</span>
<span>{formatDate(selectedEstimate.viewedAt)}</span>
</div>
{/if}
{#if selectedEstimate.acceptedAt}
<div class="flex justify-between">
<span class="text-muted-foreground">Accepted</span>
<span>{formatDate(selectedEstimate.acceptedAt)}</span>
</div>
{/if}
{#if selectedEstimate.declinedAt}
<div class="flex justify-between">
<span class="text-muted-foreground">Declined</span>
<span>{formatDate(selectedEstimate.declinedAt)}</span>
</div>
{/if}
{#if selectedEstimate.convertedToInvoice}
<div class="flex justify-between">
<span class="text-muted-foreground">Converted to Invoice</span>
<a
href="/invoices?selected={selectedEstimate.convertedToInvoice.id}"
class="text-primary hover:underline"
>
{selectedEstimate.convertedToInvoice.invoiceNumber || 'View Invoice'}
</a>
</div>
{/if}
</div>
</div>
{/if}
</div>
{/snippet}
</CrmDrawer>

View file

@ -194,8 +194,8 @@ export const actions = {
notes: form.get('notes')?.toString() || '',
terms: form.get('terms')?.toString() || '',
// Relationships
account: form.get('accountId')?.toString() || null,
contact: form.get('contactId')?.toString() || null
account_id: form.get('accountId')?.toString() || null,
contact_id: form.get('contactId')?.toString() || null
};
// Parse line items JSON
@ -262,10 +262,23 @@ export const actions = {
notes: form.get('notes')?.toString() || '',
terms: form.get('terms')?.toString() || '',
// Relationships
account: form.get('accountId')?.toString() || null,
contact: form.get('contactId')?.toString() || null
account_id: form.get('accountId')?.toString() || null,
contact_id: form.get('contactId')?.toString() || null
};
// Parse line items JSON
const lineItemsJson = form.get('lineItems')?.toString() || '[]';
const lineItems = JSON.parse(lineItemsJson);
recurringData.line_items = lineItems.map((item, idx) => ({
description: item.description || '',
quantity: item.quantity || 1,
unit_price: item.unitPrice || '0',
discount_type: item.discountType || 'fixed',
discount_value: item.discountValue || '0',
tax_rate: item.taxRate || '0',
order: item.order || idx
}));
await apiRequest(
`/invoices/recurring/${recurringId}/`,
{

View file

@ -1,4 +1,5 @@
<script>
import { Plus, Trash2 } from '@lucide/svelte';
import { page } from '$app/stores';
import { goto, invalidateAll } from '$app/navigation';
import { enhance } from '$app/forms';
@ -248,14 +249,25 @@
autoSend: 'false',
accountId: '',
contactId: '',
currency: 'USD',
discountType: 'fixed',
discountValue: '0',
taxRate: '0',
notes: '',
terms: ''
terms: '',
lineItems: '[]'
});
// Derived values
const filters = $derived(data.filters);
const pagination = $derived(data.pagination);
const recurringInvoices = $derived(data.recurringInvoices);
const lineItemsSubtotal = $derived.by(() =>
(drawerFormData.lineItems || []).reduce(
(sum, item) => sum + getLineItemAmount(item),
0
)
);
// Count active filters
const activeFiltersCount = $derived(() => {
@ -327,9 +339,13 @@
endDate: '',
paymentTerms: 'NET_30',
autoSend: false,
currency: 'USD',
discountType: 'fixed',
discountValue: '0',
taxRate: '0',
notes: '',
terms: '',
lineItems: []
lineItems: [createRecurringLineItem()]
};
drawerOpen = true;
}
@ -347,7 +363,13 @@
if (drawerMode === 'create') {
// Already set in openCreateDrawer
} else if (selectedRecurring) {
drawerFormData = { ...selectedRecurring };
drawerFormData = {
...selectedRecurring,
lineItems:
selectedRecurring.lineItems?.length > 0
? selectedRecurring.lineItems.map((item) => ({ ...item }))
: [createRecurringLineItem()]
};
}
}
});
@ -357,6 +379,38 @@
drawerFormData[field] = value;
}
function createRecurringLineItem() {
return {
id: crypto.randomUUID(),
description: '',
quantity: 1,
unitPrice: '0',
discountType: 'fixed',
discountValue: '0',
taxRate: '0'
};
}
function getLineItemAmount(item) {
const quantity = Number(item?.quantity || 0);
const unitPrice = Number(item?.unitPrice || 0);
return quantity * unitPrice;
}
function addLineItem() {
drawerFormData.lineItems = [...(drawerFormData.lineItems || []), createRecurringLineItem()];
}
function removeLineItem(index) {
const existingItems = drawerFormData.lineItems || [];
if (existingItems.length === 1) {
drawerFormData.lineItems = [createRecurringLineItem()];
return;
}
drawerFormData.lineItems = existingItems.filter((_, itemIndex) => itemIndex !== index);
}
// Save handler
async function handleDrawerSave() {
if (drawerMode === 'create') {
@ -371,8 +425,19 @@
formState.endDate = drawerFormData.endDate;
formState.paymentTerms = drawerFormData.paymentTerms;
formState.autoSend = drawerFormData.autoSend ? 'true' : 'false';
formState.accountId = drawerFormData.account?.id?.toString() || '';
formState.contactId = drawerFormData.contact?.id?.toString() || '';
formState.currency = drawerFormData.currency || 'USD';
formState.discountType = drawerFormData.discountType || 'fixed';
formState.discountValue = drawerFormData.discountValue?.toString() || '0';
formState.taxRate = drawerFormData.taxRate?.toString() || '0';
formState.notes = drawerFormData.notes;
formState.terms = drawerFormData.terms;
formState.lineItems = JSON.stringify(
(drawerFormData.lineItems || []).filter(
(item) => item.description || Number(item.unitPrice) > 0
)
);
await tick();
createForm.requestSubmit();
@ -389,8 +454,19 @@
formState.endDate = drawerFormData.endDate;
formState.paymentTerms = drawerFormData.paymentTerms;
formState.autoSend = drawerFormData.autoSend ? 'true' : 'false';
formState.accountId = drawerFormData.account?.id?.toString() || '';
formState.contactId = drawerFormData.contact?.id?.toString() || '';
formState.currency = drawerFormData.currency || 'USD';
formState.discountType = drawerFormData.discountType || 'fixed';
formState.discountValue = drawerFormData.discountValue?.toString() || '0';
formState.taxRate = drawerFormData.taxRate?.toString() || '0';
formState.notes = drawerFormData.notes;
formState.terms = drawerFormData.terms;
formState.lineItems = JSON.stringify(
(drawerFormData.lineItems || []).filter(
(item) => item.description || Number(item.unitPrice) > 0
)
);
await tick();
updateForm.requestSubmit();
@ -491,10 +567,15 @@
<input type="hidden" name="endDate" value={formState.endDate} />
<input type="hidden" name="paymentTerms" value={formState.paymentTerms} />
<input type="hidden" name="autoSend" value={formState.autoSend} />
<input type="hidden" name="currency" value={formState.currency} />
<input type="hidden" name="discountType" value={formState.discountType} />
<input type="hidden" name="discountValue" value={formState.discountValue} />
<input type="hidden" name="taxRate" value={formState.taxRate} />
<input type="hidden" name="notes" value={formState.notes} />
<input type="hidden" name="terms" value={formState.terms} />
<input type="hidden" name="accountId" value={formState.accountId} />
<input type="hidden" name="contactId" value={formState.contactId} />
<input type="hidden" name="lineItems" value={formState.lineItems} />
</form>
<form
@ -527,10 +608,15 @@
<input type="hidden" name="endDate" value={formState.endDate} />
<input type="hidden" name="paymentTerms" value={formState.paymentTerms} />
<input type="hidden" name="autoSend" value={formState.autoSend} />
<input type="hidden" name="currency" value={formState.currency} />
<input type="hidden" name="discountType" value={formState.discountType} />
<input type="hidden" name="discountValue" value={formState.discountValue} />
<input type="hidden" name="taxRate" value={formState.taxRate} />
<input type="hidden" name="notes" value={formState.notes} />
<input type="hidden" name="terms" value={formState.terms} />
<input type="hidden" name="accountId" value={formState.accountId} />
<input type="hidden" name="contactId" value={formState.contactId} />
<input type="hidden" name="lineItems" value={formState.lineItems} />
</form>
<form
@ -829,40 +915,107 @@
{/snippet}
{#snippet activitySection()}
{#if selectedRecurring}
<div class="space-y-6">
<div class="border-t pt-4">
<h4 class="text-muted-foreground mb-2 text-sm font-medium">Statistics</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Created</span>
<span>{formatDate(selectedRecurring.createdAt)}</span>
<div class="mb-3 flex items-center justify-between">
<h4 class="text-muted-foreground text-sm font-medium">Line Items</h4>
<Button variant="ghost" size="sm" class="text-primary" onclick={addLineItem}>
<Plus class="mr-2 h-4 w-4" />
Add Line Item
</Button>
</div>
<div class="overflow-hidden rounded-md border">
<div
class="bg-muted/50 text-muted-foreground grid grid-cols-12 gap-3 border-b px-4 py-3 text-xs font-medium uppercase tracking-wide"
>
<div class="col-span-7">Description</div>
<div class="col-span-2 text-right">Qty</div>
<div class="col-span-2 text-right">Rate</div>
<div class="col-span-1"></div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Start Date</span>
<span
>{selectedRecurring.startDate ? formatDate(selectedRecurring.startDate) : '-'}</span
>
</div>
{#if selectedRecurring.endDate}
<div class="flex justify-between">
<span class="text-muted-foreground">End Date</span>
<span>{formatDate(selectedRecurring.endDate)}</span>
{#each drawerFormData.lineItems || [] as item, index (item.id || `${index}`)}
<div class="grid grid-cols-12 items-start gap-3 border-b px-4 py-3 last:border-b-0">
<div class="col-span-7 space-y-2">
<input
bind:value={item.description}
class="bg-background w-full rounded-md border px-3 py-2 text-sm"
placeholder="Description"
/>
</div>
<div class="col-span-2">
<input
type="number"
bind:value={item.quantity}
min="0"
step="1"
class="bg-background w-full rounded-md border px-3 py-2 text-right text-sm"
/>
</div>
<div class="col-span-2">
<input
type="number"
bind:value={item.unitPrice}
min="0"
step="0.01"
class="bg-background w-full rounded-md border px-3 py-2 text-right text-sm"
/>
</div>
<div class="col-span-1 flex justify-end">
<Button
variant="ghost"
size="icon"
class="text-muted-foreground hover:text-destructive h-8 w-8"
onclick={() => removeLineItem(index)}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
{/if}
<div class="flex justify-between">
<span class="text-muted-foreground">Next Invoice</span>
<span
>{selectedRecurring.nextGenerationDate
? formatDate(selectedRecurring.nextGenerationDate)
: '-'}</span
>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Invoices Generated</span>
<span class="font-medium">{selectedRecurring.invoicesGenerated}</span>
{/each}
<div class="bg-muted/20 flex items-center justify-between px-4 py-3 text-sm">
<span class="text-muted-foreground">Subtotal</span>
<span class="font-medium">
{formatCurrency(lineItemsSubtotal, drawerFormData.currency || 'USD')}
</span>
</div>
</div>
</div>
{/if}
{#if selectedRecurring}
<div class="border-t pt-4">
<h4 class="text-muted-foreground mb-2 text-sm font-medium">Statistics</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Created</span>
<span>{formatDate(selectedRecurring.createdAt)}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Start Date</span>
<span
>{selectedRecurring.startDate ? formatDate(selectedRecurring.startDate) : '-'}</span
>
</div>
{#if selectedRecurring.endDate}
<div class="flex justify-between">
<span class="text-muted-foreground">End Date</span>
<span>{formatDate(selectedRecurring.endDate)}</span>
</div>
{/if}
<div class="flex justify-between">
<span class="text-muted-foreground">Next Invoice</span>
<span
>{selectedRecurring.nextGenerationDate
? formatDate(selectedRecurring.nextGenerationDate)
: '-'}</span
>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Invoices Generated</span>
<span class="font-medium">{selectedRecurring.invoicesGenerated}</span>
</div>
</div>
</div>
{/if}
</div>
{/snippet}
</CrmDrawer>

View file

@ -63,18 +63,11 @@ export const actions = {
const org = locals.org;
const formData = await request.formData();
const name = formData.get('name')?.toString();
const phone = formData.get('phone')?.toString();
// Validation
const errors = {};
if (!name || name.trim().length === 0) {
errors.name = 'Name is required';
} else if (name.trim().length < 2) {
errors.name = 'Name must be at least 2 characters long';
}
// Validate phone if provided
let formattedPhone = null;
if (phone && phone.trim().length > 0) {
@ -89,14 +82,12 @@ export const actions = {
if (Object.keys(errors).length > 0) {
return fail(400, {
errors,
data: { name, phone }
data: { phone }
});
}
try {
// Transform to Django field names
const djangoData = {
name: name.trim(),
phone: formattedPhone
};
@ -119,7 +110,7 @@ export const actions = {
return fail(500, {
error:
'Failed to update profile: ' + (err instanceof Error ? err.message : 'Unknown error'),
data: { name, phone }
data: { phone }
});
}
}

View file

@ -22,7 +22,6 @@
// Form data state - initialized by $effect below
let formData = $state({
name: '',
phone: ''
});
@ -30,7 +29,6 @@
$effect(() => {
if (!isEditing) {
formData = {
name: data.user.name || '',
phone: data.user.phone || ''
};
phoneError = '';
@ -66,7 +64,6 @@
if (!isEditing) {
// Reset form data when canceling edit
formData = {
name: data.user.name || '',
phone: data.user.phone || ''
};
phoneError = '';
@ -194,16 +191,17 @@
<div class="grid gap-6 sm:grid-cols-2">
<!-- Name -->
<div class="sm:col-span-2">
<Label for="name" class="">Full Name *</Label>
<Label for="name" class="">Full Name</Label>
<Input
type="text"
id="name"
name="name"
bind:value={formData.name}
required
placeholder="Enter your full name"
class="mt-1.5"
value={data.user.name || data.user.email}
disabled
class="bg-muted mt-1.5"
/>
<p class="text-muted-foreground mt-1 text-xs">
Name changes are not supported yet on this account.
</p>
</div>
<!-- Email (read-only) -->

View file

@ -27,6 +27,23 @@ import { env } from '$env/dynamic/public';
const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api`;
/**
* Flatten nested API validation errors into a readable message.
* @param {unknown} value
* @returns {string[]}
*/
function collectErrorMessages(value) {
if (!value) return [];
if (typeof value === 'string') return [value];
if (Array.isArray(value)) {
return value.flatMap((item) => collectErrorMessages(item));
}
if (typeof value === 'object') {
return Object.values(value).flatMap((item) => collectErrorMessages(item));
}
return [];
}
/**
* Make authenticated API request
* @param {string} endpoint
@ -50,7 +67,8 @@ async function apiRequest(endpoint, options = {}, context) {
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(errorData.errors || errorData.error || response.statusText);
const messages = collectErrorMessages(errorData.errors || errorData.error);
throw new Error(messages[0] || response.statusText);
}
return await response.json();
@ -91,7 +109,7 @@ export async function load({ locals, cookies }) {
// Combine active and inactive users, transform to match expected format
const allUsers = [
...activeUsers.map((profile) => ({
odId: profile.user_details?.id || profile.id,
odId: profile.id,
organizationId: org.id,
role: profile.role,
user: {
@ -103,7 +121,7 @@ export async function load({ locals, cookies }) {
profile
})),
...inactiveUsers.map((profile) => ({
odId: profile.user_details?.id || profile.id,
odId: profile.id,
organizationId: org.id,
role: profile.role,
user: {
@ -162,13 +180,7 @@ export const actions = {
// Create user via Django API
// Django endpoint: POST /api/users/
const userData = {
email,
role,
// Django requires these fields for user creation
username: email.split('@')[0],
password: Math.random().toString(36).slice(-12) // Random password
};
const userData = { email, role };
await apiRequest(
'/users/',
@ -179,7 +191,7 @@ export const actions = {
{ cookies, org }
);
return { success: true };
return { success: true, action: 'add_user' };
} catch (err) {
console.error('Error adding user:', err);
// Check for specific error messages

View file

@ -82,7 +82,9 @@
// Handle form results
$effect(() => {
if (form?.success) {
if (form.action === 'create_team') {
if (form.action === 'add_user') {
toast.success('Member added successfully');
} else if (form.action === 'create_team') {
toast.success('Team created successfully');
teamDialogOpen = false;
editingTeam = null;

View file

@ -189,7 +189,7 @@ async function generateOAuthUrl(cookies) {
/** @type {import('@sveltejs/kit').Actions} */
export const actions = {
requestMagicLink: async ({ request }) => {
default: async ({ request }) => {
const formData = await request.formData();
const email = formData.get('email');

View file

@ -6,7 +6,7 @@
import imgLogo from '$lib/assets/images/logo.png';
import { ArrowRight } from '@lucide/svelte';
let { data } = $props();
let { data = {} } = $props();
let isLoading = $state(false);
let email = $state('');
@ -20,12 +20,15 @@
function handleMagicLink() {
isSendingLink = true;
magicLinkError = '';
return async ({ result }) => {
isSendingLink = false;
if (result.type === 'success') {
if (result?.type === 'success') {
magicLinkSent = true;
} else if (result.type === 'failure') {
} else if (result?.type === 'failure') {
magicLinkError = result.data?.error || 'Something went wrong. Please try again.';
} else if (!result) {
magicLinkError = 'Something went wrong. Please try again.';
}
};
}
@ -80,7 +83,7 @@
<p class="magic-link-hint">The link expires in 10 minutes.</p>
</div>
{:else}
<form method="POST" action="/login?/requestMagicLink" use:enhance={handleMagicLink} class="magic-link-form">
<form method="POST" use:enhance={handleMagicLink} class="magic-link-form">
<input
type="email"
name="email"
@ -100,6 +103,9 @@
{/if}
</button>
</form>
{#if magicLinkError}
<p class="magic-link-error">{magicLinkError}</p>
{/if}
{/if}
</div>
@ -317,6 +323,13 @@
padding: 1rem 0;
}
.magic-link-error {
margin-top: 0.75rem;
font-size: 0.875rem;
color: #c0392b;
text-align: center;
}
.magic-link-success p {
color: #33475b;
font-size: 1rem;

View file

@ -4,8 +4,8 @@
import { Building2, LogOut, Plus, ChevronRight, Users, Shield } from '@lucide/svelte';
import { enhance } from '$app/forms';
let { data } = $props();
let orgs = $derived(data.orgs);
let { data = { orgs: [] } } = $props();
let orgs = $derived(data?.orgs ?? []);
let loading = $state(false);
let selectedOrgId = $state(null);
@ -47,7 +47,7 @@
<!-- Organizations List -->
{#if orgs.length > 0}
<div class="space-y-3">
{#each orgs as org}
{#each orgs as org (org.id)}
<form
method="POST"
action="?/selectOrg"