Autosave: 20260201-171858
This commit is contained in:
parent
3bb4e04513
commit
4975582b68
BIN
assets/pasted-20260201-154837-60ba680b.png
Normal file
BIN
assets/pasted-20260201-154837-60ba680b.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import User
|
||||
from .models import Profile, Parcel, Country, Governate, City, PlatformProfile, Testimonial, DriverRating, NotificationTemplate, PricingRule, DriverReport, DriverRejection
|
||||
from .models import Profile, Parcel, Country, Governate, City, PlatformProfile, Testimonial, DriverRating, NotificationTemplate, PricingRule, DriverReport, DriverRejection, ParcelType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.urls import path, reverse
|
||||
from django.shortcuts import render, redirect
|
||||
@ -109,7 +109,7 @@ class CustomUserAdmin(UserAdmin):
|
||||
class ParcelAdmin(admin.ModelAdmin):
|
||||
change_list_template = 'admin/core/parcel/change_list.html'
|
||||
|
||||
list_display = ('tracking_number', 'shipper', 'carrier', 'price', 'driver_amount', 'platform_fee', 'distance_km', 'status', 'payment_status', 'created_at')
|
||||
list_display = ('tracking_number', 'shipper', 'carrier', 'parcel_type', 'price', 'driver_amount', 'platform_fee', 'distance_km', 'status', 'payment_status', 'created_at')
|
||||
list_filter = (
|
||||
'status',
|
||||
'payment_status',
|
||||
@ -123,7 +123,7 @@ class ParcelAdmin(admin.ModelAdmin):
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('tracking_number', 'shipper', 'carrier', 'status', 'payment_status', 'thawani_session_id')
|
||||
'fields': ('tracking_number', 'shipper', 'carrier', 'parcel_type', 'status', 'payment_status', 'thawani_session_id')
|
||||
}),
|
||||
(_('Description'), {
|
||||
'fields': ('description', 'receiver_name', 'receiver_phone')
|
||||
@ -402,3 +402,4 @@ class NotificationTemplateAdmin(admin.ModelAdmin):
|
||||
return False
|
||||
|
||||
admin.site.register(NotificationTemplate, NotificationTemplateAdmin)
|
||||
admin.site.register(ParcelType)
|
||||
|
||||
@ -2,7 +2,7 @@ from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import get_language
|
||||
from .models import Profile, Parcel, Country, Governate, City, DriverRating, DriverReport
|
||||
from .models import Profile, Parcel, Country, Governate, City, DriverRating, DriverReport, ParcelType
|
||||
|
||||
class ContactForm(forms.Form):
|
||||
name = forms.CharField(max_length=100, label=_("Name"), widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Your Name')}))
|
||||
@ -240,6 +240,7 @@ class ParcelForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Parcel
|
||||
fields = [
|
||||
'parcel_type',
|
||||
'description', 'weight', 'price',
|
||||
'pickup_country', 'pickup_governate', 'pickup_city', 'pickup_address',
|
||||
'pickup_lat', 'pickup_lng',
|
||||
@ -249,6 +250,7 @@ class ParcelForm(forms.ModelForm):
|
||||
'receiver_name', 'receiver_phone'
|
||||
]
|
||||
widgets = {
|
||||
'parcel_type': forms.Select(attrs={'class': 'form-control'}),
|
||||
'description': forms.Textarea(attrs={'rows': 3, 'class': 'form-control', 'placeholder': _('What are you sending?')}),
|
||||
'weight': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||
'price': forms.TextInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
|
||||
@ -276,6 +278,7 @@ class ParcelForm(forms.ModelForm):
|
||||
'receiver_phone': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
}
|
||||
labels = {
|
||||
'parcel_type': _('Parcel Type'),
|
||||
'description': _('Package Description'),
|
||||
'weight': _('Weight (kg)'),
|
||||
'price': _('Calculated Price (OMR)'),
|
||||
|
||||
31
core/migrations/0032_parceltype_parcel_parcel_type.py
Normal file
31
core/migrations/0032_parceltype_parcel_parcel_type.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-01 17:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0031_platformprofile_ticker_bg_color_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ParcelType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name_en', models.CharField(max_length=100, verbose_name='Name (English)')),
|
||||
('name_ar', models.CharField(max_length=100, verbose_name='Name (Arabic)')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Parcel Type',
|
||||
'verbose_name_plural': 'Parcel Types',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parcel',
|
||||
name='parcel_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.parceltype', verbose_name='Parcel Type'),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -63,6 +63,23 @@ class City(models.Model):
|
||||
verbose_name = _('City')
|
||||
verbose_name_plural = _('Cities')
|
||||
|
||||
class ParcelType(models.Model):
|
||||
name_en = models.CharField(_('Name (English)'), max_length=100)
|
||||
name_ar = models.CharField(_('Name (Arabic)'), max_length=100)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
if get_language() == 'ar':
|
||||
return self.name_ar
|
||||
return self.name_en
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Parcel Type')
|
||||
verbose_name_plural = _('Parcel Types')
|
||||
|
||||
class Profile(models.Model):
|
||||
ROLE_CHOICES = (
|
||||
("shipper", _("Shipper")),
|
||||
@ -257,6 +274,7 @@ class Parcel(models.Model):
|
||||
shipper = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sent_parcels', verbose_name=_('Shipper'))
|
||||
carrier = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='carried_parcels', verbose_name=_('Carrier'))
|
||||
|
||||
parcel_type = models.ForeignKey(ParcelType, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('Parcel Type'))
|
||||
description = models.TextField(_('Description'))
|
||||
weight = models.DecimalField(_('Weight (kg)'), max_digits=5, decimal_places=2, help_text=_("Weight in kg"))
|
||||
price = models.DecimalField(_('Total Price (OMR)'), max_digits=10, decimal_places=3, default=Decimal('0.000'))
|
||||
|
||||
@ -11,7 +11,7 @@ def calculate_haversine_distance(lat1, lon1, lat2, lon2):
|
||||
return Decimal('0.00')
|
||||
|
||||
# Convert decimal degrees to radians
|
||||
lat1, lon1, lat2, lon2 = map(float, [lat1, lon1, lat2, lon2])
|
||||
lat1, lon1, lat2, lon2 = map(math.radians, [float(lat1), float(lon1), float(lat2), float(lon2)])
|
||||
|
||||
# Haversine formula
|
||||
dlon = lon2 - lon1
|
||||
|
||||
@ -40,6 +40,13 @@
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-secondary mb-3">{% trans "Shipment Information" %}</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-bold" for="{{ form.parcel_type.id_for_label }}">{{ form.parcel_type.label }}</label>
|
||||
{{ form.parcel_type }}
|
||||
{% if form.parcel_type.errors %}
|
||||
<div class="text-danger small">{{ form.parcel_type.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-bold" for="{{ form.description.id_for_label }}">{{ form.description.label }}</label>
|
||||
{{ form.description }}
|
||||
@ -166,6 +173,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Summary Display -->
|
||||
<div id="pricingSummary" class="card border-0 bg-light p-4 mb-4 d-none" style="border-radius: 20px; border: 2px dashed #0d6efd !important;">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6 mb-3 mb-md-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary bg-opacity-10 p-3 rounded-circle me-3">
|
||||
<i class="bi bi-geo-alt-fill text-primary h3 mb-0"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted mb-0 small text-uppercase fw-bold">{% trans "Estimated Distance" %}</p>
|
||||
<h2 class="fw-bold mb-0"><span id="summaryDistance">0</span> <small class="h6 text-muted">km</small></h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<div class="d-inline-block text-start text-md-end">
|
||||
<p class="text-muted mb-0 small text-uppercase fw-bold">{% trans "Total Price" %}</p>
|
||||
<h1 class="fw-bold text-success mb-0" style="font-size: 3rem;"><span id="summaryPrice">0.000</span> <small class="h4">OMR</small></h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pricingError" class="mt-3 text-danger d-none">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i> <span id="pricingErrorMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-3 justify-content-end">
|
||||
<a href="{% url 'dashboard' %}" class="btn btn-outline-secondary px-5 py-2">{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="btn btn-masarx-primary px-5 py-2">{% trans "Submit Request" %}</button>
|
||||
@ -372,8 +405,12 @@
|
||||
const deliveryLng = document.getElementById('id_delivery_lng').value;
|
||||
const weight = document.getElementById('id_weight').value;
|
||||
|
||||
const summaryDiv = document.getElementById('pricingSummary');
|
||||
const errorDiv = document.getElementById('pricingError');
|
||||
const errorMessage = document.getElementById('pricingErrorMessage');
|
||||
|
||||
if (pickupLat && pickupLng && deliveryLat && deliveryLng && weight) {
|
||||
fetch('{% url "api_calculate_price" %}', {
|
||||
fetch('{% url "api_pricing" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -391,12 +428,26 @@
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
console.error(data.error);
|
||||
summaryDiv.classList.remove('d-none');
|
||||
errorDiv.classList.remove('d-none');
|
||||
errorMessage.textContent = data.error;
|
||||
document.getElementById('summaryPrice').textContent = "0.000";
|
||||
document.getElementById('summaryDistance').textContent = "0";
|
||||
} else {
|
||||
document.getElementById('id_price').value = data.price;
|
||||
// Update hidden and readonly fields
|
||||
document.getElementById('id_price').value = data.price.toFixed(3);
|
||||
document.getElementById('id_distance_km').value = data.distance_km;
|
||||
document.getElementById('id_platform_fee').value = data.platform_fee;
|
||||
document.getElementById('id_driver_amount').value = data.driver_amount;
|
||||
document.getElementById('id_platform_fee_percentage').value = data.platform_fee_percentage;
|
||||
|
||||
// Update visual summary
|
||||
document.getElementById('summaryDistance').textContent = data.distance_km;
|
||||
document.getElementById('summaryPrice').textContent = data.price.toFixed(3);
|
||||
|
||||
// Show the summary, hide error
|
||||
summaryDiv.classList.remove('d-none');
|
||||
errorDiv.classList.add('d-none');
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
@ -411,13 +462,36 @@
|
||||
}
|
||||
|
||||
window.initMap = initMap;
|
||||
|
||||
// Helper to geocode from dropdown selection
|
||||
function geocodeFromDropdown(mode) {
|
||||
const city = getSelectedText(`id_${mode}_city`);
|
||||
const governate = getSelectedText(`id_${mode}_governate`);
|
||||
const country = getSelectedText(`id_${mode}_country`);
|
||||
|
||||
if (city && typeof google !== 'undefined' && google.maps && google.maps.Geocoder) {
|
||||
let addressQuery = city;
|
||||
if (governate) addressQuery += `, ${governate}`;
|
||||
if (country) addressQuery += `, ${country}`;
|
||||
|
||||
const geocoder = new google.maps.Geocoder();
|
||||
geocoder.geocode({ address: addressQuery }, (results, status) => {
|
||||
if (status === "OK" && results[0]) {
|
||||
const location = results[0].geometry.location;
|
||||
document.getElementById(`id_${mode}_lat`).value = location.lat();
|
||||
document.getElementById(`id_${mode}_lng`).value = location.lng();
|
||||
calculatePrice();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://maps.googleapis.com/maps/api/js?key={{ google_maps_api_key }}&loading=async&callback=initMap" async defer></script>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
function setupDependentDropdowns(countryId, governateId, cityId) {
|
||||
function setupDependentDropdowns(countryId, governateId, cityId, mode) {
|
||||
const countrySelect = document.getElementById(countryId);
|
||||
const governateSelect = document.getElementById(governateId);
|
||||
const citySelect = document.getElementById(cityId);
|
||||
@ -460,10 +534,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger calculation on city change
|
||||
citySelect.addEventListener('change', function() {
|
||||
if (typeof geocodeFromDropdown === 'function') {
|
||||
geocodeFromDropdown(mode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupDependentDropdowns('id_pickup_country', 'id_pickup_governate', 'id_pickup_city');
|
||||
setupDependentDropdowns('id_delivery_country', 'id_delivery_governate', 'id_delivery_city');
|
||||
setupDependentDropdowns('id_pickup_country', 'id_pickup_governate', 'id_pickup_city', 'pickup');
|
||||
setupDependentDropdowns('id_delivery_country', 'id_delivery_governate', 'id_delivery_city', 'delivery');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -61,6 +61,6 @@ urlpatterns = [
|
||||
# API Endpoints (for Mobile App)
|
||||
path('api/v1/parcels/', api_views.ParcelListCreateView.as_view(), name='api_parcel_list'),
|
||||
path('api/v1/parcels/<int:pk>/', api_views.ParcelDetailView.as_view(), name='api_parcel_detail'),
|
||||
path('api/v1/pricing/', api_views.PriceCalculatorView.as_view(), name='api_pricing'),
|
||||
path('api/v1/pricing/', api_views.PriceCalculatorView.as_view(), name='api_pricing'), path('api/v1/calculate-price/', api_views.PriceCalculatorView.as_view(), name='api_calculate_price'),
|
||||
path('api/v1/profile/', api_views.UserProfileView.as_view(), name='api_profile'),
|
||||
]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user