diff --git a/addon/components/customer/orders.hbs b/addon/components/customer/orders.hbs new file mode 100644 index 00000000..54716abf --- /dev/null +++ b/addon/components/customer/orders.hbs @@ -0,0 +1,22 @@ +
+
+
+
+
+

Orders

+
+
+ {{#each this.orders as |order|}} + {{order.public_id}} + {{/each}} +
+
+
+
+
+ Render order here using selected order: + {{n-a this.selectedOrder.id}} +
+
+
+
\ No newline at end of file diff --git a/addon/components/customer/orders.js b/addon/components/customer/orders.js new file mode 100644 index 00000000..3364dde3 --- /dev/null +++ b/addon/components/customer/orders.js @@ -0,0 +1,30 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; + +export default class CustomerOrdersComponent extends Component { + @service store; + @service notifications; + @service currentUser; + @tracked orders = []; + @tracked selectedOrder; + + constructor() { + super(...arguments); + this.loadCustomerOrders.perform(); + } + + @task *loadCustomerOrders() { + try { + this.orders = yield this.store.query('order', { customer: this.currentUser.id }); + } catch (error) { + this.notifications.serverError(error); + } + } + + @action selectOrder(order) { + this.selectedOrder = order; + } +} diff --git a/addon/components/leaflet-draw-control.js b/addon/components/leaflet-draw-control.js index d396bf2a..b153f7da 100644 --- a/addon/components/leaflet-draw-control.js +++ b/addon/components/leaflet-draw-control.js @@ -21,10 +21,26 @@ export default class LeafletDrawControl extends BaseLayer { L.Draw.Event.DRAWVERTEX, ]; + alwaysCapturedLeafletEvents = [ + L.Draw.Event.CREATED, + L.Draw.Event.EDITED, + L.Draw.Event.EDITSTART, + L.Draw.Event.EDITSTOP, + L.Draw.Event.EDITRESIZE, + L.Draw.Event.EDITMOVE, + L.Draw.Event.DELETED, + L.Draw.Event.DRAWSTART, + L.Draw.Event.DRAWSTOP, + ]; + leafletOptions = ['draw', 'edit', 'remove', 'poly', 'position']; - @computed('leafletEvents.[]', 'args') get usedLeafletEvents() { + @computed('leafletEvents.[]', 'alwaysCapturedLeafletEvents.[]', 'args') get usedLeafletEvents() { return this.leafletEvents.filter((eventName) => { + if (this.alwaysCapturedLeafletEvents.includes(eventName)) { + return true; + } + eventName = camelize(eventName.replace(':', ' ')); let methodName = `_${eventName}`; let actionName = `on${classify(eventName)}`; @@ -36,7 +52,7 @@ export default class LeafletDrawControl extends BaseLayer { @computed('args.{draw,edit,remove,poly,position}') get options() { return { position: getWithDefault(this.args, 'position', 'topright'), - draw: getWithDefault(this.args, 'draw', { marker: false, circlemarker: false, circle: false, polyline: false }), + draw: getWithDefault(this.args, 'draw', { marker: false, circlemarker: false, polyline: false }), edit: getWithDefault(this.args, 'edit', {}), remove: getWithDefault(this.args, 'remove', {}), poly: getWithDefault(this.args, 'poly', null), @@ -56,14 +72,10 @@ export default class LeafletDrawControl extends BaseLayer { createLayer() { const { onDrawFeatureGroupCreated } = this.args; const drawingLayerGroup = new this.L.FeatureGroup(); - const showDrawingLayer = getWithDefault(this.args, 'showDrawingLayer', false); - - if (showDrawingLayer) { - if (typeof onDrawFeatureGroupCreated === 'function') { - onDrawFeatureGroupCreated(drawingLayerGroup, this.map); - } - drawingLayerGroup.addTo(this.map); + drawingLayerGroup.addTo(this.map); + if (typeof onDrawFeatureGroupCreated === 'function') { + onDrawFeatureGroupCreated(drawingLayerGroup, this.map); } return drawingLayerGroup; @@ -74,7 +86,7 @@ export default class LeafletDrawControl extends BaseLayer { const showDrawingLayer = getWithDefault(this.args, 'showDrawingLayer', false); if (this.map && this._layer && this.L.drawLocal) { - this.options.edit = Object.assign({ featureGroup: this._layer }, this.options.edit); + this.options.edit = Object.assign({ featureGroup: this._layer }, this.L.drawLocal.edit, this.options.edit); this.options.draw = Object.assign({}, this.L.drawLocal.draw, this.options.draw); // create draw control @@ -88,19 +100,17 @@ export default class LeafletDrawControl extends BaseLayer { // Add the draw control to the map if (showDrawingLayer) { this.map.addControl(drawControl); + // trigger action/event draw control added to map + if (typeof onDrawControlAddedToMap === 'function') { + onDrawControlAddedToMap(drawControl, this.map); + } } - // trigger action/event draw control added to map - if (typeof onDrawControlAddedToMap === 'function') { - onDrawControlAddedToMap(drawControl, this.map); - } - - // If showDrawingLayer, add new layer to the layerGroup - if (showDrawingLayer) { - this.map.on(this.L.Draw.Event.CREATED, ({ layer }) => { - this._layer.addLayer(layer); - }); - } + // Track every layer created via draw control + this.map.on(this.L.Draw.Event.CREATED, ({ layer }) => { + this._layer.lastCreatedLayer = layer; + this._layer.addLayer(layer); + }); } } diff --git a/addon/components/live-map.hbs b/addon/components/live-map.hbs index 21199e5d..da8fd638 100644 --- a/addon/components/live-map.hbs +++ b/addon/components/live-map.hbs @@ -114,7 +114,7 @@ { - this.isDataLoaded = true; - }) - .finally(() => { - this.listen(); - }); + async completeSetup(liveDataPromises) { + await allSettled(liveDataPromises); + this.isDataLoaded = true; + this.listen(); } /** @@ -273,6 +270,7 @@ export default class LiveMapComponent extends Component { }, }), this.loadLiveData.perform('places'), + this.loadServiceAreas.perform(), ]); } @@ -1066,7 +1064,7 @@ export default class LiveMapComponent extends Component { @action hideDrawControls(options = {}) { this.hide('drawControls'); - const text = getWithDefault(options, 'text'); + const text = getWithDefault(options, 'text', true); const callback = getWithDefault(options, 'callback'); if (typeof callback === 'function') { @@ -1177,7 +1175,7 @@ export default class LiveMapComponent extends Component { // map except into ids only except = except .filter(Boolean) - .filter((record) => !record?.id) + .filter((record) => typeof record !== 'string' && !record?.id) .map((record) => record.id); for (let i = 0; i < this.activeServiceAreas.length; i++) { @@ -1215,7 +1213,7 @@ export default class LiveMapComponent extends Component { // map except into ids only except = except .filter(Boolean) - .filter((record) => !record?.id) + .filter((record) => typeof record !== 'string' && !record?.id) .map((record) => record.id); for (let i = 0; i < this.serviceAreaRecords.length; i++) { @@ -1795,7 +1793,7 @@ export default class LiveMapComponent extends Component { * @returns {Promise} A promise that resolves to an array of service area records. * @memberof LiveMapComponent */ - @task *loadServiceAreas() { + @task *loadServiceAreas(options = {}) { if (this.abilities.cannot('fleet-ops list service-area')) { return []; } @@ -1804,15 +1802,28 @@ export default class LiveMapComponent extends Component { const cachedRecords = this.serviceAreas.getFromCache('serviceAreas', 'service-area'); if (cachedRecords) { + this.serviceAreaRecords = cachedRecords; + if (typeof options.onLoaded === 'function') { + options.onLoaded(cachedRecords); + } + return cachedRecords; } } - const serviceAreaRecords = yield this.store.query('service-area', { with: ['zones'] }); - if (serviceAreaRecords) { - this.appCache.setEmberData('serviceAreas', serviceAreaRecords); - } + try { + this.serviceAreaRecords = yield this.store.query('service-area', { with: ['zones'] }); + if (this.serviceAreaRecords) { + this.appCache.setEmberData('serviceAreas', this.serviceAreaRecords); + } + + if (typeof options.onLoaded === 'function') { + options.onLoaded(this.serviceAreaRecords); + } - return serviceAreaRecords; + return this.serviceAreaRecords; + } catch (error) { + this.notifications.serverError(error); + } } } diff --git a/addon/engine.js b/addon/engine.js index 01315d9c..aebbba5d 100644 --- a/addon/engine.js +++ b/addon/engine.js @@ -6,6 +6,7 @@ import services from '@fleetbase/ember-core/exports/services'; import NavigatorAppConfigComponent from './components/admin/navigator-app'; import WidgetFleetOpsKeyMetricsComponent from './components/widget/fleet-ops-key-metrics'; import AdminAvatarManagementComponent from './components/admin/avatar-management'; +import CustomerOrdersComponent from './components/customer/orders'; const { modulePrefix } = config; const externalRoutes = ['console', 'extensions']; @@ -71,6 +72,13 @@ export default class FleetOpsEngine extends Engine { 'fleet-ops:template:operations:orders:new', 'fleet-ops:template:operations:orders:new:entities-input', ]); + + // Add menu items to customer portal + if (universe.didBootEngine('@fleetbase/customer-portal-engine')) { + universe.registerMenuItems('customer-portal:sidebar', [ + universe._createMenuItem('Orders', 'customer-portal.portal.virtual', { icon: 'boxes-packing', component: CustomerOrdersComponent }), + ]); + } }; } diff --git a/addon/services/service-areas.js b/addon/services/service-areas.js index a4df9b62..b9c0171d 100644 --- a/addon/services/service-areas.js +++ b/addon/services/service-areas.js @@ -10,40 +10,12 @@ import MultiPolygon from '@fleetbase/fleetops-data/utils/geojson/multi-polygon'; import Polygon from '@fleetbase/fleetops-data/utils/geojson/polygon'; import FeatureCollection from '@fleetbase/fleetops-data/utils/geojson/feature-collection'; +const L = window.L; export default class ServiceAreasService extends Service { - /** - * Inject the `store` service. - * - * @memberof ServiceAreasService - */ @service store; - - /** - * Inject the `modalsManager` service. - * - * @memberof ServiceAreasService - */ @service modalsManager; - - /** - * Inject the `notifications` service. - * - * @memberof ServiceAreasService - */ @service notifications; - - /** - * Inject the `crud` service. - * - * @memberof ServiceAreasService - */ @service crud; - - /** - * Inject the `appCache` service. - * - * @memberof ServiceAreasService - */ @service appCache; /** @@ -187,6 +159,39 @@ export default class ServiceAreasService extends Service { return polygon; } + /** + * Converts a Leaflet circle to a polygon that approximates the circle's shape. + * @param {L.Circle} circle - The Leaflet circle layer to convert. + * @param {number} [numPoints=64] - The number of points used to approximate the circle. + * @param {Object} [options={}] - Optional parameters for the polygon layer. + * @returns {L.Polygon} - The resulting Leaflet polygon layer. + */ + circleToPolygon(circle, numPoints = 64, options = {}) { + // Get circle details + const center = circle.getLatLng(); + const radius = circle.getRadius(); + + // Convert radius from meters to degrees (approximation) + const radiusInDegrees = radius / 111320; + + // Generate points around the circle's circumference + const latLngs = []; + for (let i = 0; i < numPoints; i++) { + const angle = (i / numPoints) * 2 * Math.PI; + const latOffset = radiusInDegrees * Math.sin(angle); + const lngOffset = radiusInDegrees * Math.cos(angle); + latLngs.push([center.lat + latOffset, center.lng + lngOffset]); + } + + // Close the polygon by repeating the first point at the end + latLngs.push(latLngs[0]); + + // Create a polygon from the generated points + const polygon = L.polygon(latLngs, options); + + return polygon; + } + /** * Clears the layer creation context. * @@ -287,9 +292,14 @@ export default class ServiceAreasService extends Service { return this.saveZone(...arguments); } - const { _map } = layer; - const border = this.layerToTerraformerMultiPolygon(layer); + const drawFeatureGroupLayer = layer; + const map = this.leafletMap; + layer = drawFeatureGroupLayer.lastCreatedLayer; + if (event.layerType === 'circle') { + layer = this.circleToPolygon(drawFeatureGroupLayer.lastCreatedLayer); + } + const border = this.layerToTerraformerMultiPolygon(layer); if (!border) { return; } @@ -326,7 +336,9 @@ export default class ServiceAreasService extends Service { this.notifications.success(`New ${selectedLayerType} '${record.name}' saved.`); // remove drawn layer - _map?.removeLayer(layer); + map.removeLayer(drawFeatureGroupLayer); + // Hide draw controls on finish + this.triggerLiveMapFn('hideDrawControls'); // if service area has been created, add to the active service areas if (selectedLayerType === 'Service Area') { @@ -346,7 +358,9 @@ export default class ServiceAreasService extends Service { }); }, decline: (modal) => { - _map?.removeLayer(layer); + map.removeLayer(drawFeatureGroupLayer); + // Hide draw controls on finish + this.triggerLiveMapFn('hideDrawControls'); modal.done(); }, ...options, @@ -361,9 +375,14 @@ export default class ServiceAreasService extends Service { * @param {Object} layer - The layer to be saved as a service area. */ @action saveServiceArea(event, layer) { - const { _map } = layer; - const border = this.layerToTerraformerMultiPolygon(layer); + const drawFeatureGroupLayer = layer; + const map = this.leafletMap; + layer = drawFeatureGroupLayer.lastCreatedLayer; + if (event.layerType === 'circle') { + layer = this.circleToPolygon(drawFeatureGroupLayer.lastCreatedLayer); + } + const border = this.layerToTerraformerMultiPolygon(layer); if (!border) { return; } @@ -377,7 +396,9 @@ export default class ServiceAreasService extends Service { title: 'Save Service Area', acceptButtonText: 'Confirm & Save', onFinish: () => { - _map?.removeLayer(layer); + map.removeLayer(drawFeatureGroupLayer); + // Hide draw controls on finish + this.triggerLiveMapFn('hideDrawControls'); }, }); } @@ -412,7 +433,7 @@ export default class ServiceAreasService extends Service { }, decline: (modal) => { this.clearLayerCreationContext(); - this.triggerLiveMapFn('hideDrawControls', { text: true }); + this.triggerLiveMapFn('hideDrawControls'); if (serviceArea.isNew) { serviceArea.destroyRecord(); @@ -468,10 +489,15 @@ export default class ServiceAreasService extends Service { * @returns {Promise} A promise that resolves when the zone is saved. */ @action saveZone(event, layer) { - const { _map } = layer; + const drawFeatureGroupLayer = layer; + const map = this.leafletMap; + layer = drawFeatureGroupLayer.lastCreatedLayer; + if (event.layerType === 'circle') { + layer = this.circleToPolygon(drawFeatureGroupLayer.lastCreatedLayer); + } + const border = this.layerToTerraformerPolygon(layer); const serviceArea = this.getZoneServiceAreaContext(); - const zone = this.store.createRecord('zone', { service_area_uuid: serviceArea.id, serviceArea, @@ -482,7 +508,9 @@ export default class ServiceAreasService extends Service { title: 'Save Zone', acceptButtonText: 'Confirm & Save', onFinish: () => { - _map?.removeLayer(layer); + map.removeLayer(drawFeatureGroupLayer); + // Hide draw controls on finish + this.triggerLiveMapFn('hideDrawControls'); }, }); } diff --git a/app/components/customer/orders.js b/app/components/customer/orders.js new file mode 100644 index 00000000..76a0803a --- /dev/null +++ b/app/components/customer/orders.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/customer/orders'; diff --git a/server/src/Http/Filter/OrderFilter.php b/server/src/Http/Filter/OrderFilter.php index 93736ae0..d6c4408d 100644 --- a/server/src/Http/Filter/OrderFilter.php +++ b/server/src/Http/Filter/OrderFilter.php @@ -117,7 +117,19 @@ public function status(string $status) public function customer(string $customer) { - $this->builder->where('customer_uuid', $customer); + $this->builder->where(function ($query) use ($customer) { + $query->where('customer_uuid', $customer); + $query->orWhereHas('authenticatableCustomer', function ($query) use ($customer) { + $query->where('user_uuid', $customer); + }); + }); + } + + public function authenticatedCustomer(string $authenticatedCustomer) + { + $this->builder->whereHas('authenticatableCustomer', function ($query) use ($authenticatedCustomer) { + $query->where('user_uuid', $authenticatedCustomer); + }); } public function facilitator(string $facilitator) diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 72b9ca57..dfab239f 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -26,6 +26,11 @@ use Fleetbase\Traits\Searchable; use Fleetbase\Traits\SendsWebhooks; use Fleetbase\Traits\TracksApiCredential; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Support\Carbon; use Illuminate\Support\Str; use Spatie\Activitylog\LogOptions; @@ -243,182 +248,139 @@ public function label() ])->render(); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function orderConfig() + public function orderConfig(): BelongsTo { return $this->belongsTo(OrderConfig::class)->withTrashed(); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function transaction() + public function transaction(): BelongsTo { return $this->belongsTo(Transaction::class); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function route() + public function route(): BelongsTo { return $this->belongsTo(Route::class); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function payload() + public function payload(): BelongsTo { return $this->belongsTo(Payload::class)->with(['pickup', 'dropoff', 'return', 'waypoints', 'entities']); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function company() + public function company(): BelongsTo { return $this->belongsTo(\Fleetbase\Models\Company::class); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function createdBy() + public function createdBy(): BelongsTo { return $this->belongsTo(\Fleetbase\Models\User::class); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function updatedBy() + public function updatedBy(): BelongsTo { return $this->belongsTo(\Fleetbase\Models\User::class); } /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function driverAssigned() + public function driverAssigned(): BelongsTo|Builder { return $this->belongsTo(Driver::class)->without(['devices', 'vendor']); } /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function driver() + public function driver(): BelongsTo|Builder { return $this->belongsTo(Driver::class)->without(['devices', 'vendor']); } /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function vehicleAssigned() + public function vehicleAssigned(): BelongsTo|Builder { return $this->belongsTo(Vehicle::class)->without(['devices', 'vendor', 'fleets']); } /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ - public function vehicle() + public function vehicle(): BelongsTo|Builder { return $this->belongsTo(Vehicle::class)->without(['devices', 'vendor', 'fleets']); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function comments() + public function comments(): HasMany { return $this->hasMany(\Fleetbase\Models\Comment::class, 'subject_uuid')->whereNull('parent_comment_uuid')->latest(); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function files() + public function files(): HasMany { return $this->hasMany(\Fleetbase\Models\File::class, 'subject_uuid')->latest(); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function customFields() + public function customFields(): HasMany { return $this->hasMany(CustomField::class, 'subject_uuid')->orderBy('order'); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function customFieldValues() + public function customFieldValues(): HasMany { return $this->hasMany(CustomFieldValue::class, 'subject_uuid'); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasManyThrough - */ - public function drivers() + public function drivers(): HasManyThrough { return $this->hasManyThrough(Driver::class, Entity::class, 'tracking_number_uuid', 'tracking_number_uuid'); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function trackingNumber() + public function trackingNumber(): BelongsTo { return $this->belongsTo(TrackingNumber::class)->without(['owner']); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function trackingStatuses() + public function trackingStatuses(): HasMany { return $this->hasMany(TrackingStatus::class, 'tracking_number_uuid', 'tracking_number_uuid'); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function proofs() + public function proofs(): HasMany { return $this->hasMany(Proof::class, 'subject_uuid'); } /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ - public function purchaseRate() + public function purchaseRate(): BelongsTo { return $this->belongsTo(PurchaseRate::class); } /** - * @return \Illuminate\Database\Eloquent\Relations\MorphTo + * @return MorphTo */ - public function facilitator() + public function facilitator(): MorphTo|Builder { return $this->morphTo(__FUNCTION__, 'facilitator_type', 'facilitator_uuid')->withTrashed(); } - /** - * @return \Illuminate\Database\Eloquent\Relations\MorphTo - */ - public function customer() + public function customer(): MorphTo { return $this->morphTo(__FUNCTION__, 'customer_type', 'customer_uuid'); } + public function authenticatableCustomer(): BelongsTo + { + return $this->belongsTo(Contact::class, 'customer_uuid')->where('type', 'customer'); + } + /** * Get the adhoc distance for this order, or fallback to settings or default value which is 6km. * diff --git a/tests/integration/components/customer/orders-test.js b/tests/integration/components/customer/orders-test.js new file mode 100644 index 00000000..df0d4227 --- /dev/null +++ b/tests/integration/components/customer/orders-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | customer/orders', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs``); + + assert.dom().hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom().hasText('template block text'); + }); +}); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 55929059..c723c342 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -360,7 +360,7 @@ fleet-ops: hide: Hide all service areas action: Actions... focus: Focus - blur: Blur service area... + blur: Hide service area... create-zone: Create zone inside... toolbar: create: Create Order @@ -941,7 +941,7 @@ fleet-ops: edit-zone: 'Edit zone: {zoneName}' delete-zone: 'Delete zone: {zoneName}' assign-zone: 'Assign Fleet to Zone: {zoneName}' - blur-service: 'Blur Service Area: {serviceName}' + blur-service: 'Hide Service Area: {serviceName}' create-zone: 'Create Zone within: {serviceName}' assign-fleet: 'Assign Fleet to Service Area: {serviceName}' edit-service: 'Edit Service Area: {serviceName}'