commit
24ccf66546
18 changed files with 1398 additions and 590 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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}/`,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}/`,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue