Skip to content

Instantly share code, notes, and snippets.

@reppair
Created March 27, 2019 18:14
Show Gist options
  • Save reppair/818c74b623eb1bd9246a74e31e7cf821 to your computer and use it in GitHub Desktop.
Save reppair/818c74b623eb1bd9246a74e31e7cf821 to your computer and use it in GitHub Desktop.
Vehile Intake Report
// get a translation from the stack by it's group and key
function getTranslation(group_dot_key_string) {
let [group, key] = group_dot_key_string.split('.');
if (Oxy.translations[group] === undefined || Oxy.translations[group][key] === undefined) {
return group_dot_key_string;
}
return Oxy.translations[group][key];
}
export default {
methods: {
// log the user out
logout(event) {
axios.post(event.target.href)
.then(response => document.location = response.data.redirect_to)
.catch(errors => console.log(errors));
},
// Get a translation string
trans(group_dot_key_string) {
return getTranslation(group_dot_key_string);
},
// scroll to the first error on the form
scrollToFirstError(selector = '.invalid-feedback') {
let elements = Array.from(document.querySelectorAll(selector)).filter(el => {
return el.style['display'] !== 'none';
});
if (elements.length) elements[0].parentElement.scrollIntoView();
},
// 🐿 ship it
async submitForm(method, url, data, headers = {}, thenCallback = false, catchCallback = false) {
const isValid = await this.$validator.validate(),
errorBag = this.$validator.errors;
// guard for validation failures
if (!isValid) {
this.scrollToFirstError();
return;
}
let promise = axios({
method: method,
url: url,
data: data,
headers: headers,
});
if (thenCallback) {
promise.then(thenCallback);
} else {
promise.then(response => window.location = response.data.redirect_to);
}
if (catchCallback) {
promise.catch(catchCallback)
} else {
promise.catch(error => {
// handle server side validation errors
if (error.response.status === 422) {
let errors = error.response.data.errors;
Object.keys(errors).forEach(field => {
errorBag.add({
field: field,
msg: errors[field][0],
});
});
this.scrollToFirstError();
}
});
}
},
cl(loggable) {
console.log(loggable);
},
},
filters: {
toEuro: value => {
return '€ ' + parseFloat(Number(value) / 100);
},
}
}
<template>
<form @submit.prevent="createReport()">
<!-- client -->
<div class="relative">
<v-select :options="clientOptions"
:placeholder="trans('labels.select-client')"
v-model="client"
class="mb-4 v-select-bb"
:disabled="disabled"
name="client"
v-validate="'required'"
></v-select>
<small class="invalid-feedback absolute pin-r pin-t pt-2 pr-6"
v-show="errors.has('client')"
v-text="errors.first('client')"
></small>
</div>
<alpr classes="input input-bb flex justify-between mb-4"
:placeholder="trans('labels.upload-plate-number-photo')"
v-on:plate-photo-uploaded="platePhotoUploaded($event)"
v-on:plate-recognised="plateRecognised($event)"
v-on:plate-not-recognised="clearAlprInputs()"
></alpr>
<!-- plate number -->
<text-input name="plate_number"
:placeholder="trans('labels.plate-number')"
v-model="plate_number"
v-validate="'required'"
classes="input input-bb mb-4"
:disabled="disabled"
></text-input>
<!-- brand -->
<text-input name="brand"
:placeholder="trans('labels.brand')"
v-model="brand"
v-validate="'required'"
classes="input input-bb mb-4"
:disabled="disabled"
></text-input>
<!-- model -->
<text-input name="model"
:placeholder="trans('labels.model')"
v-model="model"
v-validate="'required'"
classes="input input-bb mb-4"
:disabled="disabled"
></text-input>
<!-- color -->
<text-input name="color"
:placeholder="trans('labels.color')"
v-model="color"
v-validate="'required'"
classes="input input-bb mb-4"
:disabled="disabled"
></text-input>
<!-- mileage -->
<text-input name="mileage"
:placeholder="trans('labels.mileage')"
v-model="mileage"
v-validate="'required'"
classes="input input-bb mb-4"
:disabled="disabled"
></text-input>
<!-- chassis number -->
<text-input name="chassis_number"
:placeholder="trans('labels.chassis-number')"
v-model="chassis_number"
v-validate="'required'"
classes="input input-bb mb-4"
:disabled="disabled"
></text-input>
<!-- gate in -->
<div class="relative">
<datepicker v-model="gate_in"
input-class="input input-bb mb-4"
:placeholder="trans('labels.gate-in')"
:disabled="disabled"
name="gate_in"
v-validate="'required'"
></datepicker>
<small class="invalid-feedback absolute pin-r pin-t pt-2"
v-show="errors.has('gate_in')"
v-text="errors.first('gate_in')"
></small>
</div>
<!-- newly added photos list -->
<div class="row photos-container"></div>
<!-- photos -->
<input type="file" multiple accept="image/*" class="hidden" id="photos" @change="photosAdded($event)">
<label for="photos" class="input input-bb mb-4 flex justify-between">
{{ trans('labels.upload-report-photos') }}
<i class="fas fa-camera ml-2"></i>
</label>
<div class="row">
<!-- todo: loop original photos if used for edit -->
<!--<div class="col-12 md:col-4" v-for="image in media">-->
<!--<img src="" alt="">-->
<!--</div>-->
</div>
<div class="text-right pt-4">
<button type="submit" class="btn btn-blue" :disabled="disabled">Create Report</button>
</div>
</form>
</template>
<script>
import CommonVueMethods from "../CommonVueMethods";
import TextInput from "../components/TextInput";
import vSelect from 'vue-select/src/components/Select';
import Datepicker from 'vuejs-datepicker';
import Alpr from './Alpr';
export default {
name: 'vehicle-details-form',
extends: CommonVueMethods,
components: {
TextInput,
vSelect,
Datepicker,
Alpr,
},
props: ['action', 'report', 'clients'],
data() {
return {
disabled: false,
client: this.getReportAttribute('client', null),
plate_number: this.getReportAttribute('plate_number'),
chassis_number: this.getReportAttribute('chassis_number'),
brand: this.getReportAttribute('brand'),
model: this.getReportAttribute('model'),
mileage: this.getReportAttribute('mileage'),
color: this.getReportAttribute('color'),
gate_in: this.getReportAttribute('gate_in', null),
photos: [],
plate_photo: null, // Alpr event
alpr_results: null, // Alpr event
};
},
methods: {
// get an attribute from the report or default value
getReportAttribute(attr, fallback = '') {
if (typeof this.$props.report === 'undefined') {
return fallback;
}
switch (attr) {
case 'client':
return this.getReportClientOption();
case 'gate_in':
return this.getReportGateIn();
default:
return this.$props.report[attr];
}
},
// get the client select option when loading existing report
getReportClientOption() {
return {label: this.$props.report.client.name, value: this.$props.report.client.id}
},
// get the gate as date from the report attributes
getReportGateIn() {
let date = this.$props.report.gate_in_date.split('-');
return new Date(date[0], date[1], date[2]);
},
// add the photo/s to the photos prop & visualise them
photosAdded(event) {
let files = Array.from(event.target.files);
files.forEach(file => {
// append the file
this.photos.push(file);
// visualise it
let div = document.createElement('div'),
img = document.createElement('img'),
reader = new FileReader();
div.classList.add('col-12', 'md:col-4', 'delete-button'); // todo: add delete btn as after
reader.onload = e => img.src = e.target.result;
reader.readAsDataURL(file);
div.appendChild(img);
document.querySelector('.photos-container').appendChild(div);
});
},
// When a plate photo is uploaded
platePhotoUploaded(event) {
this.plate_photo = event;
// this.clearAlprInputs();
},
// When the ALPR recognises a plate
plateRecognised(data) {
this.alpr_results = data;
this.plate_number = data.plate;
this.brand = data.brand[0].name;
this.model = data.model[0].name;
this.color = data.color[0].name;
},
// Clear all ALPR related input fields
clearAlprInputs() {
this.plate_number = '';
this.brand = '';
this.model = '';
this.color = '';
},
// send request to create a report
createReport() {
let details = new FormData(),
append = ['plate_number', 'chassis_number', 'brand', 'model', 'mileage', 'color'];
append.forEach(attr => details.append(attr, this[attr]));
if (this.client) details.append('client_id', this.client.value);
if (this.gate_in) details.append('gate_in', this.gate_in.toISOString().split('T')[0]);
if (this.plate_photo) details.append('plate_photo', this.plate_photo);
if (this.photos.length) {
this.photos.forEach(file => {
details.append('photos[]', file);
});
}
this.submitForm('post', this.action, details);
},
},
computed: {
// options for the client select
clientOptions() {
return _.map(this.$props.clients, c => {
return {value: c.id, label: c.name};
});
}
}
}
</script>
<template>
<form @submit.prevent="updateReport()" class="pb-8">
<h2 class="mb-4">Primary check</h2>
<!-- part -->
<v-select :options="partOptions"
:placeholder="trans('labels.select-part')"
v-model="part"
class="mb-4 v-select-bb"
@change="activity = null"
></v-select>
<!-- activity -->
<v-select :options="activityOptions"
:placeholder="trans('labels.select-activity')"
v-model="activity"
class="mb-4 v-select-bb"
>
<span slot="no-options">Please, select a part first.</span>
</v-select>
<!-- ACP price checkboxes -->
<div class="row" v-if="activity_client_part">
<label class="col-12 md:col-6 flex" v-if="activity_client_part.labor_price">
<input class="mr-2"
type="checkbox"
:value="activity_client_part.labor_price"
v-model="include_labor_price"
>
<span :class="{'line-through': !include_labor_price}">
Include labor price: {{ activity_client_part.labor_price | toEuro }}
</span>
</label>
<label class="col-12 md:col-6 flex" v-if="activity_client_part.preparation_price">
<input class="mr-2"
type="checkbox"
:value="activity_client_part.preparation_price"
v-model="include_preparation_price"
>
<span :class="{'line-through': !include_preparation_price}">
Include preparation price: {{ activity_client_part.preparation_price | toEuro }}
</span>
</label>
</div>
<!-- validation error if no primary check is selected -->
<small class="invalid-feedback py-2"
v-show="errors.has('primary_check')"
v-text="errors.first('primary_check')"
></small>
<!-- add ACP -->
<div class="text-right pt-4">
<button class="btn btn-blue"
:disabled="!canAddPrimaryCheck"
@click="addPrimaryCheck()"
type="button"
>
Add
</button>
</div>
<!-- primary checks list -->
<table class="w-full my-4">
<tr class="text-left border-b border-blue">
<th class="py-2">Part</th>
<th class="py-2">Activity</th>
<th class="py-2">Labor</th>
<th class="py-2">Preparation</th>
</tr>
<tr v-for="check in primary_checks" class="py-2">
<td class="py-2">{{ check.acp.part.name }}</td>
<td class="py-2">{{ check.acp.activity.name }}</td>
<td class="py-2">{{ check.labor_price | toEuro }}</td>
<td class="py-2 relative">
{{ check.preparation_price | toEuro }}
<!-- remove row button -->
<button class="absolute pin-r text-red" @click="removePrimaryCheck(check)">x</button>
</td>
</tr>
<!-- total -->
<tr>
<td class="pt-4 pb-2"></td>
<td class="pt-4 pb-2"></td>
<td class="pt-4 pb-2"></td>
<td class="pt-4 pb-2 font-bold border-b border-blue flex justify-between">
<span>Total:</span>
<span>{{ primaryChecksTotal | toEuro }}</span>
</td>
</tr>
</table>
<h2 class="mb-4">Secondary check</h2>
<!-- secondary check -->
<v-select :options="secondaryCheckOptions"
:placeholder="trans('labels.select-secondary-check')"
v-model="secondary_check"
class="mb-4 v-select-bb"
></v-select>
<!-- secondary check value -->
<div v-if="secondary_check" class="row">
<label class="col-6 text-right">
<input type="radio"
class="mr-2"
v-model="secondary_check.value"
value="value_1"
> {{ secondary_check.model.value_1 }}
</label>
<label class="col-6">
<input type="radio"
class="mr-2"
v-model="secondary_check.value"
value="value_2"
> {{ secondary_check.model.value_2 }}
</label>
</div>
<!-- add secondary check button -->
<div class="text-right pt-4">
<button class="btn btn-blue"
:disabled="!secondary_check || !secondary_check.value"
@click="addSecondaryCheck()"
type="button"
>
Add
</button>
</div>
<!-- secondary checks list -->
<ul class="pl-4 my-4">
<li v-for="sc in secondary_checks" class="py-2 relative">
{{ sc.model.name }}:
<strong v-text="sc.model[sc.value]"></strong>
<button class="absolute pin-r text-red pl-2 -mt-1" @click="removeSecondaryCheck(sc)">x</button>
</li>
</ul>
<h2 class="mb-4">Tire profile</h2>
<!-- tire profile inputs -->
<div class="row">
<!-- front left tire -->
<div class="col-12 md:col-6">
<text-input name="tire_profile_fl"
:placeholder="trans('labels.front-left-tire')"
v-model="tire_profile['fl']"
classes="input input-bb mb-4"
v-validate="'required|numeric|min_value:0|max_value:30'"
></text-input>
</div>
<!-- front right tire -->
<div class="col-12 md:col-6">
<text-input name="tire_profile_fr"
:placeholder="trans('labels.front-right-tire')"
v-model="tire_profile['fr']"
classes="input input-bb mb-4"
v-validate="'required|numeric|min_value:0|max_value:30'"
></text-input>
</div>
<!-- rear left tire -->
<div class="col-12 md:col-6">
<text-input name="tire_profile_rl"
:placeholder="trans('labels.rear-left-tire')"
v-model="tire_profile['rl']"
classes="input input-bb mb-4"
v-validate="'required|numeric|min_value:0|max_value:30'"
></text-input>
</div>
<!-- rear right tire -->
<div class="col-12 md:col-6">
<text-input name="tire_profile_rr"
:placeholder="trans('labels.rear-right-tire')"
v-model="tire_profile['rr']"
classes="input input-bb mb-4"
v-validate="'required|numeric|min_value:0|max_value:30'"
></text-input>
</div>
</div>
<h2 class="my-4">Other activities</h2>
<!-- select other activity -->
<v-select :options="otherActivityOptions"
:placeholder="trans('labels.select-other-activity')"
v-model="other_activity"
class="mb-4 v-select-bb"
></v-select>
<!-- other activity value (optional if the activity has both repair and replacement prices) -->
<div v-if="other_activity && other_activity.multiple_values" class="row">
<label class="col-6 text-right">
<input type="radio"
class="mr-2"
v-model="other_activity.value"
value="replacement_price"
> Replacement: {{ other_activity.model.replacement_price | toEuro }}
</label>
<label class="col-6">
<input type="radio"
class="mr-2"
v-model="other_activity.value"
value="repair_price"
> Repair: {{ other_activity.model.repair_price | toEuro }}
</label>
</div>
<!-- add other activity button -->
<div class="text-right pt-4">
<button class="btn btn-blue"
:disabled="!canAddOtherActivity"
@click="addOtherActivity()"
type="button"
>
Add
</button>
</div>
<!-- primary checks list -->
<table class="w-full my-4">
<tr class="text-left border-b border-blue">
<th class="py-2">Activity</th>
<th class="py-2">Type</th>
<th class="py-2">Price</th>
</tr>
<tr v-for="activity in other_activities" class="py-2">
<td class="py-2">{{ activity.model.name }}</td>
<td class="py-2 capitalize">{{ activity.value.replace('_price', '') }}</td>
<td class="py-2 relative">
{{ activity.model[activity.value] | toEuro }}
<!-- remove row button -->
<button class="absolute pin-r text-red" @click="removeOtherActivity(activity)">x</button>
</td>
</tr>
<!-- total -->
<tr>
<td class="pt-4 pb-2"></td>
<td class="pt-4 pb-2"></td>
<td class="pt-4 pb-2 font-bold border-b border-blue flex justify-between">
<span>Total:</span>
<span>{{ otherActivitiesTotal | toEuro }}</span>
</td>
</tr>
</table>
<!-- the total coast for the whole report -->
<div class="flex py-4 mt-4 text-white font-bold">
<div class="w-1/3 ml-auto p-4 bg-blue">
<div class="flex justify-between border-b border-white pb-1">
<span>Total coast:</span>
<span>{{ totalCoast | toEuro }}</span>
</div>
</div>
</div>
<!-- submit form button -->
<div class="text-right pt-4">
<button class="btn btn-blue" type="submit">
Save Report
</button>
</div>
</form>
</template>
<script>
import CommonVueMethods from "../CommonVueMethods";
import vSelect from 'vue-select/src/components/Select';
import TextInput from "../components/TextInput";
export default {
name: 'vehicle-inspection-form',
extends: CommonVueMethods,
components: {
vSelect,
TextInput,
},
props: [
'action',
'report',
'activity_client_parts',
'parts',
'activities',
'secondary_checks_list',
'client_other_activities',
],
data() {
return {
part: null,
activity: null,
activity_client_part: null,
include_labor_price: false,
include_preparation_price: false,
primary_checks: [],
secondary_check: null,
secondary_checks: [], // todo: should all of those be marked?
secondaryCheckOptions: [],
tire_profile: {
fl: null,
fr: null,
rl: null,
rr: null,
},
other_activity: null,
otherActivityOptions: [],
other_activities: [],
};
},
mounted() {
// set the secondary check select options
this.secondaryCheckOptions = _.map(this.$props.secondary_checks_list, (sc, id) => {
return {
id: id,
label: sc.name,
model: sc,
value: null,
};
});
// set the other activity select options
this.otherActivityOptions = _.map(this.$props.client_other_activities, (oa, id) => {
let activity = {
id: id,
label: oa.name,
model: oa,
value: null,
multiple_values: oa.replacement_price !== null && oa.repair_price !== null,
};
// if the activity has one value set it here (if not set it with a radio button)
if (!activity.multiple_values) {
activity.value = oa.repair_price !== null ? 'repair_price' : 'replacement_price'
}
return activity;
});
// todo: repeat with the primary check options
},
watch: {
// on activity change
activity(activity) {
// clear input without the selected part & activity
this.resetPrimaryCheckInput(null, this.part, activity);
if (!activity) return;
// find the ACP and set it so it's prices can be shown for selection
this.activity_client_part = this.$props.activity_client_parts.find(acp => {
return acp.activity_id == activity.id && acp.part_id == this.part.id;
});
// pre-check the include labor||preparation price
this.activity_client_part.labor_price
? this.include_labor_price = true
: this.include_preparation_price = true;
},
},
methods: {
// add ACP to the primary checks list
addPrimaryCheck() {
// todo: guard here for the same ACP being added
this.primary_checks.push({
acp: this.activity_client_part,
include_labor_price: this.include_labor_price,
labor_price: this.include_labor_price ? this.activity_client_part.labor_price : 0,
include_preparation_price: this.include_preparation_price,
preparation_price: this.include_preparation_price ? this.activity_client_part.preparation_price : 0,
});
// and reset the inputs
this.resetPrimaryCheckInput();
},
// remove a primary check from the list
removePrimaryCheck(check) {
this.primary_checks = this.primary_checks.filter(c => c.acp.id != check.acp.id);
},
// reset all inputs and the selected ACP
resetPrimaryCheckInput(acp = null, part = null, activity = null, labor = false, preparation = false) {
this.activity_client_part = acp;
this.part = part;
this.activity = activity;
this.include_labor_price = labor;
this.include_preparation_price = preparation;
},
// add secondary check to the list
addSecondaryCheck() {
this.secondary_checks.push(this.secondary_check);
// find and remove this check from the select options
this.secondaryCheckOptions = this.secondaryCheckOptions.filter(option => {
return option.id != this.secondary_check.id;
});
this.secondary_check = null;
},
// remove a secondary check from the list
removeSecondaryCheck(check) {
// first push it back to the secondaryCheckOptions array for selection
check.value = null;
this.secondaryCheckOptions.push(check);
this.secondaryCheckOptions = _.sortBy(this.secondaryCheckOptions, 'id');
// remove
this.secondary_checks = this.secondary_checks.filter(sc => sc.model.id != check.model.id);
},
// add other activity to the list
addOtherActivity() {
this.other_activities.push(this.other_activity);
// remove it from the selectable options
this.otherActivityOptions = this.otherActivityOptions.filter(option => {
return option.model.id !== this.other_activity.model.id;
});
// and clear it
this.other_activity = null;
},
// remove an other activity from the list
removeOtherActivity(activity) {
// push it back to the otherActivityOptions list first so it can be re-selected
if (activity.multiple_values) activity.value = null;
this.otherActivityOptions.push(activity);
this.otherActivityOptions = _.sortBy(this.otherActivityOptions, 'id');
// remove
this.other_activities = this.other_activities.filter(a => a.model.id !== activity.model.id);
},
// send request to create a report
updateReport() {
this.$validator.errors.clear();
// validate min:1 primary check is required
if (!this.primary_checks.length) {
this.$validator.errors.add({
field: 'primary_check',
msg: this.trans('db.at-least-one-primary-check-is-required'),
});
}
let data = {
primary_checks: [],
secondary_checks: [],
tire_profile: this.tire_profile,
other_activities: [],
};
this.primary_checks.forEach(check => data.primary_checks.push({
id: check.acp.id,
include_labor: check.include_labor_price,
include_preparation: check.include_preparation_price,
}));
['secondary_checks', 'other_activities'].forEach(key => {
this[key].forEach(item => data[key].push({
id: item.model.id,
value: item.value,
}));
});
this.submitForm('patch', this.$props.action, data);
},
},
computed: {
// part select options
partOptions() {
return _.map(this.$props.parts, (value, id) => {
return {id: id, label: value};
});
},
// activity select options
activityOptions() {
if (!this.part) return [];
return _.map(this.$props.activities[this.part.id], (value, id) => {
return {id: id, label: value};
});
},
// determine if a primary check can be added to the list
canAddPrimaryCheck() {
return (this.include_labor_price || this.include_preparation_price)
&& this.part != null
&& this.activity != null;
},
// get the total price of the primary checks
primaryChecksTotal() {
if (!this.primary_checks.length) return 0;
let total = 0;
this.primary_checks.forEach(check => {
total = check.include_labor_price ? total + check.acp.labor_price : total;
total = check.include_preparation_price ? total + check.acp.preparation_price : total;
});
return total;
},
// determine if an other activity can be added to the list
canAddOtherActivity() {
if (this.other_activity === null) return false;
return this.other_activity.multiple_values
? this.other_activity.value !== null
: this.other_activity !== null;
},
// the total price for other activities
otherActivitiesTotal() {
let total = 0;
this.other_activities.forEach(a => total = total + Number(a.model[a.value]));
return total;
},
// the total coast of the whole report
totalCoast() {
return this.primaryChecksTotal + this.otherActivitiesTotal;
}
},
}
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment