build version 400

This commit is contained in:
Mohit Panjwani
2020-12-02 17:54:08 +05:30
parent 326508e567
commit 89ee58590c
963 changed files with 62887 additions and 48868 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,211 @@
<template>
<div class="col-span-5 pr-0">
<div
v-if="selectedCustomer"
class="flex flex-col p-4 bg-white border border-gray-200 border-solid"
style="min-height: 170px"
>
<div class="relative flex justify-between mb-1">
<label class="flex-1 font-medium">{{ selectedCustomer.name }}</label>
<a
class="relative my-0 ml-0 mr-6 text-sm font-medium cursor-pointer text-primary-500"
@click.prevent="editCustomer"
>
{{ $t('general.edit') }}
</a>
<a
class="relative my-0 ml-0 mr-6 text-sm font-medium cursor-pointer text-primary-500"
@click.prevent="resetSelectedCustomer"
>
{{ $t('general.deselect') }}
</a>
</div>
<div class="grid grid-cols-2 gap-4 mt-1">
<div v-if="selectedCustomer.billing_address">
<div class="flex flex-col">
<label
class="mb-1 text-sm font-medium text-gray-500 uppercase whitespace-no-wrap"
>
{{ $t('general.bill_to') }}
</label>
<div class="flex flex-col flex-1 p-0">
<label
v-if="selectedCustomer.billing_address.name"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.billing_address.name }}
</label>
<label
v-if="selectedCustomer.billing_address.address_street_1"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.billing_address.address_street_1 }}
</label>
<label
v-if="selectedCustomer.billing_address.address_street_2"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.billing_address.address_street_2 }}
</label>
<label
v-if="
selectedCustomer.billing_address.city &&
selectedCustomer.billing_address.state
"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.billing_address.city }},
{{ selectedCustomer.billing_address.state }}
{{ selectedCustomer.billing_address.zip }}
</label>
<label
v-if="selectedCustomer.billing_address.country"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.billing_address.country.name }}
</label>
<label
v-if="selectedCustomer.billing_address.phone"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.billing_address.phone }}
</label>
</div>
</div>
</div>
<div v-if="selectedCustomer.shipping_address">
<div class="flex flex-col">
<label
class="mb-1 text-sm font-medium text-gray-500 uppercase whitespace-no-wrap"
>
{{ $t('general.ship_to') }}
</label>
<div class="flex flex-col flex-1 p-0">
<label
v-if="selectedCustomer.shipping_address.name"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.shipping_address.name }}
</label>
<label
v-if="selectedCustomer.shipping_address.address_street_1"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.shipping_address.address_street_1 }}
</label>
<label
v-if="selectedCustomer.shipping_address.address_street_2"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.shipping_address.address_street_2 }}
</label>
<label
v-if="
selectedCustomer.shipping_address.city &&
selectedCustomer.shipping_address
"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.shipping_address.city }},
{{ selectedCustomer.shipping_address.state }}
{{ selectedCustomer.shipping_address.zip }}
</label>
<label
v-if="selectedCustomer.shipping_address.country"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.shipping_address.country.name }}
</label>
<label
v-if="selectedCustomer.shipping_address.phone"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.shipping_address.phone }}
</label>
</div>
</div>
</div>
</div>
</div>
<div v-else>
<sw-popup
:class="[
'p-0',
{
'border border-solid border-danger rounded': valid.$error,
},
]"
>
<div
slot="activator"
class="relative flex justify-center px-0 py-16 bg-white border border-gray-200 border-solid rounded"
style="min-height: 170px"
>
<user-icon
class="flex justify-center w-10 h-10 p-2 mr-5 text-sm text-white bg-gray-200 rounded-full font-base"
/>
<div class="mt-1">
<label class="text-lg">
{{ $t('customers.new_customer') }}
<span class="text-danger"> * </span>
</label>
<p v-if="valid.$error && !valid.required" class="text-danger">
{{ $t('estimates.errors.required') }}
</p>
</div>
</div>
<customer-select-popup :user-id="customerId" type="estimate" />
</sw-popup>
</div>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex'
import { UserIcon } from '@vue-hero-icons/solid'
export default {
components: {
UserIcon,
},
props: {
valid: {
type: Object,
default: () => {},
},
customerId: {
type: Number,
default: null,
},
},
computed: {
...mapGetters('estimate', ['getTemplateId', 'selectedCustomer']),
},
created() {
this.resetSelectedCustomer()
if (this.customerId) {
this.selectCustomer(this.customerId)
}
},
methods: {
...mapActions('estimate', ['resetSelectedCustomer', 'selectCustomer']),
...mapActions('modal', ['openModal']),
editCustomer() {
this.openModal({
title: this.$t('customers.edit_customer'),
componentName: 'CustomerModal',
id: this.selectedCustomer.id,
data: this.selectedCustomer,
})
},
},
}
</script>

View File

@ -1,50 +1,49 @@
<template>
<div class="section mt-2">
<label class="estimate-label">
<div class="flex items-center justify-between w-full mt-2 text-sm">
<label class="font-semibold leading-5 text-gray-500 uppercase">
{{ tax.name }} ({{ tax.percent }}%)
</label>
<label class="estimate-amount">
<label class="flex items-center justify-center text-lg text-black">
<div v-html="$utils.formatMoney(tax.amount, currency)" />
<font-awesome-icon
class="ml-2"
icon="trash-alt"
@click="$emit('remove', index)"
/>
<trash-icon class="h-5 ml-2" @click="$emit('remove', index)" />
</label>
</div>
</template>
<script>
import { TrashIcon } from '@vue-hero-icons/solid'
export default {
components: {
TrashIcon,
},
props: {
index: {
type: Number,
required: true
required: true,
},
tax: {
type: Object,
required: true
required: true,
},
taxes: {
type: Array,
required: true
required: true,
},
total: {
type: Number,
default: 0
default: 0,
},
totalTax: {
type: Number,
default: 0
default: 0,
},
currency: {
type: [Object, String],
required: true
}
required: true,
},
},
computed: {
taxAmount () {
taxAmount() {
if (this.tax.compound_tax && this.total) {
return ((this.total + this.totalTax) * this.tax.percent) / 100
}
@ -54,30 +53,26 @@ export default {
}
return 0
}
},
},
watch: {
total: {
handler: 'updateTax'
handler: 'updateTax',
},
totalTax: {
handler: 'updateTax'
}
handler: 'updateTax',
},
},
methods: {
updateTax () {
updateTax() {
this.$emit('update', {
'index': this.index,
'item': {
index: this.index,
item: {
...this.tax,
amount: this.taxAmount
}
amount: this.taxAmount,
},
})
}
}
},
},
}
</script>
<style lang="scss" scoped>
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +1,22 @@
<template>
<tr class="item-row estimate-item-row">
<td colspan="5">
<table class="full-width">
<tr class="box-border bg-white border border-gray-200 border-solid rounded-b">
<td colspan="5" class="p-0 text-left align-top">
<table class="w-full">
<colgroup>
<col style="width: 40%;">
<col style="width: 10%;">
<col style="width: 15%;">
<col v-if="discountPerItem === 'YES'" style="width: 15%;">
<col style="width: 15%;">
<col style="width: 40%" />
<col style="width: 10%" />
<col style="width: 15%" />
<col v-if="discountPerItem === 'YES'" style="width: 15%" />
<col style="width: 15%" />
</colgroup>
<tbody>
<tr>
<td class="">
<div class="item-select-wrapper">
<div class="sort-icon-wrapper handle">
<font-awesome-icon
class="sort-icon"
icon="grip-vertical"
/>
<td class="px-5 py-4 text-left align-top">
<div class="flex justify-start">
<div
class="flex items-center justify-center w-12 h-5 mt-2 text-gray-400 cursor-move handle"
>
<drag-icon />
</div>
<item-select
ref="itemSelect"
@ -34,87 +33,94 @@
/>
</div>
</td>
<td class="text-right">
<base-input
<td class="px-5 py-4 text-right align-top">
<sw-input
v-model="item.quantity"
:invalid="$v.item.quantity.$error"
:is-input-group="!!item.unit_name"
:input-group-text="item.unit_name"
type="text"
small
@keyup="updateItem"
@input="$v.item.quantity.$touch()"
/>
<div v-if="$v.item.quantity.$error">
<span v-if="!$v.item.quantity.maxLength" class="text-danger">{{ $t('validation.quantity_maxlength') }}</span>
<span v-if="!$v.item.quantity.maxLength" class="text-danger">
{{ $t('validation.quantity_maxlength') }}
</span>
</div>
</td>
<td class="text-left">
<div class="d-flex flex-column">
<div class="flex-fillbd-highlight">
<div class="base-input">
<money
<td class="px-5 py-4 text-left align-top">
<div class="flex flex-col">
<div class="flex-auto flex-fill bd-highlight">
<div class="relative w-full">
<sw-money
v-model="price"
v-bind="customerCurrency"
class="input-field"
:currency="customerCurrency"
:invalid="$v.item.price.$error"
@input="$v.item.price.$touch()"
/>
</div>
<div v-if="$v.item.price.$error">
<span v-if="!$v.item.price.maxLength" class="text-danger">{{ $t('validation.price_maxlength') }}</span>
<span v-if="!$v.item.price.maxLength" class="text-danger">
{{ $t('validation.price_maxlength') }}
</span>
</div>
</div>
</div>
</td>
<td v-if="discountPerItem === 'YES'" class="">
<div class="d-flex flex-column bd-highlight">
<div
class="btn-group flex-fill bd-highlight"
role="group"
>
<base-input
<td
v-if="discountPerItem === 'YES'"
class="px-5 py-4 text-left align-top"
>
<div class="flex flex-col">
<div class="flex flex-auto" role="group">
<sw-input
v-model="discount"
:invalid="$v.item.discount_val.$error"
input-class="item-discount"
class="border-r-0 rounded-tr-none rounded-br-none"
@input="$v.item.discount_val.$touch()"
/>
<v-dropdown :show-arrow="false" theme-light>
<button
<sw-dropdown>
<sw-button
slot="activator"
type="button"
class="btn item-dropdown dropdown-toggle"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
style="height: 43px; padding: 6px"
variant="white"
>
{{ item.discount_type == 'fixed' ? currency.symbol : '%' }}
</button>
<v-dropdown-item>
<a class="dropdown-item" href="#" @click.prevent="selectFixed" >
{{ $t('general.fixed') }}
</a>
</v-dropdown-item>
<v-dropdown-item>
<a class="dropdown-item" href="#" @click.prevent="selectPercentage">
{{ $t('general.percentage') }}
</a>
</v-dropdown-item>
</v-dropdown>
<span class="flex">
{{
item.discount_type == 'fixed' ? currency.symbol : '%'
}}
<chevron-down-icon class="h-5" />
</span>
</sw-button>
<sw-dropdown-item @click="selectFixed">
{{ $t('general.fixed') }}
</sw-dropdown-item>
<sw-dropdown-item @click="selectPercentage">
{{ $t('general.percentage') }}
</sw-dropdown-item>
</sw-dropdown>
</div>
<!-- <div v-if="$v.item.discount.$error"> discount error </div> -->
</div>
</td>
<td class="text-right">
<div class="item-amount">
<td class="px-5 py-4 text-right align-top">
<div class="flex items-center justify-end text-sm">
<span>
<div v-html="$utils.formatMoney(total, currency)" />
</span>
<div class="remove-icon-wrapper">
<font-awesome-icon
<div
class="flex items-center justify-center w-6 h-10 mx-2 cursor-pointer"
>
<trash-icon
v-if="isShowRemoveItemIcon"
class="remove-icon"
icon="trash-alt"
class="h-5 text-gray-700"
@click="removeItem"
/>
</div>
@ -122,8 +128,8 @@
</td>
</tr>
<tr v-if="taxPerItem === 'YES'" class="tax-tr">
<td />
<td colspan="4">
<td class="px-5 py-4 text-left align-top" />
<td colspan="4" class="px-5 py-4 text-left align-top">
<tax
v-for="(tax, index) in item.taxes"
:key="tax.id"
@ -146,96 +152,100 @@
</template>
<script>
import Guid from 'guid'
import { validationMixin } from 'vuelidate'
import { mapGetters } from 'vuex'
import TaxStub from '../../stub/tax'
import EstimateStub from '../../stub/estimate'
import ItemSelect from './ItemSelect'
import Tax from './Tax'
const { required, minValue, between, maxLength } = require('vuelidate/lib/validators')
import { TrashIcon, ChevronDownIcon } from '@vue-hero-icons/solid'
import DragIcon from '@/components/icon/DragIcon'
const {
required,
minValue,
between,
maxLength,
} = require('vuelidate/lib/validators')
export default {
components: {
Tax,
ItemSelect
ItemSelect,
TrashIcon,
ChevronDownIcon,
DragIcon,
},
mixins: [validationMixin],
props: {
itemData: {
type: Object,
default: null
default: null,
},
index: {
type: Number,
default: null
default: null,
},
type: {
type: String,
default: ''
default: '',
},
currency: {
type: [Object, String],
required: true
required: true,
},
taxPerItem: {
type: String,
default: ''
default: '',
},
discountPerItem: {
type: String,
default: ''
default: '',
},
estimateItems: {
type: Array,
required: true
}
required: true,
},
},
data () {
data() {
return {
isClosePopup: false,
itemSelect: null,
item: {...this.itemData},
item: { ...this.itemData },
maxDiscount: 0,
money: {
decimal: '.',
thousands: ',',
prefix: '$ ',
precision: 2,
masked: false
masked: false,
},
isSelected: false
isSelected: false,
}
},
computed: {
...mapGetters('item', [
'items'
]),
...mapGetters('modal', [
'modalActive'
]),
...mapGetters('currency', [
'defaultCurrencyForInput'
]),
customerCurrency () {
...mapGetters('item', ['items']),
...mapGetters('modal', ['modalActive']),
...mapGetters('company', ['defaultCurrencyForInput']),
customerCurrency() {
if (this.currency) {
return {
decimal: this.currency.decimal_separator,
thousands: this.currency.thousand_separator,
prefix: this.currency.symbol + ' ',
precision: this.currency.precision,
masked: false
masked: false,
}
} else {
return this.defaultCurrencyForInput
}
},
isShowRemoveItemIcon () {
isShowRemoveItemIcon() {
if (this.estimateItems.length == 1) {
return false
}
return true
},
subtotal () {
subtotal() {
return this.item.price * this.item.quantity
},
discount: {
@ -250,12 +260,12 @@ export default {
}
this.item.discount = newValue
}
},
},
total () {
total() {
return this.subtotal - this.item.discount_val
},
totalSimpleTax () {
totalSimpleTax() {
return window._.sumBy(this.item.taxes, function (tax) {
if (!tax.compound_tax) {
return tax.amount
@ -264,7 +274,7 @@ export default {
return 0
})
},
totalCompoundTax () {
totalCompoundTax() {
return window._.sumBy(this.item.taxes, function (tax) {
if (tax.compound_tax) {
return tax.amount
@ -273,7 +283,7 @@ export default {
return 0
})
},
totalTax () {
totalTax() {
return this.totalSimpleTax + this.totalCompoundTax
},
price: {
@ -291,51 +301,51 @@ export default {
} else {
this.item.price = newValue
}
}
}
},
},
},
watch: {
item: {
handler: 'updateItem',
deep: true
deep: true,
},
subtotal (newValue) {
subtotal(newValue) {
if (this.item.discount_type === 'percentage') {
this.item.discount_val = (this.item.discount * newValue) / 100
}
},
modalActive (val) {
modalActive(val) {
if (!val) {
this.isSelected = false
}
}
},
},
validations () {
validations() {
return {
item: {
name: {
required
required,
},
quantity: {
required,
minValue: minValue(1),
maxLength: maxLength(20)
minValue: minValue(0),
maxLength: maxLength(20),
},
price: {
required,
minValue: minValue(1),
maxLength: maxLength(20)
maxLength: maxLength(20),
},
discount_val: {
between: between(0, this.maxDiscount)
between: between(0, this.maxDiscount),
},
description: {
maxLength: maxLength(255)
}
}
maxLength: maxLength(255),
},
},
}
},
created () {
created() {
window.hub.$on('checkItems', this.validateItem)
window.hub.$on('newItem', (val) => {
if (this.taxPerItem === 'YES') {
@ -346,36 +356,43 @@ export default {
}
})
},
mounted() {
this.$v.item.$reset()
},
methods: {
updateTax (data) {
updateTax(data) {
this.$set(this.item.taxes, data.index, data.item)
let lastTax = this.item.taxes[this.item.taxes.length - 1]
if (lastTax.tax_type_id !== 0) {
this.item.taxes.push({...TaxStub, id: Guid.raw()})
this.item.taxes.push({ ...TaxStub, id: Guid.raw() })
}
this.updateItem()
},
removeTax (index) {
removeTax(index) {
this.item.taxes.splice(index, 1)
this.updateItem()
},
taxWithPercentage ({ name, percent }) {
taxWithPercentage({ name, percent }) {
return `${name} (${percent}%)`
},
searchVal (val) {
searchVal(val) {
this.item.name = val
},
deselectItem () {
this.item = {...EstimateStub, id: this.item.id, taxes: [{...TaxStub, id: Guid.raw()}]}
deselectItem() {
this.item = {
...EstimateStub,
id: this.item.id,
taxes: [{ ...TaxStub, id: Guid.raw() }],
}
this.$nextTick(() => {
this.$refs.itemSelect.$refs.baseSelect.$refs.search.focus()
})
},
onSelectItem (item) {
onSelectItem(item) {
this.item.name = item.name
this.item.price = item.price
this.item.item_id = item.id
@ -383,16 +400,13 @@ export default {
this.item.unit_name = item.unit_name
if (this.taxPerItem === 'YES' && item.taxes) {
let index = 0
item.taxes.forEach(tax => {
this.updateTax({index, item: { ...tax }})
item.taxes.forEach((tax) => {
this.updateTax({ index, item: { ...tax } })
index++
})
}
// if (this.item.taxes.length) {
// this.item.taxes = {...item.taxes}
// }
},
selectFixed () {
selectFixed() {
if (this.item.discount_type === 'fixed') {
return
}
@ -400,7 +414,7 @@ export default {
this.item.discount_val = this.item.discount * 100
this.item.discount_type = 'fixed'
},
selectPercentage () {
selectPercentage() {
if (this.item.discount_type === 'percentage') {
return
}
@ -409,24 +423,24 @@ export default {
this.item.discount_type = 'percentage'
},
updateItem () {
updateItem() {
this.$emit('update', {
'index': this.index,
'item': {
index: this.index,
item: {
...this.item,
total: this.total,
totalSimpleTax: this.totalSimpleTax,
totalCompoundTax: this.totalCompoundTax,
totalTax: this.totalTax,
tax: this.totalTax,
taxes: [...this.item.taxes]
}
taxes: [...this.item.taxes],
},
})
},
removeItem () {
removeItem() {
this.$emit('remove', this.index)
},
validateItem () {
validateItem() {
this.$v.item.$touch()
if (this.item !== null) {
@ -434,7 +448,7 @@ export default {
} else {
this.$emit('itemValidate', this.index, false)
}
}
}
},
},
}
</script>

View File

@ -1,13 +1,20 @@
<template>
<div class="item-selector">
<div v-if="item.item_id" class="selected-item">
<div class="flex-1 text-sm">
<div
v-if="item.item_id"
class="relative flex items-center h-10 pl-2 bg-gray-200 border border-gray-200 border-solid rounded"
>
{{ item.name }}
<span class="deselect-icon" @click="deselectItem">
<font-awesome-icon icon="times-circle" />
<span
class="absolute text-gray-400 cursor-pointer"
style="top: 8px; right: 10px"
@click="deselectItem"
>
<x-circle-icon class="h-5" />
</span>
</div>
<base-select
<sw-select
v-else
ref="baseSelect"
v-model="itemSelect"
@ -24,25 +31,34 @@
@select="onSelect"
>
<div slot="afterList">
<button type="button" class="list-add-button" @click="openItemModal">
<font-awesome-icon class="icon" icon="cart-plus" />
<label>{{ $t('general.add_new_item') }}</label>
<button
type="button"
class="flex items-center justify-center w-full p-3 bg-gray-200 border-none outline-none"
@click="openItemModal"
>
<shopping-cart-icon class="h-5 text-primary-400" />
<label class="ml-2 text-sm leading-none text-primary-400">{{
$t('general.add_new_item')
}}</label>
</button>
</div>
</base-select>
<div class="item-description">
<base-text-area
</sw-select>
<div class="w-full pt-1 text-xs text-light">
<sw-textarea
v-autoresize
v-model.trim="item.description"
:invalid-description="invalidDescription"
:placeholder="$t('estimates.item.type_item_description')"
type="text"
rows="1"
class="description-input"
variant="inv-desc"
class="w-full text-gray-600 border-none resize-none"
@input="$emit('onDesriptionInput')"
/>
<div v-if="invalidDescription">
<span class="text-danger">{{ $tc('validation.description_maxlength') }}</span>
<span class="text-danger">{{
$tc('validation.description_maxlength')
}}</span>
</div>
</div>
</div>
@ -50,78 +66,79 @@
<script>
import { mapActions, mapGetters } from 'vuex'
import { validationMixin } from 'vuelidate'
import { XCircleIcon, ShoppingCartIcon } from '@vue-hero-icons/solid'
const { maxLength } = require('vuelidate/lib/validators')
export default {
mixins: [validationMixin],
components: {
XCircleIcon,
ShoppingCartIcon,
},
props: {
item: {
type: Object,
required: true
required: true,
},
invalid: {
type: Boolean,
required: false,
default: false
default: false,
},
invalidDescription: {
type: Boolean,
required: false,
default: false
default: false,
},
taxPerItem: {
type: String,
default: ''
default: '',
},
taxes: {
type: Array,
default: null
}
default: null,
},
},
data () {
data() {
return {
itemSelect: null,
loading: false
loading: false,
}
},
validations () {
validations() {
return {
item: {
description: {
maxLength: maxLength(255)
}
}
maxLength: maxLength(255),
},
},
}
},
computed: {
...mapGetters('item', [
'items'
])
...mapGetters('item', ['items']),
},
watch: {
invalidDescription (newValue) {
invalidDescription(newValue) {
console.log(newValue)
}
},
},
methods: {
...mapActions('modal', [
'openModal'
]),
...mapActions('item', [
'fetchItems'
]),
async searchItems (search) {
...mapActions('modal', ['openModal']),
...mapActions('item', ['fetchItems']),
async searchItems(search) {
let data = {
search,
filter: {
name: search,
unit: '',
price: ''
price: '',
},
orderByField: '',
orderBy: '',
page: 1
page: 1,
}
if (this.item) {
data.item_id = this.item.item_id
}
this.loading = true
@ -130,27 +147,27 @@ export default {
this.loading = false
},
onTextChange (val) {
onTextChange(val) {
this.searchItems(val)
this.$emit('search', val)
},
openItemModal () {
openItemModal() {
this.$emit('onSelectItem')
this.openModal({
'title': this.$t('items.add_item'),
'componentName': 'ItemModal',
'data': {taxPerItem: this.taxPerItem, taxes: this.taxes}
title: this.$t('items.add_item'),
componentName: 'ItemModal',
data: { taxPerItem: this.taxPerItem, taxes: this.taxes },
})
},
onSelect(val) {
this.$emit('select', val)
this.fetchItems()
},
deselectItem () {
},
deselectItem() {
this.itemSelect = null
this.$emit('deselect')
}
}
},
},
}
</script>

View File

@ -1,10 +1,10 @@
<template>
<div class="tax-row">
<div class="d-flex align-items-center tax-select">
<label class="bd-highlight pr-2 mb-0" align="right">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center text-base" style="flex: 4">
<label class="pr-2 mb-0" align="right">
{{ $t('estimates.tax') }}
</label>
<base-select
<sw-select
v-model="selectedTax"
:options="filteredTypes"
:allow-empty="false"
@ -16,20 +16,27 @@
@select="(val) => onSelectTax(val)"
>
<div slot="afterList">
<button type="button" class="list-add-button" @click="openTaxModal">
<font-awesome-icon class="icon" icon="check-circle" />
<label>{{ $t('estimates.add_new_tax') }}</label>
<button
type="button"
class="flex items-center justify-center w-full px-2 py-2 bg-gray-200 border-none outline-none"
@click="openTaxModal"
>
<check-circle-icon class="h-5 text-primary-400" />
<label class="ml-2 text-sm leading-none text-primary-400">{{
$t('estimates.add_new_tax')
}}</label>
</button>
</div>
</base-select> <br>
</sw-select>
<br />
</div>
<div class="text-right tax-amount">
<div class="text-sm text-right" style="flex: 3">
<div v-html="$utils.formatMoney(taxAmount, currency)" />
</div>
<div class="remove-icon-wrapper">
<font-awesome-icon
<div class="flex items-center justify-center w-6 h-10 mx-2 cursor-pointer">
<trash-icon
v-if="taxes.length && index !== taxes.length - 1"
class="remove-icon"
class="h-5 text-gray-700"
icon="trash-alt"
@click="removeTax"
/>
@ -39,49 +46,52 @@
<script>
import { mapActions, mapGetters } from 'vuex'
import { CheckCircleIcon, TrashIcon } from '@vue-hero-icons/solid'
export default {
components: {
CheckCircleIcon,
TrashIcon,
},
props: {
index: {
type: Number,
required: true
required: true,
},
taxData: {
type: Object,
required: true
required: true,
},
taxes: {
type: Array,
default: []
default: [],
},
total: {
type: Number,
default: 0
default: 0,
},
totalTax: {
type: Number,
default: 0
default: 0,
},
currency: {
type: [Object, String],
required: true
}
required: true,
},
},
data () {
data() {
return {
tax: {...this.taxData},
selectedTax: null
tax: { ...this.taxData },
selectedTax: null,
}
},
computed: {
...mapGetters('taxType', [
'taxTypes'
]),
filteredTypes () {
const clonedTypes = this.taxTypes.map(a => ({...a}))
...mapGetters('taxType', ['taxTypes']),
filteredTypes() {
const clonedTypes = this.taxTypes.map((a) => ({ ...a }))
return clonedTypes.map((taxType) => {
let found = this.taxes.find(tax => tax.tax_type_id === taxType.id)
let found = this.taxes.find((tax) => tax.tax_type_id === taxType.id)
if (found) {
taxType.$isDisabled = true
@ -92,7 +102,7 @@ export default {
return taxType
})
},
taxAmount () {
taxAmount() {
if (this.tax.compound_tax && this.total) {
return ((this.total + this.totalTax) * this.tax.percent) / 100
}
@ -102,19 +112,21 @@ export default {
}
return 0
}
},
},
watch: {
total: {
handler: 'updateTax'
handler: 'updateTax',
},
totalTax: {
handler: 'updateTax'
}
handler: 'updateTax',
},
},
created () {
created() {
if (this.taxData.tax_type_id > 0) {
this.selectedTax = this.taxTypes.find(_type => _type.id === this.taxData.tax_type_id)
this.selectedTax = this.taxTypes.find(
(_type) => _type.id === this.taxData.tax_type_id
)
}
window.hub.$on('newTax', (val) => {
@ -127,13 +139,11 @@ export default {
this.updateTax()
},
methods: {
...mapActions('modal', [
'openModal'
]),
customLabel ({ name, percent }) {
...mapActions('modal', ['openModal']),
customLabel({ name, percent }) {
return `${name} - ${percent}%`
},
onSelectTax (val) {
onSelectTax(val) {
this.tax.percent = val.percent
this.tax.tax_type_id = val.id
this.tax.compound_tax = val.compound_tax
@ -141,28 +151,28 @@ export default {
this.updateTax()
},
updateTax () {
updateTax() {
if (this.tax.tax_type_id === 0) {
return
}
this.$emit('update', {
'index': this.index,
'item': {
index: this.index,
item: {
...this.tax,
amount: this.taxAmount
}
amount: this.taxAmount,
},
})
},
removeTax () {
removeTax() {
this.$emit('remove', this.index, this.tax)
},
openTaxModal () {
openTaxModal() {
this.openModal({
'title': this.$t('settings.tax_types.add_tax'),
'componentName': 'TaxTypeModal'
title: this.$t('settings.tax_types.add_tax'),
componentName: 'TaxTypeModal',
})
}
}
},
},
}
</script>

View File

@ -1,202 +1,244 @@
<template>
<div v-if="estimate" class="main-content estimate-view-page">
<div class="page-header">
<h3 class="page-title">{{ estimate.estimate_number }}</h3>
<div class="page-actions row">
<div class="col-xs-2 mr-3">
<base-button
<base-page v-if="estimate" class="xl:pl-96">
<sw-page-header :title="pageTitle">
<template slot="actions">
<div class="mr-3 text-sm">
<sw-button
v-if="estimate.status === 'DRAFT'"
:loading="isMarkAsSent"
:disabled="isMarkAsSent"
:outline="true"
color="theme"
variant="primary-outline"
@click="onMarkAsSent"
>
{{ $t('estimates.mark_as_sent') }}
</base-button>
</sw-button>
</div>
<div class="col-xs-2">
<base-button
v-if="estimate.status === 'DRAFT'"
:loading="isSendingEmail"
:disabled="isSendingEmail"
color="theme"
@click="onSendEstimate"
>
{{ $t('estimates.send_estimate') }}
</base-button>
</div>
<v-dropdown
:close-on-select="true"
align="left"
class="filter-container"
<sw-button
v-if="estimate.status === 'DRAFT'"
:disabled="isSendingEmail"
variant="primary"
class="text-sm"
@click="onSendEstimate"
>
<a slot="activator" href="#">
<base-button color="theme">
<font-awesome-icon icon="ellipsis-h" />
</base-button>
</a>
<v-dropdown-item>
<div class="dropdown-item" @click="copyPdfUrl()">
<font-awesome-icon
:icon="['fas', 'link']"
class="dropdown-item-icon"
/>
{{ $t('general.copy_pdf_url') }}
</div>
<router-link
:to="{ path: `/admin/estimates/${$route.params.id}/edit` }"
class="dropdown-item"
>
<font-awesome-icon
:icon="['fas', 'pencil-alt']"
class="dropdown-item-icon"
/>
{{ $t('general.edit') }}
</router-link>
<div
class="dropdown-item"
@click="removeEstimate($route.params.id)"
>
<font-awesome-icon
:icon="['fas', 'trash']"
class="dropdown-item-icon"
/>
{{ $t('general.delete') }}
</div>
</v-dropdown-item>
</v-dropdown>
</div>
</div>
<div class="estimate-sidebar">
<base-loader v-if="isSearching" />
<div v-else class="side-header">
<base-input
{{ $t('estimates.send_estimate') }}
</sw-button>
<sw-dropdown class="ml-3">
<sw-button slot="activator" variant="primary">
<dots-horizontal-icon class="h-5" />
</sw-button>
<sw-dropdown-item @click="copyPdfUrl">
<link-icon class="h-5 mr-3 text-primary-800" />
{{ $t('general.copy_pdf_url') }}
</sw-dropdown-item>
<sw-dropdown-item
tag-name="router-link"
:to="`/admin/estimates/${$route.params.id}/edit`"
>
<pencil-icon class="h-5 mr-3 text-primary-800" />
{{ $t('general.edit') }}
</sw-dropdown-item>
<sw-dropdown-item @click="removeEstimate($route.params.id)">
<trash-icon class="h-5 mr-3 text-primary-800" />
{{ $t('general.delete') }}
</sw-dropdown-item>
</sw-dropdown>
</template>
</sw-page-header>
<!-- sidebar -->
<div
class="fixed top-0 left-0 hidden h-full pt-16 pb-4 ml-56 bg-white xl:ml-64 w-88 xl:block"
>
<div
class="flex items-center justify-between px-4 pt-8 pb-2 border border-gray-200 border-solid height-full"
>
<sw-input
v-model="searchData.searchText"
:placeholder="$t('general.search')"
input-class="inv-search"
icon="search"
class="mb-6"
type="text"
align-icon="right"
variant="gray"
@input="onSearched()"
/>
<div class="btn-group ml-3" role="group" aria-label="First group">
<v-dropdown
:close-on-select="false"
align="left"
class="filter-container"
>
<a slot="activator" href="#">
<base-button
class="inv-button inv-filter-fields-btn"
color="default"
size="medium"
>
<font-awesome-icon icon="filter" />
</base-button>
</a>
<div class="filter-title">
>
<search-icon slot="rightIcon" class="h-5" />
</sw-input>
<div class="flex mb-6 ml-3" role="group" aria-label="First group">
<sw-dropdown class="ml-3" position="bottom-start">
<sw-button slot="activator" size="md" variant="gray-light">
<filter-icon class="h-5" />
</sw-button>
<div
class="px-2 py-1 pb-2 mb-1 mb-2 text-sm border-b border-gray-200 border-solid"
>
{{ $t('general.sort_by') }}
</div>
<div class="filter-items">
<input
id="filter_estimate_date"
v-model="searchData.orderByField"
type="radio"
name="filter"
class="inv-radio"
value="estimate_date"
@change="onSearched"
/>
<label class="inv-label" for="filter_estimate_date">{{
$t('reports.estimates.estimate_date')
}}</label>
</div>
<div class="filter-items">
<input
id="filter_due_date"
v-model="searchData.orderByField"
type="radio"
name="filter"
class="inv-radio"
value="expiry_date"
@change="onSearched"
/>
<label class="inv-label" for="filter_due_date">{{
$t('estimates.due_date')
}}</label>
</div>
<div class="filter-items">
<input
id="filter_estimate_number"
v-model="searchData.orderByField"
type="radio"
name="filter"
class="inv-radio"
value="estimate_number"
@change="onSearched"
/>
<label class="inv-label" for="filter_estimate_number">{{
$t('estimates.estimate_number')
}}</label>
</div>
</v-dropdown>
<base-button
<sw-dropdown-item class="flex px-1 py-2 cursor-pointer">
<sw-input-group class="-mt-3 font-normal">
<sw-radio
id="filter_estimate_date"
v-model="searchData.orderByField"
:label="$t('reports.estimates.estimate_date')"
size="sm"
name="filter"
value="estimate_date"
@change="onSearched"
/>
</sw-input-group>
</sw-dropdown-item>
<sw-dropdown-item class="flex px-1 py-2 cursor-pointer">
<sw-input-group class="-mt-3 font-normal">
<sw-radio
id="filter_due_date"
v-model="searchData.orderByField"
value="expiry_date"
:label="$t('estimates.due_date')"
size="sm"
name="filter"
@change="onSearched"
/>
</sw-input-group>
</sw-dropdown-item>
<sw-dropdown-item class="flex px-1 py-2 cursor-pointer">
<sw-input-group class="-mt-3 font-normal">
<sw-radio
id="filter_estimate_number"
v-model="searchData.orderByField"
value="estimate_number"
:label="$t('estimates.estimate_number')"
size="sm"
name="filter"
@change="onSearched"
/>
</sw-input-group>
</sw-dropdown-item>
</sw-dropdown>
<sw-button
class="ml-1"
v-tooltip.top-center="{ content: getOrderName }"
class="inv-button inv-filter-sorting-btn"
color="default"
size="medium"
size="md"
variant="gray-light"
@click="sortData"
>
<font-awesome-icon v-if="getOrderBy" icon="sort-amount-up" />
<font-awesome-icon v-else icon="sort-amount-down" />
</base-button>
<sort-ascending-icon v-if="getOrderBy" class="h-5" />
<sort-descending-icon v-else class="h-5" />
</sw-button>
</div>
</div>
<div class="side-content">
<base-loader v-if="isSearching" :show-bg-overlay="true" />
<div
v-else
class="h-full pb-32 overflow-y-scroll border-l border-gray-200 border-solid sw-scroll"
>
<router-link
v-for="(estimate, index) in estimates"
:to="`/admin/estimates/${estimate.id}/view`"
:id="'estimate-' + estimate.id"
:key="index"
class="side-estimate"
:class="[
'flex justify-between side-estimate p-4 cursor-pointer hover:bg-gray-100 items-center border-l-4 border-transparent',
{
'bg-gray-100 border-l-4 border-primary-500 border-solid': hasActiveUrl(
estimate.id
),
},
]"
style="border-bottom: 1px solid rgba(185, 193, 209, 0.41)"
>
<div class="left">
<div class="inv-name">{{ estimate.user.name }}</div>
<div class="inv-number">{{ estimate.estimate_number }}</div>
<div class="flex-2">
<div
:class="'est-status-' + estimate.status.toLowerCase()"
class="inv-status"
class="pr-2 mb-2 text-sm not-italic font-normal leading-5 text-black capitalize truncate"
>
{{ estimate.user.name }}
</div>
<div
class="mt-1 mb-2 text-xs not-italic font-medium leading-5 text-gray-600"
>
{{ estimate.estimate_number }}
</div>
<sw-badge
class="px-1 text-xs"
:bg-color="$utils.getBadgeStatusColor(estimate.status).bgColor"
:color="$utils.getBadgeStatusColor(estimate.status).color"
>
{{ estimate.status }}
</div>
</sw-badge>
</div>
<div class="right">
<div class="flex-1 whitespace-no-wrap right">
<div
class="inv-amount"
class="mb-2 text-xl not-italic font-semibold leading-8 text-right text-gray-900"
v-html="
$utils.formatMoney(estimate.total, estimate.user.currency)
"
/>
<div class="inv-date">{{ estimate.formattedEstimateDate }}</div>
<div
class="text-sm not-italic font-normal leading-5 text-right text-gray-600 est-date"
>
{{ estimate.formattedEstimateDate }}
</div>
</div>
</router-link>
<p v-if="!estimates.length" class="no-result">
<p
v-if="!estimates.length"
class="flex justify-center px-4 mt-5 text-sm text-gray-600"
>
{{ $t('estimates.no_matching_estimates') }}
</p>
</div>
</div>
<div class="estimate-view-page-container">
<iframe :src="`${shareableLink}`" class="frame-style" />
<div
class="flex flex-col min-h-0 mt-8 overflow-hidden sw-scroll"
style="height: 75vh"
>
<iframe
:src="`${shareableLink}`"
class="flex-1 border border-gray-400 border-solid rounded-md frame-style"
/>
</div>
</div>
</base-page>
</template>
<script>
import { mapActions } from 'vuex'
import {
DotsHorizontalIcon,
FilterIcon,
SortAscendingIcon,
SortDescendingIcon,
SearchIcon,
LinkIcon,
TrashIcon,
PencilIcon,
} from '@vue-hero-icons/solid'
const _ = require('lodash')
export default {
data () {
components: {
DotsHorizontalIcon,
FilterIcon,
SortAscendingIcon,
SortDescendingIcon,
SearchIcon,
LinkIcon,
TrashIcon,
PencilIcon,
},
data() {
return {
id: null,
count: null,
@ -206,38 +248,50 @@ export default {
searchData: {
orderBy: null,
orderByField: null,
searchText: null
searchText: null,
},
status: ['DRAFT', 'SENT', 'VIEWED', 'EXPIRED', 'ACCEPTED', 'REJECTED'],
isMarkAsSent: false,
isSendingEmail: false,
isRequestOnGoing: false,
isSearching: false
isSearching: false,
}
},
computed: {
getOrderBy () {
if (this.searchData.orderBy === 'asc' || this.searchData.orderBy == null) {
pageTitle() {
return this.estimate.estimate_number
},
getOrderBy() {
if (
this.searchData.orderBy === 'asc' ||
this.searchData.orderBy == null
) {
return true
}
return false
},
getOrderName () {
getOrderName() {
if (this.getOrderBy) {
return this.$t('general.ascending')
}
return this.$t('general.descending')
},
shareableLink () {
shareableLink() {
return `/estimates/pdf/${this.estimate.unique_hash}`
}
},
getCurrentEstimateId() {
if (this.estimate && this.estimate.id) {
return this.estimate.id
}
return null
},
},
watch: {
$route (to, from) {
$route(to, from) {
this.loadEstimate()
}
},
},
created () {
created() {
this.loadEstimates()
this.loadEstimate()
this.onSearched = _.debounce(this.onSearched, 500)
@ -251,32 +305,67 @@ export default {
'sendEmail',
'deleteEstimate',
'selectEstimate',
'fetchViewEstimate'
'fetchViewEstimate',
]),
async loadEstimates () {
let response = await this.fetchEstimates()
...mapActions('modal', ['openModal']),
hasActiveUrl(id) {
return this.$route.params.id == id
},
async loadEstimates() {
let response = await this.fetchEstimates({ limit: 'all' })
if (response.data) {
this.estimates = response.data.estimates.data
}
setTimeout(() => {
this.scrollToEstimate()
}, 500)
},
async loadEstimate () {
scrollToEstimate() {
const el = document.getElementById(`estimate-${this.$route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
}
},
async loadEstimate() {
let response = await this.fetchViewEstimate(this.$route.params.id)
if (response.data) {
this.estimate = response.data.estimate
this.estimate = { ...response.data.estimate }
}
},
async onSearched () {
copyPdfUrl() {
let pdfUrl = `${window.location.origin}/estimates/pdf/${this.estimate.unique_hash}`
let response = this.$utils.copyTextToClipboard(pdfUrl)
window.toastr['success'](this.$tc('general.copied_pdf_url_clipboard'))
},
async onSearched() {
let data = ''
if (this.searchData.searchText !== '' && this.searchData.searchText !== null && this.searchData.searchText !== undefined) {
if (
this.searchData.searchText !== '' &&
this.searchData.searchText !== null &&
this.searchData.searchText !== undefined
) {
data += `search=${this.searchData.searchText}&`
}
if (this.searchData.orderBy !== null && this.searchData.orderBy !== undefined) {
if (
this.searchData.orderBy !== null &&
this.searchData.orderBy !== undefined
) {
data += `orderBy=${this.searchData.orderBy}&`
}
if (this.searchData.orderByField !== null && this.searchData.orderByField !== undefined) {
if (
this.searchData.orderByField !== null &&
this.searchData.orderByField !== undefined
) {
data += `orderByField=${this.searchData.orderByField}`
}
this.isSearching = true
@ -286,7 +375,7 @@ export default {
this.estimates = response.data.estimates.data
}
},
sortData () {
sortData() {
if (this.searchData.orderBy === 'asc') {
this.searchData.orderBy = 'desc'
this.onSearched()
@ -296,74 +385,68 @@ export default {
this.onSearched()
return true
},
async onMarkAsSent () {
window.swal({
title: this.$t('general.are_you_sure'),
text: this.$t('estimates.confirm_mark_as_sent'),
icon: '/assets/icon/check-circle-solid.svg',
buttons: true,
dangerMode: true
}).then(async (value) => {
if (value) {
this.isMarkAsSent = true
let response = await this.markAsSent({id: this.estimate.id})
this.isMarkAsSent = false
if (response.data) {
window.toastr['success'](this.$tc('estimates.mark_as_sent_successfully'))
async onMarkAsSent() {
window
.swal({
title: this.$t('general.are_you_sure'),
text: this.$t('estimates.confirm_mark_as_sent'),
icon: '/assets/icon/check-circle-solid.svg',
buttons: true,
dangerMode: true,
})
.then(async (value) => {
if (value) {
this.isMarkAsSent = true
let response = await this.markAsSent({
id: this.estimate.id,
status: 'SENT',
})
this.isMarkAsSent = false
if (response.data) {
this.estimate.status = 'SENT'
window.toastr['success'](
this.$tc('estimates.mark_as_sent_successfully')
)
}
}
}
})
},
async onSendEstimate(id) {
this.openModal({
title: this.$t('estimates.send_estimate'),
componentName: 'SendEstimateModal',
id: this.estimate.id,
data: this.estimate,
})
},
async onSendEstimate (id) {
window.swal({
title: this.$t('general.are_you_sure'),
text: this.$t('estimates.confirm_send_estimate'),
icon: '/assets/icon/paper-plane-solid.svg',
buttons: true,
dangerMode: true
}).then(async (value) => {
if (value) {
this.isSendingEmail = true
let response = await this.sendEmail({id: this.estimate.id})
this.isSendingEmail = false
if (response.data.success) {
window.toastr['success'](this.$tc('estimates.send_estimate_successfully'))
return true
}
if (response.data.error === 'user_email_does_not_exist') {
window.toastr['error'](this.$tc('estimates.user_email_does_not_exist'))
return true
}
window.toastr['error'](this.$tc('estimates.something_went_wrong'))
}
})
},
copyPdfUrl () {
copyPdfUrl() {
let pdfUrl = `${window.location.origin}/estimates/pdf/${this.estimate.unique_hash}`
let response = this.$utils.copyTextToClipboard(pdfUrl)
window.toastr['success'](this.$tc('Copied PDF url to clipboard!'))
window.toastr['success'](this.$tc('general.copied_pdf_url_clipboard'))
},
async removeEstimate (id) {
window.swal({
title: 'Deleted',
text: 'you will not be able to recover this estimate!',
icon: '/assets/icon/trash-solid.svg',
buttons: true,
dangerMode: true
}).then(async (value) => {
if (value) {
let request = await this.deleteEstimate(id)
if (request.data.success) {
window.toastr['success'](this.$tc('estimates.deleted_message', 1))
this.$router.push('/admin/estimates')
} else if (request.data.error) {
window.toastr['error'](request.data.message)
async removeEstimate(id) {
window
.swal({
title: this.$t('general.are_you_sure'),
text: 'you will not be able to recover this estimate!',
icon: '/assets/icon/trash-solid.svg',
buttons: true,
dangerMode: true,
})
.then(async (value) => {
if (value) {
let request = await this.deleteEstimate({ ids: [id] })
if (request.data.success) {
window.toastr['success'](this.$tc('estimates.deleted_message', 1))
this.$router.push('/admin/estimates')
} else if (request.data.error) {
window.toastr['error'](request.data.message)
}
}
}
})
}
}
})
},
},
}
</script>